There is a specific kind of hardware that gets bought once, used for a week, and then lives in a drawer forever. Mine was a DigiLand DL7006-KB — a 7-inch Android tablet from the bottom shelf. Quad-core Cortex-A7 at 1.3 GHz, 1 GB of RAM, 8 GB of storage, a 1024×600 panel, and a 2100 mAh battery. It shipped with Android 7.0 on a Linux 3.18 kernel and it was slow the day it came out of the box.
It now boots NEONWIRE: a custom Linux userland with a Rust shell that draws straight to the framebuffer, Wi-Fi, SSH over Tailscale from anywhere in the world, a live-coding music synthesizer that renders songs on-device, a camera pipeline, and an AI agent running natively on the tablet that reads its own logs every 30 minutes and writes up what it finds.
No X11. No Wayland. No GPU driver. No Android. Just pixels written into /dev/fb0 by a Rust program.

This is the devlog: the story of how, and — more usefully — of everything that went wrong. If you'd rather see it than read it, the field dossier is the visual version, with the boot sequence and the tablet playing its own music on video.
The rules
One constraint shaped the whole project: the hardware is disposable, but the discipline is not. A $40 tablet is the perfect lab platform precisely because bricking it costs nothing emotionally. That freedom is what makes it possible to poke at boot chains and firmware loaders that you'd never risk on a real device.
The second rule was harder: no shortcuts through Android. It would have been trivial to ship an Android app with a webview and call it a custom OS. The point was to own the stack down to the ioctl.
Chapter 1: Owning the boot
The first real milestone is the least visually interesting: replacing the boot image with one whose ramdisk you control, and getting a shell on it. MediaTek's boot chain is well-trodden ground, but every board has its own flavor of pain. Once init was ours, the tablet was a Linux box with no userland worth speaking of — which is exactly where the fun starts.
Chapter 2: Drawing pixels on a panel that doesn't want to refresh
The shell is neonwire, a Rust binary with a small graphics crate (neon-gfx) underneath it. Everything is software rendering into a memory-mapped framebuffer: text, panels, the corner brackets, the glow. The font is a bitmap of JetBrains Mono baked into the binary.
The first thing that broke was the display. I'd write pixels, and nothing would happen. Then something would happen, but a frame late, and only sometimes.
This panel is command-mode MIPI. Unlike a video-mode display that continuously scans out of memory, a command-mode panel only takes a new frame when it's explicitly told to. Writing to the mapped framebuffer changes memory and nothing else. The fix is one ioctl — FBIOPAN_DISPLAY — after every frame. Miss it and you're drawing into the void.
Touch had its own dialect: a type-A multitouch device, the older evdev protocol where contacts are separated by SYN_MT_REPORT rather than carrying slot IDs. Every modern example you find online assumes type B.
Chapter 3: The wall — Wi-Fi
This is the one that took the longest by a wide margin, and it's the most interesting piece of reverse engineering in the whole project.
The MT8127's Wi-Fi is not a discrete chip on an SDIO bus. It's a combo block on-die — MediaTek's "CONSYS" — carrying Wi-Fi, Bluetooth, and GPS behind a shared transport called STP. Getting it up required three separate discoveries, each of which produced hours of silence before it clicked:
1. The transport mode is BTIF, not SDIO. The driver takes a mode parameter. Every reference I found used 0x24 (SDIO). On this SoC the block is on-die and the correct mode is 0x23 (BTIF). Wrong value, no chip.
2. The kernel asks userspace for its firmware. This is the genuinely strange part. On boot, the driver opens a request on /dev/stpwmt and waits for a userspace process to answer questions about firmware patches — it emits a srh_patch request, and expects a program to reply with SET_PATCH_NUM and SET_PATCH_INFO ioctls and then write "ok" back to the device. There is no daemon that does this in a normal Linux userland, because this dance only exists inside Android's vendor stack. I wrote a small C program (wmtctl2) to play the part.
3. Run the loader exactly once. wmt_loader detects the chip and installs the driver stack. Running it a second time in the same boot oopses the kernel in sdio_detect_exit and takes the device down with it. The boot script now guards on the presence of /dev/stpwmt.
Even after the interface appeared, association would succeed and the connection would never complete. The culprit: wpa_supplicant driving this chip through wext associates but never finishes the 4-way handshake. Rebuilt against nl80211 with libnl-tiny, it connects immediately.
The payoff: with dropbear and Tailscale on top, the tablet is reachable by SSH from anywhere, which is what made everything after this point possible. I have not physically touched the device for most of its development.
Chapter 4: Sound, and a struct that was four bytes too short
With networking done, the tablet got audio — again with no libraries, talking to ALSA through raw ioctls on /dev/snd/pcmC0D5p.
Playback came up relatively painlessly. Then came the silence bug: patterns played, the UI animated, the meters moved, and nothing came out of the speaker. The speaker amplifier is behind a mixer control, and every attempt to set it returned ENOTTY — "inappropriate ioctl for device," the kernel's way of saying I don't recognize that request at all.
The request number encodes the size of the struct being passed. My snd_ctl_elem_value was 708 bytes. On 32-bit ARM the kernel's is 712.
The reason is a union. The value union contains, among other members, long long[64], which forces the union to 8-byte alignment. On arm32 that inserts four bytes of padding that a naive field-by-field transcription of the header misses entirely. Four bytes, and the ioctl number is wrong, and the kernel rejects it before looking at a single byte of the payload.
The fix is ugly and correct:
#[repr(C, align(8))]
struct CtlValueUnion([u8; 512]);
// ... and then, so this can never silently drift again:
const _: () = assert!(std::mem::size_of::<SndCtlElemValue>() == 712);
That const assertion is the real deliverable. The bug is a one-line fix; the guarantee that it stays fixed is what you actually want.
Chapter 5: A synthesizer that fits in 1 GB
The tablet plays music by generating it, not by decoding files.
I'd previously written strudel-rs, a Rust port of the Strudel live-coding pattern language. NEONWIRE embeds it: songs are pattern code, evaluated and rendered to PCM in a streaming block loop on a 1.3 GHz A7 while the UI keeps running.

