SimCity 2000 Win95 Demo (simdemo.exe)

Registry id simcity2000_demo, local-only (test/binaries/candidates/simcity-2000-demo/installed/). Maxis/MFC, MDI frame + one MDI child. Loads at the usual 0x400000, so a runtime EIP is the original VA unchanged.

Reaching the city headlessly:

node test/run.js --no-build --app=simcity2000_demo --max-batches=60000 \
  --max-seconds=60 --no-close --quiet-api \
  --input=25:dlg-cmd:1,2000:mousedown:215:93,2050:mouseup:215:93 --png=/tmp/sc2k.png

25:dlg-cmd:1 answers the startup "Video Warning" box; the click at 215,93 is the "Load Demo City" button on the launcher window. A few thousand batches later a demo notice ("I will now demonstrate some of the Disasters") appears over a live, simulating map.

Functions identified

VA What it is
0x00478c77 startup display check — GetDeviceCaps RASTERCAPS/BITSPIXEL/PLANES, then the Video Warning
0x004a87af registry-backed settings read (RegQueryValueEx through [0x4d7760])
0x0049b7ab matching settings write
0x004a4977 MFC MRU scan: walk an HMENU for a command id in 0xE130..0xE13F

"You're not running in 256 colors" is correct, and one-shot

0x00478c77 reads GetDeviceCaps(hdc, 12) BITSPIXEL into EDI, (hdc, 14) PLANES into EBX and (hdc, 38) RASTERCAPS masked with RC_PALETTE (0x100). We report 32 / 1 / 15033 — a true-colour display, which is what the emulator actually presents — so the cmp edi, 8 at 0x478cde fails and the warning path runs. It is not a failure path: the code at 0x478cff reads HKCU\Software\Maxis\SimCity 2000 Win95 Demo\Windows\Last Color Depth, warns only when the stored depth differs from the current one, and writes the current depth back. So the box appears once per fresh registry and never again, exactly as it would on real hardware after a resolution change. Nothing about the game is degraded by it; the animations it mentions are palette animations a 32bpp display has no equivalent for.

"Load Demo City" used to hang: detached HMENU (fixed)

0x004a4977 is MFC's scan for the MRU id block:

esi = GetMenuItemCount(hMenu) - 1
if (!count) return
loop: edi = GetSubMenu(hMenu, esi)
      if (edi) for (ebp = 0; ebp < GetMenuItemCount(edi); ebp++)
                   if (0xE130 <= GetMenuItemID(edi, ebp) <= 0xE13F) return found
      eax = esi; esi--; if (eax) goto loop

hMenu is 0x00BE0003 — MFC's CMultiDocTemplate::m_hMenuShared, loaded by LoadMenuA(0x400000, 3) and never attached to a window. Our menu model kept every blob in MENU_DATA_TABLE[slot] and resolved a handle by finding the window that owns it, so GetMenuItemCount answered -1 for this one. -1 - 1 is -2, test eax, eax is nonzero, and the loop counts down through four billion iterations.

Measured before the fix (--handler-hist --handler-hist-thread=0 --handler-hist-start=2060): the three blocks 0x004a4993 / 0x004a499e / 0x004a49ce take 21,333,15x entries each, 31.17% apiece — 93% of all work in the run, with 23M Win32 calls against ~5 per batch before the click. [sync] ABANDONED wndproc hwnd=0x1003c msg=0x222 ... after 64 rounds in the same log is the same event seen from the other side: $wnd_send_message gave the MDI child's WM_MDIACTIVATE 64 × 1,000,000 steps and gave up.

Fixed in src/09c5-menu.wat by materializing an unattached LoadMenu handle as an ordinary dynamic (MNUD) menu built from its RT_MENU template, cached by resource id ($menu_detached_handle). Afterwards: 711k API calls and 60,000 batches in 14.8s where the same route was 23M calls and 134k batches in 71s, and the map draws. Covered by test/test-detached-menu-handle.js.

