Porting DOS assembly to the browser

My tic-tac-toe game is written in 16-bit x86 real mode assembly. There is no compiler that turns that into WebAssembly. The instruction set, the segmented memory model and the DOS interrupts have no equivalent target.

So the plan is emulation: assemble the original source with NASM, link it into a DOS executable, and run that executable inside DOSBox compiled to WebAssembly. The WebAssembly here is the emulator, not my code, and I think that honesty matters more than the label.

The build already works: nasm -f obj assembles both source files without a single change, and Open Watcom’s linker produces a 3,244 byte DOS executable.

What the game actually does

It is 1,740 lines across two files. vca.asm holds the game: the command parser, the board, win detection. draw.asm holds the graphics primitives.

The entire game state is nine bytes:

tabuleiro    db    '         '

Nine ASCII spaces. A move overwrites one of them with X or C, and a new game is nine spaces again. There is no struct, no bitboard, no packing. The board is literally the string you would draw on paper.

Win detection is fully unrolled. Eight winning lines, two players, sixteen blocks of explicit cmp byte [tabuleiro + n], al, one after another, with no loop and no table of line indices. That single decision accounts for a good share of the 1,187 lines in vca.asm.

Everything on screen arrives by one of two routes, and the two have nothing in common. The position labels are BIOS text: int 10h with AH=02h to place the cursor and AH=09h to write a character in the ROM font, straight onto a graphics-mode screen. The board, the crosses and the circles are pixels, plotted one at a time by Bresenham routines the game implements itself.

One interrupt per pixel

draw.asm is 553 lines and contains exactly three int 10h calls. The third one is the whole graphics layer:

plot_xy:
        ; ... prologue: saves bp and every register it touches
        mov     ah,0ch
        mov     al,[cor]
        mov     bh,0
        mov     dx,479
        sub     dx,[bp+4]
        mov     cx,[bp+6]
        int     10h

AH=0Ch is the BIOS write-pixel service. Every line, every circle, every X on the board goes through it, one pixel per interrupt. Nothing is ever written directly to video memory at A000h, and none of the VGA planar tricks that made mode 12h fast are anywhere in the source.

The mov dx,479 / sub dx,[bp+4] is the other detail I like. VGA counts rows from the top; the game thinks bottom-left, like graph paper. Rather than convert at every call site, it flips the axis once, inside the only routine that touches a pixel.

The cost is easy to measure. The empty grid is two horizontal lines 313 pixels long and two vertical lines 297 pixels long: about 1,220 BIOS calls before a single move is played. A circle of radius 40 is another 250 or so. In the browser every one of those traps into the emulator instead of into real ROM.

It does not matter (a tic-tac-toe board is a few thousand pixels, and the thing draws instantly), but it does clarify what emulation is buying here. Not speed. Fidelity of a BIOS that has not existed in hardware for decades.

The shape of the bundle

js-dos takes a .jsdos file, which turns out to be a plain zip. Mine holds two files: VCA.EXE, and a 77-byte .jsdos/dosbox.conf telling DOSBox to boot the machine as vgaonly, mount the bundle as C: and run the executable. That is the entire packaging step.

The whole bundle is 2,236 bytes. The emulator that runs it is 1.4 MB of wdosbox.wasm plus another 315 KB of loader. The WebAssembly alone is 450 times the size of the 3,244-byte program it exists to run. That asymmetry is why the demo loads on a click and not on page load: someone who came to read about the project should not pay for a DOS box they never asked to boot.

Two things the browser half cost me

js-dos defaults pathPrefix to its own CDN. Leave it alone and the page quietly downloads 1.4 MB of WebAssembly from someone else’s server, which is exactly the thing this site is built not to do. The failure is silent: everything works, and the page just is not what I said it was. My existing check for third-party requests read src and href attributes out of the built HTML, so it was blind by construction: this request is born at runtime, inside JavaScript. The new check scans the published JavaScript for off-origin URLs instead.

The second one was louder. js-dos ships a stylesheet, and that stylesheet opens with a full Tailwind reset: h1..h6{font-size:inherit}, a{color:inherit; text-decoration:inherit}. It loads after mine, at the same specificity. Clicking “Play” flattened every heading and drained the colour out of every link on the page, all at once. The fix was to stop serving those 118 KB entirely and hand-write the handful of rules the emulator’s own DOM actually needs, scoped under .demo-screen.

The readme was wrong

The game’s readme says to type OLC to play a circle. The code compares against 'C', at vca.asm:80, and C22 is what worked when I first ran it under DOSBox. The messages never moved: the game still prints O Venceu and Vez do O. At some point the display kept O and the parser took C, and the readme followed the half that was easier to see.

So the demo documents C, and there is a test that fails if anyone helpfully “corrects” it back to O by reading the readme. It is a small thing, but it is the kind of thing that only surfaces when you actually run the program instead of describing it. Seven years later, that is most of what this port was for.