Field notes

Bug Case Studies

The happy path explains what the code does; these essays explain why it is shaped the way it is. Each one starts with the background you need, keeps the wrong turns in - the dead ends carry most of the lesson - and ends at the before/after and the regression test that pins the fix. The raw, unedited journal lives in docs/debug_log.md.

A packed byte, half-masked

The Share-Bits Handle Leak

Command & Conquer: Red Alert (1996)

Background

When a DOS program opens a file, it calls INT 21hINT 21hThe main DOS API interrupt. Programs select services such as open, read, EXEC, and exit with AH. with AH=3Dh and gets back a small integer - a file handle. Behind that integer sit two tables. The kernel keeps a global table of open-file state (position, size, where the file lives on disk); the process keeps a tiny per-process table in its PSPPSPThe DOS data block placed before each program, holding terminate vectors, the job file table, command tail, and environment pointer., the Job File Table, which maps the handle numbers a program sees onto the kernel's slots. LainDOS sizes the global table at twenty entries, the first five reserved for stdin, stdout, and friends - so a program can hold about fifteen files open at once. That sounds tight until you remember that era programs open, read, and close in quick succession; fifteen concurrent files was generous in 1996.

The other half of this story is the mode byte the program passes in AL. In DOS 1.x it was simple: 0 for read, 1 for write, 2 for both. Then DOS 3.0 grew networks and SHARE.EXE, and the byte got subdivided: the low three bits kept the access mode, and the middle bits gained a sharing mode - a declaration of how other processes may open the same file concurrently. 'Deny none' (0x20) says: I am reading this, and anyone else may do whatever they like. Compilers' runtime libraries emitted these bits by default for years. A program that opens a file with AX=3D20h is not doing anything exotic; it is being a well-mannered citizen of a world that might contain a network.

One more piece: when a handle is closed, DOS has a chore to do if - and only if - the file was written. A written file's directory entry must be updated with the new size and timestamp. A file that was only read needs nothing; the close just returns the slot to the table.

What happened

EA released the Red Alert ISOs as freeware in 2008, which made it an irresistible bring-up target. The C: image built, the disc mounted, the DOS installer launched - and drew nothing. A black screen, with the CD activity light pegged solid, for as long as anyone cared to wait. The same black screen appeared under QEMUQEMUThe default fast emulator for automated LainDOS builds, tests, monitor probes, and game smoke runs. and 86Box86BoxA PC emulator used here as a period-hardware comparison target when QEMU behavior looks suspicious., which was the first useful fact: whatever this was, it was not an emulator quirk. It was ours.

The investigation

The kernel has a tracing mode that logs INT 21hINT 21hThe main DOS API interrupt. Programs select services such as open, read, EXEC, and exit with AH. calls over serial, so the next step was to simply watch what the installer did. The trace looked perfectly healthy: open D:\SETUP\SETUP.MIX, seek, read a few hundred bytes, close, repeat - a Westwood program pulling UI assets out of its MIX archive, one at a time. Nothing failed. Nothing hung. And yet, reading more carefully, something was off: every open returned a fresh handle. 5, 6, 7, 8 ... 0x13. Closes were issued for every one of them - and the numbers still climbed. After handle 0x13, the last slot in the table, the trace degenerated into a wall of identical opens: forty thousand attempts in ninety seconds, every one failing, the installer patiently retrying its asset loads forever. There was the black screen, and there was the constant CD access.

The natural suspicion was a handle leak in the CD path, so the natural move was a minimal repro: a little program that opens a CD file and closes it, fifty times in a loop, printing the handle each time. It printed 5 5 5 5 5 - perfect reuse. A second repro ran as a shell child, like the installer; a third opened a file in a subdirectory, like the installer. All clean. Three repros, each copying one more circumstance of the real thing, and none of them leaked. A repro that refuses to fail is information: it means the bug lives in a variable you have not copied yet.

Rather than guess at a fourth circumstance, the next instrument went under the calls: a serial mark on every slot allocation (+N), every slot release (-N), and every error exit in the close path. The ledger told a very different story from the call trace: opens allocated, closes returned success to the program - and no release ever ran. Route markers narrowed the disappearance to one branch. The close had decided this file was written and needed its directory entry flushed; the flush failed - the CD is read-only and a CD handle has no directory entry to update, only a placeholder - and the error path bailed out before the line that frees the slot.

