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

CHAPTER 4 · KERNEL FORENSICS

KERNEL FORENSICS

The screen was black, the logs had gone quiet, and Windows 2000's installer had stopped mid-thought somewhere inside Microsoft's kernel. What followed was forensics in the proper sense: a debugger transport proved with a 79-byte DOS program, a 128 MiB dump of the sleeping machine, and a purpose-built census tool that counted 71 threads — every one of them waiting, none of them, apparently, wakeable. It was all correct. And it led everyone to the wrong conclusion.

5,757 B
one all-black 640×480 PNG, byte-identical across four days of captures — "the wall"
79 B
comtx.com, the DOS program written to prove the serial debug transport, byte by byte
161
valid KD_STATE_CHANGE64 packets from Windows 2000's own kernel-debug stub, via the emulated 16550
128 MiB
hang.bin — the sleeping machine's entire RAM, preserved 23:12 on 14 July, SHA-256 logged
71 / 0
threads found by the census / threads runnable — every one of them Waiting
≈18.5/s
protected-mode stack transitions during a 150 s "zero-runnable" window — the machine was not asleep

The wall you could weigh

Chapter 2 ended with a restart loop; chapter 3 ended with real interrupt fixes and a wall that had moved downstream to meet them. By mid-July the failure had changed character entirely: the installer running inside mddosem no longer failed loudly. It simply stopped. Every capture run ended the same way — a black 640×480 frame. Not approximately black: the automated screenshot rig wrote the same PNG file over and over, exactly 5,757 bytes, byte-for-byte identical, across four days of captures. Every progress-*.png, every sample-*.png; days later, even the first attempt to launch FreeCell on the freshly installed system produced the identical 5,757 bytes. The wall was the only exhibit in the archive you could identify by weight.

A black screen is an absence of information; a byte-identical black screen is a measurement. It said, precisely, that nothing on the display side had changed at all — and that whatever the machine was doing, it was doing it in the dark. Getting at it would take instruments, and the instruments escalated.

The escalation ladder

An emulator is the best debugger there is, because it is also the hardware. mddosem can pause its guest between any two instructions and read anything — registers, memory, device state — without the guest's consent or knowledge. That power is exposed through a control pipe (--ctl-pipe): a named pipe on the host accepting commands — pause, step, read registers, read memory. The first instruments were hand-rolled Python scripts speaking that pipe — dbg.py, dbgstack.py, dbgbytes.py — a debugger assembled in an afternoon, one command at a time. Here it is diagnosing an earlier freeze in NTLDR, Windows' boot loader, live:

# dbg.py over the ctl-pipe — the NTLDR freeze, held still (FINDINGS.md, 13 July)
CS=0x08 EIP=0x408E ESP=0x540 FLAGS: IF=0
STEP does NOT advance EIP (pinned at 0x408E) => exception/fault loop, not a spin
43 byte-identical frames, ~135 s guest

That is a diagnosis no screenshot could make: the instruction pointer pinned, interrupts off, single-step refusing to advance — a CPU re-entering the same fault forever, distinguishable from an ordinary busy-loop only when you can hold the machine still and poke it. The pipe scaled up from there. When runs started dying in blue screens, the harness polled the kernel's own confession: NT records every crash's stop code and parameters in a kernel variable, KiBugCheckData, before it paints the screen, and polling it live caught a STOP 0x1E within about four seconds of it happening. When live inspection was not enough, a RAMDUMP command froze the guest's entire physical memory to disk, and offline scanners walked the registry's hive cells in the captured images. And when all of that still watched from outside the kernel's own machinery, there was one instrument left — the one Microsoft built.

A kernel debugger from spare parts

Every Windows NT kernel ships with a debug stub. Boot it with /debug /debugport=com1 /baudrate=19200 in boot.ini and KDCOM, the kernel-debug serial transport, speaks a packet protocol out of COM1, letting a second machine stop the kernel, walk its structures, and name its threads. Inside an emulator the "serial port" is the emulated 16550 UART and the "second machine" is the host, so the transport chain became: WinDbg ⇄ a named pipe ⇄ a relay ⇄ TCP ⇄ the emulated COM1.

It was proven link by link, from the guest outward. The first witness was comtx.com — a 79-byte DOS program, written in assembler for the occasion, which programs the UART at port 0x3F8 and transmits KDTEST-COM1- forever. The host capture received 57,960 bytes of the clean repeating string, and under load the transport measured effectively lossless: 4,139 clean records, 14 stray bytes — 0.024% — and exactly one resync, at the connection join.

