RollerCoaster Tycoon (shareware/demo)

test/binaries/shareware/rct/English/RCT.exe — Chris Sawyer / Hasbro Interactive, 1999. App id rct. Image base 0x00400000, entry 0x00412d80; the whole game is one EXE with no shipped DLLs, so every address below is both an original and a runtime VA and needs no module+0x arithmetic.

The registry entry (lib/apps.js, rctFiles) mounts the tree from test/binaries/shareware/rct/: Data/ (CSG1/CSG1I plus CSS1–CSS17, GAME.CFG, TUTORIAL.DAT, KANJI.DAT, MP.DAT), Scenarios/ (SC.IDX and ten *.SC4), Tracks/, Saved Games/. The VFS has never been the problem — an earlier note calling RCT "VFS-blocked" was wrong, and --trace-fs shows every file it asks for resolving on the first try.

Status (2026-09-01): reaches the title screen

timeout -s KILL 250 node test/run.js --app=rct --quiet-api \
  --batch-size=200000 --max-batches=20000 --max-seconds=90 \
  --no-close --png=/tmp/rct.png

A healthy capture is the animated demo park: the RollerCoaster Tycoon logo top right, wooden and steel coasters over a pine forest, and the four-button menu bar along the bottom. --dx-surfaces reports the primary (640x480x8) with ~108 distinct colours; a broken run reports colors=1 nonZero=0.