Why did the close think a read-only file on read-only media had been written to? Because the installer opened it with AX=3D20h - read access, deny-none sharing - and the close path tested the whole stored mode byte against zero. The share bits made the byte nonzero, and nonzero meant 'writable'. The three repros had all opened with AL=0. The discriminating variable was never what the installer did; it was one bit in how it asked.

Field noteLedger the resource, not the calls. The call trace swore everything was fine, because at the call level everything was fine. Only pairing allocations with releases exposed that closes consumed slots without returning them.

The bug

The decision 'does this file need its directory entry flushed on close?' is an access-bits question, but the code asked it of the whole mode byte. The kernel's write path had always masked correctly - writes really did require write access - which is exactly why writing files never misbehaved and the bug could sit in the close path unnoticed until a program with polite sharing habits met read-only media.

Before - the bug

    ; close_root_handle, before: the whole mode byte decides
    cmp byte [cs:si+handles+H_MODE], 0
    je .mark_free
    call flush_handle_dir_entry      ; share bits land here...
    jc .err                          ; ...the CD flush fails...
                                     ; ...and the slot never frees

The fix - anchored to current source

Mask the access bits before deciding, the same way the write path always had. With the mask in place a deny-none read closes like any read: no flush, slot freed, and Red Alert's installer paints its red Westwood setup screen where six minutes of black used to be.

src/kernel/path_dir.incNASM · 16-bit
446
448
450
454
455
456
457
461
463
close_root_handle:
mov ax, [cs:si+handles+H_DIR_LBA]
je .mark_free
mov al, [cs:si+handles+H_MODE]
and al, 7
jz .mark_free
call wf_flush_handle_dir_entry
.mark_free:
call release_handle_slot

Epilogue

When a field packs two concerns into one byte, every consumer needs the same mask - and the day you add the second concern is the day to audit all of them. DOS 3.0 added sharing bits in 1984; this bug was that audit, arriving forty-two years late.

Pinned by test_cd_share: thirty deny-none open/close cycles on a CD file must reuse a single handle slot, with every close succeeding.

A game bug that real DOS hides

The Sign-Extended Segment

The Settlers II Gold Edition (1996)

Background

Real-mode x86 addresses memory through segment:offset pairs: the linear address is segment times sixteen, plus offset. The segment is just a 16-bit number, and nothing about the architecture says it is signed or unsigned - shifted left four bits it covers the famous one megabyte either way. But the moment a protected-mode program converts a real-mode pointer to a flat 32-bit address in C, the question suddenly matters: shift a *signed* 16-bit segment like 0x9F8B left by four and the compiler sign-extends first, producing 0xFFF9F8B0 instead of 0x0009F8B0. Segments at 0x8000 and above have the sign bit set; segments below do not.

DOS/4GWDOS/4GWA DOS extender that switches games into protected mode while still using DOS for startup and file services.-era games do this conversion all the time. The game runs in protected mode, but the BIOSBIOSFirmware services available before DOS exists; it loads the boot sector and provides interrupts such as INT 13h disk I/O. and DOS still live in real modereal modeThe original 8086-compatible CPU mode where addresses are segment:offset and there is no memory protection., so the game allocates a small real-mode 'transfer buffer', asks DOS where it landed, and converts that segment to a flat pointer to read the results of real-mode calls - VESA video mode queries, for instance. Where does the buffer land? Wherever DOS's allocator puts it. Real DOSreal DOSA comparison run using MS-DOS, PC DOS, or FreeDOS instead of LainDOS to separate guest bugs from emulator bugs. uses first fit: scan the memory arena from the bottom, take the first free block that is big enough. For a freshly started program, that means low addresses - segments like 0x0Bxx - and a sign bit that is never set.

