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 (updated 2026-10-10; test/test-simcity2000-gameplay.js):

node test/run.js --no-build --app=simcity2000_demo --batch-size=20000 \
  --tick-ms-per-batch=16 --max-batches=3200 --max-seconds=240 --stuck-after=0 \
  --no-close --quiet-api \
  --input=25:dlg-cmd:1,700:mousedown:315:148,720:mouseup:315:148 --png=/tmp/sc2k.png

25:dlg-cmd:1 answers the startup "Video Warning" box; "Load Demo City" is now at (315,148) on the launcher (the old (215,93) misses since the 8-bit desktop change). Use these batch settings: at run.js's default 200 ms per batch the game is starved (about 0.1 palette ticks per guest-second) and the demo notices ("I will now demonstrate some of the Disasters", then "still only a demo ... return to the main menu") arrive before anything can be done. At 20000-block / 16 ms batches the map is live from ~batch 1500, the date runs May 1982 -> Aug at batch 3000, and the January 1983 Budget window (pauses the sim) is up by batch 5000.

The map animates by palette cycling, so an RGB screenshot diff cannot tell input from animation: 16.8% of the map region (180,70 440x360) changes colour in 3 batches with no input, while two captures 90 batches apart can be identical (the same phase of the cycle -- an earlier version of the test took that as a 0% null band). The main window's surface is 8-bit and 23% of its pixels (71,458) use the animated indices 0xab..0xdb, so the animation is on screen. Read the indices instead: test_gdi_window_surface_record(hwnd) gives bits (+16), stride (+20), bpp (+24) in WASM memory.

Control response: the floating toolbar's rotate-left button (110,262) turns the whole view -- 37.4% of the map region's palette indices change, against 0% between two animation frames (whose colours differ by 16.8%).

Animation counter: SC2K has no frame loop and redraws the full map (BitBlt 612x390 from its offscreen DC, return 0x466d83) only ~4 times in 16 guest-seconds. Its animation clock is palette cycling: AnimatePalette on palette 0x410014 from one site (return 0x4489ac), entries 0xab+49 every tick and 0xe0+16 on a slower cadence; the PALETTEENTRY array at 0x4b17d8 rotates (PC_RESERVED grey/dark-blue/black). The test reads a --count=0x4489ac hit counter through exports.get_count(0) at both ends of batches 2100-3100: 90 calls = 5.63 per guest-second (81 of them the 0xab range), identical across runs. Do not count it with --trace-api: that re-enables the per-call API log and writes ~130 MB per run.

Test-harness trap found here: piping run.js stdout (execFileSync) loses the tail of the log, because run.js ends with process.exit while pipe writes are still queued -- it looked like a nondeterministic route. Give run.js a file descriptor (stdio: ['ignore', fd, fd]) instead.

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

256-color warning: original cause and indexed-desktop fix (2026-10-09)

0x00478c77 reads GetDeviceCaps(hdc, 12) BITSPIXEL into EDI, (hdc, 14) PLANES into EBX and (hdc, 38) RASTERCAPS masked with RC_PALETTE (0x100). The original desktop reported 32 / 1 / 15033, matching its true-color backing, so the cmp edi, 8 at 0x478cde failed 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. The warning describes a real limitation: palette animation cannot recolor already-converted 32-bit pixels.

The app registry now requests desktopColorDepth: 8. Both browser and CLI configure process-shared display state before guest execution. Window surfaces and compatible bitmaps retain 8-bit pixels, while the host converts through a stable RGBQUAD system palette for presentation. GetDeviceCaps, current-mode enumeration A/W and GetSystemPaletteEntries expose that same state. RealizePalette assigns physical slots; AnimatePalette changes only the requested reserved entries and uploads existing window pixels without rewriting them. Deleting a logical palette releases its physical-slot ownership.

Two additional rendering gaps mattered: native shape drawing previously rejected 8-bit surfaces (black/missing controls), and ordinary BitBlt converted indexed pixels through RGB even when the palettes matched. That collapses separately animated entries of the same initial color. Matching indexed copies now retain indices; transient DIB_PAL_COLORS transfers carry their logical-to-physical mapping instead of quantizing those colors again.

test/test-wat-gdi-indexed-desktop.js checks real backing depth/stride, A/W mode queries, retained-pixel animation, offscreen bitmap copies, independently animated duplicate colors, animation-range isolation, nearest unrealized colors, PC_EXPLICIT, control backgrounds, and restoration to truecolor. Existing palette and window-surface tests cover the default mode as well.

This adds an initial indexed desktop; it is not a complete implementation of foreground/background palette arbitration or depth-only ChangeDisplaySettings. DirectDraw exclusive overlays retain their existing truecolor composition.

"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.

Indexed desktop verification (2026-10-09)

Before run: scratch/runs/20261009T0010Z-simcity-truecolor-before. Final run: scratch/runs/20261009T0048Z-simcity-indexed-desktop. Original fixture, fresh Chrome151 on isolated bx_jhtwtuhf, ordinary Load Demo City and centering click; no guest memory writes. Warning absent, simulation advances and viewport responds. Over34.55seconds,31 physical palette entries changed and4116/20000 water-crop pixels changed. All seven live windows use8-bit backing. Reviewed launcher, city and budget-dialog screenshots are in the run.

Canonical build and indexed-desktop, palette and window-surface regressions pass. Tested module SHA256: 7c0734dbc1670ea141e8e90f0fc5422be061848cd434c84f03d281b609a26b9e. Source pins and original fixture are preserved with the captures. Browser exited cleanly and boat stopped. FPS/audio were not measured; foreground/background palette arbitration and depth-only ChangeDisplaySettings remain outside this change.