Skip to content

Seeding fix. Fixes #13 - #14

Open
fdrocha wants to merge 4 commits into
SimHacker:mainfrom
fdrocha:seeding-fix
Open

Seeding fix. Fixes #13#14
fdrocha wants to merge 4 commits into
SimHacker:mainfrom
fdrocha:seeding-fix

Conversation

@fdrocha

@fdrocha fdrocha commented Aug 19, 2026

Copy link
Copy Markdown

Summary

Two determinism bugs in the engine, plus the tests that pin them down. Fixes #13.

The headline one: seedRandom() is exposed to embedders but has no observable effect. initWillStuff() reseeds the RNG from the wall clock, and every caller runs doSimInit() before returning, so a caller-supplied seed is always discarded before it can matter. Loading the same city with the same seed produced a different world on every run, and generateSomeCity(int seed) was broken the same way — its own seedRandom(seed) was clobbered on the next line.

The second one surfaced while testing the first: loadCity() ran doSimInit() on a city loadFile() had already initialized, and because that second call saw initSimLoad == 0 its dispatch was dead — what actually executed was a stray mapScan(0, WORLD_W), a full zone-simulation pass. The city you got back was one uninitialized simulation step past the city on disk.

Both fixes are small (two lines of behavior change each) and neither changes the default experience: an unseeded run is still random.

Commits

  1. fcffed3 — Add deterministic-seeding tests for the engine RNG. Added first, as the reproduction. 3 of the 4 fail at this point in history.
  2. a7508ba — Fix engine build under current Emscripten; regenerate WASM artifacts. See the note below; this commit is shared with the bugfix-crashes PR.
  3. 280f68a — Don't reseed the RNG in initWillStuff(); let seedRandom() stick.
  4. 64250c1 — Don't run doSimInit() twice when loading a city.

Note on commit 2

This is the same commit as in the bugfix-crashes PR — cherry-picked, identical patch-id (b86915b3539eeb25ec9a2666d3e0e35d716bd076), message, author, and author date. Only the SHA differs, because the parent does.

It's here because it's a hard prerequisite: without it, a fresh make install produces a module that aborts on load in Node, so none of this was testable. Whichever of the two PRs merges second, this commit will drop out as already-applied (or want a trivial makefile conflict resolved). Happy to rebase this branch onto bugfix-crashes instead if you'd rather review them stacked.

Bug 1: seedRandom() never takes effect

initWillStuff() opened with randomlySeedRandom(), which seeds from gettimeofday(). All four of its callers run doSimInit() before returning to the caller:

Caller
micropolis.cpp:741 simInit(), reached from init()
fileio.cpp:386 loadFile(), reached from loadCity()
fileio.cpp:553 loadScenario()
generate.cpp:112 generateSomeCity()

doSimInit()'s map scans draw from the RNG and write the results straight into map tiles:

// zone.cpp:503
map[xx][yy] = HOUSE + BLBNCNBIT + getRandom(2) + value * 3;
// zone.cpp:677
map[x][y] = LHTHR + value + getRandom(2) + BLBNCNBIT;

So the clock-derived seed was baked into tile data before the caller regained control. Calling seedRandom() afterwards couldn't help — the divergence had already happened. There was no ordering of the public API that produced a reproducible run.

Reseeding also wasn't this function's job. Its own doc comment reads "Reset many game state variables," and randomizing the RNG is not resetting state. Each caller already knows whether it wants a fresh random world, so the call moved out to the one that does — simInit().

generateSomeCity(int seed) is fixed as a side effect, with no edit to generate.cpp: it calls generateMap(seed), which correctly does seedRandom(seed), and initWillStuff() on the very next line used to throw that away. The terrain honored the seed; everything derived from it did not.

Bug 2: loadCity() ran a stray simulation pass

loadFile() ends with:

initSimLoad = 1;
doInitialEval = false;
doSimInit();
invalidateMaps();