Separately: DOS does not own all 640K. The BIOSBIOSFirmware services available before DOS exists; it loads the boot sector and provides interrupts such as INT 13h disk I/O. keeps an Extended BIOSBIOSFirmware services available before DOS exists; it loads the boot sector and provides interrupts such as INT 13h disk I/O. Data Area at the very top of conventional memory and reports the boundary through INT 12h - typically 639K, not 640. The last kilobyte is where, among other things, the BIOSBIOSFirmware services available before DOS exists; it loads the boot sector and provides interrupts such as INT 13h disk I/O. PS/2 mouse services keep their state. A DOS that hands that kilobyte to programs is lending out the BIOSBIOSFirmware services available before DOS exists; it loads the boot sector and provides interrupts such as INT 13h disk I/O.'s desk.

What happened

Settlers II Gold installed cleanly from its CD and then died on launch: DOS/4GWDOS/4GWA DOS extender that switches games into protected mode while still using DOS for startup and file services. reported a divide-by-zero inside what disassembled to a VESA bank-switching routine, before the game drew a single pixel. The same binary ran fine on real MS-DOS 5 - which is the kind of fact that simultaneously clears the game and convicts the host.

The investigation

The crash math worked back to a VBE video-mode record that was all zeros - yet the kernel had verifiably filled that record in. The game was reading its mode record from somewhere other than where the kernel wrote it. The kernel wrote it to the transfer buffer at segment 0x9F8B; the game read it from flat address 0xFFF9F8B0. Sign extension. The game's conversion is simply buggy - and has been since 1996.

So why did the same binary work on real DOSreal DOSA comparison run using MS-DOS, PC DOS, or FreeDOS instead of LainDOS to separate guest bugs from emulator bugs.? Because on real DOSreal DOSA comparison run using MS-DOS, PC DOS, or FreeDOS instead of LainDOS to separate guest bugs from emulator bugs. the buffer never lands that high. LainDOS at the time carried an early allocation heuristic that biased small allocations toward the top of the arena - a leftover from bring-up days that had quietly survived because nothing had ever objected. Real DOSreal DOSA comparison run using MS-DOS, PC DOS, or FreeDOS instead of LainDOS to separate guest bugs from emulator bugs.'s first fit hands out low segments, where the sign bit cannot be set, and so the game's latent bug never fires. There was nothing to patch in the game; there was a default to make faithful in the host.

Pulling that thread surfaced a second, unambiguous kernel fault: the arena's top end was hardcoded at 640K, but INT 12h on this machine says 639K. The kernel had been handing out the EBDA - and the BIOSBIOSFirmware services available before DOS exists; it loads the boot sector and provides interrupts such as INT 13h disk I/O. mouse services were scribbling on whatever program data landed there. Two fixes, then: size the arena from the BIOSBIOSFirmware services available before DOS exists; it loads the boot sector and provides interrupts such as INT 13h disk I/O.'s own answer, and retire the high-bias heuristic in favor of plain DOS first fit. Removing the bias immediately exposed two of our own test programs that had been under-allocating overlay buffers and getting away with it because last-fit placement happened to park them harmlessly - the suite's way of collecting its toll.

Field noteWhen the same binary works on real DOSreal DOSA comparison run using MS-DOS, PC DOS, or FreeDOS instead of LainDOS to separate guest bugs from emulator bugs. and fails here, stop reading game code and start diffing environments. The difference is usually a default: where memory lands, what a field is initialized to, which way a tie breaks.

The bug

Two faithfulness gaps stacked. The arena top ignored INT 12h and lent out the BIOSBIOSFirmware services available before DOS exists; it loads the boot sector and provides interrupts such as INT 13h disk I/O.'s EBDA; the allocator biased small requests high, manufacturing segments with the sign bit set that no real DOSreal DOSA comparison run using MS-DOS, PC DOS, or FreeDOS instead of LainDOS to separate guest bugs from emulator bugs. would ever produce for a fresh program - and that one game on one afternoon finally noticed.

Before - the bug

    ; arena top, before: hardcoded to 640K -- the EBDA included
    mov ax, MEM_TOP - MCB_START - 1
    mov word [es:3], ax
    ; allocation strategy, before: small requests biased high
    cmp word [cs:am_req], SMALL_ALLOC_HIGH_MAX
    jbe .am_strat_high               ; buffers land at 0x9F8B...

The fix - anchored to current source

