Seeding fix. Fixes #13 - #14
Open
fdrocha wants to merge 4 commits into
Open
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 runsdoSimInit()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, andgenerateSomeCity(int seed)was broken the same way — its ownseedRandom(seed)was clobbered on the next line.The second one surfaced while testing the first:
loadCity()randoSimInit()on a cityloadFile()had already initialized, and because that second call sawinitSimLoad == 0its dispatch was dead — what actually executed was a straymapScan(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
fcffed3— Add deterministic-seeding tests for the engine RNG. Added first, as the reproduction. 3 of the 4 fail at this point in history.a7508ba— Fix engine build under current Emscripten; regenerate WASM artifacts. See the note below; this commit is shared with thebugfix-crashesPR.280f68a— Don't reseed the RNG ininitWillStuff(); letseedRandom()stick.64250c1— Don't rundoSimInit()twice when loading a city.Note on commit 2
This is the same commit as in the
bugfix-crashesPR — 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 installproduces 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 ontobugfix-crashesinstead if you'd rather review them stacked.Bug 1:
seedRandom()never takes effectinitWillStuff()opened withrandomlySeedRandom(), which seeds fromgettimeofday(). All four of its callers rundoSimInit()before returning to the caller:micropolis.cpp:741simInit(), reached frominit()fileio.cpp:386loadFile(), reached fromloadCity()fileio.cpp:553loadScenario()generate.cpp:112generateSomeCity()doSimInit()'s map scans draw from the RNG and write the results straight into map tiles: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 togenerate.cpp: it callsgenerateMap(seed), which correctly doesseedRandom(seed), andinitWillStuff()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 passloadFile()ends with:doSimInit()dispatches oninitSimLoad—simLoadInit()for a just-loaded city (== 1),initSimMemory()for a new one (== 2) — and clears it to 0 on the way out. So whenloadCity()calleddoSimInit()a second time, that dispatch was dead: neither branch ran, and what executed was the unconditional tail, includingmapScan(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 timeloadCity()returned. Fixed by dropping the redundant call —loadFile()already leaves the simulation initialized, and it's the only path intoloadCity()that needs it.Tests
apps/micropolis/src/lib/seedDeterminism.test.ts, 4 tests, using the repo's ownloadMicropolisMainModule()andcallbackMethodNamesrather than a hand-rolled loader:generateMap(seed)is reproducible. The control: it seeds the same RNG through the sameseedRandom()but never routes throughinitWillStuff(). 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 becauseinit()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
Micropolisinstance per process, deliberately: constructing a second instance in one process trips an unrelated uninitialized-callbackbug (fixed inbugfix-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.
kobetiles differing, load 1 vs 2generateMapcontrolAlso checked:
generateMapdigest is byte-identical before and after (ddf9ecd4952ca093), i.e. map generation itself is untouched.main's C++ that all three still fail — they weren't weakened into tautologies..wasm. The only nondeterminism in the generated output is Emscripten temp-file paths inside.jscomments.monorepo.integrationfailures, which needpackages/mooshow/dist/from a priorpnpm build(the directory doesn't exist on a clean checkout) and fail identically onmain.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: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.crimeAverageandlandValueAveragecan differ by a point or two on a first load. They come from the file'smiscHistviasimLoadInit()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
micropolisengine.js/.wasmdiff is regenerated build output. In commit 2 the meaningful part is theINCOMING_MODULE_JS_APIlist; in commits 3 and 4 the.jsdiff is only Emscripten temp-file paths in comments, and the real change is in the.wasm.+16/-4lines, and all but three of those are explanatory comments: the reseed call moves frominitWillStuff()tosimInit()(one line deleted, one added), and the redundantdoSimInit()call inloadCity()is deleted. Everything else in the diff is tests, comments, and rebuilt artifacts.init()→seedRandom(n)→loadCity(...), and anyone who wants the old reseed-on-load behavior can call the already-exposedrandomlySeedRandom()themselves.