A 1981 IBM PC, emulated inside a USB stick the size of a thumbnail — running FreeDOS, joined to WiFi, serving its own screen to a browser, and playing a 1984 platform game about a cat.
Ninety-two commits. Three working days. This is what we learned, including — especially — the things we got wrong.
CGA palette 1 — every colour this machine can draw. There is no blue. That is not a bug.
192KBConventional RAM
382,464/sInstructions
161/192Pages run from flash
0BPSRAM
92Commits
3Days
The premise
A whole PC, in the gap between a plug and a case
The LilyGo T-Dongle S3 is a USB stick with an ESP32-S3 inside it, a screen on the front the size of a postage stamp, one RGB LED, and a microSD slot. It has 16 MB of flash and no PSRAM at all — a fact that cost us our first boot, and which we will come back to.
The goal was to take 8086tiny — a beautiful, obsessively small 8086 emulator — and make it run on that stick. Not as a demo. As a machine: it boots FreeDOS, it joins your WiFi, it has a keyboard and a screen, and you can plug it into any computer in the world and it will introduce itself as 8086tiny PC.
It does all of that now, from one firmware image, at the same time. But almost nothing about how it got there was what we expected on day one, and the interesting part of this story is not the milestones. It's the wrong turns.
Act I
Three signs of life
M110 JUL
Hello, LED, splash
The entire first milestone was: print a greeting over USB, cycle the LED, show something on the screen. Zero emulator. It exposed two things immediately.
The board boot-looped on PSRAM — every guide, and our own spec, said it had 8 MB of octal PSRAM. It has none. Only the separate Plus variant does. And the greeting was losing a race: this dongle is USB-powered, so plugging it in is a cold boot, and the boot-time print happens before the host has finished enumerating. It now waits for a host and then says hello.
M2 · M310 JUL
A filesystem, a network, a web server
LittleFS, WiFi credentials in NVS, an HTTP server answering on 8086tiny.local. Along the way, a bootloop that took real time to find: the system event task was overflowing its stack. And a lesson that shaped everything after — panic output on this board is invisible, because the panic handler writes into a USB FIFO that the reset then destroys. So we built a panic journal in no-init RAM that survives the reboot and replays into dmesg on the next boot. We would need it.
M410 JUL
The keyboard, and a question that took nine days to answer
A WebSocket carrying video frames and XT scan codes. The user looked at the first colour test and asked: “no blue?”
No blue. CGA palette 1 is cyan, magenta and white, and the only blue a 320×200 screen can show is as the background colour. The instinct — every time — is to fix the palette until it looks nice. The rule we settled on instead: read the register, never hardcode what looks good. That rule caught a real bug five milestones later.
Act II
The machine wakes up
M510 JUL
Forty-five instructions
The first time the 8086 core ran on hardware, it executed forty-five instructions of a bring-up ROM and halted with exactly the register values it should have. A very small number, and one of the best moments of the project.
M612 JUL
FreeDOS — and a one-byte bug hiding behind 45,416 bytes
The PC's memory is not one flat block; it is chunks, scattered across whatever heap we could find. Which means a 16-bit write can straddle a chunk boundary. We wrote 38 host assertions for that seam, and they caught something we would never have found by running DOS:
An 8-bit write to the last byte of a chunk silently reverted the first byte of the next one. Harmless — unless something copies straight through the seam. FreeDOS relocates its own kernel with a single REP MOVSB of 45,416 bytes, right across it.
// the bug, in one line
mem[chunk_end] = value; // wrote here
mem[next_start] = stale; // and quietly undid here
M6b12 JUL
You cannot switch off memory you have already linked
FreeDOS needs 192 KB. We were 160 KB short. The obvious fix: a runtime flag to turn WiFi off and reclaim the RAM.
It cannot work, and the reason is worth internalising. net off disabled the radio, but esp_wifi, lwIP, mbedTLS and the HTTP server were still linked into the binary. Their static memory is spoken for before main() runs. A runtime flag cannot reclaim linked BSS.
And the real blocker was not free bytes at all — it was contiguity. There was enough memory; there was no single piece big enough. Dropping the chunk size from 32 KB to 8 KB is what actually booted DOS.
“The host rig proves correctness. Only the metal proves fit.”
M6 shipped as a “working” milestone on the strength of a host-side test run. It had unlimited RAM and no flash cache. On the actual dongle it was 160 KB short. We wrote that sentence on the wall and it has saved us twice since.
Act III
The memory wars
This is the heart of the project. We had 192 KB of PC to fit into a microcontroller that also had to run WiFi, a TCP stack, TLS and a web server. Three ideas were tried. Two of them died, and the autopsies were more valuable than the survivor.
Dead end · disproved by measurement
Lazy / zero-page copy-on-write
The theory: DOS surely doesn't touch most of its 192 KB, so only allocate the pages it writes.
We instrumented every write and counted. FreeDOS writes 189 KB of the 192 KB. It needs 192 KB because it uses 192 KB. There was nothing to be lazy about.
Dead end · disproved by measurement
Map RAM back to the floppy image
The theory: KERNEL.SYS is on the floppy, already in flash. Why hold a second copy in RAM? Just point the pages at the disk image.
Of the 89 sectors of the kernel, zero survive verbatim in memory. It relocates itself with fixups — every sector is patched as it loads. There was no second copy to avoid.
M1713 JUL
The idea that worked: boot the machine at build time
If DOS writes almost all of its memory, and none of it matches the disk — then stop trying to avoid the copy, and move it out of RAM entirely.
So: boot FreeDOS on a workstation, snapshot the 192 KB of memory the instant it reaches A:\>, and bake that image into the firmware's read-only data. On the dongle, the PC's RAM is memory-mapped flash. Pages become real RAM only when the guest writes to them — copy-on-write, at 1 KB granularity.
The machine now boots to a DOS prompt instantly, because it doesn't boot at all. It wakes up already booted.
8086tiny> pc
conv RAM 192 KB, of which only 33.8 KB is DRAM161 of 192 pages still in FLASH
speed 348,160 instructions/sec (baseline 345-375K)
free heap 112,388 B
Three quarters of every instruction fetch now comes from flash, and it costs nothing. The 8 KB hot working set simply lives in the CPU's instruction cache — even with WiFi competing for it. And it collapsed the project's two separate firmware builds into one: the DOS machine and the networked machine stopped being a trade-off.
Act IV
It grows a face
M913 JUL
CGA graphics — and the bottom three scanlines nobody could see
Two findings, and the brief was wrong about the first one. This BIOS never writes the CGA registers at all. It drives a Hercules mode register at port 0x3B8. We had been watching the wrong port.
The second was a real bug, and it had been hiding since M5. CGA video memory is 16,384 bytes, and we had mapped a 16,000-byte window — the obvious size, since the picture is 16,000 bytes. But CGA interleaves its scanlines into two banks, and scanline 199 begins at byte 16,191. Every graphics screen this machine had ever drawn was missing its bottom three odd scanlines, into open bus.
M1013 JUL
Alley Cat was never a video problem
The game had been on the floppy since before the emulator could execute a single instruction. It was the named acceptance test for a milestone about an exotic 160×100 video mode. It never ran, and we assumed the video mode was why.
It needed 54,555 bytes. DOS's free arena had 48,688. It had never loaded, not once, not for any reason to do with graphics. Trimming BUFFERS and FILES in CONFIG.SYS fixed it. And it turns out Alley Cat uses ordinary CGA mode 4 — the entire premise of the milestone was wrong.
(The 160×100 mode survived anyway, and got its revenge later. Keep reading.)
M1813 JUL
The dongle says its own name
It stopped enumerating as USB JTAG/serial debug unit and started introducing itself properly. Plug it into anything:
Bus 001 Device 054: ID 303a:4086 mcu-pc.net 8086tiny PC
And then the genuinely silly part, which is also the best part. We wrote HOSTCFG.COM — a real DOS program, 1,192 bytes, sitting on the floppy — that reaches out of the emulator to configure the host it is running on.
A:\> HOSTCFG SET WIFI myssid mypassword
A:\> HOSTCFG REBOOT
The emulated 1981 PC can reconfigure the WiFi of the 2026 microcontroller that is dreaming it.
M16b13 JUL
The cat was waiting at a menu
This one is our favourite, because the bug was never where anyone was looking.
The emulated machine has two displays, and always has — 8086tiny's own documentation says so. A text device, where the BIOS teletypes every character out through a hook (it carries no font at all, so text can never appear in the pixel framebuffer), and a pixel device, where programs write video memory. Two independent monitors, running at once.
Our browser page received the pixels. Our serial console received the text. Neither had ever received both.
So when Alley Cat started, switched to graphics, and drew nothing — we spent a long time debugging the graphics. The game was fine. It had printed this, to the monitor nobody was connected to, and was politely waiting for an answer:
Do you want to use a joystick (Y/N)?
Either joystick is not attached or
Game Control Adapter is not present.
Please select your skill level:
(K)itten
(H)ouse Cat
(T)omcat
(A)lley Cat
The menu was never invisible. It was on the other monitor. Both streams now ride both transports, on one connection, and the cat is playable — over WiFi, or down a USB cable, with real XT scan codes, because an arrow key is not a character and never was.
M1113 JUL
The little screen joins in
And here is where that abandoned 160×100 mode gets its revenge. The dongle's LCD is 160×80. The CRTC tweak mode is 160×100. The width matches exactly — pixel for pixel, no scaling, no filtering.
The 160×100 tweak is the dongle's native resolution. It was never a curiosity. It is the mode this machine's own screen speaks.
In text mode the LCD is a front panel. The moment a program enters graphics, it becomes a monitor — automatically, driven by the mode register. The game plays on the stick itself.
The 160×100×16 CRTC tweak. Not a graphics mode at all — it is text mode, with the character height set to two scanlines and blink disabled, every cell drawing a half-block glyph. 80 cells × 2 = 160 pixels. The dongle's exact width.Alley Cat, reconstructed from the raw frames the dongle put on the wire — the fence graffiti, the bins, the birds. We never took a photo of a screen. We reassembled the pixels from the bytes.
Try it
Put a PC on your own stick
If you have a LilyGo T-Dongle S3, you can install this from the browser. No toolchain, no ESP-IDF, no drivers. Plug it in and click.
It erases the dongle and writes 13.4 MB: the bootloader, the firmware, and a FreeDOS floppy with Alley Cat on it. Needs Chrome or Edge — Firefox and Safari have never shipped WebSerial.
Give it about three minutes. It shows you a progress bar the whole way. Most of what it is writing is the floppy, not the firmware — the PC is 1.5 MB and the disk it boots from is 11.9.
Optional · and it saves you the three paragraphs below
Tell it your WiFi and it will come up already on your network. Then you never need the USB serial port at all: wait ten seconds and open http://8086tiny.local. No cable, no udev rule, no sudo.
The password is never sent anywhere. It is written into the firmware image by this page, in your browser, moments before the image is flashed — which is the only reason these fields are here rather than on a server. And the honest other half: it then sits in the dongle's flash in the clear, exactly as it already would in the dongle's settings. Anyone holding the stick can read it back. That is a fair trade for a toy, and you should know it before you make it.
How to install — hold the button, it is not optional
Unplug the dongle.
Squeeze and hold the BOOT button, and plug it in while holding. The case is sealed, but there is a flexible panel over the button — press it deliberately, it wants to be meant. Keep holding for a second after it is in, then let go. Nothing will light up. That is right.
Click Install below and pick the dongle in the chooser your browser puts up. About three minutes.
When it finishes, unplug it and plug it back in — this time WITHOUT holding BOOT. Now it boots.
Why the button, when other flashers say you do not need it: a new T-Dongle arrives running whatever demo firmware the factory put on it — often a blinking LED, sometimes nothing you can see. Whether the browser can politely talk that firmware into handing over its bootloader is a coin toss: sometimes it works, sometimes the bootloader is simply not reachable, and there is no way to tell which from here. Holding BOOT is not a workaround. It is the thing that makes it deterministic — the chip reads that pin in silicon at power-up, before any firmware, factory or ours, gets a vote.
Step 4 is the one people miss, and it looks exactly like failure. A dongle that entered the bootloader by the button usually stays there after flashing — the reset the installer performs does not get it out. So the install says it worked and the dongle just… sits there. Dark screen, nothing on the USB bus, no DOS. It is not bricked and the flash was not wasted. It is parked in the bootloader, waiting for real power. Pull it out, push it in, and FreeDOS comes up.
Your browser can't do this — WebSerial only exists in Chrome and Edge.This page has to be served over HTTPS to reach the USB port. Open mcu-pc.net.
When it finishes: leave it plugged in, give it about ten seconds to join your network, and open http://8086tiny.local. If your network doesn't do mDNS, look for 8086tiny in your router's client list. The serial port is now optional — the Linux box below is for people who want a cable anyway.
Afterwards it will appear as 8086tiny PC on your USB bus. Open mcu-pc.net to get a screen and a keyboard, or just screen /dev/ttyACM0 115200 and type pc console — you'll be at a DOS prompt.
Linux · Raspberry Pi · read this one
The flash will work, and then the dongle will look dead. This is the single thing most likely to make you give up, so here it is before it happens.
A dongle in its ROM bootloader is world-accessible, so installing works with no setup at all. Then it reboots into this firmware and comes back as a brand-new USB device that matches no rule on your system — so it lands in root:dialout, and your browser is neither. The page sits at “opening…” and gives up. Nothing is broken. The device belongs to root.
One rule fixes it, and it takes effect on the next replug — no logout, no reboot:
The 60- in that filename is not decoration. Our first attempt shipped it as 99- and it was a complete no-op — udev reported the rule applied, and the permission never was. It has to sort before systemd’s own rule at 70-, or the thing that reads it has already gone home.
Then unplug it and plug it back in. (It tags the device for whoever is logged in at the seat, rather than making you join dialout — which works too, but only after you log out and back in, which is a wretched thing to find out three minutes into a five-minute project. Raspberry Pi OS users are usually in dialout already and may find it just works.)
If you are worried
You cannot brick it. Not with a bad flash, not with a half-written image, not with anything we could ship you, and not by unplugging it at the wrong moment. The ESP32 decides whether to run your firmware or its own ROM by reading one pin at power-up, and that decision is made in silicon before a single byte of anybody’s code executes. No firmware can veto it. If it ever looks dead: unplug, hold BOOT, plug in, install again. That door cannot be locked.
// what lands on the stick
bootloader 21 KB
partition tbl 3 KB
firmware 1.49 MB
floppy + app 11.86 MB---------13.40 MB
v0.17.0 · ESP32-S3 · FreeDOS 1.x · ALLEYCAT.EXE included
Received wisdom
Things the internet confidently told us, which were wrong
This board has a history of people guessing. Every row below cost somebody time — usually us.
Thing
What everyone says
What is actually true
PSRAM
8 MB octal
None. Enabling it boot-loops
RGB LED
WS2812 / NeoPixel
APA102 — and the colour order is BGR
LCD backlight
Active-high PWM
Active-low. Write 0 for bright
Video registers
CGA at 0x3D8 / 0x3D9
Hercules at 0x3B8. Truly
The BIOS
It's a ROM
It writes to itself — 12 addresses
Video memory
16,000 bytes
16,384. Or you lose 3 scanlines
lsusb lists it
The device is alive
It proves nothing — that's a cache
The spare dongle
Four bugs that only existed for somebody else
We put the flasher online and then — this is the good part — tested it on a second dongle. A spare. A stick with no WiFi configured, no rule on the machine granting access to it, and an owner who had not spent three days learning its habits.
It found four bugs in an hour. Not one of them could be found on the dongle we had been developing on, and the reason is the same every time: our machine was privileged, and we had stopped being able to see it.
Masked by the instrument
The cable was mute, and our tests hid it
Plug the freshly flashed dongle in, open the page, and nothing happens. The browser never raises DTR — and the USB stack we adopted treats DTR as "is anybody there?", so the device dropped every byte it sent and ignored every byte it got. Both sides sitting politely, waiting to be introduced.
It had been broken for two milestones. Every test we ran passed — because we tested with a Python script, and pyserial raises DTR on open, all by itself. The bug wasn't subtle. It was hidden by the instrument we were measuring with.
Invisible from inside
The dongle is not dead, it belongs to root
Flashing works. Then the dongle reboots into its new firmware, comes back as a brand-new USB device, matches no rule on the system, and lands in root:dialout — which your browser is not. The page says "not connected". The stick looks dead.
Our dongle never hit this. Its serial number was hard-coded into a rule months earlier and forgotten. And Linux is the only platform that does this: macOS and Windows hand the port over without a word. The one platform our audience is on is the only one with the wall.
The fix that fixed nothing
A rule that matched, and did nothing
So we shipped a udev rule. It was a complete no-op, and it would have failed silently for every single person who followed the instructions.
It tagged the device for uaccess from a file numbered 99 — which runs after the systemd rule at 70 that reads the tag. We tagged the device after the only thing that was listening had already gone home. udev cheerfully reported the tag applied. The permission never was. A rule that matches and does nothing is the most confusing failure there is.
A gift from three days ago
Do not run a PC out of a flash chip you are erasing
Updating the firmware over WiFi killed the machine. Three times, with three different crashes — until the chip finally said it plainly: something touched the external memory bus while the cache was gone.
That is M17's bill, arriving late. The PC's 192 KB of RAM is memory-mapped flash — the trick this whole project is built on — so the emulator reads flash on every instruction. An update erases that same flash, and erasing flash turns the cache off. The emulator was guaranteed to be mid-read when the floor disappeared.
Nobody could have caught it. The last time anyone updated over WiFi was three days before M17 existed. The two features had never once been in the same room.
The lesson is not "test more". It is that a machine you have been developing on for three days is not a machine anyone else has. It has your network, your permissions, your habits, and a udev rule with its serial number in it that you wrote so long ago you have forgotten it is there. It cannot fail the way a stranger's will.
A second, unprivileged, ordinary device is not a nice-to-have. It is the only honest test there is.
Still open
What is still broken, honestly
Half fixed · still open
Updating over WiFi
The emulator no longer dies when you push new firmware to it — it is frozen for the duration now, which is the only sane thing to do to a machine whose memory is the flash you are erasing.
But a second crash lives underneath, inside the chip vendor's own cross-core flash machinery, and that is a different animal. So over-the-air updates are still broken. Nothing shipped depends on them: the browser installs the firmware, and the floppy could never travel that way regardless. It is not destructive — the device reboots and rolls back, every time, unbothered.
Fixed · and the number is the story
9,288 bytes
That was the low-water heap, once the little screen started mirroring games. The line the project nearly died on is 8,000. It was not a milestone, it was a loaded gun — a 25.6 KB framebuffer, a 16 KB shadow and a 4 KB buffer, all open at exactly the moment somebody sits down to play.
So the framebuffer was deleted. The panel is painted ten rows at a time out of two small buffers and nothing keeps a screen in RAM at all. That bought back 19.5 KB. Measured after: 141,884 bytes.
Coda
The thing that keeps being true about this project is that almost every hard problem turned out to be a measurement problem. Not a cleverness problem. We did not out-think the 192 KB; we counted the writes and found out it really did need all of it. We did not debug the graphics; we finally read the other monitor. We did not fix Alley Cat's video mode; we discovered it had never loaded.
Every one of those was found by looking at what the machine actually did, rather than at what it should have done.
And the corollary, which took a spare dongle to teach us: you cannot measure a machine you are standing on. Ours had the network, the permissions, the habits and a forgotten rule with its serial number in it. It was incapable of failing the way yours will.
And now there is a PC on a stick. It boots FreeDOS in no time at all, because it wakes up already booted. It runs three quarters of its instructions straight out of flash and doesn't notice. It tells you its name when you plug it in. Its 1981 operating system can change its 2026 WiFi password. And it plays a game about a cat, on a screen the size of a fingernail, that its own BIOS is too small to have a font for.
We did it.
A sibling
This one is not alone. There is a companion project that started from the same 8086 and the same habit — write down the dead ends, measure everything — and then ran in the exact opposite direction.
emu86 is a 1980s PC written in pure TypeScript — no WebAssembly, no compiled core — booting a real 16-bit Unix (ELKS) in a browser tab, on the wager that readable beats fast. Duplicate the tab and a second machine appears: the two telnet into each other and resolve names over a network that exists only between your open tabs.
Same person, same 1981, the same claim argued from both ends. One PC fits in the gap between a plug and a case. The other fits in a browser tab. It tells its own story — and, this being how we found out, it links back here.