how a from-scratch PC emulator learned to boot Windows 2000

CHAPTER 6 · THE CPU

ONE INSTRUCTION AT A TIME

Chapters 2 to 5 cleared away everything that was not a bug — the red herrings, a census read perfectly and misread completely, a dialog nobody could see. What remained were four places where the emulator’s idea of an Intel PC differed from silicon by one bit or one instruction. This chapter is those four mechanisms: an interrupt shadow flattened by batching, a timer parked for a caller that never comes, a status bit that never latched, and a four-megabyte page mistaken for a table of smaller ones.

1
instruction — the exact length of the STI shadow; after the fix, the scheduler owns exactly one protected successor
16
instructions — the whole grant — a single batch retired through a POPFD-opened recognition window; the legal count is 1
0xE3
the live PDE value: Present + PS — a 4 MiB page the I/O-bitmap walker misread as a page-table pointer
22 min
from Fix 4 landing (03:05, 19 July) to boot-to-desktop acceptance being recorded (03:27)

Four defects, one desktop

When the invisible dialogs of chapter 5 were finally answered — CD in the drive, the product key entered properly — native Windows 2000 setup ran its full course inside mddosem for the first time. That it could was down to four corrections the acceptance record credits by name — three of them landed with fail-first regressions, the fourth under adversarial review — each a statement about what real hardware does rather than what Windows wants. None is Windows-specific; all four were in the interpreter-only binary that finally booted (the dynamic recompiler sat this campaign out). Three landed in one night, 15–16 July; the fourth at five past three in the morning of the 19th, twenty-two minutes before acceptance was recorded.

The four wear the panes of the flag throughout this site. Fixes 1, 2 and 4 are this chapter’s subject in depth. Fix 3 belongs to the interrupt wiring of chapter 3 and gets a summary here.

Fix 1 — the one-instruction shadow

An x86 processor checks for pending interrupts at every instruction boundary — that is the contract. The IF flag gates it: CLI closes the door, STI opens it. But STI has a peculiar clause, present since the 8086: interrupts become recognisable not after STI itself, but after the instruction that follows it. That one-instruction “shadow” exists so software can compose two instructions atomically. STI; HLT — enable interrupts and halt, with no gap between them into which a wake-up could fall and be consumed, leaving the machine asleep forever. STI; RET — re-enable and return before the next interrupt piles a frame onto the same stack. The same one-instruction grace covers MOV SS, POP SS and LSS, so a stack switch — segment then pointer — cannot be split down the middle. And one instruction restores IF with no shadow at all: POPF/POPFD, which makes recognition legal at the very next boundary.

mddosem’s interpreter, for speed, runs the guest in grants of up to sixteen instructions between visits to the scheduler — and the grant machinery had flattened the shadow’s edges. The regression suite caught three failure shapes red-handed: STI; DEC ECX; CLI retired three instructions when silicon would deliver the pending interrupt after two; POPFD; CLI retired the entire sixteen-instruction grant when the legal count is one; and STI applied its shadow twice, protecting a second successor no manual describes. Each shape has the same lethal consequence: a following CLI could close the only boundary at which a pending interrupt was ever allowed to be recognised.

Why did Windows 2000, of all guests, die of this? Because NT’s ubiquitous critical-section shape is PUSHFD; CLI; …; POPFD — save the flags, close interrupts, do the work, restore. Kernel, HAL and drivers execute it constantly. If the interpreter never opens a poll window between the IF-restoring POPFD and the next CLI, completion interrupts die in the gap — and a lost completion is a lost wake-up for exactly the machinery chapter 4’s census stared at: the kernel-stack inswap that would have brought setup’s threads back from their long sleep. A DOS game polls; NT waits. Invent an interrupt-free window no silicon ever had, and NT is the guest that will park in it.