--batch-size=200000 is not optional. At the default budget the game is still decoding Scenarios/*.SC4 after 20000 batches and has drawn nothing, which reads exactly like a hang. It scans the scenario directory at startup, opening and decoding each .SC4 in 1KB ReadFile chunks before it will show a menu.

The two bugs that were in the way

1. DPLAYX ordinals were answered with DirectSound ids (fixed)

RCT imports DPLAYX.dll by ordinal only (tools/pe-imports.js shows [0] ordinal 1, [1] ordinal 2), and the stub at 0x0041b08e is jmp [0x55e034] — inside DPLAYX's IAT at RVA 0x15e030, not DSOUND's at 0x15e03c. $system_ordinal_api_id in src/08b-dll-loader.wat gated its DSOUND rule on $guest_name_is_static_system_dll == 4, but that helper returns a 1-based position into ole32/user32/comctl32/dplayx/ddraw/dsound/d3drm, where dplayx is 4 and dsound is 6. So every dplayx ordinal was resolved as a DirectSound API and the DSOUND rule never fired at all.

RCT's ordinal 2 is DirectPlayEnumerateA, whose callback is (LPGUID, LPSTR, DWORD major, DWORD minor, LPVOID ctx) and ends ret 0x14 at 0x004107b2. Our DirectSoundEnumerateA handler pushes four arguments, so the callback popped one dword too many and the guest returned to EIP 0 after 11 batches, with dbg_prev_eip=0x004107b2. The callback itself is at 0x00410710 (allocates a 0x10c-byte provider record, stores the GUID at +0 and strcpys the name to +4, links it at [0x56306c]/[0x563070]).

test/test-directsound-ordinals.js was already failing at HEAD on the DSOUND half of this and now covers both DLLs.

2. RICHEDIT_FORMAT_TABLE was allocated on top of MM_TIMER_TABLE (fixed)

RCT drives its entire game loop from one multimedia timer: timeSetEvent(50, 10, 0x0040c7a6, 0, TIME_PERIODIC) at 0x0040d269, called from 0x0045231d. MM_TIMER_TABLE and MM_TIMER_NEXT_ID were raw (i32.const 0x00010800) / 0x000108C0 in src/01-header.wat, in no region at all — and the WATX allocator, which only knows about declared regions, had put the 1KB RICHEDIT_FORMAT_TABLE region at exactly 0x00010800.

The first CreateDialog with controls therefore wrote hwnd-slot zeroes over timer slot 0's interval (+4) and callback (+8) while leaving its id (+0) and last-tick (+16) intact. A zero interval reads as "always due" and a zero callback makes DispatchMessageA decline the message, so the pump filled with MM_TIMER (0x7FF0, hwnd 0, wParam 1, lParam 0) forever and the game never ran another frame — 153 million API calls in 180s, all PeekMessage / TranslateMessage / DispatchMessage.

Reproduction of the corruption itself, which is app-independent (the guest address is the g2w alias of the linear address, 0x10808 + 0x400000 - 0x12000):

node test/run.js --app=rct --quiet-api --batch-size=200000 \
  --max-batches=800 --max-seconds=60 --no-close --watch=0x3FE808 --watch-log

Before the fix that prints the callback being stored at batch 624 (EIP 0x40d269) and zeroed at batch 715, right at the [CreateDialog] line. The live table can be read from a --control session with instance.exports.dbg_mm_timer(slot, field) (fields: 0 id, 1 interval, 2 callback, 3 dwUser, 4 last_tick, 5 oneshot).

Both are now region.declared in src/00-regions.wat, so the region gate makes a future overlap a build failure rather than a silent one.

Dead ends

Startup API profile

Ordinary CRT init, then LoadCursorA/SetErrorMode/timeBeginPeriod(1), GetVersionExA, GetSystemInfo, GlobalMemoryStatus, GetUserNameA, GetComputerNameA, RegisterClassA("RollerCoaster Tycoon") (wndproc 0x00403c7d), then DirectPlayEnumerateA. Graphics come up through GetProcAddress(DirectDrawCreate)IDirectDraw_EnumDisplayModes (three times) → CreateWindowExA 640x480 → SetCooperativeLevelSetDisplayMode(640,480,8) → primary + back surfaces + clipper + palette. Input is DirectInput 5 (DirectInputCreateA(.., 0x0500, ..), keyboard + mouse devices). Audio is DirectSound with three secondary buffers locked at startup.

Two compat patches already exist for this binary and fire at load ([compat] patched RCT ... video-mode change invalidates cached geometry at 0x0045268d and 0x0042d2d3).

Named addresses (original VAs)

VA What
0x00412d80 PE entry
0x00403903 stores five globals from its args; block ending here calls 0x004036d3 and is where the dialog path begins
0x00403b2e main message pump (PeekMessageA PM_REMOVE, cmp [ebp-0x1c], 0x12 for WM_QUIT)
0x00403c7d main window procedure
0x004036d3 function entered right before the first CreateDialog
0x00410710 DirectPlay provider-enumeration callback (ret 0x14)
0x004107c0 its caller; 0x004107e0 is the DirectPlayEnumerateA call, returning to 0x004107e5
0x0040c7a6 multimedia-timer TimeProc — the sound-channel service pump, not the game loop (an earlier version of this row said game loop; wrong). It walks a 4-slot channel table at 0x5672e0 (stride 0x16C, computed by the lea chain as 364) and calls 0x40bb20 per active slot; with no sound playing all four slots are empty and it does nothing, healthily. --count=0x0040c7a6 still checks the timer is alive
0x0040d269 return site of the timeSetEvent call that arms it
0x0045231d caller of that arming function
0x0042f5a50x0042f5ff the .SC4 decode inner loop the startup scan spends its time in

The real game loop (mapped in-scenario, 2026-09-01, browser frozen tile)

Sawyer's outer loop lives around 0x4010e9/0x40110a: pump(0x403b2e)0x402bb3 (FPS bookkeeping only — accumulates ms at 0x565dc8, publishes frames/sec to 0x560124 once a second; it is NOT the tick) → 0x438248, the whole per-frame function, then back to the pump.

Inside 0x438248, steady state (byte [0x59fc98]=1 once init ran): elapsed = 0x404640()[0x8e0fd0], clamped to 500ms, stored as a word at 0x8e0fd8. Sim tick count = clamp(elapsed/31, 1, 4), and the sim loop at 0x4384fc (game update 0x436234 + the UI update battery) is gated by byte [0x8e31a9] == 0 — that byte is the pause flag the toolbar pause button (top-left, ~(10,10) at 640x480) toggles. When paused the loop is skipped wholesale, which is also why "Construction not allowed while game is paused!" pairs with a completely still frame. The frame ends in a 25ms frame-limiter spin: 0x43867d re-reads the clock until 25ms have passed since frame start — spin-park's clock-spin detector (yield 14) is what keeps that cheap for the host.

Diagnosing "RCT looks stuck" from a browser session, fastest order:

  1. exports.set_count(i, 0x436234) + step — the game update counting is the one-line health check (like --count headless).
  2. byte [0x8e31a9] nonzero = paused; the pause button at (10,10) toggles it.
  3. A running, unpaused, closed park with no rides in view is legitimately pixel-static for minutes: no guests, no ride motion, date changes monthly. Do not read a byte-identical screenshot as a stall without checking 1–2. 0x59fc99 is a "screen re-init done" latch (cleared by the mode-change / screenshot-request handler at 0x42e8d8, raised by the redraw path), not a per-frame render gate; 0x56fdb2 is a screen-effect state machine (1 = normal). The DirectSound guard dword at 0x562f2c protects the TimeProc above, unrelated to all of this.

Permanent gameplay gate (2026-09-05)

The native demo now has a deterministic one-process acceptance route beyond the title screen. After 3,000 frozen 200,000-block batches, a click at (198,430) opens Select Scenario For New Game. Clicking (310,166) picks the enabled Forest Frontiers scenario. After its load, (428,157) closes the objective window and exposes the live park.

Two park frames separated by 120 explicit batches differed at 61,107 pixels in the verified run. Clicking toolbar coordinate (382,15) then opened the real Path Construction panel; more than 10,000 pixels changed in its left-hand region. This proves both live scenario simulation/rendering and interactive construction UI, rather than only the animated title-screen attract mode.

bash tools/build.sh
RCT_SCREENSHOT=/private/tmp/rct-construction.png node test/test-rct-gameplay.js

The test launches one --control-stdin --frozen CLI process, advances only by explicit step commands, and uses the CLI's internal --max-seconds guard. It does not wrap the emulator in an external signal timeout.