Drum samples live on the SD card and are decoded by a dependency-free WAV reader. The visualizers — spectrum bands, scope, VU, and an "event rain" that falls in time with pattern events — run off a 1024-point radix-2 FFT and a tap into the pattern processor.
The performance work here was mostly about discovering what's actually slow, which is never what you assume. Bank discovery originally queried the pattern for a 256-cycle window in one call, which took over 40 seconds on nested patterns and looked exactly like a hang. Chunking it into 4-cycle windows made it instant. Sample pre-warming moved the stall out of playback and into a loading indicator.

Chapter 6: The tablet gets an AI — and starts auditing itself
The most recent chapter. ZeroClaw is a Rust agent runtime whose pitch is that it runs on tiny hardware. The DL7006 is a fair test of that claim.
The official ARM builds are glibc, so they can't run on this musl userland — it had to be cross-compiled from source for armv7-unknown-linux-musleabihf with a lean feature set, dropping the email/Telegram/Discord channels that drag in the worst of the musl-hostile dependency tree. The result is a 28 MB static binary, built in about seven minutes, that runs natively on the tablet. There's a touch app for it in the shell, with a voice path — wake word, push-to-talk, speech-to-text — and a keyboard for typing.
Then the interesting part. The agent runs a heartbeat: every 30 minutes it wakes up, reads the device's own logs, and writes up anything that looks wrong.

It is genuinely good at this. In its first unattended runs it:
- Worked out that Tailscale's router integration is broken because
iptablesdoesn't exist on the image and kernel 3.18's netfilter can't do what the modern client asks of it. - Correlated a network blip against one it had reported hours earlier and concluded the pattern was upstream connection resets rather than a stuck daemon — a conclusion only available to something that remembered its own previous report.
- Flagged
wmt_loader's exit code 255 as noise that would mask a real loader failure, and suggested gating on the presence of/dev/stpwmtinstead. That's a legitimate critique of my boot script. - Caught me. It reported
neonwire exited rc=143 after 16010s, explicitly distinguished it from the intentional restart it had already catalogued, and asked who sent the SIGTERM. That was me hot-swapping the binary over SSH. Its watchdog noticed and refused to file it under a known-benign case.
It runs as a deliberately declawed agent: supervised, thirteen read-only commands, no delegation, filesystem access limited to the log directory and its own workspace. The interactive assistant you talk to has full privileges; the thing that wakes up unattended at 3 AM does not.
The bug that taught the best lesson
The heartbeat ran perfectly for hours. Then the tablet rebooted, and it silently stopped.
The heartbeat worker only exists inside ZeroClaw's daemon command. The boot script was launching gateway start, which serves the HTTP endpoint but never ticks the heartbeat. I'd fixed that — in the git repo. I never copied the script to the device's SD card. So the reboot quietly reverted to gateway-only, and every surface I'd have checked still looked healthy: the gateway answered, the assistant replied, the UI was green.
A watchdog that stops watching produces no logs to notice it by.
The fix was a liveness indicator that reads the daemon's own state file — deliberately not anything the agent writes, because a dead component can't report its own death:
GATEWAY UP
WATCHDOG OK - last tick 5m ago
WATCHDOG LATE - last tick 41m ago
WATCHDOG STALE - no tick in 3h20m
WATCHDOG DOWN - no daemon state (gateway-only?)
That last line is exactly the failure that fooled me, now stated out loud on the panel that was previously showing green.
Some numbers
- ~13,800 lines of Rust across 44 files, in a workspace of crates for graphics, songs, and the shell.
- 1 GB of RAM, of which the whole running system — UI, synth, agent daemon, Tailscale — uses well under a third.
- 28 MB for the agent runtime; 2.2 MB for the entire OS shell.
- Zero display servers, GPU drivers, or Android components.
- A 712-byte struct that spent an evening being 708.
What it actually taught me
Three things worth carrying off this board:
Assumptions fail silently; the machine fails loudly. Every long bug here — the panel, the ioctl, the loopback interface, the heartbeat — was a case where my mental model and the hardware disagreed and the hardware didn't bother to argue. ENOTTY doesn't mean "your struct is four bytes short." Loopback being down doesn't announce itself as EADDRNOTAVAIL in a way that points at lo. You have to go look.
Encode the fix, not just the repair. The const size assertion, the boot-script guard on /dev/stpwmt, the WATCHDOG STALE line. Each one exists because I'd already lost hours to the failure it prevents.
"Too slow to be useful" is usually a software claim wearing hardware's clothes. This tablet was unusable running the OS it shipped with. The silicon was never the problem.
Open source
The whole thing is on GitHub at nukleas/neonwire-os — the OS, the reverse-engineering notes for the Wi-Fi and audio paths, and the boot scripts.
It's dual-licensed by component: the graphics crate is MIT, while the music path is AGPL-3.0, inheriting from Strudel upstream. The reverse-engineering documentation is, I hope, the most reusable part for anyone else holding an MT8127 board and a free weekend.