The arena now ends where INT 12h says conventional memory ends, and allocation is plain first fit. The Settlers II buffer lands low, its sign bit stays clear, and the game runs to its menu - its own bug intact, exactly as on the machines it shipped for.

src/kernel.asmNASM · 16-bit
202
212
213
214
215
216
217
218
219
; arena ends at the BIOS conventional-memory line, not at 640K: the
mov ax, [mem_kib]
mov cl, 6
shl ax, cl
cmp ax, MEM_TOP
jbe .arena_top_ok
mov ax, MEM_TOP
.arena_top_ok:
sub ax, MCB_START + 1

Epilogue

Era programs encode their authors' machines. A compatibility DOS does not get to choose clever defaults, because every divergence from the boring ones is a latent bug in somebody's 1996 release waiting for permission to fire. Faithfulness is not pedantry; it is the contract.

Pinned by test_memtop: the arena top must respect the INT 12h line and stay out of the EBDA; the MI2 and Settlers II smokes pin first-fit placement downstream.

Caches versus physical reality

The Disk Swap Nobody Saw

Wing Commander (1990)

Background

Floppy drives are slow, so every DOS that ever mattered cached aggressively: the FAT and the root directory live in RAM after the first read, and most file lookups never touch the hardware again. For a fixed disk this is free performance. For a floppy it is a standing bet that the disk in the drive is still the disk you read - and the user can lose that bet for you at any moment, with one hand, mid-installer.

The hardware offers a partial answer: drives assert a change line, a signal that trips when the door opens, and INT 13hINT 13hThe BIOS disk interrupt used by boot code before the DOS filesystem is available. AH=16h reads it back. But the change line only helps if somebody asks, and a kernel serving everything from cache has no reason to ask. Real DOSreal DOSA comparison run using MS-DOS, PC DOS, or FreeDOS instead of LainDOS to separate guest bugs from emulator bugs. therefore wraps it in a protocol called the media check, run before trusting cached volume data on a removable drive - softened by a famous heuristic, the 2-second rule: if the disk was verified within the last two seconds (36 timer ticks), assume it has not changed, because no human swaps floppies that fast. Every era DOS programmer eventually meets this rule; it is why rapid-fire file operations on a floppy do not spend half their time interrogating the drive.

What happened

Wing Commander's installer copies disk 1, prompts for disk 2, and waits. The disk was swapped - via the emulator's media-change command, the equivalent of an instant hand - and the installer kept reading disk 1's directory forever. A minimal repro needed no game at all: switch to A:, swap the disk, TYPE a file from the new disk. File not found. DIR listed the old disk, indefinitely.

The investigation

The previous floppy work had handled the case where a physical read hits the change line: the read fails with error 06h, the kernel remounts the volume and retries. The trap in the installer scenario is that there is no physical read. The prompt-and-swap happens while A: is the current drive, every lookup after the swap is answered from the cached FAT and root directory, and the change-line error has no read to surface through. The cache was not wrong about anything it had been told; it had simply stopped being told things.

The fix is real DOSreal DOSA comparison run using MS-DOS, PC DOS, or FreeDOS instead of LainDOS to separate guest bugs from emulator bugs.'s own move: a media check on the trust-the-cache path. Before serving cached volume state for a removable drive, check the tick count against the 2-second rule, and past it, ask AH=16h whether the disk changed. Straightforward - until QEMUQEMUThe default fast emulator for automated LainDOS builds, tests, monitor probes, and game smoke runs. added its twist. QEMUQEMUThe default fast emulator for automated LainDOS builds, tests, monitor probes, and game smoke runs.'s change line latches: once it has tripped, AH=16h answers 'changed' forever, even immediately after successful reads. Trusting that answer naively meant remounting the volume on every file operation, which discarded pending FAT state - and five write tests promptly failed. The suite was making a point: the fix had a bug.

The resolution is to treat 'changed' as a claim to verify rather than a fact: re-read the first root directory sector and compare it against the cached copy. A mismatch is a different disk - remount. A match is the same disk - or, to be honest, an identical disk, which real DOSreal DOSA comparison run using MS-DOS, PC DOS, or FreeDOS instead of LainDOS to separate guest bugs from emulator bugs. cannot distinguish either; the WC install disks are byte-identical DOS 3.x formats with no serial number, so the root sector is the only fingerprint there is.

