diff --git a/campaign/board.md b/campaign/board.md index fdd8038..0041e38 100644 --- a/campaign/board.md +++ b/campaign/board.md @@ -63,7 +63,7 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`). | budget tail coverage (expenses/aid/debt) | verify | backlog | — | 0% | 2026-09-08 | 8 ComputeBudget slots were always 0 in ref-turn2 (no sliders, no aid, no debt, no handicap). Need a save with expense sliders, a debtor and a research-aid treaty to exercise ExpenseTotal + the aid/bonus tail | | hook GetDifficultyMods | meta | backlog | — | 0% | 2026-09-08 | B1 derived the two difficulty rows from trace values (AI maintenance divisor 3, research x1.5) instead of snapshotting them; hook it properly so they stop being constants | | section-loader compare crash | verify | backlog | — | 0% | 2026-09-08 | SectionDictionary compare crashes the engine while the identical weapon path succeeds -> fault is in re-running LoadSection, not the manifest reader. Next boundary: hook LoadSection itself. docs/M2.md has 3 ranked leads | -| P2-B4 colony + movement (behavioral) | phase2 | code-complete | med | 70% | 2026-09-08 | CODE-COMPLETE (engine 89c0013), dist-b4 staged, ctest 30/30. Verified 3 signatures (ProcessTurn takes NO stack arg - Ghidra's 2nd param was fiction, would have crashed). 22 corrections incl: population growth has NO pop/capacity term (base is a suitability ratio through pow()) - our model was wrong in SHAPE; range margin is +0.05f not -0.05 (fleets stopped 0.1 short); failed jump SCATTERS around the destination (2nd draw); AccrueSystemBonus gate is ntdev not rbtn; rounding is fistp ties-to-even; only ProcessRebellion draws RNG in a colony turn. NO replace/oracle this milestone (input boundary, deliberate). UNTESTED sub-paths: plague, rebellion, slaves, terraform, addiction, unowned decay, jumps, gate teleports, node-line travel, stranded fleets | AUDIT: `ours` re-reads LIVE post-original fleet state (breaks the compare invariant, verdict partly self-fulfilling); morale events computed then DROPPED (DescribeMoraleEvents never called); PassSchedule/DescribeGateTraffic/DescribeFleetStep are DEAD CODE so the 'predicts call order' claim is unimplemented. Fix before trusting its compare | +| P2-B4 colony + movement (behavioral) | phase2 | verified | high | 100% | 2026-09-08 | LIVE: 36 calls compared, 0 divergences, tracecmp exit 0. Scout's headline: RNG left-delta 0 and mt hash identical on ALL 28 systems (only ProcessRebellion draws; none fired) - fpu_cw 0x127f confirmed. One real fleet move reproduced bit-for-bit; PlanFleetMovement's schedule matched. 3 prototypes + 6 helpers VERIFIED and written back to Ghidra; 22 formula corrections. THREE hook bugs found by reading the trace, not the verdict (stale args from describe_args-before-regions; StrategyServer has TWO bases 4 bytes apart; off_Fleets was a Ghidra-base number used as raw) - each would have given a clean compare that checked nothing. COVERAGE IS THIN: only 3 owned systems, 1 moving fleet, gate traffic all-zero; bats2, plague, rebellion, slaves, terraform, jumps, arrivals untested. No replace mode (input boundary). VM released: main menu, hooks=trace, build b4-fix2-20260908T0615Z | | harness gap: undeclared side-effect lists | verify | verified | high | 100% | 2026-09-08 | FIXED STRUCTURALLY (engine 3f0721f+): compile-time-required Coverage on every descriptor (a hook without one does not compile); Guard regions that catch AND localise undeclared writes (names player+0x2b0, not 'the hash moved'); replace mode now emits records; tracecmp prints coverage on every report + --strict-coverage. A hook claiming 'complete' while a guard caught an undeclared write now counts as a DIVERGENCE. Audit found 23 undeclared side effects: docs/harness-audit.md | | RollResearchEvent draw (behavioural) | verify | backlog | — | 0% | 2026-09-08 | ServerPlayer::RollResearchEvent draws one NextFloat when the completing def is the current target and +0x3b4 is set. Region declared, ours reproduces it, but the branch never fired on ref-turn2 - needs a workload that sets research_roll_pending | | golden-trace recapture (post-guards) | verify | backlog | — | 0% | 2026-09-08 | region sets changed, so goldens must be recaptured: ProcessResearch (expect NEW divergences - the event defect becoming visible), ComputeBudget (verdict should stay 0), and first captures for OnTechResearched / ServerSystem::ProcessTurn / MoveFleet | diff --git a/findings/subsystems/formula-gaps.md b/findings/subsystems/formula-gaps.md index 0485c0b..1507dbd 100644 --- a/findings/subsystems/formula-gaps.md +++ b/findings/subsystems/formula-gaps.md @@ -330,3 +330,25 @@ into the Ghidra project. **any** negative value, with the result clamped into `[0, adjustedSlaveCount]`. * **`HazardMod`'s 0.1 (0x00a1a438) is a true double**, not `(double)0.1f`, and a zero band at the ideal returns NaN (which the capacity's `max(v,0)` then turns into 0). + +### B4 LIVE (2026-09-08) — what the game confirmed, and three offset traps + +36 calls compared, 0 divergences. Confirmations: +* **`ProcessRebellion` really is the only RNG consumer in a colony turn.** Over 28 systems the + generator's `left` did not move and the `mt[624]` hash was identical before and after, on + every single record. The static sweep was right. +* `fpu_cw = 0x127f` (53-bit precision) on every colony record. +* `ServerSystem::ProcessTurn` really does take no arguments — hooked as a one-argument + `__thiscall` for 28 calls per turn across three full runs with no corruption. +* The movement step reproduced bit for bit on the one fleet that moved, and the pass schedule + matched `PlanFleetMovement`'s prediction exactly (no pursuits in this save). + +**Offset traps, all three found live and all three now fixed in the contract:** +1. **The StrategyServer has two bases four bytes apart.** `ServerSystem+0x10` (`owner`) points + at a base four bytes ABOVE the one the class's own methods get in ECX — 0x007437f0 does + `owner - 4` before reading the generator. Every `StrategyServer_off_*` entry here is relative + to the RAW (owner) base; a hook whose `this` is the method base must add 4 first. +2. `StrategyServer_off_Fleets` is **0x60** relative to the raw base (Ghidra `+0x64`). The + earlier 0x64 made a hook enumerate the vector's spare capacity: `ProcessFleetMovement` + reported 1 fleet in a turn where `MoveFleet` was called 7 times. +3. `off_EntityHash` 0x80, `off_ArrivedSet` 0x200, `off_InMotionSet` 0x210 — same −4 correction. diff --git a/ghidra/addresses.json b/ghidra/addresses.json index 48ce08f..02dad63 100644 --- a/ghidra/addresses.json +++ b/ghidra/addresses.json @@ -2286,21 +2286,21 @@ "name": "StrategyServer_off_Players", "offset": "0x00000050", "convention": "offset", - "prototype": "std::vector (begin @+0x50, end @+0x54); numPlayers = (end-begin)>>2", + "prototype": "std::vector (begin @+0x50, end @+0x54); numPlayers = (end-begin)>>2 /* TWO BASES: every StrategyServer_off_* here is relative to the RAW base a ServerSystem's owner word (+0x10) points at. The class's own methods receive a base FOUR BYTES LOWER in ECX (0x007437f0 does owner-4), so a hook on MoveFleet or ProcessFleetMovement must add 4 to `this` before applying these */", "status": "verified", "source": "handoff/b4-colony-movement.md" }, { "name": "StrategyServer_off_Fleets", - "offset": "0x00000064", + "offset": "0x00000060", "convention": "offset", - "prototype": "std::vector (begin @+0x64, end @+0x68) -- one flat global list, not per player", + "prototype": "std::vector (begin @+0x60, end @+0x64) -- ALL fleets of ALL players, one flat global list. B4 live: the earlier 0x64 was the Ghidra-base number transcribed as a raw-base one; it made ProcessFleetMovement enumerate the vector's spare capacity instead of its elements", "status": "verified", "source": "handoff/b4-colony-movement.md" }, { "name": "StrategyServer_off_EntityHash", - "offset": "0x00000084", + "offset": "0x00000080", "convention": "offset", "prototype": "16-bucket hash of entity id -> object, hashed by (key & 0xF); 0x008b9240 is the lookup", "status": "verified", @@ -2316,7 +2316,7 @@ }, { "name": "StrategyServer_off_ArrivedSet", - "offset": "0x00000204", + "offset": "0x00000200", "convention": "offset", "prototype": "std::set> cleared at the head of ProcessFleetMovement", "status": "verified", @@ -2324,7 +2324,7 @@ }, { "name": "StrategyServer_off_InMotionSet", - "offset": "0x00000214", + "offset": "0x00000210", "convention": "offset", "prototype": "std::set> that MoveFleet fills for fleets still in motion; OnFleetArrived takes the set difference", "status": "verified", diff --git a/ghidra/generated/sots_addresses.h b/ghidra/generated/sots_addresses.h index 4e40ca4..d7a7c50 100644 --- a/ghidra/generated/sots_addresses.h +++ b/ghidra/generated/sots_addresses.h @@ -1,5 +1,5 @@ // GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1). -// Source: sots-re ghidra/addresses.json @ 5b6f812, generated 2026-09-08 by tools/gen_addresses.py +// Source: sots-re ghidra/addresses.json @ b878c4c, generated 2026-09-08 by tools/gen_addresses.py // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated). #pragma once #include @@ -577,18 +577,18 @@ constexpr uint32_t ServerSystem_off_ntdev = 0x000002c4; constexpr uint32_t ServerSystem_off_rbtn = 0x000002cc; // offset int ModCount (the turn counter), relative to the RAW server base a ServerSystem's +0x10 points at [verified] constexpr uint32_t StrategyServer_off_ModCount = 0x00000008; -// offset std::vector (begin @+0x50, end @+0x54); numPlayers = (end-begin)>>2 [verified] +// offset std::vector (begin @+0x50, end @+0x54); numPlayers = (end-begin)>>2 /* TWO BASES: every StrategyServer_off_* here is relative to the RAW base a ServerSystem's owner word (+0x10) points at. The class's own methods receive a base FOUR BYTES LOWER in ECX (0x007437f0 does owner-4), so a hook on MoveFleet or ProcessFleetMovement must add 4 to `this` before applying these */ [verified] constexpr uint32_t StrategyServer_off_Players = 0x00000050; -// offset std::vector (begin @+0x64, end @+0x68) -- one flat global list, not per player [verified] -constexpr uint32_t StrategyServer_off_Fleets = 0x00000064; +// offset std::vector (begin @+0x60, end @+0x64) -- ALL fleets of ALL players, one flat global list. B4 live: the earlier 0x64 was the Ghidra-base number transcribed as a raw-base one; it made ProcessFleetMovement enumerate the vector's spare capacity instead of its elements [verified] +constexpr uint32_t StrategyServer_off_Fleets = 0x00000060; // offset 16-bucket hash of entity id -> object, hashed by (key & 0xF); 0x008b9240 is the lookup [verified] -constexpr uint32_t StrategyServer_off_EntityHash = 0x00000084; +constexpr uint32_t StrategyServer_off_EntityHash = 0x00000080; // offset Mars::RNG* relative to the RAW base (== the -4-adjusted base's +0x16c) [verified] constexpr uint32_t StrategyServer_off_RNGPtr = 0x00000168; // offset std::set> cleared at the head of ProcessFleetMovement [verified] -constexpr uint32_t StrategyServer_off_ArrivedSet = 0x00000204; +constexpr uint32_t StrategyServer_off_ArrivedSet = 0x00000200; // offset std::set> that MoveFleet fills for fleets still in motion; OnFleetArrived takes the set difference [verified] -constexpr uint32_t StrategyServer_off_InMotionSet = 0x00000214; +constexpr uint32_t StrategyServer_off_InMotionSet = 0x00000210; // offset int id (also the entity-hash key) [verified] constexpr uint32_t StarFleet_off_Id = 0x00000004; // offset the object whose +0x80 holds the entity hash the waypoint target is resolved in [verified]