Save the Echo AI's building-order id (savegame-resumed AI games were not reproducible) - #253
Conversation
`BuildingOrder::id` is assigned at runtime by `Echo::add_building_order` from `BuildingRegister::register_building`, and is the key the order is known by in `BuildingRegister::pending_buildings`. Neither `save()` nor `load()` ever touched it, and the constructor `load()` uses -- the private `BuildingOrder()` -- initialised nothing, so every pending building order restored from a savegame carried whatever happened to be in the heap. `Echo::update_building_orders` then used that as a map key, in `br.issue_order((*i)->id, ...)` and in the `AssignWorkers` management orders it queues. So the AI's building placement, and through it the whole simulation, depended on heap layout. Observable on master: eight Nicowars on Playground resumed from a tick-25000 save and run to 27000 settle on one of two outcomes, 502 or 510 deliveries, roughly coin-flipped across runs. `setarch -R` makes it stable; with ASLR already off, `MALLOC_PERTURB_=85` alone flips it, and so does enabling `GLOB2_CHECKSUM_SIDECAR`, which only moves the heap. Memcheck names it: uninitialised value read in `BuildingRegister::issue_order`, origin the `operator new` in `Echo::load`. Serialise it, and default the member to -1 so an order that is built and never registered is still readable. Saves written before version 96 do not carry the field; `Echo::load` hands those a fresh registration rather than a sentinel, since the value has to be a real register key. `BuildingRegister` is loaded earlier in the same function, so the registration is valid by then. A game played from the start was never affected -- the id is only garbage on the load path. Opus 5 helped authoring this commit.
The fix in 3c48922 landed without one. BuildingOrder::id is the BuildingRegister key handed out by Echo::add_building_order, and save()/load() never moved it, so every pending building order restored from a save carried an uninitialised heap value into issue_order() and AssignWorkers. EchoBuildingOrderSaveLoadTest pins the three properties the fix establishes: the version-96 round trip preserves the id, an unregistered order's -1 survives the Uint32 on the wire rather than returning as a huge positive key, and a pre-96 stream leaves the -1 sentinel that Echo::load replaces with a fresh register_building() key, with every following field still decoding from the right offset. Negative control: removing the two serialisation lines from BuildingOrder.cpp fails exactly the "id preserved" assertion and leaves the other eleven passing, so the fixture tests the fix and not the scaffolding around it. BuildingOrder::save/load, the id member and the default constructor are private; the test reaches them through a friend declaration, the same way GradientBFSTest reaches AIEcho::Gradients::Gradient. EchoBuildingOrderTestStubs satisfies the find_location / passes_conditions link surface (globalContainer, BuildingsTypes, Map, FlagMap, GradientManager and the Constraint / Condition factories) that a constraint-free order never reaches at runtime. The binary is named *Test, so the existing "Build and run the tests" CI step picks it up with no workflow change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5GEG5t9ithd47SFm6PTDZ
|
Reviewed and verified locally (macOS/arm64, clang). Approving; I've pushed the one thing the PR was missing rather than asking for a round trip. What I checked, beyond the description
Added in 7a2a4f4: the missing regression testThe verification here was all prose in the description, which per CLAUDE.md isn't evidence a reviewer can check, and every comparable save/load fix in this tree ships a fixture. Negative control: removing the two serialisation lines from Local results on the pushed head: Two non-blocking notes
Not covered by me: Windows/MinGW and a cross-architecture checksum comparison; CI covers the former. |
Summary
AIEcho::Construction::BuildingOrder::idis assigned at runtime (Echo::add_building_order→BuildingRegister::register_building) and is the key the order is known by inBuildingRegister::pending_buildings.BuildingOrder::save()andload()never touched it, and the constructorload()uses initialised nothing, so every pending Echo/Nicowar building order restored from a savegame carried whatever was in the heap.Echo::update_building_ordersthen used that value as a map key (br.issue_order) and passed it toAssignWorkers.Consequences on master:
9421f63c/6e93bf5b, 502 vs 510 deliveries), roughly coin-flipped.setarch -Rmakes it stable; with ASLR off,MALLOC_PERTURB_=85alone flips it, and so does enablingGLOB2_CHECKSUM_SIDECAR, which only moves the heap.Memcheck on a symbolised build names it: uninitialised value read in
BuildingRegister::issue_order(BuildingRegister.cpp:105), origin theoperator newinEcho::load(EchoSerialization.cpp:65).A game played from the start was never affected; the id is only garbage on the load path.
Change
idinBuildingOrder::save()/load(), read whenversionMinor >= 96.-1, so an order constructed and never registered is still readable.Echo::loadhands those orders a freshbr.register_building()rather than a sentinel, because the value has to be a real register key.BuildingRegisteris loaded earlier in the same function, so the registration is valid at that point.VERSION_MINOR95 → 96,REPLAY_MINIMUM_VERSION_MINOR96.MINIMUM_VERSION_MINOR(58) is unchanged; old saves still load.Known limitation of the legacy path, deliberate: for a pre-96 save, the register's old
pending_buildingsentry for a not-yet-issued order stays behind under its old key (those entries are never aged out inBuildingRegister::tick). It is one orphaned map entry per pending order at load time, never read again. Issued orders age out or get found on their own.Verification
checkSum 9421f63c, ASLR left on, freshHOMEper run) on the save that coin-flipped on master. It settles on the value master reaches with ASLR off.MALLOC_PERTURB_0 / 85 / 170 on every seed; on master seed 7 spread 3322/3322/3289.register_building()fallback.TestsRunner(187), savegame-safety, buffered-file, trapped-unit, hiring-bucket, resource-fetch-target, round-trip-hunger-gate, inn-swap, team-stats.Not claimed: this does not change the result of
savegame-safety-teston master (it passes there), so it is not evidence about #232. Re-running #232 on top of this is worthwhile, but the two are not shown to be the same bug.Found and fixed by Carol while trying to make a hiring A/B reproduce; opened by Bob on Leo's request.
🤖 Generated with Claude Code