City simulation: where the CPU goes (measured 2026-09-21)

Window: batches 8000-20000 of the recipe above (game date Mar -> Jul 1982), --handler-hist --handler-hist-thread=0 --handler-hist-start=8000 --hot-block-dump= plus --quiet-blocks (without it the per-batch register line is 45% of the process). Counts, not timings:

region (share of block entries) what it is
0x45ad35 fn, loop 0x45b021 (18.2%) per-tile score: two 4-compare bounds ladders, tile-bit tests via row tables 0x4b50f0/0x4b3710, one if/else join jmp
0x448e20 (12.6%) neighbour walk: call 0x40171c per neighbour, then a tile-type range ladder (cmp ax,lo / jb / cmp ax,hi / jb)
0x454f3e (10.8%) 128x128 pass clearing flag bits 0x10/0x08, four bounds checks per tile per bit
0x458117 (8.3%) 128x128 bit-mask pass then tile-type tests

0x40171c is jmp 0x423a11: the exe is incrementally linked, 1624 e9 thunks at 0x401000..0x402fb8, and thunk blocks are 1.95% of all block entries in the window.

Two decoder costs this code shape exposes (counts from the same window):

Wasm self-time split (30000-batch run, tools/dispatch-attribution.js): $branch_end_at 9.4% (the desk, including its successor dispatch), the specialised Jcc handlers 10.9%, $get_of 2.1%, GDI/controls ~17% (font face string compares on every text out, $gdi_bitmap_font_face_equal 2.0%, and $ctrl_get_wh_packed 1.8%).

What those three leads are worth (judged 2026-09-21)

App win = per-event saving × the event's share. Worked out for each lead before spending the quiet box on it:

What is left is the 16-bit ALU / compare-ladder code itself: alu_r16_i16 → Jcc is the top pair at 3.7% of ops. A fold for that shape is the only lead here that could clear the null band.

"Zones never develop" was the city, not the emulator (2026-09-21)

A hand-built test city sat for a year of game time with every zone empty. Hit counters on the monthly zone pass (the function holding call site 0x42a465) showed why, and each rejection was the game's own rule:

A second city laid out to those rules (zones ≤3 tiles from a road, a power line touching every block) developed within four sim months and kept growing. The budget, too, behaves: taxes accrue and are paid in January, so funds are flat for eleven months of the year. Useful layers besides XBIT: XBLD 0x4b50f0 (building id; 0x1d..0x2b roads, 0x0e..0x1c lines, 0xcf coal plant), XZON 0x4b2df0 (low nibble zone type, 0x10..0x80 building corner bits), XTER 0x4b2b58 (low nibble slope; 0 = flat). All are 128-entry row-pointer tables indexed [table + y*4] + x.

Driving it headlessly, three traps:

Third city (2050 start, 2026-09-21): more UI gaps and workarounds

Fourth city (1900 start, 2026-09-22): an 18-year timelapse

Played from 1900 to the demo's end (Apr 1919) in the frozen control VM (run.js --control=PORT --frozen, driven by tools/ctl.js) with record on. Three emulator bugs turned up and were fixed:

What looked like a sim bug and was not: by 1909 about 60% of zoned tiles were XBLD 0x8a/0x8b. The building names are string-table entries starting at 612 for id 0x70, so 0x88/0x89 are "Construction" and 0x8a/0x8b are "Abandoned building". The causes were the game's own rules:

Layers added to the list above: XUND 0x4b3dd0 (pipes 0x10..0x1e; a pump tile reads 0x1e, a surface pipe crossing 0x11), altitude 0x4b3a10 (u16 per tile, 256-byte rows, low 5 bits = height).

Driving notes:

Fifth city (1950 start, 2026-09-22): a 32px-zoom timelapse

A 7x2 road grid (blocks of 6x6), coal plant west, pumps on the river bank, recorded at the default 32px zoom instead of the whole-map view.