Compare commits

...

5 commits

Author SHA1 Message Date
alex
d4f868f823 board: std::string contradiction resolved (0x1c correct, 65 layouts audited, zero wrong); enumeration rule 2026-09-08 05:13:50 -04:00
alex
3768f24f7a dashboard refresh
Regenerated after the lane S integration; it was last written at fdd0b72 and had
drifted 12 board rows. Drops the now-resolved 'notes disagree on std::string
layout' open question.
2026-09-08 05:10:10 -04:00
alex
f965c8c769 lane S: std::string is 0x1c binary-wide; ObservedTech element fully mapped
Settles the 0x18-vs-0x1c contradiction lane X raised. 0x1c is right, everywhere,
and there is exactly one std::string instantiation in this binary:
_Bx@0, _Mysize@0x10, _Myres@0x14, _Alval@0x18.

ObservedTech+0x24 is that string's trailing empty-allocator word, not the
unaccounted data field it was read as. Three complete enumerations of the element
each skip it: ObservedTech::Write 0x00817cf0, the ctor 0x008562a0, and the copy
ctor inlined at 0x0079a184. Generalised with a new scanner, tools/strfootprint.py,
which recovers every (base, disp, tag) handed to the Mars::Stream string helpers:
65 std::string members off a non-stack base across every serializer in the exe,
ZERO with a sibling member inside the 0x1c span, and 51 of the 52 measurable
inter-member gaps exactly 0x1c. Corroborated by the vector<string> walk stride
(add esi,0x1c @0x00699c29), PostEvent's by-value strings at [ebp+8]/[ebp+0x24]
with RET 0x4c, and MoraleEvent 0x50 = name@0x34 + 0x1c.

Blast radius: zero recovered struct tables were wrong. Every string-bearing layout
already used 0x1c spans and 0x1c gaps -- ServerPlayer::pswd @0x2dc..0x2f7, the row
flagged for re-checking, included. Only prose carried the 0x18 number: the
loader-prototypes conventions line, the GlobalConst_ParseString prototype, and the
ObservedTech element table. struct-recovery S0 additionally had _Mysize/_Myres
transposed (size@0x14, res@0x18) while every table in the same file used the
correct offsets; fixed.

ObservedTech's four on-disk fields are now mapped rather than guessed, by reading
the serializer as lane X suggested: +0x04 uint16 otnF, +0x06 uint16 otnL, +0x08
bool odet (ONE BYTE, WriteBool), +0x0c std::string otch (0x1c), +0x28 int owith
= 0x2c exactly. That matches save_reader.py's on-disk order already. Game::
ObservedWeapon (0x00817bc0/0x00817b10) is the same element with tag owep.

Oracles unaffected and re-run: save_reader 36/36 and --strict exit 0 on all three
real saves; state_checksum 38 tests OK, coverage PROVED byte-for-byte on turn1 and
turn3. sots-engine wip/strings 32d3e36 syncs the header and corrects two stale
"unpinned" comments: clean_room_check OK, host ctest 33/33.

Standing rule this produced: never size a struct member from the offsets the code
touches. This build's STL puts the empty allocator LAST in both string (0x1c) and
vector (0x10), and an empty allocator is never loaded or stored, so a touch-scan
undercounts by exactly 4 every time. Size from an enumeration instead.
2026-09-08 05:09:33 -04:00
alex
4606fc5585 board: fpu_cw experiment verified, VM140 released
Marks the fpu_cw sensitivity row verified with the result and the correction to
the briefed control-word values, annotates the state-checksum and determinism-
oracle rows, and sets VM140 exclusivity back to FREE with the lane-F gotchas
(the >60 s startup is real and cost a wasted run; pin the SavedGames file set so
the Load dialog rows do not move; PowerShell-over-SSH quoting).

NOTE: campaign/board.md is shared and was already modified in the working tree
when lane F started, so this commit also carries another lane's in-flight rows
for ObservedTech / std::string. Those are not lane F's edits.
2026-09-08 05:07:59 -04:00
alex
849c5069fc lane F: x87 precision sensitivity measured; STATE_CHECKSUM 3.5 closed
Seven End Turns from ref-turn2.sav on VM140, six control words, whole-state
checksum on every post-turn autosave.

53-bit and 64-bit x87 give byte-identical state across all 35,394 leaves, so
an x64/SSE port computing in IEEE double has NO double-rounding budget to
preserve and floats=bits is free. Two settings do move state, each reproduced
on a repeat run:

  0x007f (24-bit)   Sys[112 "Gamma Cephei"]/Pop2/PopG/PopC 540000000 -> 540000002
  0x1a7f (round-up) Flt[34 "Beta Fleet"]/Pos/.[0] and /Pos/.[2], 1 ULP each

So the port must hold intermediates at 53 bits and use round-to-nearest --
both SSE defaults, now measured rather than assumed, each with a named
regression witness.

The briefed triple was under-powered: 0x027f is 53-bit (it differs from 0x127f
only in bit 12, infinity control, ignored since the 387) and 0x137f is 64-bit,
not a rounding change. Run as written all three come back identical, and that
would have "proved" something false on both axes that matter.

Evidence the forced word actually held: read-back at each force site plus 38
independent in-pipeline hook samples per run spanning turn phases 4, 6 and 8,
all reading the forced value. Mars::Application::Run calls
_controlfp(0x50000,0x3070300) at 0x0089f606 every frame, which is 0x127f, so
forcing at StrategyClient::EndTurn is wiped before the turn runs;
StrategyServer::BeginProcessTurn is the point that works.

Also re-confirms the End-Turn determinism oracle on engine cef889e:
bb4fd9ac... / 978041ac... unchanged.

New tools: verify/fpu-cw/cw_census.py, verify/fpu-cw/trace_bitdiff.py (the
latter exists because under a forced 24-bit word the CRT's own %g rendering
degrades, so trace text is not a valid comparison surface).
2026-09-08 05:07:53 -04:00
55 changed files with 1852 additions and 90 deletions

View file