doSimInit() dispatches on initSimLoadsimLoadInit() for a just-loaded city (== 1), initSimMemory() for a new one (== 2) — and clears it to 0 on the way out. So when loadCity() called doSimInit() a second time, that dispatch was dead: neither branch ran, and what executed was the unconditional tail, including mapScan(0, WORLD_W).

Measured on kobe: 50 of 12000 tiles differed from the same city loaded a second time. Tile 612 (INDBASE, an empty industrial zone) had already developed into 657 by the time loadCity() returned. Fixed by dropping the redundant call — loadFile() already leaves the simulation initialized, and it's the only path into loadCity() that needs it.

Tests

apps/micropolis/src/lib/seedDeterminism.test.ts, 4 tests, using the repo's own loadMicropolisMainModule() and callbackMethodNames rather than a hand-rolled loader:

  • generateMap(seed) is reproducible. The control: it seeds the same RNG through the same seedRandom() but never routes through initWillStuff(). It passes both before and after the fix, which is what rules out "the PRNG is just nondeterministic under wasm" and isolates the bug to the reseeding.
  • seedRandom() + loadCity() + ticks is reproducible across processes. The property that actually matters. Runs in child processes because init() itself seeds from the clock, and because the first load in a process is the only one starting from a virgin world — so this is the only test that exercises a first load, which is why it's the one that ticks.
  • seedRandom() + loadCity() is reproducible on repeat loads, including the first load into a virgin world (this is what bug 2 broke).
  • seedRandom() + loadCity() + ticks is reproducible on repeat loads.

Everything runs on a single Micropolis instance per process, deliberately: constructing a second instance in one process trips an unrelated uninitialized-callback bug (fixed in bugfix-crashes, not here), so reusing one instance keeps these tests measuring seeding and nothing else.

Verification

Red/green confirmed in both directions rather than assumed — I reintroduced each bug, rebuilt, and checked the tests go red again.

before after
Cross-process seeded load + ticks 3 distinct digests 4/4 identical
Repeat seeded reload (map) 3 distinct identical
Repeat seeded reload + ticks 3 distinct identical
kobe tiles differing, load 1 vs 2 50 of 12000 0
generateMap control identical identical (same digest)