Field noteReduce the game-shaped failure to a shell one-liner before debugging anything. And when your fix breaks five unrelated tests, be grateful: the ladder caught the fix's own bug in minutes instead of letting it ship as a slow corruption.

The bug

Cached volume data was trusted unconditionally whenever the drive had not changed letters - correct for fixed disks, and an open-ended bet for removable ones. With no physical read in the window, the swap was invisible by construction.

Before - the bug

    ; activate_drive, before: same drive means trust every cached byte
    cmp al, [cs:active_drive_num]
    je .ok                           ; no physical read, no check --
                                     ; a swapped disk stays invisible

The fix - anchored to current source

The same-drive path now runs the media check: 2-second rule first, then the BIOSBIOSFirmware services available before DOS exists; it loads the boot sector and provides interrupts such as INT 13h disk I/O. change line, then - because the emulator's change line cannot be trusted to clear - a root-sector content compare before any cached state is discarded. Swaps are seen; clean change lines stay fast; pending writes survive.

src/kernel.asmNASM · 16-bit
1236
1252
1253
1255
1257
1258
1279
1281
floppy_media_check:
sub ax, [cs:floppy_check_tick]
cmp ax, 36
mov ah, 0x16
int 0x13
cmp ah, 0x06
repe cmpsw
call floppy_media_remount

Epilogue

A cache is a claim that the world will hold still. Removable media exists specifically to break that claim, which is why real DOSreal DOSA comparison run using MS-DOS, PC DOS, or FreeDOS instead of LainDOS to separate guest bugs from emulator bugs. carried not just an API for floppies but a small institutionalized paranoia about them. Faithfulness meant copying the paranoia too.

Pinned by test_hdfloppy: the stay-on-A: swap scenario - stale data must be rejected and the new volume mounted, while the write suite proves clean change lines are still trusted.

A missing status bit runs a UI backwards

The Inverted CD Player

The Settlers II Gold Edition - CD audio

Background

CD audio in DOS games does not stream through the program. The drive plays Redbook tracks by itself, analog audio routed straight to the sound card; the program is only a remote control. The remote-control protocol is MSCDEX's device-request interface: INT 2Fh AX=1510h hands the CD driver a request header - a little packet with a command code and a status word - for commands like Play Audio, Stop Audio, and read-the-table-of-contents.

Because playback happens without the program, the program's only window into 'is music playing right now?' is that status word. The spec gives it a busy bit, 0x0200, which the driver must set in completed requests while an audio play operation is in progress. This is load-bearing in a way that is easy to underestimate: a CD player UI fires Play and then polls some request - any request - watching the busy bit to learn whether the track is still going, has finished, or never started. The data path can be perfect; if that one bit is wrong, the UI is flying on a broken instrument.

What happened

After CD audio support first landed, the user report read like a riddle: the game lists all eight soundtrack titles - so the TOC plumbing works - but selecting a track plays nothing, while the 'stop playback' button starts the music. Pressing stop again does nothing. Clicking around stops it again.

The investigation

The first useful observation came from outside the guest entirely: 86Box86BoxA PC emulator used here as a period-hardware comparison target when QEMU behavior looks suspicious.'s status-bar CD icon flipped to 'playing' the instant a track was selected. The Play command demonstrably reached the drive and the drive demonstrably obeyed - and then the game immediately told it to stop. The UI was not broken; it was acting, correctly and consistently, on wrong information.

That reframing - consistently wrong, not randomly wrong - is the tell for a bad status channel. The game fires Play, polls for the busy bit, sees it clear (because the kernel never set it), and concludes the track has already finished; so it tidies up by stopping the drive. Its internal state now says 'stopped' while reality briefly said 'playing'. Every button press thereafter toggles a state machine that is the mirror image of the hardware: 'stop' from a state that thinks it is stopped means 'start the selected track', which is why the stop button played music.

A second spec subtlety surfaced in the same pass: Stop Audio, command 133, is not a hard stop. The spec gives it pause semantics - keep a resume point so Resume Audio (136) can continue - which maps to ATAPI's PAUSE/RESUME, not its STOP. Both halves answer from the drive's own sub-channel state now, rather than from anything the kernel guesses.