Then a human-shaped blocker. boot.ini lives on an NTFS volume, and no headless tool on the bench could edit it: mddskimg, the fleet's own disk-image inspector, falsely reported "NTFS directory loop detected" and refused. Arthur fixed mddskimg's NTFS reader the same day (v0.57.5 → v0.57.6); the patch itself went in through an ntfs-3g loop mount, and the /debug-patched disk went back into the emulator. What came out was quietly historic for this project: Windows 2000's own KDCOM emitted 161 valid KD_STATE_CHANGE64 packets through the emulated UART — correct leaders, correct types, payloads carrying genuine ntoskrnl code:

# emulated 16550 → host capture, 14 July — the guest's own kernel debugger, talking
leader 30 30 30 30 type 07 00 (KD_STATE_CHANGE64) ByteCount bc 03
payload 8b 44 24 04 cc c2 04 00 → mov eax,[esp+4] · int3 · ret 4
161 valid packets tx_total=156520 rx_total=0 — the kernel calling out; nobody yet answering

Later that night the answering side arrived too: kd.exe, the command-line debugger, was dug out of the WinDbg Preview Store package and completed the full KDCOM handshake, scripted. And then the anticlimax. Symbols are the map from raw addresses to names — and Microsoft's symbol server no longer carries Windows 2000. The SP4 CD ships none. Without symbols, !process and !thread are blind: the debugger connected perfectly to a kernel it could no longer name. So the team kept the transport as a proof, and pivoted to something better suited to an emulator anyway — a debugger that needs no symbols at all.

The dump and the census

At 23:12 on 14 July the hung machine's entire physical memory — exactly 128 MiB, 134,217,728 bytes — was preserved as hang.bin, its SHA-256 recorded beside it. Everything an operating system knows, it knows in RAM: the process list, every thread, every wait queue, every object name. A memory dump is the whole crime scene. What you need is a map of the rooms.

The map came as a tool: w2kcensus, a purpose-built forensic walker checked into the emulator's own source tree — initially 1,172 lines of paranoid Rust. It refuses to run unless the kernel in front of it fingerprints as exactly the SP4 build — ntoskrnl 5.00.2195.6611, PE timestamp 0x3EE6C002. It finds the process list the way a debugger would — resolve PsInitialSystemProcess from the kernel's export table, then walk the EPROCESS list — but it validates every hard-coded structure offset by reciprocity: doubly-linked lists must close (follow Flink forward and Blink back, and you must land where you began); a thread's wait blocks must point back at their thread; pool allocations must carry the right tags. It walks 4 KiB and 4 MiB pages, reads object names only through the kernel's own OBJECT_HEADER_NAME_INFO records, and works both live over the control pipe and offline over the dump — the offline run reproduced the live findings exactly.

The paranoia paid immediately, because the published literature was wrong. The standard reference for NT structure offsets — the Geoff Chappell "3.51–5.1" tables — places KTHREAD.WaitBlockList at 0x5C. In this kernel it is at 0x58; that four-byte error "was why earlier wait-object reads were garbage". ThreadListEntry likewise: 0x1A4, not the published 0x110.

The census returned a result of eerie clarity: 8 processes, 71 non-idle threads, and every single one of them Waiting. Zero ready. Zero running. The CPU parked in the kernel's idle loop at 0x800695A2, executing HLT, with only the timer interrupt firing. And setup.exe — PID 352, the process this whole campaign existed to shepherd — had exactly three threads, each blocked on its own anonymous, unsignalled synchronisation event, their kernel stacks swapped out to disk, something NT does only to a thread it expects to sleep for a long time. The verdict came from the live census, recorded at 06:38 the next morning, and wrote itself: "a genuine whole-system lost-wakeup deadlock: everyone blocked, nothing to wake them." Later that day w2kcensus reproduced it exactly, offline, over hang.bin (address space CR3 0x00030000).

THE CENSUS EXPLORER — 71 THREADS, ALL WAITING click a process · setup.exe expands to its three terminal waits
Recreation of w2kcensus output over hang.bin (134,217,728 B, taken 2026-07-14 23:12) with CR3 0x00030000 — thread counts, addresses and wait pairs as recorded in the 2026.07.15 endgame master journal. Tool source: src/bin/w2kcensus.rs in mddosem.