The fix gives the boundary a name. The interpreter now returns an explicit result, RunResult::InterruptPollBoundary, after STI, MOV SS, POP SS, LSS, or any POPF/POPFD that turns IF on — and the scheduler owns exactly one shadow-protected successor, run under a budget of one, never two. Inner and outer scheduling share a single readiness predicate, pending_interrupt_delivery_ready(), which also counts a byte already sitting in the keyboard controller’s output buffer as deliverable — visible 8042 state matters even before IRQ1 materialises in the PIC. At landing, 2,511 mddosem-interp tests were green; by acceptance, 2,512.

THE SHADOW STEPPER — WHERE RECOGNITION IS LEGAL ⏮ ⏭ step · ← → keys · toggle before/after
Mechanism, red witnesses and fix from the interrupt-recognition commit (399d34b3b, 15 July 23:04): RunResult::InterruptPollBoundary, one protected successor, one shared pending_interrupt_delivery_ready() predicate including visible 8042 output. Validation at landing: 2,511 mddosem-interp tests. Ledger: MDD-BUG-KILN-00004.

Fix 2 — the parked timer

The second defect was quieter. In its default configuration mddosem services BIOS interrupts in Rust — high-level emulation, HLE — and one of its optimisations defers the bookkeeping of the 8254 timer’s channel-0 counter, leaving the HLE timer path to commit the deferred track when it next runs. Under --real-boot (chapter 1), there is no “next runs”: hardware interrupts vector through the guest’s own IDT, the HLE timer path never executes, and the deferred commit never comes. The 8254’s counter simply froze between interrupts.

Windows 2000 noticed because it reads that counter. On the Standard PC HAL — the one this machine’s ACPI-less firmware selects — QueryPerformanceCounter is the 8254, frequency 1,193,182 Hz. The kernel’s timekeeping and the scheduler’s wake path consult PIT state; a counter that only moves at interrupt time starves everything paced between ticks. The fix is a one-condition answer: can_defer_pit_channel0_track() returns no under native boot, so channel 0 advances immediately, while the DOS and DPMI HLE paths keep their deferral untouched. The fail-first regression caught the parked state in the act:

Nine focused channel-0 regressions pin the behaviour (test_on_pit_channel0_event_real_boot_never_defers_hle_counter_track among them). A real 8254 decrements on every input clock no matter who handles the interrupt; there is no “defer until the BIOS looks at me” mode on any board ever soldered.

Fix 3 — the silent status bit

The third correction lives on the wires, and chapter 3 owns its mechanism; here is its shape. A real PIIX3 IDE controller latches its per-channel BMISTA Interrupt status bit the moment the drive raises INTRQ — during DMA or not. mddosem raised IRQ14 with BMISTA reading zero (and the secondary channel’s status register hardwired to zero), so a PCI IDE interrupt handler could look down at the controller, see “no channel interrupt”, and dismiss its own IRQ as not-mine. The fix latches the bit at assertion time — independent review proved that latching at the end of the scheduling quantum was still too late, because the guest can execute another I/O instruction in the same batch and observe stale status; the latch must be visible before the I/O instruction returns. Landed 16 July at 01:09 (e0e9fe9a5), with 5,544 mddosem-hw tests green.

Fix 4 — the four-megabyte page

The last fix needs three pieces of background, each brief. First, paging: a 386-class processor translates addresses through two levels of tables. CR3 points at a page directory; the top ten bits of a linear address select a page-directory entry (PDE), which points at a page table; the next ten bits select a page-table entry, which names a 4 KiB page. Second, PSE: later processors added page-size extension — set the PS bit in a PDE (with CR4.PSE enabled) and there is no second level at all; that one entry is a 4 MiB page. Windows 2000 maps its kernel — and with it the TSS — through exactly one such page. Third, the I/O-permission bitmap: when the HAL needs the VGA BIOS (a real-mode citizen) it runs it in V86 mode, and every port instruction executed in V86 mode is checked against a bitmap held in the TSS — one bit per port, fetched by the CPU itself mid-instruction. The interpreter performs that fetch with a hand-rolled supervisor page walk (iopm_walk_supervisor), because it cannot take the ordinary fault path halfway through a permission check.