Field noteA UI that misbehaves consistently is faithfully executing on one bad input. Find the instrument it flies by - here, one status bit - before debugging any of its reactions.

The bug

Every device request returned its status word with the done bit and nothing else. The busy bit the spec promises during audio playback simply did not exist, so every polling CD player concluded that every track ended the moment it began.

Before - the bug

    ; request status, before: done bit only -- never busy
.ok:
    mov ax, 0x0100
.status:
    mov [es:bx+3], ax                ; the game polls bit 9 in vain

The fix - anchored to current source

Every completed request now asks the drive, via READ SUB-CHANNEL, whether audio is in progress, and ORs the busy bit into the status word accordingly. Track selection plays, stop stops, and the jukebox in Sam & Max's compilation menu confirmed the same path from a second, unrelated program.

src/kernel/cdrom.incNASM · 16-bit
2041
2044
2046
2048
2050
cd_audio_or_busy:
call cd_audio_subchannel
mov cx, CD_BUF
cmp byte [es:1], 0x11
or ax, 0x0200

Epilogue

Specs hide load-bearing bits in their status words. You can implement every data path a protocol has and still fail it completely, if the program cannot see what the hardware is doing. The bit cost one OR instruction; its absence cost the whole feature.

Pinned by test_cd_audio pins the request/status contract in the default ladder; actual playback is verified under 86Box86BoxA PC emulator used here as a period-hardware comparison target when QEMU behavior looks suspicious., where the mixed-mode cue/bin can mount.

Every clue real, every clue downstream

The Bug Was in the Test

Red Alert bring-up - a 'kernel CD hang' that wasn't

Background

A .COM file is the simplest executable format ever shipped: no header, no relocations, no metadata - the file is memory, loaded at offset 0x100, run. Crucially, it also has no BSS section, no way to declare 'I need this much scratch space beyond my file'. So DOS supplies a convention instead: a .COM gets the largest free block of memory, usually all of it, with the stack pointer parked at the top of the segment (0xFFFE) and the zero word at [SP]. Era programs lean on this hard - their buffers simply live past the end of the image, in space the format gives them no way to ask for. The flip side of the convention is the shrink: a program that wants to spawn a child must first give memory back with AH=4Ah, which is why every well-written .COM of the era carries a little resize prologue.

One geometric detail matters here: the x86 stack always grows downward. 'SP at the top of the segment' describes where the stack starts, not which way it moves - pushes walk it toward lower addresses, toward the program's code and data. So a running .COM is two things approaching each other from opposite ends of one room: buffers grow upward from the image's end, the stack grows downward from wherever SP began, and the convention works because a 64 KiB room leaves them some 58 KiB of gap.

LainDOS's boot launcher - the path that starts the test program named at build time - predated all of this nuance. It allocated a block sized to the file plus a few kilobytes of slack - placed high in the arena, just under the BIOSBIOSFirmware services available before DOS exists; it loads the boot sector and provides interrupts such as INT 13h disk I/O.'s own work area, which matters later - and the COM entry convention then started SP at the top of that small block, only a few kilobytes above the program's own data. The two-ends-of-the-room layout still held, but the room had shrunk to a closet: every boot-launched test program in the suite had its downward-growing stack parked a few kilobytes from its upward-growing buffers without knowing it.

What happened

While chasing Red Alert's installer, a new CD stress test - read a 200 KB file in adversarial chunk sizes and verify every byte - hung the machine. Deterministically, and with surgical specificity: a 6144-byte read at offset 12287 hung; a 4096-byte read at the same offset worked; an aligned 6144-byte read worked. The kernel's CD path was the obvious suspect, and the debugger dumps were damning: a corrupted disk-address packet, BIOSBIOSFirmware services available before DOS exists; it loads the boot sector and provides interrupts such as INT 13h disk I/O. work areas full of garbage, and an INT 21hINT 21hThe main DOS API interrupt. Programs select services such as open, read, EXEC, and exit with AH. frame that appeared to have been invoked from inside the BIOSBIOSFirmware services available before DOS exists; it loads the boot sector and provides interrupts such as INT 13h disk I/O. itself.