@ -1,20 +1,20 @@
# SotS RE campaign — coverage dashboard
Generated 2026-09-08 07:47 UTC · `sots-re` @ fdd0b72,2026-09-08 · `sots-engine` @ 45bdf7d,2026-09-08 (74 commits) · regenerate with `tools/dashboard.py`
Generated 2026-09-08 09:10 UTC · `sots-re` @ f965c8c,2026-09-08 · `sots-engine` @ f0cc2a9,2026-09-08 (81 commits) · regenerate with `tools/dashboard.py`
> **North star:** A functional reimplementation of the engine — behavior-equivalent, NOT byte-for-byte
## 1. Map coverage (campaign/board.md)
75 targets · mapped-or-better **55/75** `[███████░░░] 73%` · verified **36/75** `[█████░░░░░] 48%`
87 targets · mapped-or-better **67/87** `[████████░░] 77%` · verified **47/87** `[█████░░░░░] 54%`
| Status | Count | % |
|---|---:|---:|
| verified | 36 | 48% |
| mapped | 19 | 25% |
| in-progress | 3 | 4% |
| backlog | 15 | 20% |
| blocked | 2 | 3% |
| verified | 47 | 54% |
| mapped | 20 | 23% |
| in-progress | 2 | 2% |
| backlog | 16 | 18% |
| blocked | 2 | 2% |
| Type | verified | mapped | in-progress | backlog | blocked | total |
|---|---:|---:|---:|---:|---:|---:|
@ -22,16 +22,16 @@ Generated 2026-09-08 07:47 UTC · `sots-re` @ fdd0b72,2026-09-08 · `sots-engine
| control-flow | 0 | 2 | 0 | 0 | 0 | 2 |
| subsystems | 2 | 7 | 1 | 3 | 1 | 14 |
| engine | 10 | 0 | 0 | 0 | 0 | 10 |
| verify | 8 | 2 | 0 | 9 | 0 | 19 |
| phase2 | 5 | 2 | 2 | 2 | 0 | 11 |
| meta | 6 | 4 | 0 | 1 | 0 | 11 |
| other | 0 | 0 | 0 | 0 | 1 | 1 |
| verify | 11 | 2 | 0 | 10 | 0 | 23 |
| phase2 | 6 | 3 | 1 | 2 | 0 | 12 |
| meta | 10 | 4 | 0 | 1 | 0 | 15 |
| other | 3 | 0 | 0 | 0 | 1 | 4 |
## 2. Binary understanding
- RTTI type descriptors: **1,924** (`Game::` 1,404, `Mars::` 194; serializable types 179)
- Classes with recovered member layouts: **21** / 179 serializable types `[█░░░░░░░░░] 12%` — heuristic: distinct `Game::X`/`Mars::X` in `##`–`####` headings of `struct-recovery.md` + `schema-gaps-resolved.md`
- Functions: **41,411** (parsed from `01-fingerprint.md`); named/annotated in the **address contract** (`ghidra/addresses.json`, not Ghidra's full rename count): **386**, verified **379** `[██████████] 98%`
- Classes with recovered member layouts: **22** / 179 serializable types `[█░░░░░░░░░] 12%` — heuristic: distinct `Game::X`/`Mars::X` in `##`–`####` headings of `struct-recovery.md` + `schema-gaps-resolved.md`
- Functions: **41,411** (parsed from `01-fingerprint.md`); named/annotated in the **address contract** (`ghidra/addresses.json`, not Ghidra's full rename count): **414**, verified **406** `[██████████] 98%`
## 3. Data layer
@ -49,17 +49,17 @@ Generated 2026-09-08 07:47 UTC · `sots-re` @ fdd0b72,2026-09-08 · `sots-engine
| `game/data` | 2,053 | 12 | 462 | yes | game-data.md |
| `game/design` | 1,024 | 17 | 250 | yes | game-design.md |
| `game/effects` | 973 | 3 | 231 | yes | game-effects.md |
| `game/events` | 451 | 3 | 112 | yes | E-events.md |
| `game/sim` | 2,637 | 9 | 599 | yes | game-sim.md |
| `game/events` | 558 | 3 | 152 | yes | E-events.md |
| `game/sim` | 2,720 | 9 | 611 | yes | game-sim.md |
| `mars/parse` | 875 | 12 | 277 | yes | mars-parse.md |
| `mars/rng` | 206 | 0 | 0 | yes | mars-rng.md |
| `mars/stream` | 3,703 | 6 | 222 | yes | mars-stream.md |
| `mars/text` | 899 | 8 | 245 | yes | mars-text.md |
| `mars/vfs` | 788 | 9 | 140 | yes | mars-vfs.md |
| `shim` | 7,620 | 0 | 0 | direct (WIN32) | M0.md |
| `shim/hooks` | 5,050 | 0 | 0 | direct (WIN32) | M0.md |
| `shim` | 8,120 | 0 | 0 | direct (WIN32) | M0.md |
| `shim/hooks` | 5,550 | 0 | 0 | direct (WIN32) | M0.md |
| `shim/trace` | 2,258 | 9 | 273 | direct (WIN32) | shim-trace.md |
| **total** | **29,095** | **92** | **2942** | | |
| **total** | **30,285** | **92** | **2994** | | |
Board `engine:` rows: verified **10**, mapped 0, in flight 0 (of 10) — verified & merged `[██████████] 100%`
@ -84,22 +84,22 @@ Board `engine:` rows: verified **10**, mapped 0, in flight 0 (of 10) — verifie
## 7. Open questions
Open **27** · resolved/parked 10 · backlog items: Now 4, Next 3, Later 2, Breadth queue 7, Parked 1, From the RE how-to 4, Behavioral slice 4
Open **26** · resolved/parked 11 · backlog items: Now 4, Next 3, Later 2, Breadth queue 7, Parked 1, From the RE how-to 4, Behavioral slice 4
Most recent open:
- Some truths are unreachable by compare — B3's draw-divisor bug differed on 0.78% of draws yet flip…
- x87 precision-control mode at runtime — 53-bit MSVC default vs 24-bit if D3D9 grabbed the FPU; mov…
- Struct-modelling hazard (found by M2) — an MSVC-2010 `std::vector` member is three words, so a…
- Notes disagree on MSVC-2010 `std::string` layout — `struct-recovery.md` §0 vs `turn-spine.md` §1.1…
- SAVE_FORMAT tag corrections (fix Python reader + spec) — real on-disk tags: `otnF` (not `ontF`) in…
- Not traced end-to-end — `Species/_NPC/weapons/*.weapon` loading and the `.effect` dictionary entry…
## 8. Delta since previous dashboard
- verified targets: 35 → 36 (+1) · mapped-or-better: 54 → 55 (+1)
- engine LOC: 29,095 → 29,095 (+0) · test files: 92 → 92 (+0) · checks: 2,942 → 2,942 (+0)
- addresses verified: 373 → 379 (+6) · recovered layouts: 21 → 21 (+0) · open questions: 27 → 27 (+0)
- verified targets: 36 → 47 (+11) · mapped-or-better: 55 → 67 (+12)
- engine LOC: 29,095 → 30,285 (+1,190) · test files: 92 → 92 (+0) · checks: 2,942 → 2,994 (+52)
- addresses verified: 379 → 406 (+27) · recovered layouts: 21 → 22 (+1) · open questions: 27 → 26 (-1)
---
warnings: board.md: unknown types objects; mars-rng.md: no oracle total row parsed; mars-stream.md: no oracle total row parsed; mars-vfs.md: no oracle total row parsed
<!-- dashboard-metrics {"verified": 36, "mapped_plus": 55, "targets": 75, "loc": 29095, "tests": 92, "checks": 2942, "addr_verified": 379, "addr_total": 386, "layouts": 21, "open_q": 27} -->
<!-- dashboard-metrics {"verified": 47, "mapped_plus": 67, "targets": 87, "loc": 30285, "tests": 92, "checks": 2994, "addr_verified": 406, "addr_total": 414, "layouts": 22, "open_q": 26} -->

View file

@ -46,7 +46,7 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`).
| engine: mars/stream + rng | engine | verified | high | 100% | 2026-09-07 | merging: 100% exact dump agreement on 3 saves; typed shapes round-trip byte-identical whole file; RNG = seed(RSeed)+2 twists confirmed; 4 tag-name fixes for SAVE_FORMAT |
| engine: game/data catalogs | engine | verified | high | 100% | 2026-09-07 | merged: WeaponDef/ShipSectionDef/TurretTable/IdRegistry/TechTree/StringTable + cross_check; oracle 229,042 values 0 diffs; crosslink set reproduced exactly; 34 malformed shipped tokens pinned |
| engine: mars/vfs (gob) | engine | verified | high | 100% | 2026-09-07 | merged: ZIP reader + native override; 8352+2035 entries = unzip -l; all 10268 files CRC-clean; byte-equal spot checks; ctest 11/11 |
| determinism oracle | verify | verified | high | 100% | 2026-09-07 | BYTE-IDENTICAL across 5 runs incl. cross-process: (Autosave).sav 978041ac…, (Autosave EndTurn).sav bb4fd9ac…; gzip MTIME=0; only loaded-post-turn re-save differs (Player.Status 4->0, Summary.Checksum). findings/subsystems/determinism-oracle.md |
| determinism oracle | verify | verified | high | 100% | 2026-09-07 | BYTE-IDENTICAL across 5 runs incl. cross-process: (Autosave).sav 978041ac…, (Autosave EndTurn).sav bb4fd9ac…; gzip MTIME=0; only loaded-post-turn re-save differs (Player.Status 4->0, Summary.Checksum). findings/subsystems/determinism-oracle.md RE-CONFIRMED 2026-09-08 on engine `cef889e` (lane M's movement fix included) by lane F: `bb4fd9ac…` / `978041ac…` unchanged, and reproduced a further 3x under forced control words that leave the arithmetic alone (0x027f/0x127f/0x137f). |
| engine: shim trace/compare emitter | engine | verified | high | 100% | 2026-09-07 | merged (sots-engine 9e110b4): emitter byte-exact vs mkfixture, tracer, snapshot/diff, Hook<Descriptor>; ctest 18/18; tracecmp exits 0/1/2 as specified; shim links |
| engine parity: first-wins keys + tokenizer rules | engine | verified | high | 100% | 2026-09-07 | merged: Mars::Script tokenizer rules, first-wins keys, trailing-pair drop in mars/parse+mars/text AND Python oracle; oracles 1531/1531, 64/64; only real-data effects: 2 dropped trailing pairs (engine uses defaults), systemnames.txt nesting |
| save-format tag corrections | verify | verified | high | 100% | 2026-09-07 | byte-confirmed at offsets (otnF x62, nextid, FAIDes/DHide/DWep/DName x43, ords, wpts, paths); reader A()-matches them; 36 tests; strict 3/3, infos 259->197; SAVE_FORMAT §10 changelog |
@ -58,7 +58,7 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`).
| P2-B3 ProcessResearch (behavioral, RNG) | phase2 | mapped | high | 85% | 2026-09-08 | LIVE, PARTIAL PASS: 15 calls compared, 13 zero-divergence; RNG post-state matched 14/15 incl. every roll (validates MT19937 + draw mapping + odds together). 2 divergences are the declared SetResearched boundary. ORACLE FAILS by exactly one item across 40,300: an unposted EVENT_RESEARCH_OVERBUDGET - compare was blind because the event list was never a declared region. fpu_cw=0x127f => 53-bit double, x87 question SETTLED. No Zuul in the save: double roll still disassembly-only . RECAPTURED WITH GUARDS 2026-09-08 (lane R): the oracle gap is now a COMPARE DIVERGENCE - `side.events.after.v.next_id orig=4 ours=3` on call 0, its only divergent field, with node[144] progress 2879->5768 and flag 1->2 both reproduced and the single RNG draw identical. 15 calls over 5 turns: 3 diverged, **RNG 15/15** (better than the original 14/15 - no tech-effect draw in this session). Guards on the two completion calls map SetResearched: ConMod[0..2]/OutMod/PopMod, ResTNm, TechTree+0x20 order counter, and the undeclared otch vector |
| RNG signatures (Ghidra) | meta | verified | high | 100% | 2026-09-08 | Seed/Twist/NextFloat/NextInt verified; draw = y/(2^32-1); NextInt [0,n] inclusive; lazy twist; left@+0x9c4. RUNTIME CONFIRMED: fpu_cw=0x127f (53-bit double, round-nearest) - our next_float model is right, float_from_pc24 is an unused contingency |
| engine: game/effects | engine | verified | high | 100% | 2026-09-08 | merged: TechId enum (196 slots @10000+i), 44 ids with typed strategic effects, species flag bits, ApplyTechEffect; 254 checks |
| VM140 exclusivity (lab rule) | meta | verified | high | 100% | 2026-09-08 | one agent at a time. Holder: **F-fpucw** (M-movefleet released 2026-09-08 03:49 local; R-recapture before it). QUEUE: empty. VM left at the MAIN MENU, `hooks=trace`, build `recap-7584bad-20260908T0615Z` restored from `C:\SOTS\shimdist-recap` (that dist also carries `shim.cfg.recap{trace,b3,b1,misc}`). Lane M also left `C:\SOTS\shimdist-mf` + `C:\SOTS\ui\mf{deploy,release}.ps1` in place - harmless, and a working template for the next lane. Windows Update DISABLED/paused on the VM. Non-holders build /srv/re-lab/build/sots-engine-<lane>, stage dist-<lane>, deploy C:\SOTS\shimdist-<lane>. GOTCHA (lane R): after `schtasks /Run /TN SOTS` the main menu can take >60 s - SCREENSHOT AND VERIFY before clicking, or the click path lands in Credits. GOTCHA (lane M): drive the load dialog ONE rui.ps1 CALL PER CLICK with a screenshot between - a single chained cmd.txt loses sync and silently ends up somewhere else. And the Load Game dialog does NOT pre-select Single Player on a fresh launch: the documented path really is Load Game (512,536) -> Single Player (512,290) -> OK (551,523) -> row -> OK (682,624) -> Launch (511,663). ref-turn2 row is at (400,436) |
| VM140 exclusivity (lab rule) | meta | verified | high | 100% | 2026-09-08 | one agent at a time. Holder: **FREE** (F-fpucw released 2026-09-08 05:05 local; M-movefleet before it). QUEUE: empty. VM left at the MAIN MENU, `hooks=trace`, build `recap-7584bad-20260908T0615Z` restored from `C:\SOTS\shimdist-recap` (that dist also carries `shim.cfg.recap{trace,b3,b1,misc}`). Lane M also left `C:\SOTS\shimdist-mf` + `C:\SOTS\ui\mf{deploy,release}.ps1` in place - harmless, and a working template for the next lane. Windows Update DISABLED/paused on the VM. Non-holders build /srv/re-lab/build/sots-engine-<lane>, stage dist-<lane>, deploy C:\SOTS\shimdist-<lane>. GOTCHA (lane R): after `schtasks /Run /TN SOTS` the main menu can take >60 s - SCREENSHOT AND VERIFY before clicking, or the click path lands in Credits. GOTCHA (lane M): drive the load dialog ONE rui.ps1 CALL PER CLICK with a screenshot between - a single chained cmd.txt loses sync and silently ends up somewhere else. And the Load Game dialog does NOT pre-select Single Player on a fresh launch: the documented path really is Load Game (512,536) -> Single Player (512,290) -> OK (551,523) -> row -> OK (682,624) -> Launch (511,663). ref-turn2 row is at (400,436) GOTCHA (lane F, confirms lane R): the >60 s startup is REAL and cost a whole wasted run — do not sleep-and-click, **verify the main menu from a screenshot** (`verify/fpu-cw/` run scripts poll a screenshot until the Load Game / Exit buttons are bright red; 3 probes ≈ 25 s was typical). TIP (lane F): reset `SavedGames\` to a fixed file set before every run — the Load dialog row positions depend on how many files are listed, so a constant set means the click path never has to be re-derived (with the 4-file set ref-turn2 sits at (400,348), not (400,436)). PowerShell over SSH mangles quoting badly: send snippets base64 as `powershell -EncodedCommand`, or use `-ExecutionPolicy Bypass -File`. Lane F left `C:\SOTS\shimdist-fpu` + `C:\SOTS\ui\f{deploy,grab,fpu}.ps1` + `C:\SOTS\ui\preF\` (the pre-lane-F SavedGames snapshot, restored) in place. VM RESTORED: recap build `recap-7584bad-20260908T0615Z`, `hooks=trace`, SavedGames back to the 7-file pre-F set, main menu verified by screenshot. |
| Zuul double-roll (behavioural) | verify | backlog | — | 0% | 2026-09-08 | CONFIRMED NEEDED: ref-turn2 has only species 0 and 2, so the double roll is verified by disassembly + host tests only. Needs one compare from a species-5 save; the check is just that `left` drops by 2 not 1 |
| 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 . CONFIRMED AND WORSE 2026-09-08 (lane R, 4284 calls): **13 of 22 slots are 0 on every call** - tradeIncome, shipCarriedPop, secondaryManager, bonusIncome, systemIncomeNeg, debtInterest, construction, expenses, researchMoneyGiven, savingsGiven, tra, researchPointsGiven, trp |
| 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 |
@ -75,20 +75,23 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`).
| B1 replace double-run | verify | backlog | — | 0% | 2026-09-08 | ComputeBudget replace mode runs the original a second time to harvest budget slots; ComputeOutput repairs ships in orbit as a side effect, so this is a real per-turn double effect on objects no region covers. Needs a design fix (harvest without re-running, or declare+revert) |
| ReVa MCP link drop (workaround) | meta | verified | high | 100% | 2026-09-08 | The ReVa MCP client link dropped mid-session while the CT111 server stayed healthy (systemd active, :8080 listening, valid key -> 200). `tools/reva_call.py <tool> '<json>'` calls the same server over plain HTTP (initialize -> notifications/initialized -> tools/call; replies are SSE with a leading `id:` line, initialize is plain JSON). Key is NEVER stored in the repo: $REVA_KEY, else ~/.claude.json, else ssh to the CT properties file. Use this whenever mcp__plugin_ReVa_ReVa__* is unavailable |
| event posting API | subsystem | mapped | high | 90% | 2026-09-08 | RECOVERED (lane E, `findings/subsystems/events.md`). Container: `EventStorage` embedded at `ServerPlayer+0x29c` (0x1c), `EvNxID` at +0x14 = player+0x2b0 — exactly the guard's byte run. Nested `vector<TurnEvents{int EvTurn; vector<PlayerEvent>}>`, record 0x74 B, tags `EvEID EvDsc EvMsg EvImg EvLoc EvPos EvAct EvCID`; layout confirmed field-by-field against turn3-state.sav, which CONTAINS the overbudget record. Entry point `int __thiscall EventStorage::PostEvent(this, string BYVAL, string BYVAL, obj*, Vector3*, turn, const char* img, int act)` 0x008862b0 RET 0x4c — **161 call sites in 113 functions, the whole sim's event API**. B3 defect fully explained: 0x00587b97, in the completion-roll-FAILED branch under `!wasDone && nowDone && owner`. 3 note corrections (EvPos is FLT_MAX not inf; the save array is turn-bucketed not flat; TECHS_UNLOCKED has no parent clause). 56 entries in addresses.json; 11 prototypes + 13 labels + 12 comments + 2 structs written back to Ghidra. Engine: `sots-engine` branch `wip/events` a7348be, `src/game/events` + 112 checks, ctest 32/32. NOT YET WIRED INTO A HOOK — see `docs/E-events.md` for the proposed region/Coverage change |
| state-checksum replay harness | verify | verified | high | 90% | 2026-09-08 | Lane C: `verify/state-checksum/` (tool, 38 tests, `STATE_CHECKSUM.md`, evidence in `verify/results/state-checksum/`). Whole-state digest tree; **coverage is PROVED by byte-for-byte re-serialisation**, not declared - the answer to empty-region-set green verdicts. Localises: the known load->re-save delta reports as exactly 5 named leaves (`/Sim/players/Player[496 "Singularity"]/Status: 4 -> 0`, `/Summary/Checksum`), and one real End Turn as 108 attributed diffs. All 10 saves STABLE + COVERED. Float policy = exact bits by default, `canonical` for -0.0/NaN only, **tolerance deliberately not a hashing mode** (it lives in `--ulps` on the differ); corpus has 0 NaN/-0.0/subnormals so canonical is a no-op today. Chain record/verify validated on the real turn1-3 saves. REMAINING 10%: the VM-driven replay loop is designed (§5) but UNRUN - needs the VM holder. Open question named in §3.5 with the experiment that settles it (force `fpu_cw` 0x027f/0x127f/0x137f across End Turn, checksum the three autosaves) |
| state-checksum replay harness | verify | verified | high | 90% | 2026-09-08 | Lane C: `verify/state-checksum/` (tool, 38 tests, `STATE_CHECKSUM.md`, evidence in `verify/results/state-checksum/`). Whole-state digest tree; **coverage is PROVED by byte-for-byte re-serialisation**, not declared - the answer to empty-region-set green verdicts. Localises: the known load->re-save delta reports as exactly 5 named leaves (`/Sim/players/Player[496 "Singularity"]/Status: 4 -> 0`, `/Summary/Checksum`), and one real End Turn as 108 attributed diffs. All 10 saves STABLE + COVERED. Float policy = exact bits by default, `canonical` for -0.0/NaN only, **tolerance deliberately not a hashing mode** (it lives in `--ulps` on the differ); corpus has 0 NaN/-0.0/subnormals so canonical is a no-op today. Chain record/verify validated on the real turn1-3 saves. REMAINING 10%: the VM-driven replay loop is designed (§5) but UNRUN - needs the VM holder. Open question named in §3.5 with the experiment that settles it (force `fpu_cw` 0x027f/0x127f/0x137f across End Turn, checksum the three autosaves) **§3.5 CLOSED 2026-09-08 by lane F** (see the fpu_cw row): measured, not assumed — 53-bit == 64-bit, so `floats=bits` costs the SSE port nothing; 24-bit and round-up each name one witness. The tool localised both to single leaves out of 35,394, with coverage PROVED on all 8 new saves. |
| MoveFleet position ULP divergence | phase2 | verified | high | 100% | 2026-09-08 | **DONE (lane M).** First arithmetic divergence caught by BEHAVIOURAL compare rather than static reading, and it is fixed by matching the original's precision sequence rather than by fitting numbers - see the `MoveFleet position rounding (1 ULP)` row for the mechanism. Live 8 -> 0 on the same 45 calls, control run included so the before/after is this lane's own measurement. **COVERAGE IS UNCHANGED AND STILL THIN**: 15 of 45 calls move and all 15 are the same straight-run waypoint type. Waypoint types 2-5 were ATTEMPTED and could NOT be reached - the only player that would travel a node line has `DE 00 CR 00 DN 00` at its home system on this save, so its Move/Manage Fleets buttons are greyed out every turn and there is literally nothing to send along the node lines the map draws. Reaching them needs a ship BUILT over several turns, or (cheaper) a purpose-built save that starts with a fleet in orbit beside a node line. The node-line step is still wrong by construction: NodeLineStep/BuildStutterSegments exist and are unit-tested but are not wired into the hook |
| ObservedTech append (undeclared) | verify | backlog | — | 0% | 2026-09-08 | Lane R's guards caught a vector<ObservedTech> append at `player+0x274` during SetResearched. It is SERIALIZED state and appears in NO coverage note anywhere - found only because guards localise rather than just flag a moved hash. Needs a declared region + a model in ours |
| fpu_cw sensitivity experiment | verify | backlog | — | 0% | 2026-09-08 | QUEUED FOR VM140 (lane M holds it). Lane C cannot tell whether any turn-pipeline value actually DEPENDS on x87 intermediate precision - every save on this host was made at fpu_cw=0x127f, so "the x64/SSE port must match bit-for-bit" is currently POLICY, not a measured requirement. Experiment: End Turn from ref-turn2.sav 3x with the shim forcing fpu_cw to 0x027f / 0x127f / 0x137f, then state_checksum the three autosaves. All equal => no double-rounding budget needed, closes STATE_CHECKSUM 3.5. Different => the diff IS the answer, naming every precision-sensitive field |
| fpu_cw sensitivity experiment | verify | verified | high | 100% | 2026-09-08 | **DONE (lane F): 53-bit vs 64-bit x87 makes NO difference — the SSE port has no double-rounding budget to preserve, `STATE_CHECKSUM.md` §3.5 CLOSED.** 7 End Turns from ref-turn2, 6 control words, whole-state checksum on each. stock / 0x027f / 0x127f / **0x137f (64-bit)** all give `978041ac…` identical across all 35,394 leaves. Two settings DO move state, each reproduced on a repeat run: **0x007f (true 24-bit)** -> `/Sim/systems/Sys[112 "Gamma Cephei"]/Pop2/PopG/PopC 540000000->540000002` (+derived Summary/Checksum); **0x1a7f (53-bit, round-UP)** -> `/Sim/fleets/Flt[34 "Beta Fleet"]/Pos/.[0]` and `/Pos/.[2]`, 1 ULP each. So the port must hold intermediates at 53 bits (never compute a chain in `float`) and use round-to-nearest — both SSE defaults, now measured. **THE BRIEFED TRIPLE WAS UNDER-POWERED: 0x027f is 53-bit (differs from 0x127f only in bit 12, infinity control, ignored since the 387) and 0x137f is 64-bit, not a rounding change** — run as written, all three come back identical and would have 'proved' something false. PC=bits 8-9, RC=bits 10-11. Setting PROVEN to hold: read-back at each force + 38 in-pipeline hook samples per run spanning turn phases 4/6/8, all at the forced value (`verify/results/fpu-cw/cw-census.txt`). `Mars::Application::Run` calls `_controlfp(0x50000,0x3070300)` at 0x0089f606 EVERY FRAME = 0x127f, so forcing at EndTurn is wiped before BeginProcessTurn; BeginProcessTurn is the point that works. TRAP: under 24-bit the CRT's own %g rendering degrades, so trace TEXT is not a comparison surface — use `verify/fpu-cw/trace_bitdiff.py`. findings/subsystems/fpu-precision-sensitivity.md |
| Summary.Checksum algorithm | objects | blocked | — | 0% | 2026-09-08 | Lane C RULED OUT two candidates so nobody repeats them: NOT a byte sum over the inflated stream, NOT a sum over the int leaves. Each is consistent with the -16 re-save delta but leaves no constant residual across turns |
| event posting in ours | phase2 | in-progress | — | 0% | 2026-09-08 | Lane P: make ours actually post events so ProcessResearch's `side.events.after.v.next_id` 4->3 divergence closes. Converts harness-audit row 1 from known-defect to checked, and unbounds B2/B3 whose clean compares currently cover economy fields only |
| LAB RULE: no `git add -A` in sots-re while lanes run | meta | verified | high | 100% | 2026-09-08 | MY error, caught by lane M: an integrator `git add -A` in the SHARED `sots-re` clone swept a running lane's in-progress files into commit 9d385a7 mid-run (remainder landed in f5b37c2). Nothing was lost, but authorship and atomicity were. RULE: while any lane is live, the integrator stages sots-re by explicit PATH only (`git add campaign/board.md campaign/DASHBOARD.md`), never `-A`. Lanes own their own subtrees. sots-engine is unaffected - lanes work in per-lane worktrees there, which is exactly why that repo has not had this problem |
| MoveFleet waypoint types 2-5 | verify | backlog | — | 0% | 2026-09-08 | Still ZERO behavioural coverage after lane M. Not for lack of trying: the only mover in ref-turn2 is the AI (straight runs only), and the player that would travel a node line has DE/CR/DN all 00 at its home system, so Move/Manage Fleets are greyed out every turn - there is nothing to send along the node lines the map draws. Needs a ship built over several turns or a purpose-built save. The type-2 node-line step is still WRONG BY CONSTRUCTION (B4). Also: sim::Distance deliberately left in double (only stutter geometry uses it); Mars_Vec3_Length says it is probably 1 ULP out the same way, but there is zero behavioural evidence to correct it against - do not "fix" it blind |
| P2-P event posting in ours | phase2 | mapped | high | 80% | 2026-09-08 | HOST-VERIFIED, VM RUN QUEUED (lane F holds VM140). next_id reaches 4 in a host reproduction of recap-b3 call 0, fixture rebuilt from raw bytes at the real 0x1c/0x18/0x74 strides and cross-checked against turn3-state.sav with lane C's state_checksum --tree. Count-only (lane E option a): ours never calls the game's PostEvent and REPLACE MODE WRITES NOTHING - a bumped EvNxID with no record behind it would corrupt the very save the oracle hashes. Three design points: the event scan is taken in describe_args BEFORE the original (taken after, ours would dedup against the original's own posts and agree for the wrong reason); dedup risk is MEASURED and reported as events_dedup_risk, not assumed; KeylessEventText resolves keys to "%s" so the shim carries no prose. VERIFIED from the instruction stream: SetResearched 0x00581e10 calls owner vft+0x10 with (flags>>2)&1 and ProcessResearch passes flags=2, so silent=false and the completion event IS posted - previously only inferable from "EvNxID moved by two". ctest 33/33, shim cross-builds on CT111 (lane P could only syntax-check) |
| EVENT_TECHS_UNLOCKED not posted (predicted residual) | verify | backlog | — | 0% | 2026-09-08 | Lane P FLAGGED RATHER THAN GUESSED. Trigger IS pinned (SetResearched's sweep sets state=2 + stamps turnAvailable sticky at -1; tail loop collects state==2 && turnAvailable==currentTurn) but evaluating it needs the unlock cascade ours deliberately does not run. The driver takes the unlock list as an INPUT and is handed nullptr ("no list") - deliberately distinct from an empty list ("computed, empty"). PREDICTED RESIDUAL: next_id short by exactly 1 on every completion call. Posting it "whenever something completed" would score on this save and be WRONG the first time a completion unlocks nothing - the exact false-pass shape this project keeps catching |
| sizeof(ObservedTech) unpinned | objects | verified | high | 100% | 2026-09-08 | **PINNED (lane X).** `sizeof(Game::ObservedTech) = 0x2c (44)` -- three independent proofs: the magic divide `0x2e8ba2e9 sar 3` (= /44, exact) at 0x0087239f, `imul reg,reg,0x2c` at 0x0087243a / 0x007b735b, and the search stride `add edi,0x2c` at 0x007ba257. **Append site = `RecordObservedTech+0xdf` (0x007ba27f): `lea ecx,[player+0x274]; call vector_ObservedTech_push_back 0x007b7320`** -- a de-duplicating append, direct callee of OnTechResearched 0x00891790; the realloc through 0x007b5820 is why all three vector words move. Element: vptr 0x00a2439c at +0 (RTTI `.?AVObservedTech@Game@@`), std::string at +0x0c (0x18 B); +0x04/+0x06 (16-bit) and +0x08/+0x24/+0x28 hold the four on-disk ints in an order NOT yet determined. Built the general tool the row asked for: `tools/x86disp.py`, an x86 displacement xref scanner (100% code coverage, 0.17% desync). `findings/subsystems/observedtech-append.md` |
| sizeof(ObservedTech) unpinned | objects | verified | high | 100% | 2026-09-08 | **PINNED (lane X).** `sizeof(Game::ObservedTech) = 0x2c (44)` -- three independent proofs: the magic divide `0x2e8ba2e9 sar 3` (= /44, exact) at 0x0087239f, `imul reg,reg,0x2c` at 0x0087243a / 0x007b735b, and the search stride `add edi,0x2c` at 0x007ba257. **Append site = `RecordObservedTech+0xdf` (0x007ba27f): `lea ecx,[player+0x274]; call vector_ObservedTech_push_back 0x007b7320`** -- a de-duplicating append, direct callee of OnTechResearched 0x00891790; the realloc through 0x007b5820 is why all three vector words move. Element FULLY MAPPED (lane S, from ObservedTech::Write 0x00817cf0 / Read 0x00817c40): +0x00 vptr 0x00a2439c (RTTI `.?AVObservedTech@Game@@`), +0x04 uint16 `otnF`, +0x06 uint16 `otnL`, +0x08 **bool** `odet` (1 byte), +0x0c std::string `otch` (**0x1c**, so +0x24 is its _Alval, NOT a field), +0x28 int `owith` -- 0x2c exactly, nothing unaccounted. Same shape as Game::ObservedWeapon (Write 0x00817bc0, tag `owep`). Built the general tool the row asked for: `tools/x86disp.py`, an x86 displacement xref scanner (100% code coverage, 0.17% desync). `findings/subsystems/observedtech-append.md` |
| lea-displacement xref scanner | meta | verified | high | 100% | 2026-09-08 | `tools/x86disp.py` -- fixes the systemic blind spot that Ghidra does not index ModRM displacements. Full x86-32 length decoder swept from Ghidra's 41,089 function starts: 2,174,504 instructions, 612,166 displacement sites, **100.0% code coverage, 70 desyncs (0.17%), zero unknown opcodes**. Validated against ground truth before use (re-finds `lea eax,[ecx+0x29c]` in GetEventStorage, both OnTechResearched +0x29c sites, and one NEW ProcessTurn site). HONEST LIMITS: it is a **recall** tool, not an oracle -- class-level precision at 0x274 is ~13% by function (99 sites / 45 functions, ~6 real), i.e. a 900x search-space cut that still needs one call-graph check. The naive byte scan it replaces is not wrong so much as **blind**: it misses 80/99 real sites at 0x274 and 13,784/14,611 at disp8 0x14. `cohort` ranking must never be used as a hard filter -- it would have discarded the correct ObservedTech answer. Works off a gitignored local cache in `dumps/`, so it does not hammer CT111 |
| lea-displacement xref scanner | meta | verified | high | 100% | 2026-09-08 | `tools/x86disp.py` - full x86-32 length decoder swept from 41,089 Ghidra function starts: 2,174,504 instructions, 612,166 disp sites, 0 unknown opcodes, 100.0% code coverage, 0.17% desyncs. VALIDATED against ground truth before any new claim (rediscovers GetEventStorage's lea ecx+0x29c, EvNxID +0x2b0; positive control: given 50 known ServerPlayer offsets, FUN_0087fac0 scores 50/50 = the serializer, nothing close). BUILD GOTCHA: clipping sweeps at fva+Ghidra sizeInBytes lost 11% of functions to mid-instruction truncation; sweeping to the NEXT function start took coverage 89% -> 100%. HONEST LIMIT: the win is RECALL not precision - naive lea-only scan MISSES 80 of 99 real 0x274 sites; class-level precision ~13% by function. Value = search space 41,411 -> 45 (~900x), then disambiguate by call graph |
| RANKER TRAP: cohort filter discards correct answers | meta | verified | high | 100% | 2026-09-08 | Lane X's cohort ranker WOULD HAVE DISCARDED THE CORRECT ANSWER. RecordObservedTech touches only 0x274/0x278 and nothing else on ServerPlayer, so every --min>=1 cohort filter drops it. What actually closed the case was plain `query` + ONE call-graph lookup on OnTechResearched's callees. RULE now in the tool docstring: it is a RANKER, NEVER a filter. Displacement scan for recall, call graph for disambiguation; neither alone sufficed |
| ObservedTech struct | objects | verified | high | 100% | 2026-09-08 | sizeof = 0x2c (44) by THREE independent proofs: exact magic divide 0x2e8ba2e9 sar 3 (= ceil(2^35/44), emulated against n=0..1000) at 0x0087239f; imul reg,reg,0x2c at 0x0087243a/0x007b735b; search stride add edi,0x2c at 0x007ba257. APPEND SITE: RecordObservedTech+0xdf (0x007ba27f) `lea ecx,[player+0x274]; call vector_ObservedTech_push_back 0x007b7320`. RecordObservedTech (0x007ba1a0) is a DIRECT CALLEE of OnTechResearched and DE-DUPLICATES BY TECH NAME before appending - a naive push_back in the reimpl WOULD DIVERGE on re-observation. Realloc through 0x007b5820 explains why lane R saw all three vector words move. Element: vptr 0x00a2439c at +0 (RTTI .?AVObservedTech@Game@@). The four on-disk ints map onto +0x04/+0x06 (16-bit) and +0x08/+0x24/+0x28 - NOT DETERMINED, lane X did not guess it; settle via ObservedTech's serializer or lane P's live byte delta (should read exactly 44) |
| std::string size 0x18 vs 0x1c CONTRADICTION | objects | backlog | — | 0% | 2026-09-08 | Lane X reports ObservedTech's embedded std::string at +0x0c occupying 0x18 bytes, NOT the 0x1c our standing MSVC fact asserts (_Bx@0, _Mysize@0x10, _Myres@0x14, _Alval@0x18). 0x1c is used across MANY recovered layouts, so if the real allocator member is elided (empty-base optimisation) in some instantiations we may have LATENT OFF-BY-4 ERRORS in other structs. Foundational - audit before trusting any further string-bearing layout |
| ObservedTech struct | objects | verified | high | 100% | 2026-09-08 | sizeof = 0x2c (44) by THREE independent proofs: exact magic divide 0x2e8ba2e9 sar 3 (= ceil(2^35/44), emulated against n=0..1000) at 0x0087239f; imul reg,reg,0x2c at 0x0087243a/0x007b735b; search stride add edi,0x2c at 0x007ba257. APPEND SITE: RecordObservedTech+0xdf (0x007ba27f) `lea ecx,[player+0x274]; call vector_ObservedTech_push_back 0x007b7320`. RecordObservedTech (0x007ba1a0) is a DIRECT CALLEE of OnTechResearched and DE-DUPLICATES BY TECH NAME before appending - a naive push_back in the reimpl WOULD DIVERGE on re-observation. Realloc through 0x007b5820 explains why lane R saw all three vector words move. Element FULLY MAPPED by lane S via the serializer lane X pointed at: +0x00 vptr 0x00a2439c (RTTI .?AVObservedTech@Game@@), +0x04 uint16 otnF, +0x06 uint16 otnL, +0x08 bool odet (ONE BYTE), +0x0c std::string otch (0x1c -> +0x24 is the string's _Alval, not a field), +0x28 int owith. Matches save_reader.py's on-disk order exactly. Lane P's live byte delta should still read exactly 44 |
| std::string size 0x18 vs 0x1c CONTRADICTION | objects | verified | high | 100% | 2026-09-08 | **RESOLVED (lane S): 0x1c was right all along; ONE layout binary-wide.** `_Bx@0, _Mysize@0x10, _Myres@0x14, _Alval@0x18`, sizeof 0x1c. `ObservedTech+0x24` is the string's own trailing allocator word, not the unaccounted data field lane X read it as. Settled by three COMPLETE ENUMERATIONS of the element, each of which skips +0x24: `ObservedTech::Write` 0x00817cf0 (serialises +0x04/+0x06/+0x08/+0x0c/+0x28 and nothing else), `ObservedTech_ctor` 0x008562a0, and the inlined copy ctor at 0x0079a184. Then generalised: new `tools/strfootprint.py` recovers every `(base,disp,tag)` handed to the Mars::Stream string helpers across the whole exe -- **65 std::string members off a non-stack base, ZERO with a sibling field inside the 0x1c span, 51 of the 52 measurable inter-member gaps exactly +0x1c** (the one +0x20 is StrategyServer KeyPath, +0x1c on its own Read side -- the writer skips a member). Corroborated by the vector<string> walk stride `add esi,0x1c` @0x00699c29, PostEvent's by-value strings at [ebp+8]/[ebp+0x24] with RET 0x4c, and MoraleEvent 0x50 = name@0x34 + 0x1c. NO empty-base variant, no custom allocator, no game-local string class. BLAST RADIUS: **zero recovered struct tables were wrong** -- every string-bearing layout in struct-recovery / save-editor-structs / events / schema-gaps-resolved already used 0x1c, `ServerPlayer::pswd` @0x2dc..0x2f7 included (Write 0x008563e0 puts the next member exactly 0x1c above). Only 4 prose statements carried the 0x18 number, all corrected. save_reader 36/36 and state_checksum unaffected and still green. **LESSON: never size a struct member from the offsets the code TOUCHES** -- `_Alval` is an empty allocator, never loaded or stored, so a touch-scan undercounts every string AND every vector by exactly 4. Size from an enumeration: serializer, ctor, copy ctor, or container stride. `observedtech-append.md` §9 |
| harness-audit row 11 CORRECTED (Budget+0x64) | verify | verified | high | 100% | 2026-09-08 | Lane X: row 11 is NOT SUPPORTED. ComputeBudget writes its Budget* only through esi into +0x00..+0x54; its only two +0x64 accesses are LOADS OFF A DIFFERENT BASE. And ProcessResearch's `int* overbudget` is a ProcessTurn STACK LOCAL (lea edx,[ebp-0x14] at 0x008914a5), not Budget+0x64. Agrees with lane R's 0-of-4284 guard result. Reclassified to "nothing shown to write it"; only a watchpoint settles it definitively |
| STANDING RULE: size structs by enumeration, never by touch-scan | meta | verified | high | 100% | 2026-09-08 | Produced by lane S after lane X's 0x18/0x1c scare. `_Alval` is std::allocator<char>, an EMPTY class: it occupies a word but is NEVER loaded or stored, so it is INVISIBLE to any analysis based on what the code touches. Sizing a member that way undercounts by exactly 4. RULE: size a member from an ENUMERATION - serializer, ctor, copy ctor, or container stride - because an enumeration can show ABSENCE where a touch-scan cannot. Same trap is live for std::vector here: {_Myfirst,_Mylast,_Myend,_Alval} = 0x10, ALLOCATOR-LAST, the opposite of the MSVC _String_val allocator-first shape the textbooks describe. Now in re-windows-2000s-howto.md 1c and struct-recovery.md 0 |
| std::string 0x18 vs 0x1c CONTRADICTION - RESOLVED | objects | verified | high | 100% | 2026-09-08 | 0x1c WAS RIGHT ALL ALONG; lane X mis-attributed ObservedTech+0x24, which is the string's trailing empty-allocator word. ONE layout binary-wide: _Bx@0 (16-byte SSO union), _Mysize@0x10, _Myres@0x14, _Alval@0x18. Proof by three COMPLETE ENUMERATIONS of ObservedTech (Write 0x00817cf0, ctor 0x008562a0, inlined copy ctor 0x0079a184) each of which skips +0x24, then generalised by new `tools/strfootprint.py`: 65 std::string members off a non-stack base, ZERO with a sibling inside the 0x1c span, 51 of 52 measurable inter-member gaps exactly 0x1c (the one 0x20 is StrategyServer::KeyPath, 0x1c on its own Read side - the WRITER skips a member). No 0x18 instantiation, no EBO variant, no custom allocator, no game-local string class. BLAST RADIUS: 65 layouts audited, ZERO were wrong - including ServerPlayer::pswd, the row lane X flagged. Only PROSE carried the 0x18 number. Separately found+fixed: struct-recovery.md 0 had _Mysize/_Myres TRANSPOSED while every table in the same file used the correct offsets |
| ObservedTech on-disk mapping | objects | verified | high | 100% | 2026-09-08 | READ, NOT GUESSED, from ObservedTech::Write 0x00817cf0 / Read 0x00817c40: +0x00 vptr, +0x04 u16 otnF, +0x06 u16 otnL, +0x08 bool odet (ONE byte, WriteBool), +0x0c std::string otch (0x1c), +0x28 int owith = 0x2c exactly, nothing unaccounted. Lane X's flagged-as-hypothesis first-seen/last-seen pair CONFIRMED by the tag names. Game::ObservedWeapon (0x00817bc0/0x00817b10) is the identical element with tag owep. Matches save_reader.py's on-disk order exactly - independent agreement between disassembly and the save oracle. `odet` typed int in the reader is BENIGN (for a 4-char tag a bool item and an int item are both 12 bytes, same value); all 60/61/61 real-save values are 00000000 so the SAVES do not discriminate - the binary does |

View file

@ -38,7 +38,7 @@ Each links to the finding that raised it. Promoted to backlog or closed by **re-
- **SAVE_FORMAT tag corrections (fix Python reader + spec)** — real on-disk tags: `otnF` (not `ontF`) in Odes/Owep/Otch, `nextid` (not `nextId`) in NdGr2, Design = `FAIDes/DHide/DWep/DName`; `ords`/`wpts` are real tags. Python's positional R() matching hid these. RNG: float mapping `(float)(y*2^-32)`, `next_int` mask, and lazy-vs-eager twist at `left==0` still need binary confirmation. (from [[mars-stream]])
- **RESOLVED: tech `allows` default** — unlisted species = 1.0 (confirmed in `FUN_005822d0` tree-creation roll, strategic-turn-internals §2); `game/data` uses 100. Still open from game/data: what the engine's converter does with the 34 malformed tokens (`force_right o`, `crew false`, `1.0f`, `0-5`, ``90\``); repeated scalars in a block are last-wins per the sequential if/else consumers (loader-prototypes §M3) — confirm on a fixture in compare mode. (from [[game-data]])
- **RESOLVED: SAVE_FORMAT tag names** — all corrections confirmed by bytes and applied to the Python reader + spec (§10). Residual doc debt: `findings/objects/save-editor-structs.md` and `verify/design-rules/SHIP_DESIGN_RULES.md` still quote R1's `ontF/faiDes/nextId` spellings (R1-provenance; annotate rather than rewrite). (from [[SAVE_FORMAT]])
- **Notes disagree on MSVC-2010 `std::string` layout** — `struct-recovery.md` §0 vs `turn-spine.md` §1.1 give different offsets; pin both from the `_Myres >= 16` SSO branch in `Stream::WriteString` and correct the loser. (from [[re-windows-2000s-howto]])
- **RESOLVED (2026-09-08, lane S): `std::string` layout and size, once and for all** — `_Bx@0, _Mysize@0x10, _Myres@0x14, _Alval@0x18`, **sizeof 0x1c**, and there is exactly **one** instantiation in this binary. `struct-recovery.md` §0 was right (its §0 prose had `size`/`res` transposed — fixed); `turn-spine.md` §1.1 was wrong (already annotated); `loader-prototypes.md` and one `addresses.json` prototype said 0x18 — fixed. Lane X's `ObservedTech` 0x18 reading was a mis-attribution: `+0x24` is the string's trailing `_Alval`, not a data field, proved by three complete enumerations of the element (`ObservedTech::Write` 0x00817cf0, ctor 0x008562a0, copy ctor 0x0079a184) and generalised over the whole exe by `tools/strfootprint.py` (65 string members, 0 collisions inside the 0x1c span, 51/52 gaps exactly 0x1c). **Zero recovered struct tables were wrong** — `pswd` included. Standing rule that follows: *never size a struct member from the offsets the code touches* — this build's STL puts the empty allocator **last** in both `string` (0x1c) and `vector` (0x10), and an empty allocator is never loaded or stored, so a touch-scan undercounts by 4 every time. See `observedtech-append.md` §9. (from [[re-windows-2000s-howto]], [[observedtech-append]])
- **RESOLVED: formula gaps (all 8)** — see [[formula-gaps]]; game/sim's low-confidence functions can now be pinned. **RESOLVED: std::string** = `_Bx@0,_Mysize@0x10,_Myres@0x14,_Alval@0x18`, sizeof 0x1c (struct-recovery §0 right; turn-spine §1.1 WRONG — annotated). **CORRECTION:** the 116-entry table @0x00a19718 is a name-membership list, not the effects table; use `g_TechIdNames` (196) + `OnTechResearched`. Open: values of the 6-entry AI-tech bonus table (0x00a17888); producer of ServerPlayer +0x224/+0x228/+0x22c beyond the setup-record copy (0x0077b620). (from [[tech-effects]])
- **RESOLVED: RNG semantics** — `Seed` is `thiscall(this, uint32)` RET 4 (MT19937 init + immediate twist); `Twist` takes `this` in ECX only; **twist is LAZY** (`if (left==0) Twist()` inside the draw), `left` lives at `+0x9c4`, confirming the save blob layout. **float mapping, needs a targeted check (not a red flag):** `NextFloat` decompiles as returning `float10`, but that is simply how x86 float returns look (value in `ST(0)`), so it is weak evidence of extended-precision *computation*; a single `y * 2^-32` multiply rounds once either way. B3 should still compare the mapping explicitly (exact constant and whether the draw is `y*2^-32`, `(y>>8)*2^-24`, or divided by 2^32-1), and the x87-vs-SSE float-parity policy from the RE how-to is still needed before the x64 standalone. `RNG_NextInt` signature still unverified. (own Ghidra pass)
- **Struct-modelling hazard (found by M2)** — an MSVC-2010 `std::vector` member is **three words**, so a naive C translation put the next pointer at `+0x18` when it is really at `+0x14`; the hook silently reported the unmodelled word (string bytes `"TION"`) as a real field. Any hand-modelled game struct must pin its size with `static_assert` and account for 3-word vectors. (from [[M2]])

View file

@ -68,8 +68,17 @@ Readers accept legacy tags (`ISuit`, `Income`, `HPop`, `Bats`, `Builds`, `Clr`,
`NShps`, `SysID`, `TrdID`, `Caps`, `GtTrf`, `FtSens`, `FtInc`, `Pris`, `NumPlgs`, `lcid`, `morev`, `cme`)
by reading them into scratch/NULL — these are pre-1.8 fields, NOT members.
Common Mars/MSVC layouts seen: `std::string` = 0x1c bytes (MSVC10 `_Bx` union@0, size@0x14, res@0x18;
`FUN_008b9d70` does the `res>=16 ? heap : sso` check); `std::vector<T>` = {begin@0, end@4, cap@8};
Common Mars/MSVC layouts seen: **`std::string` = 0x1c bytes** — `_Bx` union@0 (16-byte SSO buffer, or a
`char*` when `_Myres >= 16`), `_Mysize`@**0x10**, `_Myres`@**0x14**, `_Alval`@0x18 (empty allocator, occupies
a word, never read or written). `FUN_008b9d70` does the `res>=16 ? heap : sso` check.
*(2026-09-08: this line previously transposed the two to `size@0x14, res@0x18`; the 0x10/0x14 offsets are the
verified ones — `Stream::WriteString` `cmp [str+0x14],0x10`, `basic_string::assign` 0x00425550.)*
The allocator is **trailing** in this build's STL, in strings and vectors alike, so it is invisible to any
scan of "which offsets does the code touch" and costs exactly 4 bytes if you size a member that way. See
`findings/subsystems/observedtech-append.md` §9 for the whole-binary audit (`tools/strfootprint.py`): 65
`std::string` members across every serializer, zero with a sibling field inside the 0x1c span, 51 of 52
measurable inter-member gaps exactly 0x1c. There is one string layout in this binary.
`std::vector<T>` = {begin@0, end@4, cap@8, `_Alval`@0xc — 0x10 bytes};
`std::map/set` node = {left@0, parent@4, right@8, key@0xc, value@0x10, …, color/isnil bytes at tail};
`std::list` = {head*@0, size@4}. `Mars::NetworkObject` = {vptr@0, int id@4}.
@ -371,6 +380,32 @@ Four `std::list`s: `+4 list<SpyReportDefences>` (count `defc2`@+8, items `def`),
### 2.5 `Game::TechTree` — Write `FUN_005890a0` (tags `NumTechs`, `TNm`, `NumBrs`; per-tech body in a
sub-writer not decompiled here; logs "TechTree: Tech %d not found saving tech tree").
### 2.6 `Game::ObservedTech` (0x2c) — Write `0x00817cf0`, Read `0x00817c40`
Elements of `ServerPlayer::otch`, the `vector<ObservedTech>` at `ServerPlayer+0x274`. Polymorphic:
vftable `0x00a2439c`, RTTI `.?AVObservedTech@Game@@`, slots `{[0] 0x00793610 dtor, [1] Read, [2] Write}`.
| off | type | save name | notes |
|---|---|---|---|
| 0x00 | vptr | — | `0x00a2439c` |
| 0x04 | `uint16` | `otnF` | turn first observed; widened to int32 on disk |
| 0x06 | `uint16` | `otnL` | turn last observed; widened to int32 on disk |
| 0x08 | `bool` | `odet` | **one byte** (+3 pad); `WriteBool`/`ReadBool` |
| 0x0c..0x27 | `std::string` | `otch` | tech name; `_Mysize`@0x1c, `_Myres`@0x20, `_Alval`@0x24 |
| 0x28 | `int` | `owith` | last member; `0x28 + 4 = 0x2c` = sizeof, no slack |
`sizeof` = `0x2c` (44), pinned three ways by lane X (magic divide `0x2e8ba2e9 sar 3` at `0x0087239f`,
`imul reg,reg,0x2c`, search stride `add edi,0x2c`); the member map is the serializer's own order.
`Game::ObservedWeapon` (Write `0x00817bc0`, Read `0x00817b10`) is the identical element with tag `owep`
in place of `otch`; `odes` elements are the smaller `otnF otnL odid opid` record.
Appended by `RecordObservedTech` `0x007ba1a0`, which **de-duplicates by tech name** — a reimplementation
that just `push_back`s will diverge on re-observation. It writes `otnF` and `otnL` from the *same* source
word on first sighting, so first-seen == last-seen initially.
**Do not read `+0x24` as a field.** It is the name string's trailing `_Alval`. See §0 and
`findings/subsystems/observedtech-append.md` §9 — this is the class that produced the 0x18-vs-0x1c
`std::string` scare, and the resolution is that `0x1c` was right everywhere.
---
## 3. `Game::StarFleet` (R1 `SimFleetDetails`) — Write `FUN_00701070`, Read `FUN_00702470`; `this` = obj+8

View file

@ -0,0 +1,207 @@
# x87 precision sensitivity of the turn pipeline — measured (2026-09-08, lane F)
Question (`verify/state-checksum/STATE_CHECKSUM.md` §3.5): *does any value the turn pipeline
produces actually **depend** on the x87 control word?* Until now every save on this host was
made at the game's own `fpu_cw = 0x127f`, so "the x64/SSE port must match bit-for-bit" was
**policy, not measurement**.
It is now measured. Seven End-Turn runs from the same `ref-turn2.sav`, six distinct control-word
settings, whole-state checksum on each post-turn autosave.
## Verdict
| forced `fpu_cw` | precision | rounding | `(Autosave).sav` | vs baseline |
|---|---|---|---|---|
| *(stock, none forced)* | 53-bit | nearest | `978041ac…` 67,219 B | — (baseline) |
| `0x027f` | 53-bit | nearest | `978041ac…` 67,219 B | **identical** |
| `0x127f` | 53-bit | nearest | `978041ac…` 67,219 B | **identical** |
| `0x137f` | **64-bit (extended)** | nearest | `978041ac…` 67,219 B | **identical** |
| `0x007f` | **24-bit (single)** | nearest | `ba2435be…` 67,222 B | **2 leaves** |
| `0x1a7f` | 53-bit | **up (+∞)** | `e48e25fa…` 67,219 B | **2 leaves** |
The pre-turn `(Autosave EndTurn).sav` is `bb4fd9ac…` in **all seven** runs — the state at the
moment End Turn was pressed is upstream of every forced value, which is the run-to-run control.
**For the reimplementation:**
1. **The x87's 64-bit intermediates are not load-bearing.** 53-bit and 64-bit produce the same
state, leaf for leaf, across 35,394 leaves. A port that computes in IEEE `double` reproduces
this turn exactly, and there is **no double-rounding budget to preserve** — the strict
`floats=bits` policy costs an x64/SSE port nothing on this axis. §3.5 closes.
2. **Narrowing intermediates to `float` does change state.** At 24-bit the home system's
civilian population lands two people higher. So the port must hold in `double` exactly where
the original holds in an x87 register, and narrow exactly where the original stores to a
`dword` — which is precisely the discipline lane M documented for `MoveFleet`
(`movefleet-position-rounding.md`: five separate float32 narrowings, products and sums held
at 53 bits in between). Getting a narrowing point wrong is not a rounding nicety; it moves
saved integers.
3. **Round-to-nearest is required.** Round-toward-+∞ moves fleet 34's position by 1 ULP in two
of three components. SSE defaults to round-to-nearest, so this is satisfied for free — but it
is now a measured requirement rather than an assumption.
### The two named precision-sensitive witnesses
Everything the experiment found, in full — this is the complete list for this turn:
```
0x007f (24-bit) vs baseline:
/Sim/systems/Sys[112 "Gamma Cephei"]/Pop2/PopG/PopC : 540000000 -> 540000002
/Summary/Checksum : -769976634 -> -769976632 (derived)
0x1a7f (round-up) vs baseline:
/Sim/fleets/Flt[34 "Beta Fleet"]/Pos/.[0] : -10.563499450683594 -> -10.563498497009277 [1 ulp]
/Sim/fleets/Flt[34 "Beta Fleet"]/Pos/.[2] : 1.564733624458313 -> 1.5647337436676025 [1 ulp]
```
Both reproduce exactly on a repeat run (`run-007f-rep`, `run-1a7f-rep`) — same root hash, same
leaves. `Summary/Checksum` moves by the sum of the changed bytes, consistent with the additive
checksum already characterised in `determinism-oracle.md`; treat it as derived, never as state.
The two witnesses are on **different axes and different subsystems**: the population integer is
precision-sensitive but not rounding-sensitive, and the fleet position is rounding-sensitive but
not precision-sensitive. That is a useful shape — it says an SSE port can go wrong in two
independent ways, and each has a cheap regression witness on turn 2 of `ref-turn2.sav`.
## The evidence that the control word actually held
This is what makes the result mean anything: an experiment where the setting silently reverted
would produce identical saves and a confident, false "nothing depends on precision".
**1. Read-back at the point of forcing.** Each force site logs observed-before → requested →
read-back-after. Every one reports `OK`, e.g. (`run-007f/shim.log`):
```
fpu: FORCE at StrategyServer::BeginProcessTurn: observed=0x127f 53bit-double/nearest
-> requested=0x007f -> readback=0x007f 24bit-single/nearest OK
fpu: sample at StrategyServer::ProcessTurn: cw=0x007f 24bit-single/nearest
```
`StrategyServer::ProcessTurn` is a **sample-only** hook — it never writes the word. Its reading
is independent evidence that the value forced at the turn gate survived into the simulation.
**2. 38 independent in-pipeline samples per run.** Every template hook already snapshots the
control word it finds. In each of the five forced runs, all 38 samples read the forced value and
nothing else (`verify/results/fpu-cw/cw-census.txt`):
```
run-007f: ServerSystem::ProcessTurn cw=0x007f x28 call_id 1110..1137
StrategyServer::MoveFleet cw=0x007f x7 call_id 1103..1109
TechTree::ProcessResearch cw=0x007f x3 call_id 1140..1144
ALL SAMPLES: 0x007f x38
```
Those three hooks sit in turn phases **4 (movement), 6 (colony) and 8 (per-player research)** —
so the samples span the pipeline from its first mover to its last research pass, in call-id
order, with no gap and no other value. The stock run reads `0x127f` x38 in the same places.
**3. The forcing is a single write per turn, never re-applied inside the pipeline.** Re-forcing
at each hook would have guaranteed the samples without proving anything.
## Why the forcing point matters: the game re-arms the word every frame
`Mars::Application::Run` (`0x0089f5b0`) calls **`_controlfp(0x50000, 0x3070300)` once per frame**
at `0x0089f606`, between `OnUpdate` (vft+0x18) and `OnTick` (vft+0x1c). In MSVC's abstract
encoding that is `_PC_53 | _IC_AFFINE` under the mask `_MCW_DN|_MCW_IC|_MCW_PC|_MCW_RC`, which
is exactly the x87 word **`0x127f`**. There is no literal `0x127f` anywhere in the image
(`find-constant-uses` → 0 hits); the value is synthesised by the CRT. `_controlfp` is also
called from `Mars::Application::Initialize` (`0x008a10c6`) and from `FUN_00451920`
(`0x00451929`, one caller, a data reference).
Two consequences, both visible in the logs:
* Forcing at `StrategyClient::EndTurn` **does not survive**. The client's End Turn only raises
`SETurnEndPending` and sends `SNMEndTurn`; the server's `OnMessage` → `BeginProcessTurn` →
`ProcessTurn` runs on a **later frame**, and the per-frame `_controlfp` wipes the setting in
between. Every log shows `FORCE at StrategyClient::EndTurn … readback=0x007f` at *t*, then
`FORCE at StrategyServer::BeginProcessTurn: observed=0x127f` about a second later.
`BeginProcessTurn` is the forcing point that works, because `BeginProcessTurn` and
`ProcessTurn` are called from the same `OnMessage`, inside one `OnUpdate`, with no
`_controlfp` between them.
* The per-tick sampler never logs a change, and that is **expected, not evidence**: `_controlfp`
runs immediately before `OnTick`, so `OnTick` always observes a freshly re-armed `0x127f`.
The sampler's real contribution is the negative one — it proves the process never sits in a
forced state outside the turn, so nothing leaks between runs.
This also retro-explains why every previous lane measured `fpu_cw = 0x127f` at every hook: the
main loop guarantees it, frame by frame.
## The lane brief's three values do not span three FPU modes
The experiment as specified in §3.5 and in the lane brief would have been under-powered, and
this is worth recording so nobody re-derives it:
| value | actually is | brief called it |
|---|---|---|
| `0x027f` | 53-bit, nearest (MSVC CRT default) | "24-bit mantissa (single precision)" |
| `0x127f` | 53-bit, nearest (+ infinity-control bit) | "53-bit — the game's own setting" ✓ |
| `0x137f` | **64-bit extended**, nearest | "53-bit but a different rounding mode" |
`0x027f` and `0x127f` differ **only in bit 12** (infinity control), which the 387 and every later
x87 ignore — they are the same arithmetic. So the briefed triple spans two modes (53-bit and
64-bit), not three, tests single precision **not at all**, and tests rounding **not at all**.
Run literally, all three come back identical (they do — see the table) and the honest conclusion
from them alone would have been "no precision sensitivity", which is false for rounding and
unproven for single precision.
The control-word layout: bits 0–5 exception masks, **bits 8–9 precision control**
(`00`=24-bit, `10`=53-bit, `11`=64-bit), **bits 10–11 rounding control**
(`00`=nearest, `01`=down, `10`=up, `11`=truncate), bit 12 infinity control (ignored).
So the genuine probes are `0x007f` (24-bit) and `0x1a7f` (53-bit, round-up), and those are the
two runs that found something.
## Trap: the trace's own float rendering is precision-sensitive
Under a forced 24-bit control word, **the shim's trace text is not a valid comparison surface.**
The emitter prints an f32 with `%.9g`, and the CRT's digit generation is itself x87 arithmetic,
so the same float32 renders differently:
```
127f run: "z":{"t":"f32","v":1.56473362} both are float32 0x3fc84931
007f run: "z":{"t":"f32","v":1.5647336} — the value did not move, the printf did
127f run: "suit":{"t":"f32","v":1.65671718} both are float32 0x3fd40f4f
007f run: "suit":{"t":"f32","v":1.65671721}
```
A text diff of the two traces reports dozens of these as divergences. `verify/fpu-cw/trace_bitdiff.py`
re-quantises every float to its IEEE bit pattern before comparing, and then the 24-bit run shows
**zero** differences in any hooked call — the population change happens in a byte no hook
declares. This is the `STATE_CHECKSUM.md` §1 thesis with a fresh example: the per-function
verdict was clean and the state had still moved; only the whole-state checksum saw it.
Any future `tracecmp`-style comparison across control words must compare bits, not text.
## Incidental: guard spans differ between byte-identical runs
`ServerSystem::ProcessTurn`'s undeclared-write spans are not stable run to run even when the
saves are byte-identical (`trace-bitdiff.txt`, `127f vs off`): the same three writes at
`system+420..432` are reported once as one 12-byte span and once as three 3-byte spans, and once
shifted by a byte. Harmless for a verdict (the *set* of touched bytes is the same) but it means
guard-span text is not a stable comparison surface either. Not chased — outside this lane.
## Method
* VM140, `ref-turn2.sav` (`ab4ac2d7…`), click path per `determinism-oracle.md`. Shim build
`fpucw-cef889e-20260908T0803Z`, hook set held **identical** across all seven runs — the only
difference between `shim.cfg.fpu*` files is the `fpu.force` line.
* `SavedGames\` is reset to the same four files before every run, so the Load dialog rows never
move and the click path is constant.
* Forcing implemented in `sots-engine/src/shim/fpu_force.cpp` (branch `wip/fpucw`):
register-transparent asm stubs, same pattern as the `Application::Initialize` hook, so the
`[unverified]` prototypes of the turn-gate functions are never relied on.
shim.cfg keys `fpu.force=<cw>|off` and `fpu.sample_ticks=on|off`.
* Evidence: `verify/results/fpu-cw/run-<tag>/` — both autosaves, `shim.log`, `shim.cfg`,
gzipped trace; plus `cw-census.txt`, `state-checksum-diffs.txt`, `trace-bitdiff.txt`.
* Tools: `verify/fpu-cw/cw_census.py` (per-hook control-word census),
`verify/fpu-cw/trace_bitdiff.py` (bit-exact trace comparison).
## Coverage and limits
* The whole-state checksum **proves** coverage on every run: 609,080 bytes rebuilt byte-for-byte
from 35,394 leaves. "Identical" here means no leaf anywhere moved, not "no declared region
moved".
* **One turn, one save, one galaxy.** Turn 2→3 of `ref-turn2.sav`: 28 systems, 3 owned, 7
`MoveFleet` calls of which one fleet actually moves, 3 research passes, no combat, no Zuul, no
plague/rebellion/terraform. The two witnesses are what *this* turn exposes; a turn with combat
or more movers could expose more. The result "53-bit == 64-bit" is the one that generalises
best, because it held across all 35,394 leaves and all 38 in-pipeline samples.
* Nothing here says the *original* is x87-free — only that its results on this turn do not move
between 53-bit and 64-bit intermediates, which is the property an SSE port needs.

View file

@ -12,7 +12,9 @@ Raw decompiles (before rename) are in `/srv/re-lab/handoff/loader-decompiles/rec
decompiles in `/srv/re-lab/handoff/loader-decompiles/verify/<addr>.c`.
Conventions: `thiscall` = `this` in ECX, args pushed right-to-left, callee pops (`RET n`); `cdecl` = all on stack, caller
pops. `std::string` = MSVC-2010 layout (16-byte SSO buffer / pointer, size @+0x10, capacity @+0x14, 0x18 bytes).
pops. `std::string` = **0x1c bytes**: 16-byte SSO buffer / pointer `_Bx` @+0, `_Mysize` @+0x10, `_Myres` @+0x14,
`_Alval` (empty allocator, never read or written) @+0x18. *Corrected 2026-09-08 — this line said 0x18, which is
the extent of the fields the code touches, not the size of the object; see `observedtech-append.md` §9.*
"Case-insensitive" always means MSVCR100 `_stricmp` (ASCII fold).
---

View file

@ -168,30 +168,49 @@ COL `0x00a81c78` → type descriptor `0x00aeede4` → **`.?AVObservedTech@Game@@
`ObservedTech` is polymorphic and its first word is a vptr, not a data field — which the
on-disk shape does not tell you.
| offset | size | evidence |
|---|---|---|
| `+0x00` | 4 | vptr `0x00a2439c`; ctor writes it, RTTI-confirmed |
| `+0x04` | 2 | `mov word [eax-0x28],cx` at `0x007ba2b0` (`eax` = `_Mylast`, element = `_Mylast-0x2c`), source `[ebx+0xc]` |
| `+0x06` | 2 | `mov word [eax-0x26],dx` at `0x007ba2c6`, **same source word** `[ebx+0xc]` |
| `+0x08` | 4 | **unaccounted** |
| `+0x0c..+0x23` | 0x18 | `std::string` (the `otch` tech name). Object base is `+0x0c`, MSVC layout `{_Bx[16] @+0x00, _Mysize @+0x10, _Myres @+0x14}`. Ctor writes `[+0x0c]=0`, `[+0x1c]=0`, `[+0x20]=0xf`; the search loop reads `[+0x1c]` as the length and calls the compare with `this = +0x0c`; the post-append assign uses `lea ecx,[_Mylast-0x20]` = `+0x0c` |
| `+0x24` | 4 | **unaccounted** |
| `+0x28` | 4 | **unaccounted** |
> **Superseded 2026-09-08 by lane S — see §9.** The table as first written called the
> embedded string 0x18 bytes and left `+0x08`, `+0x24`, `+0x28` unaccounted. `+0x24` is not a
> field: it is the string's own trailing allocator word. The corrected map is below; the
> original reasoning is kept in §9 because the way it went wrong is the useful part.
Note the string here is **0x18 bytes**, not the 0x1c the `ServerPlayer::pswd` row in
`struct-recovery.md` implies — three separate reads inside this element agree on
`{buf16, _Mysize@+0x10, _Myres@+0x14}`. Worth re-checking `pswd` against that.
| offset | size | member | evidence |
|---|---|---|---|
| `+0x00` | 4 | vptr `0x00a2439c` | ctor writes it, RTTI-confirmed |
| `+0x04` | 2 | `uint16 otnF` (turn first observed) | `mov word [eax-0x28],cx` at `0x007ba2b0` (`eax` = `_Mylast`, element = `_Mylast-0x2c`), source `[ebx+0xc]`; tag from `ObservedTech::Write` |
| `+0x06` | 2 | `uint16 otnL` (turn last observed) | `mov word [eax-0x26],dx` at `0x007ba2c6`, **same source word** `[ebx+0xc]` — first sighting sets first == last |
| `+0x08` | 1 | `bool odet` | ctor stores a **byte** (`mov [esi+0x8],bl`, `0x008562f4`); copy-ctor copies a byte; serialised with `WriteBool`/`ReadBool` |
| `+0x0c..+0x27` | 0x1c | `std::string otch` (tech name) | object base `+0x0c`, MSVC `{_Bx[16] @0, _Mysize @0x10, _Myres @0x14, _Alval @0x18}`. Ctor writes `[+0x0c]=0`, `[+0x1c]=0`, `[+0x20]=0xf`; the search loop reads `[+0x1c]` as the length and calls compare with `this = +0x0c`; the post-append assign uses `lea ecx,[_Mylast-0x20]` = `+0x0c`. `+0x24` is `_Alval` — never read, never written, by anything |
| `+0x28` | 4 | `int owith` | ctor zeroes it; `ObservedTech::Write` emits it last |
The four on-disk ints (`otnF`, `otnL`, `odet`, `owith`) have to map onto `+0x04`/`+0x06` (two
16-bit fields) and the three 4-byte slots `+0x08`, `+0x24`, `+0x28`. **That mapping is not
determined here and is deliberately not guessed.** The one suggestive observation, flagged as a
*hypothesis only*: `+0x04` and `+0x06` are two adjacent 16-bit fields written from the same
source word on first observation, which is the shape you would expect of a first-seen /
last-seen turn pair — but nothing here proves it.
`4 + 2 + 2 + 1(+3 pad) + 0x1c + 4 = 0x2c` exactly — sizeof is fully accounted for, with no
padding slack and no unaccounted field.
What would settle the mapping: read `ObservedTech`'s `Read`/`Write` serializer, where the
member order is explicit. Lane P's `observed_techs` region byte delta remains a valid live
cross-check and should now come back as exactly 44 per completion.
### Where the mapping came from — the serializer
`Game::ObservedTech`'s vftable `0x00a2439c` is the usual 3 slots
`{ [0] 0x00793610 scalar deleting dtor, [1] 0x00817c40 Read, [2] 0x00817cf0 Write }`.
`Write` enumerates the entire object, in order, and touches nothing else:
```
0x00817cff movzx ecx,word [edi+0x04] push 0xa2b38c "otnF" -> stream vft+0x24 (int)
0x00817d0f movzx ecx,word [edi+0x06] push 0xa2b384 "otnL" -> stream vft+0x24 (int)
0x00817d26 lea eax,[edi+0x08] push 0xa2b36c "odet" -> WriteBool 0x008b9c20
0x00817d37 lea ecx,[edi+0x0c] push 0xa2b3e8 "otch" -> WriteString 0x008b9d70
0x00817d46 mov eax,[edi+0x28] push 0xa2b364 "owith" -> stream vft+0x24 (int)
```
`Read` (`0x00817c40`) is the exact mirror: `otnF`/`otnL` read as ints and stored back with
16-bit `mov word [ebx],ax`, `odet` through `ReadBool`, `otch` through `ReadString`, `owith`
reached as `add edi,0x28`. That matches the on-disk order `save_reader.py` already had
(`Otch = otnF otnL odet otch(string) owith`), which is a nice independent agreement between
the disassembly and the save oracle.
`Game::ObservedWeapon` (`Write` `0x00817bc0`, `Read` `0x00817b10`) is the same element shape
with tag `owep` (`0x00a2b3f0`) in place of `otch` — a second instance of the identical 0x2c
layout.
Lane P's `observed_techs` region byte delta remains a valid live cross-check and should come
back as exactly 44 per completion.
## 5. False-positive rate, honestly
@ -276,3 +295,111 @@ the stride evidence on the first four.
`RecordObservedTech`, `vector_ObservedTech_push_back`, `vector_ObservedTech_assign`,
`ObservedTech_ctor`, `vector_44B_grow`; `ServerPlayer_off_ObservedTechs` updated from
"NOT PINNED" to `verified`.
**Lane S round (2026-09-08).** Labels: `ObservedTech_Write` `0x00817cf0`, `ObservedTech_Read`
`0x00817c40`, `ObservedTech_scalar_deleting_dtor` `0x00793610`, `ObservedWeapon_Write`
`0x00817bc0`, `ObservedWeapon_Read` `0x00817b10`, `vector_ObservedTech_uninit_copy` `0x0079a150`,
`vector_string_find_by_name` `0x00699bd0`. Prototypes on the five class methods. Plate comments
carrying the full member map on the two serializers, the ctor, the dtor and the copy helper, and
the `sizeof(std::string) = 0x1c` fact on `Stream::WriteString` `0x008b9d70` and on the
`vector<string>` stride site `0x00699bd0` — the two places a future lane is most likely to look.
`addresses.json` +10 entries (`std_string_sizeof`, `ObservedTech_Read/_Write/_dtor/_copy_ctor`,
`ObservedTech_off_TurnFirst/_TurnLast/_Detected/_With`, `ObservedWeapon_Write`), 4 corrected.
---
## 9. `std::string` is 0x1c, not 0x18 — the correction, and why it mattered (lane S, 2026-09-08)
§4 above originally reported the embedded string as **0x18 bytes**, against the campaign-wide
`_Bx@0, _Mysize@0x10, _Myres@0x14, _Alval@0x18`, sizeof `0x1c`. Lane X flagged it as "worth
re-checking" rather than asserting it, which was the right call: **`0x1c` is correct, and it is
correct everywhere in this binary.** `ObservedTech+0x24` is the string's own trailing allocator
word, not a data member.
### Why the 0x18 reading looked right
Everything lane X observed was accurate. The string's *live* fields really do stop at `+0x14`
(`_Bx@0`, `_Mysize@0x10`, `_Myres@0x14`), because `_Alval` is `std::allocator<char>` — an empty
class. It occupies a word of the object but is never loaded or stored, so it is invisible to
any evidence based on **what the code touches**. Sizing a type from its accessed fields
undercounts it by exactly the tail padding. The same trap is live for `std::vector` in this
build, which is `{_Myfirst, _Mylast, _Myend, _Alval}` = `0x10` while only three words are ever
read (`events.md` already records the `_Alval` word at `EventStorage+0x10` and `+0x14`).
### The three things that settle it inside `ObservedTech`
Each is a *complete enumeration* of the object, which is the right instrument here — an
enumeration can show a field's **absence**, a touch-scan cannot.
1. **`ObservedTech::Write` `0x00817cf0`** serialises `+0x04, +0x06, +0x08, +0x0c, +0x28`.
No `+0x24`. (`Read` `0x00817c40` mirrors it.)
2. **`ObservedTech_ctor` `0x008562a0`** initialises `+0x00, +0x04, +0x08, +0x0c(string), +0x28`.
No `+0x24` — while zeroing every other scalar in the object.
3. **The copy constructor**, inlined at `0x0079a184` inside the vector's uninitialised-copy
helper `FUN_0079a150`, copies `+0x04, +0x06, +0x08`, the string at `+0x0c`, and `+0x28`.
No `+0x24`.
A 4-byte data member that the constructor, the copy constructor and the serializer all ignore
is not a data member.
### Binary-wide check — `tools/strfootprint.py`
One class is an anecdote, so the same question was put to the whole binary. Every serializer
hands member pointers to the `Mars::Stream` primitive helpers with a fixed idiom
(`lea r,[base+disp]; push r; push tag; push stream; call helper`), so the scanner recovers
`(base, disp, tag)` for every string site and then asks: does the same function touch any other
offset inside `(N, N+0x1c)` off the same base register?
```
string helper call sites : 241
resolved to a class member offset : 65
stack temporaries (ebp/esp base) : 30
offset not reached by a plain lea : 146
members with a sibling inside (N, N+0x1c) : 0
gap from a string member to the next member on the same base:
+0x1c : 51
+0x20 : 1
```
**65 string members across every serializer in the exe, zero collisions, and 51 of the 52
measurable gaps are exactly `0x1c`.** The single `+0x20` is `StrategyServer::KeyPath` at
`+0x134`, whose *Read* side gives `+0x1c` to `+0x150` — the writer simply skips a member.
There is no `0x18` instantiation, no empty-base-optimised variant, and no game-local string
class. One layout, `0x1c`.
Two exclusions matter or the scan reports noise, and both are why a naive version of this
would have "confirmed" 0x18:
* **`ebp`/`esp` bases are stack temporaries, not members.** A local string at `[ebp-0x2c]`
shows accesses at `-0x1c` and `-0x18` — which read exactly like two sibling fields inside
the span. All 30 such sites are excluded.
* **`N+0x10` and `N+0x14` are the string's own `_Mysize`/`_Myres`**, touched inline whenever
the compiler expands the `_Myres >= 16 ? _Ptr : _Buf` test at a call site.
### Independent corroboration outside the scan
* `FUN_00699bd0` walks a `vector<std::string>` doing the SSO test at `[esi+0x14]`/`[esi]` —
element base *is* string base — and advances `add esi,0x1c` (`0x00699c29`). The stride of a
vector of strings **is** `sizeof(std::string)`.
* `EventStorage::PostEvent` `0x008862b0` takes two by-value `std::string`s at `[ebp+0x08]` and
`[ebp+0x24]` (spacing `0x1c`) and `RET 0x4c` = `2*0x1c + 5*4`.
* `MoraleEvent` is 0x50 bytes with its name string at `+0x34`: `0x34 + 0x1c = 0x50` exactly.
### Blast radius — what actually had to change
Nothing in any recovered struct table. Every string-bearing layout in `struct-recovery.md`,
`save-editor-structs.md`, `events.md` and `schema-gaps-resolved.md` already used `0x1c` spans
and `0x1c` inter-field gaps, and the binary agrees with all of them. `ServerPlayer::pswd` at
`0x2dc..0x2f7` — the row §4 asked to re-check — is **correct**: `Write` `0x008563e0` puts the
next member exactly `0x1c` above it. The corrections were confined to three prose statements
that had propagated the 0x18 number (`loader-prototypes.md` conventions line, the
`GlobalConst_ParseString` prototype in `addresses.json`, and §4 here) plus one transposition in
`struct-recovery.md` §0 that said `size@0x14, res@0x18` where the verified offsets are
`0x10`/`0x14`.
The lesson worth carrying, and the reason this was worth chasing rather than reconciling: **do
not size a struct member from the offsets the code touches.** Trailing empty-allocator words in
this build's STL are invisible to a touch-scan and cost exactly 4 bytes every time. Size types
from an enumeration — a serializer, a constructor, a copy constructor, or a container stride.

View file

@ -334,7 +334,7 @@
"name": "GlobalConst_ParseString",
"addr": "0x008b7670",
"convention": "cdecl",
"prototype": "void (std::string* storage, const char* text) /* *storage = text; MSVC2010 std::string 0x18 bytes {+0 char buf[16] | char* ptr when capacity > 15, +0x10 size, +0x14 capacity}; 146 keys (*_NAME, *_SOUND, *_TEXTURENAME, DERELICT_SECTION_nn ...); seen in the M1 trace */",
"prototype": "void (std::string* storage, const char* text) /* *storage = text; MSVC std::string is 0x1c bytes {+0 char buf[16] | char* ptr when capacity > 15, +0x10 _Mysize, +0x14 _Myres, +0x18 _Alval (empty allocator, never read/written)} -- see std_string_sizeof; 146 keys (*_NAME, *_SOUND, *_TEXTURENAME, DERELICT_SECTION_nn ...); seen in the M1 trace */",
"status": "verified-by-trace",
"source": "sots-engine docs/M1.md"
},
@ -3174,7 +3174,7 @@
"name": "ObservedTech_sizeof",
"offset": "0x2c",
"convention": "constant",
"prototype": "sizeof(Game::ObservedTech) = 0x2c (44 bytes). Confirmed independently by (a) the compiler's magic division by 44 (mov eax,0x2e8ba2e9; imul; sar edx,3) at 0x0087239f and 0x007b7339, (b) imul reg,reg,0x2c at 0x0087243a, 0x00872468, 0x007b735b, and (c) the iterator advance `add edi,0x2c` in RecordObservedTech's linear search at 0x007ba257. Matches the on-disk lower bound exactly (4 int + one 0x1c std::string = 44), so there is no padding slack.",
"prototype": "sizeof(Game::ObservedTech) = 0x2c (44 bytes). Confirmed independently by (a) the compiler's magic division by 44 (mov eax,0x2e8ba2e9; imul; sar edx,3) at 0x0087239f and 0x007b7339, (b) imul reg,reg,0x2c at 0x0087243a, 0x00872468, 0x007b735b, and (c) the iterator advance `add edi,0x2c` in RecordObservedTech's linear search at 0x007ba257. FULL MEMBER MAP, from ObservedTech_Write 0x00817cf0 / ObservedTech_Read 0x00817c40 (lane S 2026-09-08): +0x00 vptr 0x00a2439c; +0x04 uint16 otnF; +0x06 uint16 otnL; +0x08 bool odet (1 byte, +3 pad); +0x0c std::string otch (0x1c, so _Mysize at +0x1c, _Myres at +0x20, _Alval at +0x24); +0x28 int owith. 4 + 2 + 2 + 4 + 0x1c + 4 = 0x2c exactly, no padding slack and no unaccounted field.",
"status": "verified",
"source": "findings/subsystems/observedtech-append.md (lane X, tools/x86disp.py displacement scan)"
},
@ -3190,7 +3190,7 @@
"name": "ObservedTech_off_Name",
"offset": "0x0c",
"convention": "offset",
"prototype": "std::string (save tag `otch`, the tech name) at ObservedTech+0x0c, 0x18 bytes, spanning +0x0c..+0x23. MSVC layout relative to the string object: _Bx[16] @+0x00, _Mysize @+0x10, _Myres @+0x14 -- i.e. ObservedTech+0x1c is the length and +0x20 the capacity. Evidence: ObservedTech_ctor 0x008562a0 writes [elem+0x0c]=0, [elem+0x1c]=0, [elem+0x20]=0xf; RecordObservedTech's search reads [elem+0x1c] as the length and calls compare with this=elem+0x0c; the post-append assign uses lea ecx,[_Mylast-0x20]. NOTE this string is 0x18 bytes, not the 0x1c implied by the ServerPlayer pswd row in struct-recovery.md. UNACCOUNTED in the element: +0x08, +0x24, +0x28 (4 bytes each) plus two 16-bit fields at +0x04/+0x06 written from one source word at 0x007ba2b0 / 0x007ba2c6 -- the mapping of the four on-disk ints (otnF, otnL, odet, owith) onto those slots is NOT determined.",
"prototype": "std::string (save tag `otch`, the tech name) at ObservedTech+0x0c, 0x1c bytes, spanning +0x0c..+0x27. MSVC layout relative to the string object: _Bx[16] @+0x00, _Mysize @+0x10, _Myres @+0x14, _Alval @+0x18 -- i.e. ObservedTech+0x1c is the length, +0x20 the capacity, +0x24 the empty-allocator word (never read or written by anything). Evidence: ObservedTech_ctor 0x008562a0 writes [elem+0x0c]=0, [elem+0x1c]=0, [elem+0x20]=0xf; RecordObservedTech's search reads [elem+0x1c] as the length and calls compare with this=elem+0x0c; the post-append assign uses lea ecx,[_Mylast-0x20]. CORRECTION (lane S 2026-09-08): an earlier revision called this string 0x18 bytes and listed +0x24 as an unaccounted data field. It is not one -- three independent whole-object enumerations skip +0x24 entirely: ObservedTech_ctor 0x008562a0, ObservedTech_copy_ctor 0x0079a184, and ObservedTech_Write 0x00817cf0 (which serialises +0x04,+0x06,+0x08,+0x0c,+0x28 and nothing else). sizeof(std::string)=0x1c holds binary-wide; see std_string_sizeof.",
"status": "verified",
"source": "findings/subsystems/observedtech-append.md (lane X, tools/x86disp.py displacement scan)"
},
@ -3198,7 +3198,7 @@
"name": "RecordObservedTech",
"addr": "0x007ba1a0",
"convention": "thiscall",
"prototype": "void (this, ServerPlayer* observer, ?, std::string* techName) -- appends to observer->otch. Linear-searches observer->otch (ServerPlayer+0x274) with stride 0x2c comparing each element's name string; if not found, default-constructs an ObservedTech on the stack (0x008562a0) and push_backs it (0x007b7320 @0x007ba288), then writes two 16-bit fields into the new element at +0x04 and +0x06 from param_1+0xc. DIRECT CALLEE of ServerPlayer::OnTechResearched 0x00891790; also called from 0x007be228, 0x007be4e1, 0x007be535. This is the append lane P could not find.",
"prototype": "void (this, ServerPlayer* observer, ?, std::string* techName) -- appends to observer->otch. Linear-searches observer->otch (ServerPlayer+0x274) with stride 0x2c comparing each element's name string; if not found, default-constructs an ObservedTech on the stack (0x008562a0) and push_backs it (0x007b7320 @0x007ba288), then writes otnF (+0x04) and otnL (+0x06), both 16-bit, from the same source word param_1+0xc -- i.e. first-observed turn == last-observed turn on the first sighting, which is what the tag names now confirm. DIRECT CALLEE of ServerPlayer::OnTechResearched 0x00891790; also called from 0x007be228, 0x007be4e1, 0x007be535. This is the append lane P could not find. DE-DUPLICATING: appends only when no existing element carries that tech name, so a reimplementation must not naively push_back on re-observation.",
"status": "verified",
"source": "findings/subsystems/observedtech-append.md (lane X, tools/x86disp.py displacement scan)"
},
@ -3222,9 +3222,89 @@
"name": "ObservedTech_ctor",
"addr": "0x008562a0",
"convention": "thiscall",
"prototype": "Game::ObservedTech* (ObservedTech* this) -- default ctor; sets vptr 0x00a2439c and empties the name string.",
"prototype": "Game::ObservedTech* (ObservedTech* this) -- default ctor. Writes exactly: vptr 0x00a2439c at +0x00; dword 0 at +0x04 (otnF+otnL zeroed together); BYTE 0 at +0x08 (`mov [esi+0x8],bl`, 0x008562f4 -- odet is a bool, not an int); the empty string at +0x0c (_Buf[0]=0, _Mysize=0, _Myres=0xf, then assign(\"\") 0x00425550); dword 0 at +0x28 (owith). It never touches +0x24 -- that word is the string's _Alval.",
"status": "verified",
"source": "findings/subsystems/observedtech-append.md (lane X, tools/x86disp.py displacement scan)"
"source": "findings/subsystems/observedtech-append.md (lane X; element map corrected + serializer read by lane S 2026-09-08)"
},
{
"name": "ObservedTech_Write",
"addr": "0x00817cf0",
"convention": "thiscall",
"prototype": "void Game::ObservedTech::Write(Mars::Stream* s) RET 4. Vftable 0x00a2439c slot [2]. Serialises the whole object, in order and with nothing else: `otnF` = movzx word [this+0x04] via stream vft+0x24 (int); `otnL` = movzx word [this+0x06] via vft+0x24; `odet` = WriteBool 0x008b9c20 (&this[+0x08]); `otch` = WriteString 0x008b9d70 (&this[+0x0c]); `owith` = [this+0x28] via vft+0x24. THIS IS THE MEMBER MAP: there is no field at +0x24. Tag pointers 0x00a2b38c/0x00a2b384/0x00a2b36c/0x00a2b3e8/0x00a2b364.",
"status": "verified",
"source": "lane S own disassembly 2026-09-08"
},
{
"name": "ObservedTech_Read",
"addr": "0x00817c40",
"convention": "thiscall",
"prototype": "void Game::ObservedTech::Read(Mars::Stream* s) RET 4. Vftable 0x00a2439c slot [1]. Mirror of ObservedTech_Write: `otnF`/`otnL` through stream vft+0x10 (int-by-ref) stored back as 16-bit (`mov word [ebx],ax`) at +0x04/+0x06, `odet` = ReadBool 0x008b9c00 (&this[+0x08]), `otch` = ReadString 0x008b9d90 (&this[+0x0c]), `owith` at +0x28 (reached as `add edi,0x28`).",
"status": "verified",
"source": "lane S own disassembly 2026-09-08"
},
{
"name": "ObservedTech_dtor",
"addr": "0x00793610",
"convention": "thiscall",
"prototype": "ObservedTech* (ObservedTech* this, int flags) RET 4. Vftable 0x00a2439c slot [0], scalar deleting dtor. Inlines ~basic_string on the name at +0x0c (`cmp [esi+0x20],0x10` -> operator delete [esi+0x0c], then _Tidy: [esi+0x20]=0xf, [esi+0x1c]=0, byte [esi+0x0c]=0), restores the base vptr 0x009e22bc, and frees `this` when flags&1. The string is the ONLY member needing destruction -- confirming there is no second string or owned pointer in the element.",
"status": "verified",
"source": "lane S own disassembly 2026-09-08"
},
{
"name": "ObservedTech_copy_ctor",
"addr": "0x0079a184",
"convention": "site",
"prototype": "site inside the vector<ObservedTech> uninitialised-copy helper FUN_0079a150(&_Alval, dest, src). The inlined copy constructor writes vptr 0x00a2439c, `mov word [dst+0x04],[src+0x04]`, `mov word [dst+0x06],[src+0x06]`, `mov byte [dst+0x08],[src+0x08]`, the string at +0x0c (via basic_string::assign 0x00425430), and `mov [dst+0x28],[src+0x28]`. Nothing is copied at +0x24 -- second independent proof that +0x24 belongs to the 0x1c string, not to a data member.",
"status": "verified",
"source": "lane S own disassembly 2026-09-08"
},
{
"name": "ObservedTech_off_TurnFirst",
"offset": "0x04",
"convention": "offset",
"prototype": "uint16 otnF -- turn the tech was first observed. Widened to int on disk by stream vft+0x24. Written together with otnL from one source word at RecordObservedTech 0x007ba2b0.",
"status": "verified",
"source": "lane S: ObservedTech_Write 0x00817cf0 / ObservedTech_Read 0x00817c40"
},
{
"name": "ObservedTech_off_TurnLast",
"offset": "0x06",
"convention": "offset",
"prototype": "uint16 otnL -- turn the tech was last observed. Widened to int on disk. Written at RecordObservedTech 0x007ba2c6 from the same source word as otnF.",
"status": "verified",
"source": "lane S: ObservedTech_Write 0x00817cf0 / ObservedTech_Read 0x00817c40"
},
{
"name": "ObservedTech_off_Detected",
"offset": "0x08",
"convention": "offset",
"prototype": "bool odet -- ONE BYTE (ctor `mov [esi+0x8],bl` 0x008562f4; copy-ctor `mov byte` 0x0079a1a0), serialised through WriteBool/ReadBool, so on disk it is 1 byte + 3 NUL joint padding. Note the save reader types `odet` as int; for a 4-character tag a bool item and an int item are the same 12 bytes and the value is identical, so that is benign, not a parse error.",
"status": "verified",
"source": "lane S: ObservedTech_Write 0x00817cf0 / ObservedTech_ctor 0x008562a0"
},
{
"name": "ObservedTech_off_With",
"offset": "0x28",
"convention": "offset",
"prototype": "int owith -- last member of the element; serialised through stream vft+0x24 with tag 0x00a2b364. +0x28..+0x2b is the tail of the 0x2c-byte element, which is why sizeof is 0x2c with no padding slack.",
"status": "verified",
"source": "lane S: ObservedTech_Write 0x00817cf0 / ObservedTech_Read 0x00817c40"
},
{
"name": "ObservedWeapon_Write",
"addr": "0x00817bc0",
"convention": "thiscall",
"prototype": "void Game::ObservedWeapon::Write(Mars::Stream* s) RET 4. Byte-for-byte the same shape as ObservedTech_Write with tag `owep` (0x00a2b3f0) in place of `otch`: otnF u16 @+0x04, otnL u16 @+0x06, odet bool @+0x08, owep std::string (0x1c) @+0x0c, owith int @+0x28. Reader is 0x00817b10. Independent second instance of the same 0x2c element shape.",
"status": "verified",
"source": "lane S own disassembly 2026-09-08"
},
{
"name": "std_string_sizeof",
"offset": "0x1c",
"convention": "constant",
"prototype": "sizeof(std::basic_string<char>) = 0x1c (28) binary-wide, ONE layout only: _Bx (16-byte SSO union, char[16] or char*) @+0x00, _Mysize @+0x10, _Myres @+0x14, _Alval (empty allocator) @+0x18. The allocator word is trailing, never read and never written -- the same allocator-last shape as this build's std::vector {_Myfirst,_Mylast,_Myend,_Alval} = 0x10. Evidence: (a) Stream::WriteString 0x008b9d76 `cmp [str+0x14],0x10` / `mov eax,[str]` and basic_string::assign 0x00425550 (_Mysize +0x10, _Myres +0x14) fix the field offsets; (b) the vector<std::string> scan loop FUN_00699bd0 advances `add esi,0x1c` (0x00699c29) while doing the SSO test at [esi+0x14]/[esi] -- element base == string base, so the stride IS the sizeof; (c) PostEvent 0x008862b0 takes two by-value strings at [ebp+0x08] and [ebp+0x24] and RET 0x4c = 2*0x1c + 5*4; (d) a whole-binary sweep of the Stream string helpers recovered 65 std::string members off a non-stack base across all serializers -- ZERO have any sibling member inside (N, N+0x1c), and 51 of the 52 that have a next member have it at exactly N+0x1c (the one exception, StrategyServer KeyPath @+0x134, is +0x20 on the Write side and +0x1c on the Read side, i.e. the writer skips a member). There is no 0x18 instantiation, no empty-base variant and no game-local string class.",
"status": "verified",
"source": "lane S 2026-09-08; scanner tools/strfootprint.py"
},
{
"name": "vector_44B_grow",

View file

@ -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 @ 1a58bcd, generated 2026-09-08 by tools/gen_addresses.py
// Source: sots-re ghidra/addresses.json @ d523d27, generated 2026-09-08 by tools/gen_addresses.py
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
#pragma once
#include <cstdint>
@ -89,7 +89,7 @@ constexpr uint32_t GlobalConst_ParseColour = 0x004b7110;
constexpr uint32_t GlobalConst_ParseRect = 0x004b7040;
// cdecl void (float xyz[3], const char* text) /* '%f %f %f'; 5 keys (HIVER_SPAWN_*_GATE_HEIGHT / _SHIP_OFFSET ...); inferred from the M1 trace */ [verified-by-trace]
constexpr uint32_t GlobalConst_ParseVec3 = 0x004b7080;
// cdecl void (std::string* storage, const char* text) /* *storage = text; MSVC2010 std::string 0x18 bytes {+0 char buf[16] | char* ptr when capacity > 15, +0x10 size, +0x14 capacity}; 146 keys (*_NAME, *_SOUND, *_TEXTURENAME, DERELICT_SECTION_nn ...); seen in the M1 trace */ [verified-by-trace]
// cdecl void (std::string* storage, const char* text) /* *storage = text; MSVC std::string is 0x1c bytes {+0 char buf[16] | char* ptr when capacity > 15, +0x10 _Mysize, +0x14 _Myres, +0x18 _Alval (empty allocator, never read/written)} -- see std_string_sizeof; 146 keys (*_NAME, *_SOUND, *_TEXTURENAME, DERELICT_SECTION_nn ...); seen in the M1 trace */ [verified-by-trace]
constexpr uint32_t GlobalConst_ParseString = 0x004b7670;
// cdecl void (void) /* called once from Application::Initialize; sets g_GlobalConstsLoaded */ [verified]
constexpr uint32_t GlobalConsts_LoadAll = 0x004b76d0;
@ -799,20 +799,40 @@ constexpr uint32_t Mars_Vec3_NormaliseEpsilon = 0x005e1ef8;
constexpr uint32_t StrategyServer_MoveFleet_straight_leg = 0x003da0f2;
// site site inside StrategyServer::MoveFleet /* the move != distance branch: exactly two roundings per component, tmp.c = float32(dir.c * move) then pos.c = float32(pos.c + tmp.c). `move` is reloaded from a float32 slot. The sibling branch (move == distance, an EXACT float compare) copies the destination's three words verbatim with mov, so an arrival never steps onto its destination. */ [verified]
constexpr uint32_t StrategyServer_MoveFleet_position_update = 0x003da2ac;
// constant sizeof(Game::ObservedTech) = 0x2c (44 bytes). Confirmed independently by (a) the compiler's magic division by 44 (mov eax,0x2e8ba2e9; imul; sar edx,3) at 0x0087239f and 0x007b7339, (b) imul reg,reg,0x2c at 0x0087243a, 0x00872468, 0x007b735b, and (c) the iterator advance `add edi,0x2c` in RecordObservedTech's linear search at 0x007ba257. Matches the on-disk lower bound exactly (4 int + one 0x1c std::string = 44), so there is no padding slack. [verified]
// constant sizeof(Game::ObservedTech) = 0x2c (44 bytes). Confirmed independently by (a) the compiler's magic division by 44 (mov eax,0x2e8ba2e9; imul; sar edx,3) at 0x0087239f and 0x007b7339, (b) imul reg,reg,0x2c at 0x0087243a, 0x00872468, 0x007b735b, and (c) the iterator advance `add edi,0x2c` in RecordObservedTech's linear search at 0x007ba257. FULL MEMBER MAP, from ObservedTech_Write 0x00817cf0 / ObservedTech_Read 0x00817c40 (lane S 2026-09-08): +0x00 vptr 0x00a2439c; +0x04 uint16 otnF; +0x06 uint16 otnL; +0x08 bool odet (1 byte, +3 pad); +0x0c std::string otch (0x1c, so _Mysize at +0x1c, _Myres at +0x20, _Alval at +0x24); +0x28 int owith. 4 + 2 + 2 + 4 + 0x1c + 4 = 0x2c exactly, no padding slack and no unaccounted field. [verified]
constexpr uint32_t ObservedTech_sizeof = 0x0000002c;
// data Game::ObservedTech vftable. RTTI COL 0x00a81c78 -> type descriptor 0x00aeede4 = '.?AVObservedTech@Game@@'. Written to element+0x00 by ObservedTech_ctor 0x008562a0 -- so ObservedTech is polymorphic and its first word is the vptr, not a data field. [verified]
constexpr uint32_t ObservedTech_vftable = 0x0062439c;
// offset std::string (save tag `otch`, the tech name) at ObservedTech+0x0c, 0x18 bytes, spanning +0x0c..+0x23. MSVC layout relative to the string object: _Bx[16] @+0x00, _Mysize @+0x10, _Myres @+0x14 -- i.e. ObservedTech+0x1c is the length and +0x20 the capacity. Evidence: ObservedTech_ctor 0x008562a0 writes [elem+0x0c]=0, [elem+0x1c]=0, [elem+0x20]=0xf; RecordObservedTech's search reads [elem+0x1c] as the length and calls compare with this=elem+0x0c; the post-append assign uses lea ecx,[_Mylast-0x20]. NOTE this string is 0x18 bytes, not the 0x1c implied by the ServerPlayer pswd row in struct-recovery.md. UNACCOUNTED in the element: +0x08, +0x24, +0x28 (4 bytes each) plus two 16-bit fields at +0x04/+0x06 written from one source word at 0x007ba2b0 / 0x007ba2c6 -- the mapping of the four on-disk ints (otnF, otnL, odet, owith) onto those slots is NOT determined. [verified]
// offset std::string (save tag `otch`, the tech name) at ObservedTech+0x0c, 0x1c bytes, spanning +0x0c..+0x27. MSVC layout relative to the string object: _Bx[16] @+0x00, _Mysize @+0x10, _Myres @+0x14, _Alval @+0x18 -- i.e. ObservedTech+0x1c is the length, +0x20 the capacity, +0x24 the empty-allocator word (never read or written by anything). Evidence: ObservedTech_ctor 0x008562a0 writes [elem+0x0c]=0, [elem+0x1c]=0, [elem+0x20]=0xf; RecordObservedTech's search reads [elem+0x1c] as the length and calls compare with this=elem+0x0c; the post-append assign uses lea ecx,[_Mylast-0x20]. CORRECTION (lane S 2026-09-08): an earlier revision called this string 0x18 bytes and listed +0x24 as an unaccounted data field. It is not one -- three independent whole-object enumerations skip +0x24 entirely: ObservedTech_ctor 0x008562a0, ObservedTech_copy_ctor 0x0079a184, and ObservedTech_Write 0x00817cf0 (which serialises +0x04,+0x06,+0x08,+0x0c,+0x28 and nothing else). sizeof(std::string)=0x1c holds binary-wide; see std_string_sizeof. [verified]
constexpr uint32_t ObservedTech_off_Name = 0x0000000c;
// thiscall void (this, ServerPlayer* observer, ?, std::string* techName) -- appends to observer->otch. Linear-searches observer->otch (ServerPlayer+0x274) with stride 0x2c comparing each element's name string; if not found, default-constructs an ObservedTech on the stack (0x008562a0) and push_backs it (0x007b7320 @0x007ba288), then writes two 16-bit fields into the new element at +0x04 and +0x06 from param_1+0xc. DIRECT CALLEE of ServerPlayer::OnTechResearched 0x00891790; also called from 0x007be228, 0x007be4e1, 0x007be535. This is the append lane P could not find. [verified]
// thiscall void (this, ServerPlayer* observer, ?, std::string* techName) -- appends to observer->otch. Linear-searches observer->otch (ServerPlayer+0x274) with stride 0x2c comparing each element's name string; if not found, default-constructs an ObservedTech on the stack (0x008562a0) and push_backs it (0x007b7320 @0x007ba288), then writes otnF (+0x04) and otnL (+0x06), both 16-bit, from the same source word param_1+0xc -- i.e. first-observed turn == last-observed turn on the first sighting, which is what the tag names now confirm. DIRECT CALLEE of ServerPlayer::OnTechResearched 0x00891790; also called from 0x007be228, 0x007be4e1, 0x007be535. This is the append lane P could not find. DE-DUPLICATING: appends only when no existing element carries that tech name, so a reimplementation must not naively push_back on re-observation. [verified]
constexpr uint32_t RecordObservedTech = 0x003ba1a0;
// thiscall void (std::vector<ObservedTech>* this, ObservedTech* value) RET 4. Stride 0x2c. Calls vector_44B_grow 0x007b5820 when _Mylast==_Myend -- the realloc that moves all three vector words. Exactly one caller (RecordObservedTech), so this instantiation is not COMDAT-ambiguous. [verified]
constexpr uint32_t vector_ObservedTech_push_back = 0x003b7320;
// thiscall std::vector<ObservedTech>& operator=(const std::vector<ObservedTech>&) RET 4. Both callers pass ServerPlayer+0x274 (0x00878091 in the settings copy-out, 0x00892507 in the copy-in). [verified]
constexpr uint32_t vector_ObservedTech_assign = 0x00472380;
// thiscall Game::ObservedTech* (ObservedTech* this) -- default ctor; sets vptr 0x00a2439c and empties the name string. [verified]
// thiscall Game::ObservedTech* (ObservedTech* this) -- default ctor. Writes exactly: vptr 0x00a2439c at +0x00; dword 0 at +0x04 (otnF+otnL zeroed together); BYTE 0 at +0x08 (`mov [esi+0x8],bl`, 0x008562f4 -- odet is a bool, not an int); the empty string at +0x0c (_Buf[0]=0, _Mysize=0, _Myres=0xf, then assign("") 0x00425550); dword 0 at +0x28 (owith). It never touches +0x24 -- that word is the string's _Alval. [verified]
constexpr uint32_t ObservedTech_ctor = 0x004562a0;
// thiscall void Game::ObservedTech::Write(Mars::Stream* s) RET 4. Vftable 0x00a2439c slot [2]. Serialises the whole object, in order and with nothing else: `otnF` = movzx word [this+0x04] via stream vft+0x24 (int); `otnL` = movzx word [this+0x06] via vft+0x24; `odet` = WriteBool 0x008b9c20 (&this[+0x08]); `otch` = WriteString 0x008b9d70 (&this[+0x0c]); `owith` = [this+0x28] via vft+0x24. THIS IS THE MEMBER MAP: there is no field at +0x24. Tag pointers 0x00a2b38c/0x00a2b384/0x00a2b36c/0x00a2b3e8/0x00a2b364. [verified]
constexpr uint32_t ObservedTech_Write = 0x00417cf0;
// thiscall void Game::ObservedTech::Read(Mars::Stream* s) RET 4. Vftable 0x00a2439c slot [1]. Mirror of ObservedTech_Write: `otnF`/`otnL` through stream vft+0x10 (int-by-ref) stored back as 16-bit (`mov word [ebx],ax`) at +0x04/+0x06, `odet` = ReadBool 0x008b9c00 (&this[+0x08]), `otch` = ReadString 0x008b9d90 (&this[+0x0c]), `owith` at +0x28 (reached as `add edi,0x28`). [verified]
constexpr uint32_t ObservedTech_Read = 0x00417c40;
// thiscall ObservedTech* (ObservedTech* this, int flags) RET 4. Vftable 0x00a2439c slot [0], scalar deleting dtor. Inlines ~basic_string on the name at +0x0c (`cmp [esi+0x20],0x10` -> operator delete [esi+0x0c], then _Tidy: [esi+0x20]=0xf, [esi+0x1c]=0, byte [esi+0x0c]=0), restores the base vptr 0x009e22bc, and frees `this` when flags&1. The string is the ONLY member needing destruction -- confirming there is no second string or owned pointer in the element. [verified]
constexpr uint32_t ObservedTech_dtor = 0x00393610;
// site site inside the vector<ObservedTech> uninitialised-copy helper FUN_0079a150(&_Alval, dest, src). The inlined copy constructor writes vptr 0x00a2439c, `mov word [dst+0x04],[src+0x04]`, `mov word [dst+0x06],[src+0x06]`, `mov byte [dst+0x08],[src+0x08]`, the string at +0x0c (via basic_string::assign 0x00425430), and `mov [dst+0x28],[src+0x28]`. Nothing is copied at +0x24 -- second independent proof that +0x24 belongs to the 0x1c string, not to a data member. [verified]
constexpr uint32_t ObservedTech_copy_ctor = 0x0039a184;
// offset uint16 otnF -- turn the tech was first observed. Widened to int on disk by stream vft+0x24. Written together with otnL from one source word at RecordObservedTech 0x007ba2b0. [verified]
constexpr uint32_t ObservedTech_off_TurnFirst = 0x00000004;
// offset uint16 otnL -- turn the tech was last observed. Widened to int on disk. Written at RecordObservedTech 0x007ba2c6 from the same source word as otnF. [verified]
constexpr uint32_t ObservedTech_off_TurnLast = 0x00000006;
// offset bool odet -- ONE BYTE (ctor `mov [esi+0x8],bl` 0x008562f4; copy-ctor `mov byte` 0x0079a1a0), serialised through WriteBool/ReadBool, so on disk it is 1 byte + 3 NUL joint padding. Note the save reader types `odet` as int; for a 4-character tag a bool item and an int item are the same 12 bytes and the value is identical, so that is benign, not a parse error. [verified]
constexpr uint32_t ObservedTech_off_Detected = 0x00000008;
// offset int owith -- last member of the element; serialised through stream vft+0x24 with tag 0x00a2b364. +0x28..+0x2b is the tail of the 0x2c-byte element, which is why sizeof is 0x2c with no padding slack. [verified]
constexpr uint32_t ObservedTech_off_With = 0x00000028;
// thiscall void Game::ObservedWeapon::Write(Mars::Stream* s) RET 4. Byte-for-byte the same shape as ObservedTech_Write with tag `owep` (0x00a2b3f0) in place of `otch`: otnF u16 @+0x04, otnL u16 @+0x06, odet bool @+0x08, owep std::string (0x1c) @+0x0c, owith int @+0x28. Reader is 0x00817b10. Independent second instance of the same 0x2c element shape. [verified]
constexpr uint32_t ObservedWeapon_Write = 0x00417bc0;
// constant sizeof(std::basic_string<char>) = 0x1c (28) binary-wide, ONE layout only: _Bx (16-byte SSO union, char[16] or char*) @+0x00, _Mysize @+0x10, _Myres @+0x14, _Alval (empty allocator) @+0x18. The allocator word is trailing, never read and never written -- the same allocator-last shape as this build's std::vector {_Myfirst,_Mylast,_Myend,_Alval} = 0x10. Evidence: (a) Stream::WriteString 0x008b9d76 `cmp [str+0x14],0x10` / `mov eax,[str]` and basic_string::assign 0x00425550 (_Mysize +0x10, _Myres +0x14) fix the field offsets; (b) the vector<std::string> scan loop FUN_00699bd0 advances `add esi,0x1c` (0x00699c29) while doing the SSO test at [esi+0x14]/[esi] -- element base == string base, so the stride IS the sizeof; (c) PostEvent 0x008862b0 takes two by-value strings at [ebp+0x08] and [ebp+0x24] and RET 0x4c = 2*0x1c + 5*4; (d) a whole-binary sweep of the Stream string helpers recovered 65 std::string members off a non-stack base across all serializers -- ZERO have any sibling member inside (N, N+0x1c), and 51 of the 52 that have a next member have it at exactly N+0x1c (the one exception, StrategyServer KeyPath @+0x134, is +0x20 on the Write side and +0x1c on the Read side, i.e. the writer skips a member). There is no 0x18 instantiation, no empty-base variant and no game-local string class. [verified]
constexpr uint32_t std_string_sizeof = 0x0000001c;
// thiscall vector<T>::_Reserve/grow for a 44-byte element type. Shared with FUN_0086dec0, so it may be a COMDAT-folded body -- do NOT assume it is ObservedTech-specific. [mapped]
constexpr uint32_t vector_44B_grow = 0x003b5820;

View file

@ -117,7 +117,7 @@ call-outs cite `findings/` and `~/sots-engine/docs/`.
**1c. Library triage.** Apply Ghidra's VS2010 x86 FID databases (plus threatrack's) or FLIRT via ApplySig to fence off CRT/STL/D3DX code; anything left is game code. STL template instantiations compiled from headers won't match a library signature — recognise them by shape instead: MSVC-2010 `std::vector` = `{begin, end, cap}`, `std::list` = `{head*, size}`, red-black node = `{left, parent, right, key…, color/isnil tail}`, `std::string` = 0x1c bytes on x86 with the SSO union and `_Mysize`/`_Myres`.
> **Our experience:** our own notes disagree on whether `_Bx` starts at +0 or +4 (allocator stub) in this build (`struct-recovery.md` §0 vs `turn-spine.md` §1.1). The reliable lever is the `_Myres >= 16 ? _Ptr : _Buf` branch in any string consumer (`Stream::WriteString` @ 0x008b9d70), which pins both offsets. Verify per binary; do not trust a table.
> **Our experience — settled 2026-09-08.** `_Bx` starts at **+0** in this build: `_Bx@0, _Mysize@0x10, _Myres@0x14, _Alval@0x18`, sizeof `0x1c`. The lever that pins the field offsets is the `_Myres >= 16 ? _Ptr : _Buf` branch in any string consumer (`Stream::WriteString` @ 0x008b9d70). The lever that pins the **size** is different and easy to miss: `_Alval` is an empty allocator, so it is never loaded or stored and no touch-based analysis can see it. Size the type from an *enumeration* instead — a container stride (`vector<string>` walk `add esi,0x1c` @ 0x00699c29), a by-value argument's frame spacing (`PostEvent` strings at `[ebp+8]`/`[ebp+0x24]`, `RET 0x4c`), or a copy constructor / serializer that lists every member. Sizing from touched fields undercounts by exactly the trailing allocator word — that is how a lane got 0x18 for the same type. Note the allocator is **trailing** in this build's `std::vector` too (`{_Myfirst,_Mylast,_Myend,_Alval}` = 0x10), which is the opposite of the MSVC `_String_val`/`_Vector_val` allocator-first shape you will read about; verify per binary, do not trust a table.
**1d. Strings are the map.** Config keys, file names, log format strings and — decisively — **serialization tag names**. If the save format is tagged, each `Read/Write` references dozens of a struct's tag strings in order; rank functions by distinct tag xrefs, decompile, and read `this+offset → tag → type` straight off. This is the single highest-yield lever for the object model, and it is under-documented in the literature (see §4).