Also checked:

  • Unseeded runs still vary — 4/4 distinct worlds. Determinism is opt-in; the game didn't accidentally become deterministic.
  • generateMap digest is byte-identical before and after (ddf9ecd4952ca093), i.e. map generation itself is untouched.
  • Tests still catch the original bug after I revised them. When bug 2's fix made one test's premise wrong, I rewrote it and then re-verified against main's C++ that all three still fail — they weren't weakened into tautologies.
  • The wasm build is reproducible: rebuilding the same source yields a byte-identical .wasm. The only nondeterminism in the generated output is Emscripten temp-file paths inside .js comments.
  • Full app suite: 80 passing, with only the 4 pre-existing monorepo.integration failures, which need packages/mooshow/dist/ from a prior pnpm build (the directory doesn't exist on a clean checkout) and fail identically on main.

Known limitations, deliberately left alone

Both are pre-existing, both are called out in commit 64250c1's message, and neither is affected by these fixes:

  • RNG stream offset differs between a first load and a reload. simLoadInit() runs only on the first load in a process and draws a different number of values from the RNG than the reload path does. World state after the load is identical; only the stream position entering a subsequent tick loop differs. The repeat-load ticking test reseeds immediately before ticking to isolate this, and says so in a comment.
  • crimeAverage and landValueAverage can differ by a point or two on a first load. They come from the file's miscHist via simLoadInit() on a first load, but are recomputed from the scanned world on reloads. Cosmetic, and fixing it means deciding which value is authoritative.

Notes for reviewers

  • The micropolisengine.js/.wasm diff is regenerated build output. In commit 2 the meaningful part is the INCOMING_MODULE_JS_API list; in commits 3 and 4 the .js diff is only Emscripten temp-file paths in comments, and the real change is in the .wasm.
  • The C++ surface area is deliberately tiny. Across all three source files it's +16/-4 lines, and all but three of those are explanatory comments: the reseed call moves from initWillStuff() to simInit() (one line deleted, one added), and the redundant doSimInit() call in loadCity() is deleted. Everything else in the diff is tests, comments, and rebuilt artifacts.
  • No public signatures changed. Embedders opt into determinism with init()seedRandom(n)loadCity(...), and anyone who wants the old reseed-on-load behavior can call the already-exposed randomlySeedRandom() themselves.

Fabio Rocha and others added 4 commits August 19, 2026 12:06
The engine exposes seedRandom(), but there is no way for an embedder to make
a run reproducible: initWillStuff() reseeds the RNG from the wall clock
(randomlySeedRandom() -> gettimeofday), and all four of its callers run
doSimInit() before returning. The map scans in doSimInit() draw from the RNG
and write the results straight into map tiles, e.g.

  zone.cpp:503  map[xx][yy] = HOUSE + BLBNCNBIT + getRandom(2) + value * 3;

so a caller-supplied seed is discarded before it can affect anything
observable. Loading the same city with the same seed yields a different world
on every run, and generateSomeCity(int seed) is broken the same way: its own
seedRandom(seed) is clobbered by initWillStuff() on the next line.

These tests are added before the fix, as the reproduction. Three of the four
fail at this commit; the following commits make them pass.

Coverage:

  - generateMap(seed) is reproducible. This is the control: it seeds the same
    RNG through the same seedRandom() but never routes through
    initWillStuff(). It passes both before and after the fix, which is what
    rules out "the PRNG is just nondeterministic under wasm" and isolates the
    bug to the reseeding.

  - seedRandom() + loadCity() is reproducible across processes. This is the
    property that actually matters and the one the bug breaks. It has to run
    in child processes: init() itself seeds from the clock, and the first load
    in a process is the only one starting from a virgin world.

  - seedRandom() + loadCity() is reproducible in-process, with and without
    ticking.

Everything runs on a single Micropolis instance per process, deliberately.
Constructing a second instance in one process trips an unrelated
uninitialized-`callback` bug, so reusing one instance keeps these tests
measuring seeding and nothing else.

The two in-process tests skip their first iteration, because loadCity() does
not fully reset world state: a load into a virgin post-init() world differs
from a load over a previously loaded one even with identical seeding. That is
a separate bug, noted in a comment and left out of scope here; the
cross-process test is what pins the seeding contract.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rebuilding the C++ engine with a current Emscripten (6.0.7) produced a
module that aborted immediately on load in Node:

  Aborted(`Module.wasmBinary` was supplied but `wasmBinary` not included
  in INCOMING_MODULE_JS_API)

Emscripten only honours Module.* properties named in INCOMING_MODULE_JS_API
and hard-aborts on any it does not recognize. The default list omits both
`wasmBinary` and `getPreloadedPackage`, which apps/micropolis/src/lib/wasm/
node.ts passes in order to load the engine from a Buffer instead of over
HTTP. Spelling out the list (upstream default plus those two) fixes it.

We do not know which Emscripten version produced the previously committed
artifacts — the version string is not retained in the generated output, the
wasm producers section is stripped, and the codegen fingerprints do not match
any release we tried. Rather than keep guessing, this pins the build to what
the toolchain does today and fixes the incompatibility, so `make install`
works for anyone with a current emsdk.

The regenerated micropolisengine.{js,wasm} are committed together with the
makefile change, since the two only make sense as a pair. The .d.ts and .data
outputs came out byte-identical and are unchanged. Verified no behavior
change: the app test suite gives exactly the same results before and after
(same 3 known simCrash failures, same 4 pre-existing monorepo.integration
failures that need a prior `pnpm build`).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
initWillStuff() opened with randomlySeedRandom(), which seeds from
gettimeofday(). Every one of its four callers runs doSimInit() before
returning to the caller:

  micropolis.cpp  simInit()            (reached from init())
  fileio.cpp      loadFile()           (reached from loadCity())
  fileio.cpp      loadScenario()
  generate.cpp    generateSomeCity()

and doSimInit()'s map scans draw from the RNG and write the results straight
into map tiles, e.g. `map[xx][yy] = HOUSE + BLBNCNBIT + getRandom(2) + ...`
in zone.cpp. So the clock-derived seed was always baked into the world before
the caller regained control, and there was no ordering of the public API that
produced a reproducible run. Calling seedRandom() afterwards could not help:
the divergence had already happened.

Reseeding was also not this function's job. Its own doc comment reads "Reset
many game state variables", and randomizing the RNG is not resetting state.
Each caller already knows whether it wants a fresh random world, so the call
moves out to the one that does.

generateSomeCity(int seed) was broken by this in a more pointed way: it calls
generateMap(seed), which correctly does seedRandom(seed), and then
initWillStuff() on the very next line threw that seed away before doSimInit()
ran. The terrain honoured the seed; everything derived from it did not. It is
fixed as a side effect of this change, with no edit to generate.cpp.

Behaviour is otherwise unchanged. randomlySeedRandom() moves to simInit(), so
a freshly initialized simulation still gets a random world by default --
verified: four unseeded init()+loadCity() runs still produce four different
worlds. Embedders opt into determinism with init() -> seedRandom(n) ->
loadCity(), and anyone wanting the old behaviour can call the already-exposed
randomlySeedRandom() themselves.

The tests added two commits ago now pass 4/4; three of them failed before
this change. Verified in both directions by reintroducing the reseed and
confirming they go red again. Also confirmed the generateMap() control digest
is byte-identical before and after (ddf9ecd4952ca093), i.e. map generation
itself is untouched. Rest of the app suite is unaffected: 80 passing, with
only the 4 pre-existing monorepo.integration failures that need a prior
`pnpm build`.

The regenerated micropolisengine.{js,wasm} are committed alongside. The .js
diff is only Emscripten temp-file paths in comments; the .data and .d.ts
outputs came out byte-identical and are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
loadCity() called doSimInit() on a city that loadFile() had already
initialized. loadFile() ends with:

    initSimLoad = 1;
    doInitialEval = false;
    doSimInit();
    invalidateMaps();

and doSimInit() dispatches on initSimLoad -- simLoadInit() for a just-loaded
city (initSimLoad == 1), initSimMemory() for a new one (== 2) -- then clears
initSimLoad to 0 on the way out. So by the time loadCity() called doSimInit()
again, that dispatch was dead: neither branch ran, and what executed was the
unconditional tail of the function, including

    mapScan(0, WORLD_W);

which is a full zone-simulation pass. Zones grew. Loading kobe left 50 of
12000 tiles different from the same city loaded a second time -- e.g. tile 612
(INDBASE, an empty industrial zone) had already developed into 657 by the time
loadCity() returned. The city an embedder gets was one uninitialized
simulation pass beyond the city on disk.

Fixed by dropping the redundant call. loadFile() already leaves the
simulation initialized, and it is the only path into loadCity() that needs it.

Verified: repeat seeded loads of kobe now produce a byte-identical map (0 of
12000 tiles differ, previously 50), and a first load is identical to a second.
The cross-process seeded-load-and-tick digest is stable 4/4.

Two things this does not fix, both pre-existing and both left alone
deliberately:

  - simLoadInit() runs only on the first load in a process, so it draws a
    different number of values from the RNG than the reload path does. The RNG
    stream position entering a tick loop therefore differs between a first
    load and a reload. World state is identical; only the stream offset is
    not. The repeat-load ticking test reseeds immediately before ticking to
    isolate that, and says so.

  - Relatedly, crimeAverage and landValueAverage come from the file's miscHist
    via simLoadInit() on a first load, but are recomputed from the scanned
    world on reloads, so they can differ by a point or two. Cosmetic, and
    fixing it means deciding which value is authoritative -- out of scope
    here.

The cross-process test grows a tick loop as part of this change. It is the
only test that exercises a first load, so it is where a first-load simulation
difference has to be caught; without ticking it was only checking the loaded
map, which is the weaker claim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

seedRandom() cannot make the simulation reproducible — initWillStuff() reseeds from the wall clock

1 participant