That hand-rolled walker checked the PDE’s Present bit — and ignored PS. Confronted with W2K’s kernel mapping, a PDE whose live value was 0xE3 (Present, writable, PS=1), it treated the 4 MiB page’s base address as a page-table pointer, fetched a “PTE” from whatever bytes happened to live inside the mapped frame, read it as not-present, and folded to “port denied”. The port in question, 0x402, was one the guest’s bitmap explicitly allows (io_map_base 0x88, bitmap byte 0x00, TSS limit 0x20AB). The result was a spurious #GP(0) on the HAL’s own V86 OUT — and the Standard PC HAL’s V86 monitor handles INT-reflection only; anything else it treats as impossible and asserts. Bluescreen: STOP 0x1E KMODE_EXCEPTION_NOT_HANDLED. One ignored bit, in one implicit walk, ends the boot.

The fix is a single masked OR, mirroring what the real translator had done all along: if CR4.PSE and the PDE’s PS bit are set, physical = (pde & 0xFFC0_0000) | (linear & 0x003F_FFFF) — walk over. The bug had sat latent for years because DOS extenders map everything with 4 KiB pages; Windows 2000’s large kernel page was the first workload in the emulator’s life to send the IOPM walker through a PSE mapping.

THE PAGE WALK — ONE BIT, TWO WORLDS ⏮ ⏭ step · ← → keys · toggle the walker
PDE value 0xE3, io_map_base 0x88, TSS limit 0x20AB and the port-0x402 fault chain from the live diagnosis; the fix mirrors the real translator (translate_paging_checked). Landed 13 July (Claude, HUM lineage) and independently re-derived 19 July 03:05 (Codex, fe28f3aa0) — 22 minutes before acceptance.

Necessary, not sufficient

The fixes did not land into applause; they landed into walls. Fix 1 went in at 23:04 on 15 July; capture 08b, running a release binary carrying it, hit the identical black terminal state — setup flipping from resident to fully out-swapped between one watcher sample and the next. Fix 2 went in at 23:30; capture 09, on a binary that identified itself as 26915729b, walled the same way — while proving, via 2,789 protected-mode stack transitions in a 150-second “zero-runnable” window, that the machine was awake the whole time (chapter 4). Fix 3 went in at 01:09; capture 11 walled again. Three fixes, each provably real, each apparently insufficient — a drumbeat that would demoralise any lab.

Then chapter 5 happened, and the drumbeat was recast: captures 03 through 19 had been running with no CD in the drive. Those retests were never going to finish setup, however correct the CPU became — Windows was standing at an invisible dialog, politely asking for media the harness had removed. So the “failed retests” decided nothing about sufficiency. What stands is this: the fixes carry fail-first red witnesses or review-proven latch timing, refusing to pass on the old code; and the clean end-to-end install — media in, key in, all four corrections in the binary — exercised every one of them. The acceptance record credits exactly these four, none sufficient alone. There was no single ribbon-cutting bug at the bottom of Windows 2000; there was a machine that had to be right in four unrelated places at once.

Which is, in the end, the argument of the whole project: emulate the hardware, not the guest. Not one of the four fixes mentions Windows. An interrupt shadow honoured to the instruction, a timer that counts whether or not anyone is watching, a status bit latched the moment the wire rises, a page walk that believes the PS bit — every one is a statement about silicon, checkable against datasheets and rival emulators, useful to every future guest. Windows 2000 was simply the first tenant demanding enough to notice. What happened when the last of the four landed — the final night, minute by minute — is chapter 7.

Sources for this chapter — the four corrections as credited by the acceptance record (399d34b3b, 26915729b, e0e9fe9a5, fe28f3aa0) and their commit messages; the goal journal (wrk_journals, 2026.07.15) for the red witnesses and capture verdicts; bug ledger MDD-BUG-KILN-00004. Full evidence table: how we know.