The investigation

Hardware watchpoints under QEMUQEMUThe default fast emulator for automated LainDOS builds, tests, monitor probes, and game smoke runs.'s TCGTCGQEMU's Tiny Code Generator CPU backend, used when running guests without hardware virtualization. slow the guest a hundredfold, so the productive instrument turned out to be old-fashioned: serial markers compiled into the kernel at each stage of the read path. They exonerated everything, one stage at a time. The read completed; every byte landed; the stack was balanced to the word; the syscall's exit path was reached; the final IRET executed - and control never arrived back in the program. The return frame the IRET consumed had been overwritten while it waited on the stack.

The 'BIOSBIOSFirmware services available before DOS exists; it loads the boot sector and provides interrupts such as INT 13h disk I/O. context' values in the overwritten frame - a stack segment of 0x9E90, suspiciously like SeaBIOS's work area - sent the hunt through the BIOSBIOSFirmware services available before DOS exists; it loads the boot sector and provides interrupts such as INT 13h disk I/O., the extended BIOSBIOSFirmware services available before DOS exists; it loads the boot sector and provides interrupts such as INT 13h disk I/O. data area, A20 wrap theories, and interrupt-vector archaeology. All of it was real evidence, and all of it was wreckage rather than cause. The decisive datum cost one instruction: make the test program print its own SS:SP. It really was running with its stack at 0x9E90:12FC - not because anything had corrupted it, but because that is where the boot launcher had legitimately put it: top of the arena, file-sized block, SP at the block top a few KiB past the code.

From there the arithmetic closed the case. The test kept a 30 KB read buffer inside its own image - perfectly legal on real DOSreal DOSA comparison run using MS-DOS, PC DOS, or FreeDOS instead of LainDOS to separate guest bugs from emulator bugs., where a .COM owns its segment. Under the cramped boot block, buffer-plus-image overran the block top where SP lived. Three read iterations stopped just short of the saved INT 21hINT 21hThe main DOS API interrupt. Programs select services such as open, read, EXEC, and exit with AH. return frame; the fourth crossed it. The kernel returned, faithfully, into bytes of pattern data. Everything the debugger had shown - the trashed packet, the BIOSBIOSFirmware services available before DOS exists; it loads the boot sector and provides interrupts such as INT 13h disk I/O. garbage, execution wandering through high memory - was the machine's long tumble after that one bad landing.

Field noteWhen the evidence keeps exonerating suspects, audit the premises instead: print what the program itself sees - its own SS:SP - rather than inferring it. One raw serial write from inside the test settled what hours of kernel forensics could not.

The bug

Not the CD path at all. The boot launcher violated the .COM memory contract - largest free block, SP at 0xFFFE - and the test program, written to that contract like any era program, overwrote its own stack. A test that breaks an unwritten platform assumption produces evidence indistinguishable from a kernel bug.

Before - the bug

    ; boot launcher, before: a file-sized block at the arena top
.alloc_com:
    mov bx, [cs:prog_par]            ; image size plus ~4 KiB slack
    call alloc_mem_direct_high       ; parked just under the EBDA,
                                     ; SP at the cramped block top

The fix - anchored to current source

Make the assumption hold, as the real platform did: both the boot launcher and EXEC now grant a .COM the largest free block, with the DOS-exact entry stack. Twenty-five test programs gained the canonical shrink prologue that real programs always carried. This fixed the test corruption; EXEPACK binaries that still need a 1000h-or-above load segment use the bundled LOADFIX path, just as DOS 5 did.

src/kernel/exec.incNASM · 16-bit
240
245
246
248
.com_largest:
call find_largest_free_block
cmp bx, [cs:prog_par]
mov [cs:prog_par], bx

Epilogue

Tests run inside the same contracts as programs, and a violated contract does not announce itself - it manufactures convincing evidence against innocent code. The humbling part is that the fix was not 'repair the test': it was to make the unwritten assumption true, because on every machine these programs were written for, it always had been.

Pinned by test_boot_mem: a boot-launched COM owns a 64 KiB-plus block with SP at 0xFFFE and the zero word at [SP].