210
tools/strfootprint.py Normal file
View file

@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""Whole-binary audit of the std::string member footprint in Sword of the Stars.exe.
Why this exists
---------------
`sizeof(std::string)` is load-bearing: it is embedded in ~65 recovered class
layouts, and a 4-byte error in it silently shifts every field that follows.
Lane X's ObservedTech work reported the embedded string as 0x18 bytes, against
the campaign-wide 0x1c. Arguing about it from one class is how you get a
plausible answer instead of a true one, so this settles it from the whole
binary at once.
Method
------
Every serialiser in this exe hands a member pointer to one of the Mars::Stream
primitive helpers with the same idiom:
push 0xff ; default arg
lea <r>,[<base>+<disp>] ; &this->member
push <r>
push <tag> ; -> .rdata "otch", "pswd", ...
push <stream>
call WriteString / ReadString / WriteBool / ...
So for each helper call we recover (base register, displacement, tag). Then,
per (function, base register), we take every OTHER displacement the same
function touches off that register. If sizeof(std::string) were 0x18 for some
instantiation, that class would necessarily have a real member somewhere in
(N+4 .. N+0x1b). The audit is: does one exist, anywhere?
Two things must be excluded or the answer is noise:
* ebp/esp bases -- those are stack temporaries, not class members. (A local
string at [ebp-0x2c] shows _Mysize at -0x1c and _Myres at -0x18, which
looks exactly like two "members" inside the span.)
* N+0x10 and N+0x14 -- the string's OWN _Mysize/_Myres, read inline whenever
the compiler inlines the SSO `_Myres >= 16 ? _Ptr : _Buf` test.
Result (2026-09-08, lane S): 65 string members off a non-stack base, ZERO with
a sibling inside the 0x1c span, and 51 of the 52 that have a next member have
it at exactly +0x1c. One layout, 0x1c, binary-wide.
Usage: uv run python3 tools/strfootprint.py [--all]
--all also lists every recovered string member and its gap.
"""
import json
import os
import struct
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
sys.path.insert(0, HERE)
from x86disp import decode, Desync, load_pe, EXE, FUNCS # noqa: E402
# Mars::Stream primitive wrappers (struct-recovery.md S0). All take
# (stream, tagname, &member, default) and are called from the class Read/Write.
HELPERS = {
0x008b9d70: "WriteString", 0x008b9d90: "ReadString",
0x008b9c20: "WriteBool", 0x008b9c00: "ReadBool",
0x008b9be0: "WriteFloat", 0x008b9bc0: "ReadFloat",
0x008b9d50: "WriteInt", 0x008b9d20: "ReadInt",
0x008b9d00: "WriteInt16", 0x008b9cd0: "ReadInt16",
0x008b9c60: "WriteInt64", 0x008b9c40: "ReadInt64",
}
STRING_HELPERS = ("WriteString", "ReadString")
STR_SIZE = 0x1c
SSO_OWN = (0x10, 0x14) # the string's own _Mysize / _Myres
STACK_BASES = ("ebp", "esp")
# ---------------------------------------------------------------- .rdata reader
def _load_rdata():
data = open(EXE, "rb").read()
pe = struct.unpack_from("<I", data, 0x3C)[0]
nsec = struct.unpack_from("<H", data, pe + 6)[0]
optsz = struct.unpack_from("<H", data, pe + 20)[0]
base = struct.unpack_from("<I", data, pe + 24 + 28)[0]
secs = []
for i in range(nsec):
o = pe + 24 + optsz + i * 40
vsz, va, rsz, ro = struct.unpack_from("<IIII", data, o + 8)
secs.append((base + va, vsz, ro, rsz))
return data, secs
_DATA, _SECS = _load_rdata()
def cstr(va, maxn=16):
for sva, vsz, ro, rsz in _SECS:
if sva <= va < sva + max(vsz, rsz):
d = va - sva
if d >= rsz:
return None
o = ro + d
e = _DATA.find(b"\0", o, o + maxn)
if e < 0:
return None
try:
return _DATA[o:e].decode("ascii")
except UnicodeDecodeError:
return None
return None
def main():
show_all = "--all" in sys.argv
_, secs = load_pe(EXE)
sva, eva, buf, _ = secs[0]
funcs = json.load(open(FUNCS))
starts = sorted((int(k, 16), v[0]) for k, v in funcs.items())
svas = [s[0] for s in starts]
rd_lo, rd_hi = 0x9DD000, 0xAD9000
records, touch = [], {}
for idx, (fva, nm) in enumerate(starts):
if not (sva <= fva < eva):
continue
fend = svas[idx + 1] if idx + 1 < len(starts) else eva
ins, i, end = [], fva - sva, fend - sva
while i < end:
try:
ln, info = decode(buf, i, len(buf))
except Desync:
break
ins.append((sva + i, ln, info, buf[i:i + ln]))
i += ln
if not any(r[0] == 0xE8 and l == 5 and
(v + 5 + struct.unpack_from("<i", r, 1)[0]) in HELPERS
for v, l, _, r in ins):
continue
for v, l, info, r in ins:
if info and info["base"] is not None and info["disp"] is not None:
touch.setdefault((fva, info["base"]), set()).add(info["disp"])
for k, (v, l, _info, r) in enumerate(ins):
if not (r[0] == 0xE8 and l == 5):
continue
tgt = v + 5 + struct.unpack_from("<i", r, 1)[0]
if tgt not in HELPERS:
continue
tag, ptr_reg = None, None
for j in range(k - 1, max(-1, k - 15), -1):
rr, ll = ins[j][3], ins[j][1]
if rr[0] == 0x68 and ll == 5:
imm = struct.unpack_from("<I", rr, 1)[0]
if rd_lo <= imm < rd_hi:
s = cstr(imm)
if s and 1 <= len(s) <= 8 and s.isprintable():
tag = s
for m in range(j - 1, max(-1, j - 6), -1):
r2, l2 = ins[m][3], ins[m][1]
if 0x50 <= r2[0] <= 0x57 and l2 == 1:
ptr_reg = ["eax", "ecx", "edx", "ebx",
"esp", "ebp", "esi",
"edi"][r2[0] - 0x50]
break
break
base = disp = None
if ptr_reg:
for m in range(k - 1, max(-1, k - 25), -1):
i2 = ins[m][2]
if i2 and i2["lea"] and i2["reg"] == ptr_reg \
and i2["base"] is not None:
base, disp = i2["base"], i2["disp"]
break
records.append((fva, nm, base, disp, tag, HELPERS[tgt]))
strs = [r for r in records if r[5] in STRING_HELPERS]
members, stack, unresolved = [], 0, 0
for fva, nm, base, disp, tag, helper in strs:
if base is None or disp is None:
unresolved += 1
elif base in STACK_BASES:
stack += 1
else:
members.append((fva, nm, base, disp, tag, helper))
violations, gaps, seen = [], {}, set()
for fva, nm, base, disp, tag, helper in members:
others = sorted(d for d in touch.get((fva, base), set())
if d > disp and (d - disp) not in SSO_OWN)
inside = [d for d in others if d < disp + STR_SIZE]
if inside:
violations.append((fva, nm, base, disp, tag, helper, inside))
if others:
g = others[0] - disp
gaps[g] = gaps.get(g, 0) + 1
if show_all and (fva, base, disp, helper) not in seen:
seen.add((fva, base, disp, helper))
nxt = f"+0x{others[0]:x}" if others else "-"
print(f"0x{fva:08x} {nm:26s} {base} +0x{disp:<6x} {tag!r:12s} "
f"{helper:12s} next={nxt}")
print(f"\nstring helper call sites : {len(strs)}")
print(f" resolved to a class member offset : {len(members)}")
print(f" stack temporaries (ebp/esp base) : {stack}")
print(f" offset not reached by a plain lea : {unresolved}")
print(f"\nmembers with a sibling inside (N, N+0x{STR_SIZE:x}) "
f": {len(violations)} <-- must be 0 for sizeof(std::string)==0x1c")
for v in violations:
print(f" 0x{v[0]:08x} {v[1]} {v[2]}+0x{v[3]:x} {v[4]!r} "
f"inside={[hex(x) for x in v[6]]}")
print("\ngap from a string member to the next member on the same base:")
for g in sorted(gaps):
print(f" +0x{g:<4x} : {gaps[g]}")
return 1 if violations else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Census of the `fpu_cw` field the shim's template hooks record on every call.
Each hook snapshots the x87 control word as it finds it, so a run's trace carries independent
samples of the word from *inside* the turn pipeline -- phase 4 (MoveFleet), phase 6
(ServerSystem::ProcessTurn) and phase 8 (ComputeBudget / ProcessResearch). That is the evidence
that a control word forced at the turn gate actually held for the duration of the turn, rather
than being silently restored partway through (which would produce identical saves and a
completely false "nothing depends on precision" conclusion).
Usage: cw_census.py <shim.trace.jsonl> [...]
"""
import gzip
import json
import sys
from collections import Counter, defaultdict
def _open(path):
op = gzip.open if path.endswith(".gz") else open
return op(path, "rt", encoding="utf-8", errors="replace")
def census(path):
per_hook = defaultdict(Counter)
first_last = {}
n = 0
with _open(path) as f:
for line in f:
line = line.strip()
if not line.startswith("{"):
continue
try:
rec = json.loads(line)
except json.JSONDecodeError:
continue
hook = rec.get("hook")
if not hook:
continue
n += 1
for arg in rec.get("args", []) or []:
if arg.get("n") == "fpu_cw":
cw = int(arg["v"])
per_hook[hook][cw] += 1
key = (hook, cw)
if key not in first_last:
first_last[key] = [rec.get("call_id"), rec.get("call_id")]
else:
first_last[key][1] = rec.get("call_id")
return n, per_hook, first_last
def main():
for path in sys.argv[1:]:
n, per_hook, first_last = census(path)
print(f"== {path} ({n} hook records)")
total = Counter()
for hook in sorted(per_hook):
for cw, count in sorted(per_hook[hook].items()):
lo, hi = first_last[(hook, cw)]
total[cw] += count
print(f" {hook:<42} cw=0x{cw:04x} x{count:<5} call_id {lo}..{hi}")
if not per_hook:
print(" (no hook recorded an fpu_cw field)")
else:
vals = ", ".join(f"0x{cw:04x} x{c}" for cw, c in sorted(total.items()))
print(f" ALL SAMPLES: {vals}")
print()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""Bit-exact comparison of two shim traces of the same turn, hook by hook.
Why not just diff the text: **the trace's own float rendering is not a stable comparison
surface when the x87 control word has been forced.** The emitter prints an f32 with `%.9g`,
and the CRT's digit generation is itself done in x87 arithmetic, so under `fpu_cw = 0x007f`
(24-bit) the *same* float32 renders as `1.5647336` instead of `1.56473362`. Comparing the text
of a 24-bit run against a 53-bit run reports dozens of "differences" that are all the printf,
not the simulation. Every float here is therefore re-quantised to its IEEE bit pattern before
comparison, and per-process noise (pointers, thread id, timestamps, call ids, the `fpu_cw`
field that is the independent variable) is scrubbed.
Usage: trace_bitdiff.py <a.jsonl[.gz]> <b.jsonl[.gz]> [hook ...]
"""
import gzip
import json
import re
import struct
import sys
PTR = re.compile(r"^0x[0-9a-f]{8}$")
# Only hooks that fire *inside* the turn pipeline: ServerPlayer::ComputeBudget also runs once
# per UI frame, so its call count depends on how long the session sat at a menu and is not
# comparable between runs. Name it explicitly on the command line if you want it anyway.
DEFAULT_HOOKS = (
"Game::ServerSystem::ProcessTurn",
"Game::StrategyServer::MoveFleet",
"Game::TechTree::ProcessResearch",
"Game::ServerPlayer::OnTechResearched",
)
NOISE = ("ts", "call_id", "thread")
def f32_bits(v):
return struct.unpack("<I", struct.pack("<f", float(v)))[0]
def f64_bits(v):
return struct.unpack("<Q", struct.pack("<d", float(v)))[0]
def norm(o):
if isinstance(o, dict):
t = o.get("t")
if t == "ptr":
return {"t": "ptr", "n": o.get("n")}
if t == "f32":
return {"t": "f32", "n": o.get("n"), "bits": f32_bits(o["v"])}
if t == "f64":
return {"t": "f64", "n": o.get("n"), "bits": f64_bits(o["v"])}
return {k: norm(v) for k, v in o.items() if k not in NOISE}
if isinstance(o, list):
return [norm(x) for x in o]
if isinstance(o, str) and PTR.match(o):
return "<ptr>"
return o
def _open(path):
op = gzip.open if path.endswith(".gz") else open
return op(path, "rt", encoding="utf-8", errors="replace")
def load(path, hook):
out = []
needle = '"hook":"%s"' % hook
with _open(path) as f:
for line in f:
if needle not in line:
continue
rec = norm(json.loads(line))
rec["args"] = [a for a in rec.get("args", []) if a.get("n") != "fpu_cw"]
for side in (rec.get("side") or {}).values():
if not isinstance(side, dict):
continue
for snap in side.values():
if isinstance(snap, dict) and isinstance(snap.get("v"), dict):
snap["v"].pop("fpu_cw", None)
out.append(rec)
return out
def leafdiff(a, b, path=""):
out = []
if isinstance(a, dict) and isinstance(b, dict):
for k in sorted(set(a) | set(b)):
out += leafdiff(a.get(k), b.get(k), f"{path}.{k}")
elif isinstance(a, list) and isinstance(b, list) and len(a) == len(b):
for i, (x, y) in enumerate(zip(a, b)):
out += leafdiff(x, y, f"{path}[{i}]")
elif a != b:
out.append((path, a, b))
return out
def main():
pa, pb = sys.argv[1], sys.argv[2]
hooks = sys.argv[3:] or list(DEFAULT_HOOKS)
print(f"A {pa}\nB {pb}")
total = 0
for hook in hooks:
a, b = load(pa, hook), load(pb, hook)
if not a and not b:
continue
if len(a) != len(b):
print(f"{hook}: CALL COUNT DIFFERS {len(a)} vs {len(b)}")
total += 1
continue
diffs = [(i, p, x, y) for i, (ra, rb) in enumerate(zip(a, b)) for p, x, y in leafdiff(ra, rb)]
total += len(diffs)
print(f"{hook}: {len(diffs)} leaf difference(s) over {len(a)} record(s)")
for i, p, x, y in diffs:
print(f" rec[{i}] {p}: {x} -> {y}")
print(f"TOTAL: {total}")
return 1 if total else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,55 @@
# x87 precision-sensitivity experiment — artefacts (lane F, 2026-09-08)
Read `findings/subsystems/fpu-precision-sensitivity.md` for the result and the argument.
This directory is the evidence.
Seven End-Turn runs from `C:\SOTS\SavedGames\ref-turn2.sav` (`ab4ac2d7…`) on VM140, shim build
`fpucw-cef889e-20260908T0803Z`. The hook set, trace path and flush policy are identical in every
run; the ONLY difference between the `shim.cfg` files is the `fpu.force=` line.
```
run-off/ nothing forced -- oracle re-confirmation + the game's own control-word timeline
run-027f/ fpu.force=0x027f 53-bit, nearest (the MSVC CRT default; NOT 24-bit)
run-127f/ fpu.force=0x127f 53-bit, nearest -- the CONTROL: what the game sets itself
run-137f/ fpu.force=0x137f 64-bit extended, nearest
run-007f/ fpu.force=0x007f 24-bit single, nearest
run-1a7f/ fpu.force=0x1a7f 53-bit, round-toward-+infinity
run-007f-rep/ repeat of run-007f -- reproducibility of the divergence
run-1a7f-rep/ repeat of run-1a7f
```
Each run directory holds `(Autosave).sav` (post-turn, the thing under test),
`(Autosave EndTurn).sav` (pre-turn, the run-to-run control -- `bb4fd9ac…` in every run),
`shim.log` (the force / read-back / per-tick-sampler timeline), `shim.cfg` and the gzipped
trace. The two `-rep` runs keep only the saves and the log.
Derived, regenerate with the commands below:
* `cw-census.txt` — the `fpu_cw` every hooked call observed, per run. This is the evidence that
the forced word held for the *duration* of the turn: 38 samples per run spanning turn phases
4, 6 and 8, all reading the forced value.
* `state-checksum-diffs.txt` — every run's post-turn autosave against `run-127f`.
* `trace-bitdiff.txt` — bit-exact trace comparison. Note the trap it exists for: under a forced
24-bit control word the CRT's own `%g` rendering degrades, so trace *text* is not a valid
comparison surface.
```sh
uv run python3 verify/fpu-cw/cw_census.py verify/results/fpu-cw/run-*/shim.trace.jsonl.gz
uv run python3 verify/state-checksum/state_checksum.py \
"verify/results/fpu-cw/run-127f/(Autosave).sav" "verify/results/fpu-cw/run-007f/(Autosave).sav"
uv run python3 verify/fpu-cw/trace_bitdiff.py \
verify/results/fpu-cw/run-127f/shim.trace.jsonl.gz verify/results/fpu-cw/run-1a7f/shim.trace.jsonl.gz
```
Headline hashes (sha256 of the file, gzip container included -- MTIME is 0, so this is valid):
```
bb4fd9ac89f41e3bc0db2af08b18ce83417521ac4bcee695fc9fa6ce16e30948 (Autosave EndTurn).sav ALL SEVEN RUNS
978041acd168b56ed8eb3f5e42e78d5e70eae6e6517d75e659a5eb7ca3d60921 (Autosave).sav stock / 0x027f / 0x127f / 0x137f
ba2435beb17265af3abddb58cd26e926d42b39b358ef6d0c76f9897386174918 (Autosave).sav 0x007f (both runs)
e48e25fa8425a8a1d174efe3e8a553153447f11f8f91c5fda25bd7db3a26cca7 (Autosave).sav 0x1a7f (both runs)
```
The first two lines are also the **determinism-oracle re-confirmation** on engine `cef889e`
(lane M's movement fix included): unchanged, as it must be — that fix touches `ours`, never the
original.

View file

@ -0,0 +1,36 @@
== verify/results/fpu-cw/run-007f/shim.trace.jsonl.gz (2212 hook records)
Game::ServerSystem::ProcessTurn cw=0x007f x28 call_id 1110..1137
Game::StrategyServer::MoveFleet cw=0x007f x7 call_id 1103..1109
Game::TechTree::ProcessResearch cw=0x007f x3 call_id 1140..1144
ALL SAMPLES: 0x007f x38
== verify/results/fpu-cw/run-027f/shim.trace.jsonl.gz (2231 hook records)
Game::ServerSystem::ProcessTurn cw=0x027f x28 call_id 1134..1161
Game::StrategyServer::MoveFleet cw=0x027f x7 call_id 1127..1133
Game::TechTree::ProcessResearch cw=0x027f x3 call_id 1164..1168
ALL SAMPLES: 0x027f x38
== verify/results/fpu-cw/run-127f/shim.trace.jsonl.gz (2317 hook records)
Game::ServerSystem::ProcessTurn cw=0x127f x28 call_id 1195..1222
Game::StrategyServer::MoveFleet cw=0x127f x7 call_id 1188..1194
Game::TechTree::ProcessResearch cw=0x127f x3 call_id 1225..1229
ALL SAMPLES: 0x127f x38
== verify/results/fpu-cw/run-137f/shim.trace.jsonl.gz (2280 hook records)
Game::ServerSystem::ProcessTurn cw=0x137f x28 call_id 1152..1179
Game::StrategyServer::MoveFleet cw=0x137f x7 call_id 1145..1151
Game::TechTree::ProcessResearch cw=0x137f x3 call_id 1182..1186
ALL SAMPLES: 0x137f x38
== verify/results/fpu-cw/run-1a7f/shim.trace.jsonl.gz (2304 hook records)
Game::ServerSystem::ProcessTurn cw=0x1a7f x28 call_id 1187..1214
Game::StrategyServer::MoveFleet cw=0x1a7f x7 call_id 1180..1186
Game::TechTree::ProcessResearch cw=0x1a7f x3 call_id 1217..1221
ALL SAMPLES: 0x1a7f x38
== verify/results/fpu-cw/run-off/shim.trace.jsonl.gz (2449 hook records)
Game::ServerSystem::ProcessTurn cw=0x127f x28 call_id 1152..1179
Game::StrategyServer::MoveFleet cw=0x127f x7 call_id 1145..1151
Game::TechTree::ProcessResearch cw=0x127f x3 call_id 1182..1186
ALL SAMPLES: 0x127f x38

Binary file not shown.

View file

@ -0,0 +1,61 @@
04:47:30.428 [tid 2160] ==== sots-engine shim (binkw32 proxy) build fpucw-cef889e-20260908T0803Z ====
04:47:30.428 [tid 2160] exe: C:\SOTS\Sword of the Stars.exe
04:47:30.428 [tid 2160] exe base=0x00e80000 (link-time image base 0x00400000, ASLR delta +11010048) pid=7180 shim=696a0000
04:47:30.428 [tid 2160] addresses: Source: sots-re ghidra/addresses.json @ f5b37c2, generated 2026-09-08 by tools/gen_addresses.py
04:47:30.428 [tid 2160] config: hooks=trace
04:47:30.428 [tid 2160] config: hook.Shim::SelfTest::Fill=off
04:47:30.428 [tid 2160] config: hook.Mars::GlobalConsts::LoadFile=off
04:47:30.428 [tid 2160] config: hook.Game::WeaponDictionary::Init=off
04:47:30.428 [tid 2160] config: hook.Game::SectionDictionary::SectionDictionary=off
04:47:30.428 [tid 2160] config: hook.Game::StrategyServer::ProcessFleetMovement=off
04:47:30.428 [tid 2160] config: hook.Game::TechTree::ProcessResearch=trace
04:47:30.428 [tid 2160] config: hook.Game::ServerPlayer::ComputeBudget=trace
04:47:30.428 [tid 2160] config: hook.Game::ServerPlayer::OnTechResearched=trace
04:47:30.428 [tid 2160] config: hook.Game::ServerSystem::ProcessTurn=trace
04:47:30.428 [tid 2160] config: hook.Game::StrategyServer::MoveFleet=trace
04:47:30.428 [tid 2160] config: trace.path=C:\SOTS\shim.trace.jsonl
04:47:30.428 [tid 2160] config: trace.inline_max=256
04:47:30.428 [tid 2160] config: trace.flush=always
04:47:30.428 [tid 2160] config: fpu.force=0x007f
04:47:30.428 [tid 2160] config: fpu.sample_ticks=on
04:47:30.490 [tid 2160] trace: C:\SOTS\shim.trace.jsonl (default mode trace, inline_max 256, flush always)
04:47:30.490 [tid 2160] hook: Mars_Application_Initialize rva=0x004a0e50 -> va=01320e50
04:47:30.490 [tid 2160] hook: MH_Initialize -> MH_OK
04:47:30.490 [tid 2160] hook: MH_CreateHook -> MH_OK (trampoline=017d0fe0)
04:47:30.506 [tid 2160] hook: MH_EnableHook -> MH_OK
04:47:30.506 [tid 2160] cfg: GlobalConsts hook ready (scale constant 0.017453292519943295)
04:47:30.506 [tid 2160] hook: Mars::GlobalConsts::LoadFile rva=0x004b73c0 mode=off (not installed)
04:47:30.506 [tid 2160] dict: dictionaries hook ready (crt new=6adc232b delete=6adc0174)
04:47:30.506 [tid 2160] hook: Game::WeaponDictionary::Init rva=0x0019a4c0 mode=off (not installed)
04:47:30.506 [tid 2160] hook: Game::SectionDictionary::SectionDictionary rva=0x00176f40 mode=off (not installed)
04:47:30.506 [tid 2160] research: ProcessResearch hook ready (Cost=00ffda00, node=0x34, rng=0x9cc, fpu_cw=0x027f)
04:47:30.506 [tid 2160] hook: Game::TechTree::ProcessResearch rva=0x001876c0 -> va=010076c0 MH_CreateHook -> MH_OK (trampoline=017d0fc0)
04:47:30.522 [tid 2160] hook: Game::TechTree::ProcessResearch MH_EnableHook -> MH_OK mode=trace
04:47:30.522 [tid 2160] techfx: OnTechResearched hook ready (regions=15, gate=0/0, fpu_cw=0x027f)
04:47:30.522 [tid 2160] hook: Game::ServerPlayer::OnTechResearched rva=0x00491790 -> va=01311790 MH_CreateHook -> MH_OK (trampoline=017d0fa0)
04:47:30.522 [tid 2160] hook: Game::ServerPlayer::OnTechResearched MH_EnableHook -> MH_OK mode=trace
04:47:30.522 [tid 2160] hook: Game::ServerPlayer::ComputeBudget rva=0x00463030 -> va=012e3030 MH_CreateHook -> MH_OK (trampoline=017d0f80)
04:47:30.537 [tid 2160] hook: Game::ServerPlayer::ComputeBudget MH_EnableHook -> MH_OK mode=trace
04:47:30.537 [tid 2160] hook: Game::ServerSystem::ProcessTurn rva=0x003598e0 -> va=011d98e0 MH_CreateHook -> MH_OK (trampoline=017d0f60)
04:47:30.553 [tid 2160] hook: Game::ServerSystem::ProcessTurn MH_EnableHook -> MH_OK mode=trace
04:47:30.553 [tid 2160] hook: Game::StrategyServer::MoveFleet rva=0x003d9ee0 -> va=01259ee0 MH_CreateHook -> MH_OK (trampoline=017d0f40)
04:47:30.569 [tid 2160] hook: Game::StrategyServer::MoveFleet MH_EnableHook -> MH_OK mode=trace
04:47:30.569 [tid 2160] hook: Game::StrategyServer::ProcessFleetMovement rva=0x003da9a0 mode=off (not installed)
04:47:30.569 [tid 2160] fpu: module init, entry cw=0x027f 53bit-double/nearest; force=on value=0x007f sample_ticks=on
04:47:30.569 [tid 2160] fpu: StrategyClient::EndTurn rva=0x00383be0 -> va=01203be0 MH_CreateHook -> MH_OK (trampoline=017d0f20)
04:47:30.584 [tid 2160] fpu: StrategyClient::EndTurn MH_EnableHook -> MH_OK
04:47:30.584 [tid 2160] fpu: StrategyServer::BeginProcessTurn rva=0x003d98e0 -> va=012598e0 MH_CreateHook -> MH_OK (trampoline=017d0f00)
04:47:30.600 [tid 2160] fpu: StrategyServer::BeginProcessTurn MH_EnableHook -> MH_OK
04:47:30.600 [tid 2160] fpu: StrategyServer::ProcessTurn rva=0x003dc6c0 -> va=0125c6c0 MH_CreateHook -> MH_OK (trampoline=017d0ee0)
04:47:30.615 [tid 2160] fpu: StrategyServer::ProcessTurn MH_EnableHook -> MH_OK
04:47:30.615 [tid 2160] fpu: DemoApp::OnTick rva=0x0049a640 -> va=0131a640 MH_CreateHook -> MH_OK (trampoline=017d0ec0)
04:47:30.631 [tid 2160] fpu: DemoApp::OnTick MH_EnableHook -> MH_OK
04:47:30.631 [tid 2160] selftest: Shim::SelfTest::Fill mode=off checksum=075ef0c3 records=0
04:47:30.631 [tid 2160] Application::Initialize called (this=03b08128)
04:47:31.365 [tid 2160] fpu: TICK BASELINE at OnTick (this=03b08128): cw=0x127f 53bit-double/nearest
04:51:34.694 [tid 2160] fpu: FORCE at StrategyClient::EndTurn (this=0e8b0ba0): observed=0x127f 53bit-double/nearest -> requested=0x007f -> readback=0x007f 24bit-single/nearest OK
04:51:37.757 [tid 2160] fpu: FORCE at StrategyClient::EndTurn (this=351a8bf0): observed=0x127f 53bit-double/nearest -> requested=0x007f -> readback=0x007f 24bit-single/nearest OK
04:51:37.788 [tid 2160] fpu: FORCE at StrategyClient::EndTurn (this=351a6788): observed=0x007f 24bit-single/nearest -> requested=0x007f -> readback=0x007f 24bit-single/nearest OK
04:51:37.819 [tid 2160] fpu: FORCE at StrategyClient::EndTurn (this=351ab7a0): observed=0x007f 24bit-single/nearest -> requested=0x007f -> readback=0x007f 24bit-single/nearest OK
04:51:38.788 [tid 2160] fpu: FORCE at StrategyServer::BeginProcessTurn (this=0e8ebc90): observed=0x127f 53bit-double/nearest -> requested=0x007f -> readback=0x007f 24bit-single/nearest OK
04:51:38.788 [tid 2160] fpu: sample at StrategyServer::ProcessTurn (this=0e8ebc90): cw=0x007f 24bit-single/nearest

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,31 @@
# Lane F, x87 precision-sensitivity experiment (verify/results/fpu-cw in the notes repo).
# 0x007f = 24-bit significand (SINGLE precision), round-to-nearest. The genuine single-precision setting the brief meant by 0x027f, and the run most likely to move a result: it is the strongest available positive control on the whole apparatus.
#
# Every shim.cfg.fpu* file is identical except the fpu.force line: the hook set, the trace path
# and the flush policy are held fixed so the control word is the only variable across runs.
#
# x87 control-word fields: bits 0-5 exception masks, bits 8-9 precision control
# (00 = 24-bit / 10 = 53-bit / 11 = 64-bit significand), bits 10-11 rounding control
# (00 nearest / 01 down / 10 up / 11 truncate), bit 12 infinity control (ignored since the 387).
hooks=trace
hook.Shim::SelfTest::Fill=off
hook.Mars::GlobalConsts::LoadFile=off
hook.Game::WeaponDictionary::Init=off
hook.Game::SectionDictionary::SectionDictionary=off
hook.Game::StrategyServer::ProcessFleetMovement=off
hook.Game::TechTree::ProcessResearch=trace
hook.Game::ServerPlayer::ComputeBudget=trace
hook.Game::ServerPlayer::OnTechResearched=trace
hook.Game::ServerSystem::ProcessTurn=trace
hook.Game::StrategyServer::MoveFleet=trace
trace.path=C:\SOTS\shim.trace.jsonl
trace.inline_max=256
trace.flush=always
# Forced once at the turn gate (StrategyClient::EndTurn and StrategyServer::BeginProcessTurn),
# never re-forced inside the pipeline -- re-forcing would guarantee the value is present without
# proving it ever held.
fpu.force=0x007f
# Per-tick sampler: logs to shim.log whenever the word MOVES, so the log carries a timeline of
# the value rather than a single claim made at a single instant.
fpu.sample_ticks=on

View file

@ -0,0 +1,61 @@
04:19:06.590 [tid 7008] ==== sots-engine shim (binkw32 proxy) build fpucw-cef889e-20260908T0803Z ====
04:19:06.590 [tid 7008] exe: C:\SOTS\Sword of the Stars.exe
04:19:06.590 [tid 7008] exe base=0x00e80000 (link-time image base 0x00400000, ASLR delta +11010048) pid=1276 shim=696a0000
04:19:06.590 [tid 7008] addresses: Source: sots-re ghidra/addresses.json @ f5b37c2, generated 2026-09-08 by tools/gen_addresses.py
04:19:06.606 [tid 7008] config: hooks=trace
04:19:06.606 [tid 7008] config: hook.Shim::SelfTest::Fill=off
04:19:06.606 [tid 7008] config: hook.Mars::GlobalConsts::LoadFile=off
04:19:06.606 [tid 7008] config: hook.Game::WeaponDictionary::Init=off
04:19:06.606 [tid 7008] config: hook.Game::SectionDictionary::SectionDictionary=off
04:19:06.606 [tid 7008] config: hook.Game::StrategyServer::ProcessFleetMovement=off
04:19:06.606 [tid 7008] config: hook.Game::TechTree::ProcessResearch=trace
04:19:06.606 [tid 7008] config: hook.Game::ServerPlayer::ComputeBudget=trace
04:19:06.606 [tid 7008] config: hook.Game::ServerPlayer::OnTechResearched=trace
04:19:06.606 [tid 7008] config: hook.Game::ServerSystem::ProcessTurn=trace
04:19:06.606 [tid 7008] config: hook.Game::StrategyServer::MoveFleet=trace
04:19:06.606 [tid 7008] config: trace.path=C:\SOTS\shim.trace.jsonl
04:19:06.606 [tid 7008] config: trace.inline_max=256
04:19:06.606 [tid 7008] config: trace.flush=always
04:19:06.606 [tid 7008] config: fpu.force=0x007f
04:19:06.606 [tid 7008] config: fpu.sample_ticks=on
04:19:06.653 [tid 7008] trace: C:\SOTS\shim.trace.jsonl (default mode trace, inline_max 256, flush always)
04:19:06.653 [tid 7008] hook: Mars_Application_Initialize rva=0x004a0e50 -> va=01320e50
04:19:06.653 [tid 7008] hook: MH_Initialize -> MH_OK
04:19:06.653 [tid 7008] hook: MH_CreateHook -> MH_OK (trampoline=01740fe0)
04:19:06.668 [tid 7008] hook: MH_EnableHook -> MH_OK
04:19:06.668 [tid 7008] cfg: GlobalConsts hook ready (scale constant 0.017453292519943295)
04:19:06.668 [tid 7008] hook: Mars::GlobalConsts::LoadFile rva=0x004b73c0 mode=off (not installed)
04:19:06.668 [tid 7008] dict: dictionaries hook ready (crt new=6adc232b delete=6adc0174)
04:19:06.668 [tid 7008] hook: Game::WeaponDictionary::Init rva=0x0019a4c0 mode=off (not installed)
04:19:06.668 [tid 7008] hook: Game::SectionDictionary::SectionDictionary rva=0x00176f40 mode=off (not installed)
04:19:06.668 [tid 7008] research: ProcessResearch hook ready (Cost=00ffda00, node=0x34, rng=0x9cc, fpu_cw=0x027f)
04:19:06.668 [tid 7008] hook: Game::TechTree::ProcessResearch rva=0x001876c0 -> va=010076c0 MH_CreateHook -> MH_OK (trampoline=01740fc0)
04:19:06.684 [tid 7008] hook: Game::TechTree::ProcessResearch MH_EnableHook -> MH_OK mode=trace
04:19:06.684 [tid 7008] techfx: OnTechResearched hook ready (regions=15, gate=0/0, fpu_cw=0x027f)
04:19:06.684 [tid 7008] hook: Game::ServerPlayer::OnTechResearched rva=0x00491790 -> va=01311790 MH_CreateHook -> MH_OK (trampoline=01740fa0)
04:19:06.700 [tid 7008] hook: Game::ServerPlayer::OnTechResearched MH_EnableHook -> MH_OK mode=trace
04:19:06.700 [tid 7008] hook: Game::ServerPlayer::ComputeBudget rva=0x00463030 -> va=012e3030 MH_CreateHook -> MH_OK (trampoline=01740f80)
04:19:06.715 [tid 7008] hook: Game::ServerPlayer::ComputeBudget MH_EnableHook -> MH_OK mode=trace
04:19:06.715 [tid 7008] hook: Game::ServerSystem::ProcessTurn rva=0x003598e0 -> va=011d98e0 MH_CreateHook -> MH_OK (trampoline=01740f60)
04:19:06.731 [tid 7008] hook: Game::ServerSystem::ProcessTurn MH_EnableHook -> MH_OK mode=trace
04:19:06.731 [tid 7008] hook: Game::StrategyServer::MoveFleet rva=0x003d9ee0 -> va=01259ee0 MH_CreateHook -> MH_OK (trampoline=01740f40)
04:19:06.747 [tid 7008] hook: Game::StrategyServer::MoveFleet MH_EnableHook -> MH_OK mode=trace
04:19:06.747 [tid 7008] hook: Game::StrategyServer::ProcessFleetMovement rva=0x003da9a0 mode=off (not installed)
04:19:06.747 [tid 7008] fpu: module init, entry cw=0x027f 53bit-double/nearest; force=on value=0x007f sample_ticks=on
04:19:06.747 [tid 7008] fpu: StrategyClient::EndTurn rva=0x00383be0 -> va=01203be0 MH_CreateHook -> MH_OK (trampoline=01740f20)
04:19:06.762 [tid 7008] fpu: StrategyClient::EndTurn MH_EnableHook -> MH_OK
04:19:06.762 [tid 7008] fpu: StrategyServer::BeginProcessTurn rva=0x003d98e0 -> va=012598e0 MH_CreateHook -> MH_OK (trampoline=01740f00)
04:19:06.762 [tid 7008] fpu: StrategyServer::BeginProcessTurn MH_EnableHook -> MH_OK
04:19:06.762 [tid 7008] fpu: StrategyServer::ProcessTurn rva=0x003dc6c0 -> va=0125c6c0 MH_CreateHook -> MH_OK (trampoline=01740ee0)
04:19:06.778 [tid 7008] fpu: StrategyServer::ProcessTurn MH_EnableHook -> MH_OK
04:19:06.778 [tid 7008] fpu: DemoApp::OnTick rva=0x0049a640 -> va=0131a640 MH_CreateHook -> MH_OK (trampoline=01740ec0)
04:19:06.793 [tid 7008] fpu: DemoApp::OnTick MH_EnableHook -> MH_OK
04:19:06.793 [tid 7008] selftest: Shim::SelfTest::Fill mode=off checksum=075ef0c3 records=0
04:19:06.809 [tid 7008] Application::Initialize called (this=03528128)
04:19:07.528 [tid 7008] fpu: TICK BASELINE at OnTick (this=03528128): cw=0x127f 53bit-double/nearest
04:23:06.089 [tid 7008] fpu: FORCE at StrategyClient::EndTurn (this=0e2aa720): observed=0x127f 53bit-double/nearest -> requested=0x007f -> readback=0x007f 24bit-single/nearest OK
04:23:09.152 [tid 7008] fpu: FORCE at StrategyClient::EndTurn (this=34b68d80): observed=0x127f 53bit-double/nearest -> requested=0x007f -> readback=0x007f 24bit-single/nearest OK
04:23:09.167 [tid 7008] fpu: FORCE at StrategyClient::EndTurn (this=34b64bf8): observed=0x007f 24bit-single/nearest -> requested=0x007f -> readback=0x007f 24bit-single/nearest OK
04:23:09.198 [tid 7008] fpu: FORCE at StrategyClient::EndTurn (this=34b65340): observed=0x007f 24bit-single/nearest -> requested=0x007f -> readback=0x007f 24bit-single/nearest OK
04:23:10.152 [tid 7008] fpu: FORCE at StrategyServer::BeginProcessTurn (this=0e208b28): observed=0x127f 53bit-double/nearest -> requested=0x007f -> readback=0x007f 24bit-single/nearest OK
04:23:10.152 [tid 7008] fpu: sample at StrategyServer::ProcessTurn (this=0e208b28): cw=0x007f 24bit-single/nearest

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,31 @@
# Lane F, x87 precision-sensitivity experiment (verify/results/fpu-cw in the notes repo).
# 0x027f = 53-bit significand, round-to-nearest. The MSVC CRT default. Numerically identical to the game's own 0x127f: they differ only in bit 12 (infinity control), which the 387 and later ignore. Named in the lane brief as "24-bit"; it is not.
#
# Every shim.cfg.fpu* file is identical except the fpu.force line: the hook set, the trace path
# and the flush policy are held fixed so the control word is the only variable across runs.
#
# x87 control-word fields: bits 0-5 exception masks, bits 8-9 precision control
# (00 = 24-bit / 10 = 53-bit / 11 = 64-bit significand), bits 10-11 rounding control
# (00 nearest / 01 down / 10 up / 11 truncate), bit 12 infinity control (ignored since the 387).
hooks=trace
hook.Shim::SelfTest::Fill=off
hook.Mars::GlobalConsts::LoadFile=off
hook.Game::WeaponDictionary::Init=off
hook.Game::SectionDictionary::SectionDictionary=off
hook.Game::StrategyServer::ProcessFleetMovement=off
hook.Game::TechTree::ProcessResearch=trace
hook.Game::ServerPlayer::ComputeBudget=trace
hook.Game::ServerPlayer::OnTechResearched=trace
hook.Game::ServerSystem::ProcessTurn=trace
hook.Game::StrategyServer::MoveFleet=trace
trace.path=C:\SOTS\shim.trace.jsonl
trace.inline_max=256
trace.flush=always
# Forced once at the turn gate (StrategyClient::EndTurn and StrategyServer::BeginProcessTurn),
# never re-forced inside the pipeline -- re-forcing would guarantee the value is present without
# proving it ever held.
fpu.force=0x027f
# Per-tick sampler: logs to shim.log whenever the word MOVES, so the log carries a timeline of
# the value rather than a single claim made at a single instant.
fpu.sample_ticks=on

View file

@ -0,0 +1,61 @@
04:24:39.698 [tid 7416] ==== sots-engine shim (binkw32 proxy) build fpucw-cef889e-20260908T0803Z ====
04:24:39.698 [tid 7416] exe: C:\SOTS\Sword of the Stars.exe
04:24:39.698 [tid 7416] exe base=0x00e80000 (link-time image base 0x00400000, ASLR delta +11010048) pid=7776 shim=696a0000
04:24:39.698 [tid 7416] addresses: Source: sots-re ghidra/addresses.json @ f5b37c2, generated 2026-09-08 by tools/gen_addresses.py
04:24:39.698 [tid 7416] config: hooks=trace
04:24:39.698 [tid 7416] config: hook.Shim::SelfTest::Fill=off
04:24:39.698 [tid 7416] config: hook.Mars::GlobalConsts::LoadFile=off
04:24:39.698 [tid 7416] config: hook.Game::WeaponDictionary::Init=off
04:24:39.698 [tid 7416] config: hook.Game::SectionDictionary::SectionDictionary=off
04:24:39.698 [tid 7416] config: hook.Game::StrategyServer::ProcessFleetMovement=off
04:24:39.698 [tid 7416] config: hook.Game::TechTree::ProcessResearch=trace
04:24:39.698 [tid 7416] config: hook.Game::ServerPlayer::ComputeBudget=trace
04:24:39.698 [tid 7416] config: hook.Game::ServerPlayer::OnTechResearched=trace
04:24:39.698 [tid 7416] config: hook.Game::ServerSystem::ProcessTurn=trace
04:24:39.698 [tid 7416] config: hook.Game::StrategyServer::MoveFleet=trace
04:24:39.698 [tid 7416] config: trace.path=C:\SOTS\shim.trace.jsonl
04:24:39.698 [tid 7416] config: trace.inline_max=256
04:24:39.698 [tid 7416] config: trace.flush=always
04:24:39.698 [tid 7416] config: fpu.force=0x027f
04:24:39.698 [tid 7416] config: fpu.sample_ticks=on
04:24:39.761 [tid 7416] trace: C:\SOTS\shim.trace.jsonl (default mode trace, inline_max 256, flush always)
04:24:39.761 [tid 7416] hook: Mars_Application_Initialize rva=0x004a0e50 -> va=01320e50
04:24:39.761 [tid 7416] hook: MH_Initialize -> MH_OK
04:24:39.761 [tid 7416] hook: MH_CreateHook -> MH_OK (trampoline=00be0fe0)
04:24:39.777 [tid 7416] hook: MH_EnableHook -> MH_OK
04:24:39.777 [tid 7416] cfg: GlobalConsts hook ready (scale constant 0.017453292519943295)
04:24:39.777 [tid 7416] hook: Mars::GlobalConsts::LoadFile rva=0x004b73c0 mode=off (not installed)
04:24:39.777 [tid 7416] dict: dictionaries hook ready (crt new=6adc232b delete=6adc0174)
04:24:39.777 [tid 7416] hook: Game::WeaponDictionary::Init rva=0x0019a4c0 mode=off (not installed)
04:24:39.777 [tid 7416] hook: Game::SectionDictionary::SectionDictionary rva=0x00176f40 mode=off (not installed)
04:24:39.777 [tid 7416] research: ProcessResearch hook ready (Cost=00ffda00, node=0x34, rng=0x9cc, fpu_cw=0x027f)
04:24:39.777 [tid 7416] hook: Game::TechTree::ProcessResearch rva=0x001876c0 -> va=010076c0 MH_CreateHook -> MH_OK (trampoline=00be0fc0)
04:24:39.777 [tid 7416] hook: Game::TechTree::ProcessResearch MH_EnableHook -> MH_OK mode=trace
04:24:39.777 [tid 7416] techfx: OnTechResearched hook ready (regions=15, gate=0/0, fpu_cw=0x027f)
04:24:39.777 [tid 7416] hook: Game::ServerPlayer::OnTechResearched rva=0x00491790 -> va=01311790 MH_CreateHook -> MH_OK (trampoline=00be0fa0)
04:24:39.792 [tid 7416] hook: Game::ServerPlayer::OnTechResearched MH_EnableHook -> MH_OK mode=trace
04:24:39.792 [tid 7416] hook: Game::ServerPlayer::ComputeBudget rva=0x00463030 -> va=012e3030 MH_CreateHook -> MH_OK (trampoline=00be0f80)
04:24:39.808 [tid 7416] hook: Game::ServerPlayer::ComputeBudget MH_EnableHook -> MH_OK mode=trace
04:24:39.808 [tid 7416] hook: Game::ServerSystem::ProcessTurn rva=0x003598e0 -> va=011d98e0 MH_CreateHook -> MH_OK (trampoline=00be0f60)
04:24:39.823 [tid 7416] hook: Game::ServerSystem::ProcessTurn MH_EnableHook -> MH_OK mode=trace
04:24:39.823 [tid 7416] hook: Game::StrategyServer::MoveFleet rva=0x003d9ee0 -> va=01259ee0 MH_CreateHook -> MH_OK (trampoline=00be0f40)
04:24:39.839 [tid 7416] hook: Game::StrategyServer::MoveFleet MH_EnableHook -> MH_OK mode=trace
04:24:39.839 [tid 7416] hook: Game::StrategyServer::ProcessFleetMovement rva=0x003da9a0 mode=off (not installed)
04:24:39.839 [tid 7416] fpu: module init, entry cw=0x027f 53bit-double/nearest; force=on value=0x027f sample_ticks=on
04:24:39.839 [tid 7416] fpu: StrategyClient::EndTurn rva=0x00383be0 -> va=01203be0 MH_CreateHook -> MH_OK (trampoline=00be0f20)
04:24:39.855 [tid 7416] fpu: StrategyClient::EndTurn MH_EnableHook -> MH_OK
04:24:39.855 [tid 7416] fpu: StrategyServer::BeginProcessTurn rva=0x003d98e0 -> va=012598e0 MH_CreateHook -> MH_OK (trampoline=00be0f00)
04:24:39.855 [tid 7416] fpu: StrategyServer::BeginProcessTurn MH_EnableHook -> MH_OK
04:24:39.855 [tid 7416] fpu: StrategyServer::ProcessTurn rva=0x003dc6c0 -> va=0125c6c0 MH_CreateHook -> MH_OK (trampoline=00be0ee0)
04:24:39.870 [tid 7416] fpu: StrategyServer::ProcessTurn MH_EnableHook -> MH_OK
04:24:39.870 [tid 7416] fpu: DemoApp::OnTick rva=0x0049a640 -> va=0131a640 MH_CreateHook -> MH_OK (trampoline=00be0ec0)
04:24:39.886 [tid 7416] fpu: DemoApp::OnTick MH_EnableHook -> MH_OK
04:24:39.886 [tid 7416] selftest: Shim::SelfTest::Fill mode=off checksum=075ef0c3 records=0
04:24:39.902 [tid 7416] Application::Initialize called (this=038a8128)
04:24:40.636 [tid 7416] fpu: TICK BASELINE at OnTick (this=038a8128): cw=0x127f 53bit-double/nearest
04:28:38.077 [tid 7416] fpu: FORCE at StrategyClient::EndTurn (this=0e5c3758): observed=0x127f 53bit-double/nearest -> requested=0x027f -> readback=0x027f 53bit-double/nearest OK
04:28:41.139 [tid 7416] fpu: FORCE at StrategyClient::EndTurn (this=3572eed0): observed=0x127f 53bit-double/nearest -> requested=0x027f -> readback=0x027f 53bit-double/nearest OK
04:28:41.155 [tid 7416] fpu: FORCE at StrategyClient::EndTurn (this=357354c0): observed=0x027f 53bit-double/nearest -> requested=0x027f -> readback=0x027f 53bit-double/nearest OK
04:28:41.186 [tid 7416] fpu: FORCE at StrategyClient::EndTurn (this=3572f618): observed=0x027f 53bit-double/nearest -> requested=0x027f -> readback=0x027f 53bit-double/nearest OK
04:28:42.155 [tid 7416] fpu: FORCE at StrategyServer::BeginProcessTurn (this=0e5b3c40): observed=0x127f 53bit-double/nearest -> requested=0x027f -> readback=0x027f 53bit-double/nearest OK
04:28:42.155 [tid 7416] fpu: sample at StrategyServer::ProcessTurn (this=0e5b3c40): cw=0x027f 53bit-double/nearest

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,31 @@
# Lane F, x87 precision-sensitivity experiment (verify/results/fpu-cw in the notes repo).
# 0x127f = 53-bit significand, round-to-nearest. THE CONTROL: exactly what the game sets itself, so this run must reproduce the stock oracle hashes.
#
# Every shim.cfg.fpu* file is identical except the fpu.force line: the hook set, the trace path
# and the flush policy are held fixed so the control word is the only variable across runs.
#
# x87 control-word fields: bits 0-5 exception masks, bits 8-9 precision control
# (00 = 24-bit / 10 = 53-bit / 11 = 64-bit significand), bits 10-11 rounding control
# (00 nearest / 01 down / 10 up / 11 truncate), bit 12 infinity control (ignored since the 387).
hooks=trace
hook.Shim::SelfTest::Fill=off
hook.Mars::GlobalConsts::LoadFile=off
hook.Game::WeaponDictionary::Init=off
hook.Game::SectionDictionary::SectionDictionary=off
hook.Game::StrategyServer::ProcessFleetMovement=off
hook.Game::TechTree::ProcessResearch=trace
hook.Game::ServerPlayer::ComputeBudget=trace
hook.Game::ServerPlayer::OnTechResearched=trace
hook.Game::ServerSystem::ProcessTurn=trace
hook.Game::StrategyServer::MoveFleet=trace
trace.path=C:\SOTS\shim.trace.jsonl
trace.inline_max=256
trace.flush=always
# Forced once at the turn gate (StrategyClient::EndTurn and StrategyServer::BeginProcessTurn),
# never re-forced inside the pipeline -- re-forcing would guarantee the value is present without
# proving it ever held.
fpu.force=0x127f
# Per-tick sampler: logs to shim.log whenever the word MOVES, so the log carries a timeline of
# the value rather than a single claim made at a single instant.
fpu.sample_ticks=on

View file

@ -0,0 +1,61 @@
04:29:33.858 [tid 4476] ==== sots-engine shim (binkw32 proxy) build fpucw-cef889e-20260908T0803Z ====
04:29:33.858 [tid 4476] exe: C:\SOTS\Sword of the Stars.exe
04:29:33.858 [tid 4476] exe base=0x00e80000 (link-time image base 0x00400000, ASLR delta +11010048) pid=592 shim=696a0000
04:29:33.858 [tid 4476] addresses: Source: sots-re ghidra/addresses.json @ f5b37c2, generated 2026-09-08 by tools/gen_addresses.py
04:29:33.858 [tid 4476] config: hooks=trace
04:29:33.858 [tid 4476] config: hook.Shim::SelfTest::Fill=off
04:29:33.858 [tid 4476] config: hook.Mars::GlobalConsts::LoadFile=off
04:29:33.858 [tid 4476] config: hook.Game::WeaponDictionary::Init=off
04:29:33.858 [tid 4476] config: hook.Game::SectionDictionary::SectionDictionary=off
04:29:33.858 [tid 4476] config: hook.Game::StrategyServer::ProcessFleetMovement=off
04:29:33.858 [tid 4476] config: hook.Game::TechTree::ProcessResearch=trace
04:29:33.858 [tid 4476] config: hook.Game::ServerPlayer::ComputeBudget=trace
04:29:33.858 [tid 4476] config: hook.Game::ServerPlayer::OnTechResearched=trace
04:29:33.858 [tid 4476] config: hook.Game::ServerSystem::ProcessTurn=trace
04:29:33.858 [tid 4476] config: hook.Game::StrategyServer::MoveFleet=trace
04:29:33.858 [tid 4476] config: trace.path=C:\SOTS\shim.trace.jsonl
04:29:33.858 [tid 4476] config: trace.inline_max=256
04:29:33.858 [tid 4476] config: trace.flush=always
04:29:33.858 [tid 4476] config: fpu.force=0x127f
04:29:33.858 [tid 4476] config: fpu.sample_ticks=on
04:29:33.920 [tid 4476] trace: C:\SOTS\shim.trace.jsonl (default mode trace, inline_max 256, flush always)
04:29:33.920 [tid 4476] hook: Mars_Application_Initialize rva=0x004a0e50 -> va=01320e50
04:29:33.920 [tid 4476] hook: MH_Initialize -> MH_OK
04:29:33.920 [tid 4476] hook: MH_CreateHook -> MH_OK (trampoline=00e60fe0)
04:29:33.936 [tid 4476] hook: MH_EnableHook -> MH_OK
04:29:33.936 [tid 4476] cfg: GlobalConsts hook ready (scale constant 0.017453292519943295)
04:29:33.936 [tid 4476] hook: Mars::GlobalConsts::LoadFile rva=0x004b73c0 mode=off (not installed)
04:29:33.936 [tid 4476] dict: dictionaries hook ready (crt new=6adc232b delete=6adc0174)
04:29:33.936 [tid 4476] hook: Game::WeaponDictionary::Init rva=0x0019a4c0 mode=off (not installed)
04:29:33.936 [tid 4476] hook: Game::SectionDictionary::SectionDictionary rva=0x00176f40 mode=off (not installed)
04:29:33.936 [tid 4476] research: ProcessResearch hook ready (Cost=00ffda00, node=0x34, rng=0x9cc, fpu_cw=0x027f)
04:29:33.936 [tid 4476] hook: Game::TechTree::ProcessResearch rva=0x001876c0 -> va=010076c0 MH_CreateHook -> MH_OK (trampoline=00e60fc0)
04:29:33.952 [tid 4476] hook: Game::TechTree::ProcessResearch MH_EnableHook -> MH_OK mode=trace
04:29:33.952 [tid 4476] techfx: OnTechResearched hook ready (regions=15, gate=0/0, fpu_cw=0x027f)
04:29:33.952 [tid 4476] hook: Game::ServerPlayer::OnTechResearched rva=0x00491790 -> va=01311790 MH_CreateHook -> MH_OK (trampoline=00e60fa0)
04:29:33.967 [tid 4476] hook: Game::ServerPlayer::OnTechResearched MH_EnableHook -> MH_OK mode=trace
04:29:33.967 [tid 4476] hook: Game::ServerPlayer::ComputeBudget rva=0x00463030 -> va=012e3030 MH_CreateHook -> MH_OK (trampoline=00e60f80)
04:29:33.983 [tid 4476] hook: Game::ServerPlayer::ComputeBudget MH_EnableHook -> MH_OK mode=trace
04:29:33.983 [tid 4476] hook: Game::ServerSystem::ProcessTurn rva=0x003598e0 -> va=011d98e0 MH_CreateHook -> MH_OK (trampoline=00e60f60)
04:29:33.998 [tid 4476] hook: Game::ServerSystem::ProcessTurn MH_EnableHook -> MH_OK mode=trace
04:29:33.998 [tid 4476] hook: Game::StrategyServer::MoveFleet rva=0x003d9ee0 -> va=01259ee0 MH_CreateHook -> MH_OK (trampoline=00e60f40)
04:29:34.014 [tid 4476] hook: Game::StrategyServer::MoveFleet MH_EnableHook -> MH_OK mode=trace
04:29:34.014 [tid 4476] hook: Game::StrategyServer::ProcessFleetMovement rva=0x003da9a0 mode=off (not installed)
04:29:34.014 [tid 4476] fpu: module init, entry cw=0x027f 53bit-double/nearest; force=on value=0x127f sample_ticks=on
04:29:34.014 [tid 4476] fpu: StrategyClient::EndTurn rva=0x00383be0 -> va=01203be0 MH_CreateHook -> MH_OK (trampoline=00e60f20)
04:29:34.014 [tid 4476] fpu: StrategyClient::EndTurn MH_EnableHook -> MH_OK
04:29:34.014 [tid 4476] fpu: StrategyServer::BeginProcessTurn rva=0x003d98e0 -> va=012598e0 MH_CreateHook -> MH_OK (trampoline=00e60f00)
04:29:34.030 [tid 4476] fpu: StrategyServer::BeginProcessTurn MH_EnableHook -> MH_OK
04:29:34.030 [tid 4476] fpu: StrategyServer::ProcessTurn rva=0x003dc6c0 -> va=0125c6c0 MH_CreateHook -> MH_OK (trampoline=00e60ee0)
04:29:34.045 [tid 4476] fpu: StrategyServer::ProcessTurn MH_EnableHook -> MH_OK
04:29:34.045 [tid 4476] fpu: DemoApp::OnTick rva=0x0049a640 -> va=0131a640 MH_CreateHook -> MH_OK (trampoline=00e60ec0)
04:29:34.061 [tid 4476] fpu: DemoApp::OnTick MH_EnableHook -> MH_OK
04:29:34.061 [tid 4476] selftest: Shim::SelfTest::Fill mode=off checksum=075ef0c3 records=0
04:29:34.077 [tid 4476] Application::Initialize called (this=039a8128)
04:29:34.811 [tid 4476] fpu: TICK BASELINE at OnTick (this=039a8128): cw=0x127f 53bit-double/nearest
04:33:32.312 [tid 4476] fpu: FORCE at StrategyClient::EndTurn (this=0e6e6c30): observed=0x127f 53bit-double/nearest -> requested=0x127f -> readback=0x127f 53bit-double/nearest OK
04:33:35.375 [tid 4476] fpu: FORCE at StrategyClient::EndTurn (this=3503a4a0): observed=0x127f 53bit-double/nearest -> requested=0x127f -> readback=0x127f 53bit-double/nearest OK
04:33:35.390 [tid 4476] fpu: FORCE at StrategyClient::EndTurn (this=3503d050): observed=0x127f 53bit-double/nearest -> requested=0x127f -> readback=0x127f 53bit-double/nearest OK
04:33:35.422 [tid 4476] fpu: FORCE at StrategyClient::EndTurn (this=3503ed70): observed=0x127f 53bit-double/nearest -> requested=0x127f -> readback=0x127f 53bit-double/nearest OK
04:33:36.375 [tid 4476] fpu: FORCE at StrategyServer::BeginProcessTurn (this=0e705cd8): observed=0x127f 53bit-double/nearest -> requested=0x127f -> readback=0x127f 53bit-double/nearest OK
04:33:36.390 [tid 4476] fpu: sample at StrategyServer::ProcessTurn (this=0e705cd8): cw=0x127f 53bit-double/nearest

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,31 @@
# Lane F, x87 precision-sensitivity experiment (verify/results/fpu-cw in the notes repo).
# 0x137f = 64-bit significand (x87 extended), round-to-nearest. Named in the lane brief as "53-bit, different rounding"; it is in fact the precision axis, and the one an x64/SSE port cannot reproduce.
#
# Every shim.cfg.fpu* file is identical except the fpu.force line: the hook set, the trace path
# and the flush policy are held fixed so the control word is the only variable across runs.
#
# x87 control-word fields: bits 0-5 exception masks, bits 8-9 precision control
# (00 = 24-bit / 10 = 53-bit / 11 = 64-bit significand), bits 10-11 rounding control
# (00 nearest / 01 down / 10 up / 11 truncate), bit 12 infinity control (ignored since the 387).
hooks=trace
hook.Shim::SelfTest::Fill=off
hook.Mars::GlobalConsts::LoadFile=off
hook.Game::WeaponDictionary::Init=off
hook.Game::SectionDictionary::SectionDictionary=off
hook.Game::StrategyServer::ProcessFleetMovement=off
hook.Game::TechTree::ProcessResearch=trace
hook.Game::ServerPlayer::ComputeBudget=trace
hook.Game::ServerPlayer::OnTechResearched=trace
hook.Game::ServerSystem::ProcessTurn=trace
hook.Game::StrategyServer::MoveFleet=trace
trace.path=C:\SOTS\shim.trace.jsonl
trace.inline_max=256
trace.flush=always
# Forced once at the turn gate (StrategyClient::EndTurn and StrategyServer::BeginProcessTurn),
# never re-forced inside the pipeline -- re-forcing would guarantee the value is present without
# proving it ever held.
fpu.force=0x137f
# Per-tick sampler: logs to shim.log whenever the word MOVES, so the log carries a timeline of
# the value rather than a single claim made at a single instant.
fpu.sample_ticks=on

View file

@ -0,0 +1,61 @@
04:34:28.562 [tid 7608] ==== sots-engine shim (binkw32 proxy) build fpucw-cef889e-20260908T0803Z ====
04:34:28.562 [tid 7608] exe: C:\SOTS\Sword of the Stars.exe
04:34:28.562 [tid 7608] exe base=0x00e80000 (link-time image base 0x00400000, ASLR delta +11010048) pid=3296 shim=696a0000
04:34:28.562 [tid 7608] addresses: Source: sots-re ghidra/addresses.json @ f5b37c2, generated 2026-09-08 by tools/gen_addresses.py
04:34:28.562 [tid 7608] config: hooks=trace
04:34:28.562 [tid 7608] config: hook.Shim::SelfTest::Fill=off
04:34:28.562 [tid 7608] config: hook.Mars::GlobalConsts::LoadFile=off
04:34:28.562 [tid 7608] config: hook.Game::WeaponDictionary::Init=off
04:34:28.562 [tid 7608] config: hook.Game::SectionDictionary::SectionDictionary=off
04:34:28.562 [tid 7608] config: hook.Game::StrategyServer::ProcessFleetMovement=off
04:34:28.562 [tid 7608] config: hook.Game::TechTree::ProcessResearch=trace
04:34:28.562 [tid 7608] config: hook.Game::ServerPlayer::ComputeBudget=trace
04:34:28.562 [tid 7608] config: hook.Game::ServerPlayer::OnTechResearched=trace
04:34:28.562 [tid 7608] config: hook.Game::ServerSystem::ProcessTurn=trace
04:34:28.562 [tid 7608] config: hook.Game::StrategyServer::MoveFleet=trace
04:34:28.562 [tid 7608] config: trace.path=C:\SOTS\shim.trace.jsonl
04:34:28.562 [tid 7608] config: trace.inline_max=256
04:34:28.562 [tid 7608] config: trace.flush=always
04:34:28.562 [tid 7608] config: fpu.force=0x137f
04:34:28.562 [tid 7608] config: fpu.sample_ticks=on
04:34:28.640 [tid 7608] trace: C:\SOTS\shim.trace.jsonl (default mode trace, inline_max 256, flush always)
04:34:28.640 [tid 7608] hook: Mars_Application_Initialize rva=0x004a0e50 -> va=01320e50
04:34:28.640 [tid 7608] hook: MH_Initialize -> MH_OK
04:34:28.640 [tid 7608] hook: MH_CreateHook -> MH_OK (trampoline=003f0fe0)
04:34:28.672 [tid 7608] hook: MH_EnableHook -> MH_OK
04:34:28.672 [tid 7608] cfg: GlobalConsts hook ready (scale constant 0.017453292519943295)
04:34:28.672 [tid 7608] hook: Mars::GlobalConsts::LoadFile rva=0x004b73c0 mode=off (not installed)
04:34:28.672 [tid 7608] dict: dictionaries hook ready (crt new=6adc232b delete=6adc0174)
04:34:28.672 [tid 7608] hook: Game::WeaponDictionary::Init rva=0x0019a4c0 mode=off (not installed)
04:34:28.672 [tid 7608] hook: Game::SectionDictionary::SectionDictionary rva=0x00176f40 mode=off (not installed)
04:34:28.672 [tid 7608] research: ProcessResearch hook ready (Cost=00ffda00, node=0x34, rng=0x9cc, fpu_cw=0x027f)
04:34:28.672 [tid 7608] hook: Game::TechTree::ProcessResearch rva=0x001876c0 -> va=010076c0 MH_CreateHook -> MH_OK (trampoline=003f0fc0)
04:34:28.687 [tid 7608] hook: Game::TechTree::ProcessResearch MH_EnableHook -> MH_OK mode=trace
04:34:28.687 [tid 7608] techfx: OnTechResearched hook ready (regions=15, gate=0/0, fpu_cw=0x027f)
04:34:28.687 [tid 7608] hook: Game::ServerPlayer::OnTechResearched rva=0x00491790 -> va=01311790 MH_CreateHook -> MH_OK (trampoline=003f0fa0)
04:34:28.703 [tid 7608] hook: Game::ServerPlayer::OnTechResearched MH_EnableHook -> MH_OK mode=trace
04:34:28.703 [tid 7608] hook: Game::ServerPlayer::ComputeBudget rva=0x00463030 -> va=012e3030 MH_CreateHook -> MH_OK (trampoline=003f0f80)
04:34:28.718 [tid 7608] hook: Game::ServerPlayer::ComputeBudget MH_EnableHook -> MH_OK mode=trace
04:34:28.718 [tid 7608] hook: Game::ServerSystem::ProcessTurn rva=0x003598e0 -> va=011d98e0 MH_CreateHook -> MH_OK (trampoline=003f0f60)
04:34:28.718 [tid 7608] hook: Game::ServerSystem::ProcessTurn MH_EnableHook -> MH_OK mode=trace
04:34:28.734 [tid 7608] hook: Game::StrategyServer::MoveFleet rva=0x003d9ee0 -> va=01259ee0 MH_CreateHook -> MH_OK (trampoline=003f0f40)
04:34:28.734 [tid 7608] hook: Game::StrategyServer::MoveFleet MH_EnableHook -> MH_OK mode=trace
04:34:28.734 [tid 7608] hook: Game::StrategyServer::ProcessFleetMovement rva=0x003da9a0 mode=off (not installed)
04:34:28.734 [tid 7608] fpu: module init, entry cw=0x027f 53bit-double/nearest; force=on value=0x137f sample_ticks=on
04:34:28.734 [tid 7608] fpu: StrategyClient::EndTurn rva=0x00383be0 -> va=01203be0 MH_CreateHook -> MH_OK (trampoline=003f0f20)
04:34:28.750 [tid 7608] fpu: StrategyClient::EndTurn MH_EnableHook -> MH_OK
04:34:28.750 [tid 7608] fpu: StrategyServer::BeginProcessTurn rva=0x003d98e0 -> va=012598e0 MH_CreateHook -> MH_OK (trampoline=003f0f00)
04:34:28.765 [tid 7608] fpu: StrategyServer::BeginProcessTurn MH_EnableHook -> MH_OK
04:34:28.765 [tid 7608] fpu: StrategyServer::ProcessTurn rva=0x003dc6c0 -> va=0125c6c0 MH_CreateHook -> MH_OK (trampoline=003f0ee0)
04:34:28.781 [tid 7608] fpu: StrategyServer::ProcessTurn MH_EnableHook -> MH_OK
04:34:28.781 [tid 7608] fpu: DemoApp::OnTick rva=0x0049a640 -> va=0131a640 MH_CreateHook -> MH_OK (trampoline=003f0ec0)
04:34:28.797 [tid 7608] fpu: DemoApp::OnTick MH_EnableHook -> MH_OK
04:34:28.797 [tid 7608] selftest: Shim::SelfTest::Fill mode=off checksum=075ef0c3 records=0
04:34:28.812 [tid 7608] Application::Initialize called (this=02da8128)
04:34:29.562 [tid 7608] fpu: TICK BASELINE at OnTick (this=02da8128): cw=0x127f 53bit-double/nearest
04:38:23.424 [tid 7608] fpu: FORCE at StrategyClient::EndTurn (this=0e02e4b0): observed=0x127f 53bit-double/nearest -> requested=0x137f -> readback=0x137f 64bit-extended/nearest OK
04:38:26.502 [tid 7608] fpu: FORCE at StrategyClient::EndTurn (this=338724a0): observed=0x127f 53bit-double/nearest -> requested=0x137f -> readback=0x137f 64bit-extended/nearest OK
04:38:26.534 [tid 7608] fpu: FORCE at StrategyClient::EndTurn (this=33876d70): observed=0x137f 64bit-extended/nearest -> requested=0x137f -> readback=0x137f 64bit-extended/nearest OK
04:38:26.565 [tid 7608] fpu: FORCE at StrategyClient::EndTurn (this=33871d58): observed=0x137f 64bit-extended/nearest -> requested=0x137f -> readback=0x137f 64bit-extended/nearest OK
04:38:27.534 [tid 7608] fpu: FORCE at StrategyServer::BeginProcessTurn (this=0df4cbf8): observed=0x127f 53bit-double/nearest -> requested=0x137f -> readback=0x137f 64bit-extended/nearest OK
04:38:27.534 [tid 7608] fpu: sample at StrategyServer::ProcessTurn (this=0df4cbf8): cw=0x137f 64bit-extended/nearest

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,61 @@
04:52:25.866 [tid 8672] ==== sots-engine shim (binkw32 proxy) build fpucw-cef889e-20260908T0803Z ====
04:52:25.866 [tid 8672] exe: C:\SOTS\Sword of the Stars.exe
04:52:25.866 [tid 8672] exe base=0x00e80000 (link-time image base 0x00400000, ASLR delta +11010048) pid=5980 shim=696a0000
04:52:25.866 [tid 8672] addresses: Source: sots-re ghidra/addresses.json @ f5b37c2, generated 2026-09-08 by tools/gen_addresses.py
04:52:25.866 [tid 8672] config: hooks=trace
04:52:25.866 [tid 8672] config: hook.Shim::SelfTest::Fill=off
04:52:25.866 [tid 8672] config: hook.Mars::GlobalConsts::LoadFile=off
04:52:25.866 [tid 8672] config: hook.Game::WeaponDictionary::Init=off
04:52:25.866 [tid 8672] config: hook.Game::SectionDictionary::SectionDictionary=off
04:52:25.866 [tid 8672] config: hook.Game::StrategyServer::ProcessFleetMovement=off
04:52:25.866 [tid 8672] config: hook.Game::TechTree::ProcessResearch=trace
04:52:25.866 [tid 8672] config: hook.Game::ServerPlayer::ComputeBudget=trace
04:52:25.866 [tid 8672] config: hook.Game::ServerPlayer::OnTechResearched=trace
04:52:25.866 [tid 8672] config: hook.Game::ServerSystem::ProcessTurn=trace
04:52:25.866 [tid 8672] config: hook.Game::StrategyServer::MoveFleet=trace
04:52:25.866 [tid 8672] config: trace.path=C:\SOTS\shim.trace.jsonl
04:52:25.866 [tid 8672] config: trace.inline_max=256
04:52:25.866 [tid 8672] config: trace.flush=always
04:52:25.866 [tid 8672] config: fpu.force=0x1a7f
04:52:25.866 [tid 8672] config: fpu.sample_ticks=on
04:52:25.929 [tid 8672] trace: C:\SOTS\shim.trace.jsonl (default mode trace, inline_max 256, flush always)
04:52:25.929 [tid 8672] hook: Mars_Application_Initialize rva=0x004a0e50 -> va=01320e50
04:52:25.929 [tid 8672] hook: MH_Initialize -> MH_OK
04:52:25.929 [tid 8672] hook: MH_CreateHook -> MH_OK (trampoline=009e0fe0)
04:52:25.944 [tid 8672] hook: MH_EnableHook -> MH_OK
04:52:25.944 [tid 8672] cfg: GlobalConsts hook ready (scale constant 0.017453292519943295)
04:52:25.944 [tid 8672] hook: Mars::GlobalConsts::LoadFile rva=0x004b73c0 mode=off (not installed)
04:52:25.944 [tid 8672] dict: dictionaries hook ready (crt new=6adc232b delete=6adc0174)
04:52:25.944 [tid 8672] hook: Game::WeaponDictionary::Init rva=0x0019a4c0 mode=off (not installed)
04:52:25.944 [tid 8672] hook: Game::SectionDictionary::SectionDictionary rva=0x00176f40 mode=off (not installed)
04:52:25.944 [tid 8672] research: ProcessResearch hook ready (Cost=00ffda00, node=0x34, rng=0x9cc, fpu_cw=0x027f)
04:52:25.944 [tid 8672] hook: Game::TechTree::ProcessResearch rva=0x001876c0 -> va=010076c0 MH_CreateHook -> MH_OK (trampoline=009e0fc0)
04:52:25.960 [tid 8672] hook: Game::TechTree::ProcessResearch MH_EnableHook -> MH_OK mode=trace
04:52:25.960 [tid 8672] techfx: OnTechResearched hook ready (regions=15, gate=0/0, fpu_cw=0x027f)
04:52:25.960 [tid 8672] hook: Game::ServerPlayer::OnTechResearched rva=0x00491790 -> va=01311790 MH_CreateHook -> MH_OK (trampoline=009e0fa0)
04:52:25.976 [tid 8672] hook: Game::ServerPlayer::OnTechResearched MH_EnableHook -> MH_OK mode=trace
04:52:25.976 [tid 8672] hook: Game::ServerPlayer::ComputeBudget rva=0x00463030 -> va=012e3030 MH_CreateHook -> MH_OK (trampoline=009e0f80)
04:52:25.976 [tid 8672] hook: Game::ServerPlayer::ComputeBudget MH_EnableHook -> MH_OK mode=trace
04:52:25.976 [tid 8672] hook: Game::ServerSystem::ProcessTurn rva=0x003598e0 -> va=011d98e0 MH_CreateHook -> MH_OK (trampoline=009e0f60)
04:52:25.991 [tid 8672] hook: Game::ServerSystem::ProcessTurn MH_EnableHook -> MH_OK mode=trace
04:52:25.991 [tid 8672] hook: Game::StrategyServer::MoveFleet rva=0x003d9ee0 -> va=01259ee0 MH_CreateHook -> MH_OK (trampoline=009e0f40)
04:52:26.007 [tid 8672] hook: Game::StrategyServer::MoveFleet MH_EnableHook -> MH_OK mode=trace
04:52:26.007 [tid 8672] hook: Game::StrategyServer::ProcessFleetMovement rva=0x003da9a0 mode=off (not installed)
04:52:26.007 [tid 8672] fpu: module init, entry cw=0x027f 53bit-double/nearest; force=on value=0x1a7f sample_ticks=on
04:52:26.007 [tid 8672] fpu: StrategyClient::EndTurn rva=0x00383be0 -> va=01203be0 MH_CreateHook -> MH_OK (trampoline=009e0f20)
04:52:26.023 [tid 8672] fpu: StrategyClient::EndTurn MH_EnableHook -> MH_OK
04:52:26.023 [tid 8672] fpu: StrategyServer::BeginProcessTurn rva=0x003d98e0 -> va=012598e0 MH_CreateHook -> MH_OK (trampoline=009e0f00)
04:52:26.038 [tid 8672] fpu: StrategyServer::BeginProcessTurn MH_EnableHook -> MH_OK
04:52:26.038 [tid 8672] fpu: StrategyServer::ProcessTurn rva=0x003dc6c0 -> va=0125c6c0 MH_CreateHook -> MH_OK (trampoline=009e0ee0)
04:52:26.054 [tid 8672] fpu: StrategyServer::ProcessTurn MH_EnableHook -> MH_OK
04:52:26.054 [tid 8672] fpu: DemoApp::OnTick rva=0x0049a640 -> va=0131a640 MH_CreateHook -> MH_OK (trampoline=009e0ec0)
04:52:26.069 [tid 8672] fpu: DemoApp::OnTick MH_EnableHook -> MH_OK
04:52:26.069 [tid 8672] selftest: Shim::SelfTest::Fill mode=off checksum=075ef0c3 records=0
04:52:26.085 [tid 8672] Application::Initialize called (this=03748128)
04:52:26.851 [tid 8672] fpu: TICK BASELINE at OnTick (this=03748128): cw=0x127f 53bit-double/nearest
04:56:21.683 [tid 8672] fpu: FORCE at StrategyClient::EndTurn (this=0e3f2ee8): observed=0x127f 53bit-double/nearest -> requested=0x1a7f -> readback=0x1a7f 53bit-double/up OK
04:56:24.746 [tid 8672] fpu: FORCE at StrategyClient::EndTurn (this=354f8628): observed=0x127f 53bit-double/nearest -> requested=0x1a7f -> readback=0x1a7f 53bit-double/up OK
04:56:24.777 [tid 8672] fpu: FORCE at StrategyClient::EndTurn (this=354f4be8): observed=0x1a7f 53bit-double/up -> requested=0x1a7f -> readback=0x1a7f 53bit-double/up OK
04:56:24.793 [tid 8672] fpu: FORCE at StrategyClient::EndTurn (this=354f6908): observed=0x1a7f 53bit-double/up -> requested=0x1a7f -> readback=0x1a7f 53bit-double/up OK
04:56:25.761 [tid 8672] fpu: FORCE at StrategyServer::BeginProcessTurn (this=0e3e65a8): observed=0x127f 53bit-double/nearest -> requested=0x1a7f -> readback=0x1a7f 53bit-double/up OK
04:56:25.761 [tid 8672] fpu: sample at StrategyServer::ProcessTurn (this=0e3e65a8): cw=0x1a7f 53bit-double/up

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,31 @@
# Lane F, x87 precision-sensitivity experiment (verify/results/fpu-cw in the notes repo).
# 0x1a7f = 53-bit significand, round-toward-+infinity. The genuine rounding-mode change the brief meant by 0x137f.
#
# Every shim.cfg.fpu* file is identical except the fpu.force line: the hook set, the trace path
# and the flush policy are held fixed so the control word is the only variable across runs.
#
# x87 control-word fields: bits 0-5 exception masks, bits 8-9 precision control
# (00 = 24-bit / 10 = 53-bit / 11 = 64-bit significand), bits 10-11 rounding control
# (00 nearest / 01 down / 10 up / 11 truncate), bit 12 infinity control (ignored since the 387).
hooks=trace
hook.Shim::SelfTest::Fill=off
hook.Mars::GlobalConsts::LoadFile=off
hook.Game::WeaponDictionary::Init=off
hook.Game::SectionDictionary::SectionDictionary=off
hook.Game::StrategyServer::ProcessFleetMovement=off
hook.Game::TechTree::ProcessResearch=trace
hook.Game::ServerPlayer::ComputeBudget=trace
hook.Game::ServerPlayer::OnTechResearched=trace
hook.Game::ServerSystem::ProcessTurn=trace
hook.Game::StrategyServer::MoveFleet=trace
trace.path=C:\SOTS\shim.trace.jsonl
trace.inline_max=256
trace.flush=always
# Forced once at the turn gate (StrategyClient::EndTurn and StrategyServer::BeginProcessTurn),
# never re-forced inside the pipeline -- re-forcing would guarantee the value is present without
# proving it ever held.
fpu.force=0x1a7f
# Per-tick sampler: logs to shim.log whenever the word MOVES, so the log carries a timeline of
# the value rather than a single claim made at a single instant.
fpu.sample_ticks=on

View file

@ -0,0 +1,61 @@
04:39:19.877 [tid 8716] ==== sots-engine shim (binkw32 proxy) build fpucw-cef889e-20260908T0803Z ====
04:39:19.877 [tid 8716] exe: C:\SOTS\Sword of the Stars.exe
04:39:19.877 [tid 8716] exe base=0x00e80000 (link-time image base 0x00400000, ASLR delta +11010048) pid=8004 shim=696a0000
04:39:19.877 [tid 8716] addresses: Source: sots-re ghidra/addresses.json @ f5b37c2, generated 2026-09-08 by tools/gen_addresses.py
04:39:19.877 [tid 8716] config: hooks=trace
04:39:19.877 [tid 8716] config: hook.Shim::SelfTest::Fill=off
04:39:19.877 [tid 8716] config: hook.Mars::GlobalConsts::LoadFile=off
04:39:19.877 [tid 8716] config: hook.Game::WeaponDictionary::Init=off
04:39:19.877 [tid 8716] config: hook.Game::SectionDictionary::SectionDictionary=off
04:39:19.877 [tid 8716] config: hook.Game::StrategyServer::ProcessFleetMovement=off
04:39:19.877 [tid 8716] config: hook.Game::TechTree::ProcessResearch=trace
04:39:19.877 [tid 8716] config: hook.Game::ServerPlayer::ComputeBudget=trace
04:39:19.877 [tid 8716] config: hook.Game::ServerPlayer::OnTechResearched=trace
04:39:19.877 [tid 8716] config: hook.Game::ServerSystem::ProcessTurn=trace
04:39:19.893 [tid 8716] config: hook.Game::StrategyServer::MoveFleet=trace
04:39:19.893 [tid 8716] config: trace.path=C:\SOTS\shim.trace.jsonl
04:39:19.893 [tid 8716] config: trace.inline_max=256
04:39:19.893 [tid 8716] config: trace.flush=always
04:39:19.893 [tid 8716] config: fpu.force=0x1a7f
04:39:19.893 [tid 8716] config: fpu.sample_ticks=on
04:39:19.940 [tid 8716] trace: C:\SOTS\shim.trace.jsonl (default mode trace, inline_max 256, flush always)
04:39:19.940 [tid 8716] hook: Mars_Application_Initialize rva=0x004a0e50 -> va=01320e50
04:39:19.940 [tid 8716] hook: MH_Initialize -> MH_OK
04:39:19.940 [tid 8716] hook: MH_CreateHook -> MH_OK (trampoline=009b0fe0)
04:39:19.956 [tid 8716] hook: MH_EnableHook -> MH_OK
04:39:19.956 [tid 8716] cfg: GlobalConsts hook ready (scale constant 0.017453292519943295)
04:39:19.956 [tid 8716] hook: Mars::GlobalConsts::LoadFile rva=0x004b73c0 mode=off (not installed)
04:39:19.956 [tid 8716] dict: dictionaries hook ready (crt new=6adc232b delete=6adc0174)
04:39:19.956 [tid 8716] hook: Game::WeaponDictionary::Init rva=0x0019a4c0 mode=off (not installed)
04:39:19.956 [tid 8716] hook: Game::SectionDictionary::SectionDictionary rva=0x00176f40 mode=off (not installed)
04:39:19.956 [tid 8716] research: ProcessResearch hook ready (Cost=00ffda00, node=0x34, rng=0x9cc, fpu_cw=0x027f)
04:39:19.956 [tid 8716] hook: Game::TechTree::ProcessResearch rva=0x001876c0 -> va=010076c0 MH_CreateHook -> MH_OK (trampoline=009b0fc0)
04:39:19.971 [tid 8716] hook: Game::TechTree::ProcessResearch MH_EnableHook -> MH_OK mode=trace
04:39:19.971 [tid 8716] techfx: OnTechResearched hook ready (regions=15, gate=0/0, fpu_cw=0x027f)
04:39:19.971 [tid 8716] hook: Game::ServerPlayer::OnTechResearched rva=0x00491790 -> va=01311790 MH_CreateHook -> MH_OK (trampoline=009b0fa0)
04:39:19.987 [tid 8716] hook: Game::ServerPlayer::OnTechResearched MH_EnableHook -> MH_OK mode=trace
04:39:19.987 [tid 8716] hook: Game::ServerPlayer::ComputeBudget rva=0x00463030 -> va=012e3030 MH_CreateHook -> MH_OK (trampoline=009b0f80)
04:39:20.002 [tid 8716] hook: Game::ServerPlayer::ComputeBudget MH_EnableHook -> MH_OK mode=trace
04:39:20.002 [tid 8716] hook: Game::ServerSystem::ProcessTurn rva=0x003598e0 -> va=011d98e0 MH_CreateHook -> MH_OK (trampoline=009b0f60)
04:39:20.018 [tid 8716] hook: Game::ServerSystem::ProcessTurn MH_EnableHook -> MH_OK mode=trace
04:39:20.018 [tid 8716] hook: Game::StrategyServer::MoveFleet rva=0x003d9ee0 -> va=01259ee0 MH_CreateHook -> MH_OK (trampoline=009b0f40)
04:39:20.034 [tid 8716] hook: Game::StrategyServer::MoveFleet MH_EnableHook -> MH_OK mode=trace
04:39:20.034 [tid 8716] hook: Game::StrategyServer::ProcessFleetMovement rva=0x003da9a0 mode=off (not installed)
04:39:20.034 [tid 8716] fpu: module init, entry cw=0x027f 53bit-double/nearest; force=on value=0x1a7f sample_ticks=on
04:39:20.034 [tid 8716] fpu: StrategyClient::EndTurn rva=0x00383be0 -> va=01203be0 MH_CreateHook -> MH_OK (trampoline=009b0f20)
04:39:20.049 [tid 8716] fpu: StrategyClient::EndTurn MH_EnableHook -> MH_OK
04:39:20.049 [tid 8716] fpu: StrategyServer::BeginProcessTurn rva=0x003d98e0 -> va=012598e0 MH_CreateHook -> MH_OK (trampoline=009b0f00)
04:39:20.065 [tid 8716] fpu: StrategyServer::BeginProcessTurn MH_EnableHook -> MH_OK
04:39:20.065 [tid 8716] fpu: StrategyServer::ProcessTurn rva=0x003dc6c0 -> va=0125c6c0 MH_CreateHook -> MH_OK (trampoline=009b0ee0)
04:39:20.081 [tid 8716] fpu: StrategyServer::ProcessTurn MH_EnableHook -> MH_OK
04:39:20.081 [tid 8716] fpu: DemoApp::OnTick rva=0x0049a640 -> va=0131a640 MH_CreateHook -> MH_OK (trampoline=009b0ec0)
04:39:20.096 [tid 8716] fpu: DemoApp::OnTick MH_EnableHook -> MH_OK
04:39:20.096 [tid 8716] selftest: Shim::SelfTest::Fill mode=off checksum=075ef0c3 records=0
04:39:20.112 [tid 8716] Application::Initialize called (this=03258128)
04:39:20.862 [tid 8716] fpu: TICK BASELINE at OnTick (this=03258128): cw=0x127f 53bit-double/nearest
04:43:18.459 [tid 8716] fpu: FORCE at StrategyClient::EndTurn (this=0e1fac88): observed=0x127f 53bit-double/nearest -> requested=0x1a7f -> readback=0x1a7f 53bit-double/up OK
04:43:21.537 [tid 8716] fpu: FORCE at StrategyClient::EndTurn (this=33ad9ee0): observed=0x127f 53bit-double/nearest -> requested=0x1a7f -> readback=0x1a7f 53bit-double/up OK
04:43:21.553 [tid 8716] fpu: FORCE at StrategyClient::EndTurn (this=33ad7330): observed=0x1a7f 53bit-double/up -> requested=0x1a7f -> readback=0x1a7f 53bit-double/up OK
04:43:21.584 [tid 8716] fpu: FORCE at StrategyClient::EndTurn (this=33ad7a78): observed=0x1a7f 53bit-double/up -> requested=0x1a7f -> readback=0x1a7f 53bit-double/up OK
04:43:22.553 [tid 8716] fpu: FORCE at StrategyServer::BeginProcessTurn (this=0e1878a0): observed=0x127f 53bit-double/nearest -> requested=0x1a7f -> readback=0x1a7f 53bit-double/up OK
04:43:22.553 [tid 8716] fpu: sample at StrategyServer::ProcessTurn (this=0e1878a0): cw=0x1a7f 53bit-double/up

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,24 @@
# Lane F, x87 precision-sensitivity experiment. STOCK: no forcing. Oracle re-confirmation + the game's own control-word timeline.
# Identical to every other shim.cfg.fpu* except the fpu.force line: the hook set, the trace
# path and the flush policy are held fixed so the control word is the only variable.
hooks=trace
hook.Shim::SelfTest::Fill=off
hook.Mars::GlobalConsts::LoadFile=off
hook.Game::WeaponDictionary::Init=off
hook.Game::SectionDictionary::SectionDictionary=off
hook.Game::StrategyServer::ProcessFleetMovement=off
hook.Game::TechTree::ProcessResearch=trace
hook.Game::ServerPlayer::ComputeBudget=trace
hook.Game::ServerPlayer::OnTechResearched=trace
hook.Game::ServerSystem::ProcessTurn=trace
hook.Game::StrategyServer::MoveFleet=trace
trace.path=C:\SOTS\shim.trace.jsonl
trace.inline_max=256
trace.flush=always
# x87 control word forced once at the turn gate (StrategyClient::EndTurn and
# StrategyServer::BeginProcessTurn), never re-forced inside the pipeline.
fpu.force=off
# Per-tick sampler: logs the control word to shim.log whenever it MOVES, so the log carries a
# timeline of the value rather than a single claim.
fpu.sample_ticks=on

View file

@ -0,0 +1,61 @@
04:06:40.848 [tid 2040] ==== sots-engine shim (binkw32 proxy) build fpucw-cef889e-20260908T0803Z ====
04:06:40.848 [tid 2040] exe: C:\SOTS\Sword of the Stars.exe
04:06:40.848 [tid 2040] exe base=0x00e80000 (link-time image base 0x00400000, ASLR delta +11010048) pid=8848 shim=696a0000
04:06:40.848 [tid 2040] addresses: Source: sots-re ghidra/addresses.json @ f5b37c2, generated 2026-09-08 by tools/gen_addresses.py
04:06:40.848 [tid 2040] config: hooks=trace
04:06:40.848 [tid 2040] config: hook.Shim::SelfTest::Fill=off
04:06:40.848 [tid 2040] config: hook.Mars::GlobalConsts::LoadFile=off
04:06:40.848 [tid 2040] config: hook.Game::WeaponDictionary::Init=off
04:06:40.848 [tid 2040] config: hook.Game::SectionDictionary::SectionDictionary=off
04:06:40.848 [tid 2040] config: hook.Game::StrategyServer::ProcessFleetMovement=off
04:06:40.848 [tid 2040] config: hook.Game::TechTree::ProcessResearch=trace
04:06:40.848 [tid 2040] config: hook.Game::ServerPlayer::ComputeBudget=trace
04:06:40.848 [tid 2040] config: hook.Game::ServerPlayer::OnTechResearched=trace
04:06:40.848 [tid 2040] config: hook.Game::ServerSystem::ProcessTurn=trace
04:06:40.848 [tid 2040] config: hook.Game::StrategyServer::MoveFleet=trace
04:06:40.848 [tid 2040] config: trace.path=C:\SOTS\shim.trace.jsonl
04:06:40.848 [tid 2040] config: trace.inline_max=256
04:06:40.848 [tid 2040] config: trace.flush=always
04:06:40.848 [tid 2040] config: fpu.force=off
04:06:40.848 [tid 2040] config: fpu.sample_ticks=on
04:06:40.910 [tid 2040] trace: C:\SOTS\shim.trace.jsonl (default mode trace, inline_max 256, flush always)
04:06:40.910 [tid 2040] hook: Mars_Application_Initialize rva=0x004a0e50 -> va=01320e50
04:06:40.910 [tid 2040] hook: MH_Initialize -> MH_OK
04:06:40.910 [tid 2040] hook: MH_CreateHook -> MH_OK (trampoline=00940fe0)
04:06:40.926 [tid 2040] hook: MH_EnableHook -> MH_OK
04:06:40.926 [tid 2040] cfg: GlobalConsts hook ready (scale constant 0.017453292519943295)
04:06:40.926 [tid 2040] hook: Mars::GlobalConsts::LoadFile rva=0x004b73c0 mode=off (not installed)
04:06:40.926 [tid 2040] dict: dictionaries hook ready (crt new=6adc232b delete=6adc0174)
04:06:40.926 [tid 2040] hook: Game::WeaponDictionary::Init rva=0x0019a4c0 mode=off (not installed)
04:06:40.926 [tid 2040] hook: Game::SectionDictionary::SectionDictionary rva=0x00176f40 mode=off (not installed)
04:06:40.926 [tid 2040] research: ProcessResearch hook ready (Cost=00ffda00, node=0x34, rng=0x9cc, fpu_cw=0x027f)
04:06:40.926 [tid 2040] hook: Game::TechTree::ProcessResearch rva=0x001876c0 -> va=010076c0 MH_CreateHook -> MH_OK (trampoline=00940fc0)
04:06:40.941 [tid 2040] hook: Game::TechTree::ProcessResearch MH_EnableHook -> MH_OK mode=trace
04:06:40.941 [tid 2040] techfx: OnTechResearched hook ready (regions=15, gate=0/0, fpu_cw=0x027f)
04:06:40.941 [tid 2040] hook: Game::ServerPlayer::OnTechResearched rva=0x00491790 -> va=01311790 MH_CreateHook -> MH_OK (trampoline=00940fa0)
04:06:40.957 [tid 2040] hook: Game::ServerPlayer::OnTechResearched MH_EnableHook -> MH_OK mode=trace
04:06:40.957 [tid 2040] hook: Game::ServerPlayer::ComputeBudget rva=0x00463030 -> va=012e3030 MH_CreateHook -> MH_OK (trampoline=00940f80)
04:06:40.973 [tid 2040] hook: Game::ServerPlayer::ComputeBudget MH_EnableHook -> MH_OK mode=trace
04:06:40.973 [tid 2040] hook: Game::ServerSystem::ProcessTurn rva=0x003598e0 -> va=011d98e0 MH_CreateHook -> MH_OK (trampoline=00940f60)
04:06:40.988 [tid 2040] hook: Game::ServerSystem::ProcessTurn MH_EnableHook -> MH_OK mode=trace
04:06:40.988 [tid 2040] hook: Game::StrategyServer::MoveFleet rva=0x003d9ee0 -> va=01259ee0 MH_CreateHook -> MH_OK (trampoline=00940f40)
04:06:41.004 [tid 2040] hook: Game::StrategyServer::MoveFleet MH_EnableHook -> MH_OK mode=trace
04:06:41.004 [tid 2040] hook: Game::StrategyServer::ProcessFleetMovement rva=0x003da9a0 mode=off (not installed)
04:06:41.004 [tid 2040] fpu: module init, entry cw=0x027f 53bit-double/nearest; force=off value=0x0000 sample_ticks=on
04:06:41.004 [tid 2040] fpu: StrategyClient::EndTurn rva=0x00383be0 -> va=01203be0 MH_CreateHook -> MH_OK (trampoline=00940f20)
04:06:41.020 [tid 2040] fpu: StrategyClient::EndTurn MH_EnableHook -> MH_OK
04:06:41.020 [tid 2040] fpu: StrategyServer::BeginProcessTurn rva=0x003d98e0 -> va=012598e0 MH_CreateHook -> MH_OK (trampoline=00940f00)
04:06:41.035 [tid 2040] fpu: StrategyServer::BeginProcessTurn MH_EnableHook -> MH_OK
04:06:41.035 [tid 2040] fpu: StrategyServer::ProcessTurn rva=0x003dc6c0 -> va=0125c6c0 MH_CreateHook -> MH_OK (trampoline=00940ee0)
04:06:41.051 [tid 2040] fpu: StrategyServer::ProcessTurn MH_EnableHook -> MH_OK
04:06:41.051 [tid 2040] fpu: DemoApp::OnTick rva=0x0049a640 -> va=0131a640 MH_CreateHook -> MH_OK (trampoline=00940ec0)
04:06:41.066 [tid 2040] fpu: DemoApp::OnTick MH_EnableHook -> MH_OK
04:06:41.066 [tid 2040] selftest: Shim::SelfTest::Fill mode=off checksum=075ef0c3 records=0
04:06:41.082 [tid 2040] Application::Initialize called (this=03248128)
04:06:41.832 [tid 2040] fpu: TICK BASELINE at OnTick (this=03248128): cw=0x127f 53bit-double/nearest
04:12:04.617 [tid 2040] fpu: sample at StrategyClient::EndTurn (this=0e19c4b8): cw=0x127f 53bit-double/nearest [no fpu.force configured]
04:12:07.695 [tid 2040] fpu: sample at StrategyClient::EndTurn (this=34b76be0): cw=0x127f 53bit-double/nearest [no fpu.force configured]
04:12:07.726 [tid 2040] fpu: sample at StrategyClient::EndTurn (this=34b79048): cw=0x127f 53bit-double/nearest [no fpu.force configured]
04:12:07.757 [tid 2040] fpu: sample at StrategyClient::EndTurn (this=34b77a70): cw=0x127f 53bit-double/nearest [no fpu.force configured]
04:12:08.710 [tid 2040] fpu: sample at StrategyServer::BeginProcessTurn (this=0e1bf750): cw=0x127f 53bit-double/nearest [no fpu.force configured]
04:12:08.710 [tid 2040] fpu: sample at StrategyServer::ProcessTurn (this=0e1bf750): cw=0x127f 53bit-double/nearest

Binary file not shown.

View file

@ -0,0 +1,50 @@
===== state_checksum: run-127f vs run-off (post-turn autosave) =====
A 5ac4a24197e82de49f3077cd8dd25fad ../results/fpu-cw/run-127f/(Autosave).sav
B 5ac4a24197e82de49f3077cd8dd25fad ../results/fpu-cw/run-off/(Autosave).sav
policy: floats=bits mask=none reader=fe5a6f7cd4ae7910
IDENTICAL
===== state_checksum: run-127f vs run-007f (post-turn autosave) =====
A 5ac4a24197e82de49f3077cd8dd25fad ../results/fpu-cw/run-127f/(Autosave).sav
B 222b03e932f684d5d61f54c155be4f36 ../results/fpu-cw/run-007f/(Autosave).sav
policy: floats=bits mask=none reader=fe5a6f7cd4ae7910
DIVERGED: 2 leaf difference(s)
/Summary/Checksum: -769976634 -> -769976632
/Sim/systems/Sys[112 "Gamma Cephei"]/Pop2/PopG/PopC: 540000000 -> 540000002
===== state_checksum: run-127f vs run-027f (post-turn autosave) =====
A 5ac4a24197e82de49f3077cd8dd25fad ../results/fpu-cw/run-127f/(Autosave).sav
B 5ac4a24197e82de49f3077cd8dd25fad ../results/fpu-cw/run-027f/(Autosave).sav
policy: floats=bits mask=none reader=fe5a6f7cd4ae7910
IDENTICAL
===== state_checksum: run-127f vs run-137f (post-turn autosave) =====
A 5ac4a24197e82de49f3077cd8dd25fad ../results/fpu-cw/run-127f/(Autosave).sav
B 5ac4a24197e82de49f3077cd8dd25fad ../results/fpu-cw/run-137f/(Autosave).sav
policy: floats=bits mask=none reader=fe5a6f7cd4ae7910
IDENTICAL
===== state_checksum: run-127f vs run-1a7f (post-turn autosave) =====
A 5ac4a24197e82de49f3077cd8dd25fad ../results/fpu-cw/run-127f/(Autosave).sav
B e86df4556578665ce1b4cdf7a77d22a7 ../results/fpu-cw/run-1a7f/(Autosave).sav
policy: floats=bits mask=none reader=fe5a6f7cd4ae7910
DIVERGED: 2 leaf difference(s)
/Sim/fleets/Flt[34 "Beta Fleet"]/Pos/.[0]: -10.563499450683594 -> -10.563498497009277 [1 ulp]
/Sim/fleets/Flt[34 "Beta Fleet"]/Pos/.[2]: 1.564733624458313 -> 1.5647337436676025 [1 ulp]
===== state_checksum: run-127f vs run-007f-rep (post-turn autosave) =====
A 5ac4a24197e82de49f3077cd8dd25fad ../results/fpu-cw/run-127f/(Autosave).sav
B 222b03e932f684d5d61f54c155be4f36 ../results/fpu-cw/run-007f-rep/(Autosave).sav
policy: floats=bits mask=none reader=fe5a6f7cd4ae7910
DIVERGED: 2 leaf difference(s)
/Summary/Checksum: -769976634 -> -769976632
/Sim/systems/Sys[112 "Gamma Cephei"]/Pop2/PopG/PopC: 540000000 -> 540000002
===== state_checksum: run-127f vs run-1a7f-rep (post-turn autosave) =====
A 5ac4a24197e82de49f3077cd8dd25fad ../results/fpu-cw/run-127f/(Autosave).sav
B e86df4556578665ce1b4cdf7a77d22a7 ../results/fpu-cw/run-1a7f-rep/(Autosave).sav
policy: floats=bits mask=none reader=fe5a6f7cd4ae7910
DIVERGED: 2 leaf difference(s)
/Sim/fleets/Flt[34 "Beta Fleet"]/Pos/.[0]: -10.563499450683594 -> -10.563498497009277 [1 ulp]
/Sim/fleets/Flt[34 "Beta Fleet"]/Pos/.[2]: 1.564733624458313 -> 1.5647337436676025 [1 ulp]

View file

@ -0,0 +1,50 @@
===== 127f vs off =====
A verify/results/fpu-cw/run-127f/shim.trace.jsonl.gz
B verify/results/fpu-cw/run-off/shim.trace.jsonl.gz
Game::ServerSystem::ProcessTurn: 6 leaf difference(s) over 28 record(s)
rec[4] .coverage.n: 3 -> 1
rec[4] .coverage.undeclared: [{'region': 'system', 'off': 420, 'len': 3}, {'region': 'system', 'off': 424, 'len': 3}, {'region': 'system', 'off': 428, 'len': 3}] -> [{'region': 'system', 'off': 420, 'len': 12}]
rec[15] .coverage.n: 6 -> 4
rec[15] .coverage.undeclared: [{'region': 'system', 'off': 216, 'len': 1}, {'region': 'system', 'off': 368, 'len': 1}, {'region': 'system', 'off': 420, 'len': 3}, {'region': 'system', 'off': 424, 'len': 3}, {'region': 'system', 'off': 428, 'len': 3}, {'region': 'system', 'off': 568, 'len': 4}] -> [{'region': 'system', 'off': 216, 'len': 1}, {'region': 'system', 'off': 368, 'len': 1}, {'region': 'system', 'off': 420, 'len': 12}, {'region': 'system', 'off': 568, 'len': 4}]
rec[16] .coverage.n: 3 -> 1
rec[16] .coverage.undeclared: [{'region': 'system', 'off': 420, 'len': 3}, {'region': 'system', 'off': 424, 'len': 3}, {'region': 'system', 'off': 428, 'len': 3}] -> [{'region': 'system', 'off': 420, 'len': 12}]
Game::StrategyServer::MoveFleet: 0 leaf difference(s) over 7 record(s)
Game::TechTree::ProcessResearch: 0 leaf difference(s) over 3 record(s)
TOTAL: 6
===== 127f vs 007f =====
A verify/results/fpu-cw/run-127f/shim.trace.jsonl.gz
B verify/results/fpu-cw/run-007f/shim.trace.jsonl.gz
Game::ServerSystem::ProcessTurn: 0 leaf difference(s) over 28 record(s)
Game::StrategyServer::MoveFleet: 0 leaf difference(s) over 7 record(s)
Game::TechTree::ProcessResearch: 0 leaf difference(s) over 3 record(s)
TOTAL: 0
===== 127f vs 027f =====
A verify/results/fpu-cw/run-127f/shim.trace.jsonl.gz
B verify/results/fpu-cw/run-027f/shim.trace.jsonl.gz
Game::ServerSystem::ProcessTurn: 2 leaf difference(s) over 28 record(s)
rec[15] .coverage.undeclared[2].len: 3 -> 2
rec[15] .coverage.undeclared[2].off: 420 -> 421
Game::StrategyServer::MoveFleet: 0 leaf difference(s) over 7 record(s)
Game::TechTree::ProcessResearch: 0 leaf difference(s) over 3 record(s)
TOTAL: 2
===== 127f vs 137f =====
A verify/results/fpu-cw/run-127f/shim.trace.jsonl.gz
B verify/results/fpu-cw/run-137f/shim.trace.jsonl.gz
Game::ServerSystem::ProcessTurn: 0 leaf difference(s) over 28 record(s)
Game::StrategyServer::MoveFleet: 0 leaf difference(s) over 7 record(s)
Game::TechTree::ProcessResearch: 0 leaf difference(s) over 3 record(s)
TOTAL: 0
===== 127f vs 1a7f =====
A verify/results/fpu-cw/run-127f/shim.trace.jsonl.gz
B verify/results/fpu-cw/run-1a7f/shim.trace.jsonl.gz
Game::ServerSystem::ProcessTurn: 0 leaf difference(s) over 28 record(s)
Game::StrategyServer::MoveFleet: 2 leaf difference(s) over 7 record(s)
rec[6] .side.pos.after.v.x.bits: 3240690712 -> 3240690711
rec[6] .side.pos.after.v.z.bits: 1070090545 -> 1070090546
Game::TechTree::ProcessResearch: 0 leaf difference(s) over 3 record(s)
TOTAL: 2

View file

@ -165,6 +165,20 @@ satl satk`).
Objective records (Player `odes`/`owep`/`otch`, each a framed VectorHelper of `"."` elements):
`odes` = `otnF otnL odid opid`; `owep` = `otnF otnL odet owep`(string) `owith`; `otch` = `otnF otnL odet
otch`(string) `owith`.
> **Confirmed against the binary (2026-09-08, lane S).** `otch` elements are
> `Game::ObservedTech` (`Write` `0x00817cf0`, `Read` `0x00817c40`, `sizeof` `0x2c`) and `owep`
> elements are `Game::ObservedWeapon` (`Write` `0x00817bc0`, `Read` `0x00817b10`) — the same
> element shape with a different string tag. The serializers emit exactly the order this file
> already had. In-memory types, which the on-disk framing does not reveal: `otnF` and `otnL` are
> **`uint16`** widened to int by the stream (`movzx` then stream `vft+0x24`); `odet` is a
> **`bool`** written through `WriteBool`, i.e. 1 byte plus 3 NUL joint-padding bytes, not an
> int32. `save_reader.py` types `odet` as `int`, which is **benign and deliberate**: `odet` is a
> 4-character tag, so a bool item and an int item are both `pad4(4+4+1) == pad4(4+4+4) == 12`
> bytes and the little-endian value is identical (§2's stated "bool and int have identical item
> sizes for a name whose length is a multiple of 4"). Re-typing it would change nothing on
> disk; it is recorded here so the *reimplementation* uses a `bool`, and so nobody later
> "discovers" the same 3 padding bytes as a missing field.
`NdGr2` (node grid): `paths{"." n, n×"."{npt npid npfr npto npctm npcby npdtn npdtf npenp npuse nptf}}`
then `nextid`(int).
`BQ` (system build queue): `ords{"." n, n×"."{desID con conleft sav ordID}}` — count 0 in turn1..3.

View file

@ -267,7 +267,7 @@ glance that all four are genuine simulation changes (tens of thousands of ULPs
growing over a turn), not float-path noise. Had a port produced `[1 ulp]` on these instead, the
same line would say so and the judgement call would be an explicit one.
### 3.5 The x64/SSE budget, and the one thing this cannot settle
### 3.5 The x64/SSE budget — MEASURED 2026-09-08, no budget needed
x87 with PC=53 rounds an intermediate to double and then to float32 — **double rounding**. SSE
`mulss`/`addss` rounds once, directly to float32. For a minority of inputs those differ by one
@ -281,28 +281,43 @@ double rounding (compute in double, narrow explicitly at each store — which is
`fpu_cw = 0x127f` makes the original do) or to accept a documented `--ulps` budget on a named
list of fields.
**What I cannot settle from the host side:** whether the turn pipeline's results depend on the
x87 intermediate precision at all. Every save on this host was produced with `fpu_cw = 0x127f`,
so the corpus is one point, not a curve. It is possible that every value in the turn pipeline
is computed in a way that gives the same float32 under 24-bit, 53-bit and 64-bit precision
control, in which case an SSE port has **no** double-rounding budget to spend and the strict
policy is free forever. It is equally possible that a handful of fields are precision-sensitive.
**SETTLED on the VM, 2026-09-08 (lane F).** It was not settleable from the host side — every
save in the corpus was made at `fpu_cw = 0x127f`, one point rather than a curve — so lane F ran
the End Turn seven times from `ref-turn2.sav` with the shim forcing the control word, and
checksummed the results with this tool. Full write-up and the evidence chain that the setting
actually *held* (38 independent in-pipeline samples per run):
`findings/subsystems/fpu-precision-sensitivity.md`; artefacts in
`verify/results/fpu-cw/`.
**The experiment that would settle it** (VM140, lane R):
| forced `fpu_cw` | precision / rounding | root vs baseline |
|---|---|---|
| stock, `0x027f`, `0x127f`, `0x137f` | 53-bit and **64-bit**, nearest | **identical**, all 35,394 leaves |
| `0x007f` | **24-bit**, nearest | 1 leaf + derived `Summary/Checksum` |
| `0x1a7f` | 53-bit, **round-up** | 2 leaves, 1 ULP each |
**The answer for §3, in one line: 53-bit and 64-bit x87 give the same state, so an SSE port
computing in IEEE `double` has no double-rounding budget to preserve and `floats=bits` is free.**
What is *not* free is the two things the other two runs found:
```
0x007f (24-bit): /Sim/systems/Sys[112 "Gamma Cephei"]/Pop2/PopG/PopC : 540000000 -> 540000002
0x1a7f (round-up):/Sim/fleets/Flt[34 "Beta Fleet"]/Pos/.[0] and /Pos/.[2] : 1 ulp each
```
So the port must (a) hold intermediates at 53 bits and narrow to float32 only where the original
stores to a `dword` — never compute a chain in `float` — and (b) use round-to-nearest. Both are
SSE defaults; they are now measured requirements rather than assumptions, each with a named
regression witness on turn 2 of `ref-turn2.sav`.
**Correction to the experiment as it was written here.** The three values proposed above span
only *two* FPU modes. `0x027f` is 53-bit — it differs from `0x127f` only in bit 12 (infinity
control), which every x87 since the 387 ignores — and `0x137f` is 64-bit extended, not a
rounding change. Precision control is bits 8–9 (`00`=24, `10`=53, `11`=64) and rounding control
is bits 10–11. The genuine single-precision word is `0x007f` and a genuine rounding change is
`0x1a7f`; run as originally specified, all three settings come back identical and the tempting
conclusion — "nothing depends on the control word" — would have been wrong on both of the axes
that actually matter.
> Load `ref-turn2.sav`, press End Turn, and capture `(Autosave).sav` three times, with the shim
> forcing `fpu_cw` to `0x027f` (24-bit), `0x127f` (53-bit, the baseline) and `0x137f` (64-bit)
> across the turn call. Then run
> `state_checksum.py baseline.sav variant.sav` on each pair.
>
> * All three roots equal → no turn-pipeline value depends on x87 intermediate precision. The
> strict policy costs the SSE port nothing, and §3.5 can be closed.
> * Roots differ → the diff *is* the answer: it names every precision-sensitive field, and that
> list becomes the port's work item and the only place a `--ulps` budget is ever justified.
>
> Cheap, because the oracle is already byte-identical and the tool already localises. It needs
> nothing from this lane; it needs a shim knob that sets the control word around the turn call
> and the existing End-Turn click path from §5.
Until that runs, "the checksum is exact and the port must match bit-for-bit" is a *policy*, not
a measured requirement. It is the right default either way — it fails loudly rather than