The retractions

Forensic instruments lie, and the record of this week is unusually honest about catching them. Three corrections, all self-inflicted and all found by the team's own cross-checks:

And the instrument's heaviest confession: READ_MEM, the live memory-read command, was found to be disturbing the evidence — setting Accessed bits in the guest's page tables, populating its TLB, perturbing pending page-fault state. It was rebuilt as a genuinely side-effect-free supervisor page walk, and the pause command was made atomic so the watcher could never race a human's pause. One rejected snapshot even caught Windows mid-update of a wait block — a pointer transiently aimed into a stack page — exactly the kind of half-written state you want a tool to reject rather than report. The same discipline killed hypotheses, too: the week's leading rival theory — that the emulator was coalescing two interrupt completions into one — was refuted not by argument but by a counter, a log-only probe recording 0 coalescing events across 291 FLUSH CACHE commands and 15,418 disk write-commits. Measurement over belief, every time.

The observer effect

Live censuses came with a cost nobody had priced in. Capture 06 sampled the machine every second — and the sampling consumed roughly 900 of the run's 3,000 wall-seconds. Worse, the emulator's adaptive scheduler, which tunes guest speed against real time, read the watcher's stalls as its own slowness and dragged itself from 65,000 cycles per millisecond down to the low hundreds. The result was 1,602 flawless censuses of a machine that never hit the wall — because the machine under observation was no longer the machine that failed. Capture 07 relaxed the cadence to five seconds and logged errors only: the guest ran true, and the watcher's time bound expired seconds before the terminal transition it existed to catch. The very next census found the wall fully formed — kernel stacks non-resident, cycles_per_ms=65000, halted=true. Two runs, one lesson Heisenberg could have countersigned.

THE OBSERVER EFFECT — TWO CADENCES, TWO MACHINES toggle the capture · schematic drawn from the recorded numbers
guest wall-clock watcher / census sampling terminal transition
view
Schematic, not a plot — proportions and endpoints are the recorded numbers (endgame journal, 2026.07.15): capture 06 spent ≈900 of 3,000 wall-seconds in 1 s census sampling and dragged the adaptive scheduler 65,000 → low hundreds cycles/ms, never reaching the wall; capture 07 logged 691 censuses over its 3,750 s bound and expired seconds before the transition. Sampling is interleaved in reality; the stripes are illustrative.

The consulting diagnostician

Threaded through the week was a third kind of instrument: an outside mind. Fable 5 — a consulting model engaged for finite, expensive reasoning sessions — was handed the code and the evidence, and its findings were treated exactly as the census's were: every load-bearing claim independently re-verified against source before being written down. Its root-cause analysis ranked the field (a storage-path wedge at ~55%, a genuine Windows race made probable by emulator timing at ~20%, a lost timer wakeup at ~10%) and flagged the strangest numbers of the night: the DSS cryptography providers loading pathologically slowly — dssbase in 28.7 seconds, dssenh in 28.5, against three to four for their RSA siblings. Its advice on that ambiguity became a house rule: "Don't bet — pause inside the 28 s window and read the stack."

The machine that was not asleep

Which leaves the paradox the census could describe but never resolve. All the instruments agreed: 71 threads, all waiting; zero runnable; the CPU halted in the idle loop. A dead calm. And yet — during one 150-second stretch of that "zero-runnable" state in a later run (capture 09), the emulator counted 2,789 protected-mode stack transitions: about 18.5 every second. A stack transition is what happens when an interrupt or exception crosses a privilege boundary — hardware still delivering, the kernel still fielding its timer ticks, looking around, finding no one worth waking, and lying back down. The machine was not asleep. It was awake, attentive, and waiting, with the composure of something that knows exactly what it is waiting for.

The census could count the sleepers to the last thread. What it could not say was what would wake them — because the answer was not in the dump at all. It was sitting in plain sight, in the dark, one keystroke away.

Sources for this chapter — the KD-over-serial bring-up journal (2026.07.14), the thread-census journal and the endgame master journal (2026.07.15), the Fable 5 root-cause consult HLD (2026.07.14), the bug ledger MDD-BUG-KILN-00004, and the preserved run archive (hang.bin, the capture-04→29 black frames, FINDINGS.md). The census tool is src/bin/w2kcensus.rs in mddosem. Full evidence table: how we know.