sots-re/campaign/board.md

330 lines
250 KiB
Markdown

# Campaign board
Status flow: `backlog → in-progress → mapped → verified` (or `blocked`).
`verified` requires re-verifier evidence. Maintained by **re-quartermaster**.
| Target | Type | Status | Conf | Coverage | Updated | Notes |
| --- | --- | --- | --- | --- | --- | --- |
| RTTI class inventory | meta | mapped | high | 100% | 2026-09-07 | `findings/objects/00-inventory.md` — 1924 types, engine=Mars |
| `.gob` format | subsystem | mapped | high | 100% | 2026-09-07 | renamed uncompressed ZIP (community-known) |
| `Mars::AppStartup` (entry) | control-flow | mapped | high | 100% | 2026-09-07 | entry 0x00925794 -> WinMain 0x0089dd30 -> DemoApp ctor 0x0089c950 -> Mars::Application::Initialize 0x008a0e50 (config/affinity, D3D9, window, sound thread, OnStartup -> net thread). findings/control-flow/turn-spine.md |
| main loop / tick dispatch | control-flow | mapped | high | 90% | 2026-09-07 | Application::Run 0x0089f5b0: FrameTimer, PanelManager, DemoApp::OnUpdate 0x00898800 / OnTick 0x0089a640 / OnRender 0x00899210. Turn pipeline: EndTurn 0x00783be0 -> BeginProcessTurn 0x007d98e0 -> StrategyServer::ProcessTurn 0x007dc6c0 -> RunCombatRound 0x007cbe80 -> SETurnResults -> ResumePlaying 0x007ddc90. Lockstep on every machine |
| `Game::ClientPlayer` / `AIPlayer` | object | verified | high | 100% | 2026-09-07 | ServerPlayer confirmed strict vs real saves (turns 1-3) |
| `Game::StarSystem` | object | verified | high | 100% | 2026-09-07 | ServerSystem confirmed strict vs real saves; NVO.indi unconditional; haltv bool; cp1252 names |
| `Game::Planet` / `DOPlanet` | object | mapped | high | 100% | 2026-09-07 | Planet : Actor is a RENDER actor, not streamed; all colony state is in ServerSystem (+PlayerView in NVs map) |
| `Game::ShipDesignDef` / `DesignList` | object | verified | high | 90% | 2026-09-07 | engine game/design merged: rules + stats in C++; 127/127, 197/197 DOpts, gating 121/127 (hidden riders warn), oracle-identical incl. 9,673 stats; 43 unit tests |
| `Game::FleetLayout` | object | verified | high | 100% | 2026-09-07 | StarFleet/StarShip confirmed strict vs real saves; PrisonerHold PrNSp gated on PrMax>0 |
| Mars entity chain | object | mapped | high | 100% | 2026-09-07 | EntityBase@0x00a36fd0 (10) -> PhysicalEntity@0x00a3710c (12) -> TargetableEntity -> Actor@0x009e2114 (36); mixins RigidBody/Body/NetworkObject/HandleObject/IScriptable |
| `Game::GameCombatSim` + `CombatCommand*` | subsystem | backlog | — | 0% | 2026-09-07 | tactical combat (~40 command types) |
| D3D9 device init | subsystem | backlog | — | 0% | 2026-09-07 | `Mars::DrawDevice`/`Camera` → Direct3DCreate9/CreateDevice |
| `Mars::Buffer::gobio` | subsystem | verified | — | 0% | 2026-09-08 | SUPERSEDED (see engine: mars/vfs row). .gob I/O; anchors the verify/ parser |
| serializable-types vs save structs | verify | mapped | high | 100% | 2026-09-07 | findings/objects/save-editor-structs.md (~130 structs, exact order); verifier: cross-check vs binary next |
| battle-load path | subsystem | mapped | med | 60% | 2026-09-07 | mitigation = affinity pin FUN_0089ee70; sim+load on main thread; see thread-contention row (parked) |
| class hierarchy + key vftables | meta | mapped | high | 100% | 2026-09-07 | findings/objects/ghidra-recon.md - RTTI Base_Class_Array read directly; vftables for 8 core classes |
| data-model (.gob data files) | subsystem | mapped | high | 100% | 2026-09-07 | findings/subsystems/data-model.md - tech/weapons/sections/races/AI all data-driven |
| string / config intel | meta | mapped | high | 100% | 2026-09-07 | findings/subsystems/strings-and-config.md |
| Mars brace-block parser | subsystem | verified | high | 100% | 2026-09-07 | verify/parsers/ (mars_data, flat_kv, manifest, effect_txt, verify.py): 1595/1595 files parse, 0 dangling cross-links; catalogs in verify/results/data-catalogs/ |
| networking (SNM/FNM + GameSpy) | subsystem | mapped | — | 0% | 2026-09-08 | SUPERSEDED (lane G2: direct IP joins without GameSpy; identity + hosts list recovered). ~60 SNM strategy msgs, FNM file-xfer/host-migration; lockstep sim |
| battle-load: thread contention | subsystem | blocked | med | 60% | 2026-09-07 | PARKED (not on reimpl critical path). Profile: findings/subsystems/battle-load-profile.md. Combat not reached (no ships/range/encounters); audio thread inert w/o audio device; candidate = TryEnterCriticalSection->Sleep(1000) job poll @0x0071ea60 + 15.6ms timer. Resume recipe + tools in verify/harness/profiling/ |
| UI screen & flow map | meta | mapped | high | 100% | 2026-09-07 | findings/subsystems/ui-screen-map.md - 36 screens; screens are C++ on Mars controls (NOT data); turn state machine recovered |
| `Game::StrategyServer` (sim block) | object | verified | high | 100% | 2026-09-07 | full member table (47 rows) confirmed vs real saves under strict parse incl. ID lists, GOWinPly, invasions, zdsc pairs, SvSctOb (present in all 3 saves) |
| stream primitive API | subsystem | mapped | high | 100% | 2026-09-07 | IStreamable vft: +0x18 string, +0x1c bool, +0x20 float, +0x24 int, +0x28 nested, +0x30 raw; FUN_00816490 = NetworkObject handle id |
| real save for verification | verify | verified | high | 100% | 2026-09-07 | 6 saves, 3 distinct turn states in verify/results/saves/ (byte-deterministic per state). Game runs on VM140 via DXVK 3.1 + lavapipe; recipe findings/subsystems/running-the-game.md | **LANE O 2026-09-08.** Corpus is now **11 saves / 9 distinct states**: the 4 old ones plus 5 Zuul (turns 15-23) and 2 Human (turns 2-3) made this session, all `--strict` clean and all `coverage: PROVED` under `state_checksum --tree`. See the individual rows for what each unblocks.
| save_reader.py | verify | verified | high | 100% | 2026-09-07 | --strict exit 0 on all 3 real saves; 0 resyncs / 0 hint-failures; only raw = RNG blob (2503 B). 29 tests. SAVE_FORMAT.md confirmed |
| Ghidra type write-back | meta | verified | high | 100% | 2026-09-07 | structs saved in project (ServerSystem 87f, ServerPlayer 110f, StarFleet, StarShip, StrategyServer partial, 22 nested); 52 serializers + primitives + ~60 spine fns renamed; decompile shows field names |
| strategic turn internals (economy/research/colony/movement/diplomacy) | subsystem | mapped | high | 90% | 2026-09-07 | findings/subsystems/strategic-turn-internals.md: budget/RP/trade/bankruptcy formulas, research rolls (unlisted race=1.0, Zuul x2), colony growth, movement, RNG=MT19937; ~600 g_KEY labels in Ghidra |
| P2-M0 shim bootstrap | phase2 | verified | high | 100% | 2026-09-07 | sots-engine 5f5bc41: proxy binkw32.dll (66 exports identical), MinHook, asm-stub trace hook on Application::Initialize; menu reached; evidence verify/results/shim/. Initialize signature unverified -> asm stubs only |
| P2-M1 flat-KV config loader | phase2 | verified | high | 100% | 2026-09-08 | LIVE: GlobalConsts::LoadFile hooked; trace 19 calls/1088 regions (tracecmp 0); compare 19/19, 0 divergences; REPLACE loaded all 19 files and End-Turn autosaves hashed exactly to the oracle; offline replay 0. 3 extra parsers found (string/vec3/rect), pi/180 is a double. sots-engine 8b231c0, docs/M1.md; traces verify/traces/m1-* |
| P2-M2 manifest/id registry | phase2 | verified | high | 80% | 2026-09-08 | LIVE: trace 22 calls (tracecmp 0); weapons compare 1/1 0 divergences; REPLACE+oracle byte-identical with our weapon loader feeding the game. Proves 123 weapon ids == manifest, DELETED lines are comments, dict sorted by _stricmp on name, +0x14 layout fix correct. GAP: section dictionary is TRACE-ONLY - ours crashes the engine in compare mode (leads in docs/M2.md; hooking LoadSection is the next boundary) |
| P2-M3 Mars brace-block parser | phase2 | verified | — | 0% | 2026-09-08 | SUPERSEDED (engine mars/parse, oracle 1531/1531). Mars::Script pull tokenizer (Open 0x008cd7d0, ReadToken 0x008cd2f0, Next 0x008cd3e0, SkipBlock 0x008cd4b0); whitespace-only delimiting, quotes " ' ` no escapes, EOF ends, stray } ignored, LAST PAIR W/O TRAILING NEWLINE DROPPED; consumers = _stricmp if/else chains (WeaponDef::ParseScript 0x00599070, SectionDef::ParseScript 0x005744e0, MasterTechTree::ParseTech 0x0058b050). .effect = Mars::TextFileStream (Open 0x008cfb90) |
| P2-M4 gobio VFS read | phase2 | verified | — | 0% | 2026-09-08 | SUPERSEDED (engine mars/vfs, 10268 files CRC-clean). choke point: bool __cdecl gobio::ReadFile(const char*, IBuffer**) 0x008d5140; FileSystemSet ctor 0x008d6d60 pushes NativeFileSystem(".") FIRST then sots.ini [Modules] Mount<N> (fallback sots.gob, sots_local_en.gob); zip lookup case+slash-insensitive 0x008d4a50 -> matches mars/vfs NativeFirst |
| loader prototypes (Ghidra) | meta | verified | high | 100% | 2026-09-07 | findings/subsystems/loader-prototypes.md: 80 entries (79 verified) in ghidra/addresses.json; Initialize = bool(this, AppStartup*) RET 4 (M0 crash explained); 93 fns renamed + 9 structs in Ghidra |
| compare harness | verify | mapped | high | 100% | 2026-09-07 | verify/harness/compare/: TRACE_FORMAT.md, tracecmp.py, replay, oracle bridge |
| engine: mars/parse | engine | verified | high | 100% | 2026-09-07 | sots-engine: brace-block + .effect readers; oracle 1531/1531; cross-compiles i686; wired into host+shim builds |
| engine: mars/text | engine | verified | high | 100% | 2026-09-07 | flat-kv, id-manifest, csv; oracle 64/64 (Strings.csv 5722 rows); ctest green |
| engine: game/sim formulas | engine | verified | high | 100% | 2026-09-08 | sim-pin merged: all 5 low-confidence formulas pinned (3.3 on protection limit, old-state bankruptcy decisions, hazard curve, money tail, pop bonus); tests 356->466; + game/effects (196 TechIds, 44 typed effects, 254 checks) |
| 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 |
| wire-schema channel (layouts -> engine) | engine | verified | high | 97% | 2026-09-08 | **Lane G.** Lane D's serializer recovery now reaches `sots-engine` as a GENERATED WIRE SCHEMA, not struct layouts: `tools/streams.py` -> `objects/streams.json` (386 classes / 2,042 items, disk order preserved, duplicate tags preserved, **every memory fact dropped** - no off/size/sizeof/gaps/strides) -> `tools/gen_stream_schema.py` -> `include/generated/sots_stream_schema.h`. WHY layouts.json was the wrong input: `build()` sorts fields by `off_abs` (**89 of 386 classes** have offset order != write order) and merges duplicate offsets into `alt_tags` (that IS the JewelsOfTheCrown double-tag trap). The table is a SPEC, not a program - the recovery is a linear pass so it cannot see `Write`'s branches and lists conditional fields (StarShip `BQ2`/`hbq`) unconditionally, and flattens container loops; a codec driven off it desynchronises. So hand-written `io()` stays the codec and a new `SchemaProbe` archive + `test_wire_schema.cpp` CHECK it: **56 shapes bound, 657 items matched, 0 mismatches**. FOUND 4 REAL DEFECTS present in BOTH `save_reader.py` and the engine, invisible to any round-trip test: `SystemParams` field 1 is a **string** not an int (empty string == 4 zero bytes == int 0, so it round-tripped by luck; a named planet would have desynced both readers); `ObservedTech/ObservedWeapon.odet` is a **bool** not an int (byte-safe only because a 4-char tag makes bool and int items both 12 bytes); `SpeciesRatios.nv` and `ShipRecords.srbd` are **counts**, not fields. TWO TRAPS RESOLVED: `ServerTradeManager`'s no-op Read/Write is real but the call is **virtual** - `ServerTradeManagerImpl` has the real serializer (`NumTradeSectors`/`TradeID`/`Trade`/`SctSize`), same shape resolves `IServerSpyManager` -> `ServerSpyManager`; general rule: **when an interface's serializer is the inherited stub, look for the concrete `*Impl`**. `ShipDesign::Write` really does make no stream call, but `ShipDesignDef::Section` is recovered so most of `Des` is typeable anyway. COVERAGE, measured by a new `CoverageArchive` that separates items a field NAMES from items a `Node` merely CARRIES (a byte-identical round trip is not a coverage claim): **37.9% -> 97.1%** typed on turn1 (97.2/97.2/97.6 on the others), round trip still byte-identical on all 4 saves. Typed this round: `TechTree` (both NumTechs sections - the 2nd is per-tech state, field list from `SpyReportTechTree` which streams the same record), `Events` (3 nesting levels), `ShipRecs`, `sprjs`, `civr`, `comms`, `spy2`, `spymgr`, `aid`, `Ojvs`, `AIEnf`, `FNG`, `trdmgr`, `Des` sections + gun banks. STILL OPAQUE: `CD` custom data 744 items (TurnCommands_v5 is a no-orders snapshot - **needs a save with issued turn commands**), `SvSctOb` 147 (8 EncObj variants), `DOpts` 94 (`read_elem` has no std::string branch), `spies2` 56, `RNG` 2 (correctly opaque). Ratchet at 95% so typing can't regress. ctest **34/34**, clean-room OK, `test_save` skips cleanly unset. `findings/objects/wire-schema-channel.md`; sots-engine branch `wip/layouts` | **LANE O 2026-09-08.** **`CD` `TurnCommands_v5` is no longer sample-starved.** Saves taken AFTER issuing orders and BEFORE End Turn grow the block from the universal empty snapshot (`CD[0]` digest `3df7d93164fb1d7d`, 35 leaves / 122 B in turn1/2/3, zuul-turn5 AND human-turn3) to 41 / 61 / 129 leaves. Field-by-field layout in `verify/save-reader/SAVE_FORMAT.md` **section 11**: playerId, researchRate float, per-command-type present-flags, then counted lists of build orders (ordinal, designId, systemId, 0), system-rate commands (systemId + a named `SRs SRt SRsc SRtf SRi SRoh SRnr` frame - the only NAMED sub-frame in the block), colonize orders (shipId, 1) and fleet moves (fleetId, 1, destSystemId, 0). Ids cross-checked against the same file's `Sys`/`Flt` records and against the UI (384 = Gallandro, 432 = Octans, 688 = the fleet the UI showed as Alpha Fleet with Dest: Octans, 216383 = the exact Imperial Savings drop the Boost Research panel caused). The block is the PENDING client->server queue: `zuul-turn15-orders.sav` still has `ResRate 0.25 / ResTNm '' / Sav 2,902,722` on the ServerPlayer while the block already carries rate 0.97 and the boost. Saves `verify/results/saves/zuul-turn15-orders.sav`, `zuul-turn17-orders2.sav`, `human-turn2-orders.sav`
| 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 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 |
| tech effects (code-defined) | subsystem | mapped | high | 90% | 2026-09-07 | findings/subsystems/tech-effects.md: g_TechIdNames 196 @0x009ff9e4 (TechId=10000+i), effects in ServerPlayer::OnTechResearched 0x00891790; SpeciesDef table 0x00b10a00; 36 strategic / ~45 gates / ~115 none. Feeds game/sim + game/data |
| strategic formula gaps | subsystem | verified | high | 100% | 2026-09-07 | findings/subsystems/formula-gaps.md: all 8 answered with code (bankruptcy 3.3 on BnkPr, linear hazard, money tail, pbon, slider, [6] net, no speed clamp, decay hits current) |
| engine: game/design | engine | verified | high | 100% | 2026-09-07 | merged; see ShipDesignDef row |
| P2-B1 ComputeBudget (behavioral) | phase2 | verified | med | 75% | 2026-09-08 | LIVE + GREEN on declared regions (4,437 compares, 0 divergences; replace-mode oracle byte-identical). Corrections: out-param int[22]; researchMoneyKept gated on a research target. QUALIFIED BY THE HARNESS AUDIT: replace mode runs the ORIGINAL A SECOND TIME to harvest slots -> a real per-turn double effect (ComputeOutput repairs ships in orbit) that no region reaches - UNRESOLVED; the over-budget int at Budget+0x64 was captured only as an argument and args are never compared. Golden trace needs recapture with guards. Coverage gaps: 8 slots always 0 (expenses/aid/debt) . RECAPTURED WITH GUARDS 2026-09-08 (lane R): 4284 compared, 0 diverged, exit 0; `budget_object` guard 0 undeclared writes, so Budget+0x64 never took a different value (weaker than 'never written'). COVERAGE IS NARROWER THAN THE NUMBER: only **20 distinct (player, output) states** in 4284 calls (4278 are the UI polling one player; the turn pass is ~8 calls), and **13 of the 22 slots are 0 on EVERY call** - including 5 of the 6 declared input-boundary slots, which therefore prove nothing twice over. 8 modelled slots ever carry a value |
| P2-B2 OnTechResearched (behavioral) | phase2 | verified | high | 80% | 2026-09-08 | LIVE: 3 completions compared, 0 divergences (incl. one tech absent from the effects table = tail-only, the case an early return would have broken). float32 CONFIRMED ON THE GAME bit-for-bit (con_mod 1.0->0.899999976 = (float)(x+(double)0.1f)). Found B3's rolling effect: ServerPlayer::RollResearchEvent draws one NextFloat at the top of the callback - modelled + RNG region declared, but the branch never fired here so NOT behaviourally proven. KNOWN GAP: ours posts no events and the player event list is still undeclared, so the clean compare bounds the economy fields only and the replace/oracle pass was deliberately run on a no-completion turn (weak check) . RECAPTURED WITH GUARDS 2026-09-08 (lane R): 2 calls, 0 divergences; the `player` guard reports **player+0x2b0 (EvNxID) on BOTH calls**, so 'ours posts no events' is now measured, not assumed. **RollResearchEvent branch FIRED and matched** (see its row). Also caught an undeclared `vector<ObservedTech>` append at player+0x274. Reachable only after 5 End Turns from ref-turn2 |
| 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 | . **LANE V 2026-09-08 (build `eventlive-dd38117-20260908T0916Z`): the events divergence is CLOSED on the reference turn** - 3 calls, 3 compared, **0 diverged, exit 0**. Over 5 turns 15 calls / **2** diverged (was 3), both short by exactly 1 `next_id` = the unmodelled `EVENT_TECHS_UNLOCKED`. **CORRECTION to lane R's 'RNG 15/15': that was WORKLOAD LUCK, not a property.** On lane V's call 9 the completed tech had `research_roll_pending` set, so the original drew one word `ours` did not (`left` 374 vs 375, `next_index` 250 vs 249) - the `OnTechResearched` callback draw, a DECLARED out-of-scope boundary (P-events-wiring §3 says so in as many words). Honest statement: the `rng` region reproduces bit-for-bit on every call that does not complete a roll-triggering tech, and is short by exactly one draw on every call that does. Also live: `sizeof(ObservedTech)` = **44**, measured twice (`observed_techs.bytes` 440->484 and 484->528). Zuul double roll now CLOSED on a species-5 save lane V made - see its own row
| 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: **VM140=L1, VM141=L2, VM144=L3, VM145=L4, VM146=FREE (L5 released; left running l5A-widened with hooks=off)** (five parallel investigations 2026-09-08). VM140 remains the reference/oracle guest. Was: FREE (W3 released 2026-09-08, oracle bytes verified; **AND THE LAB IS NOW FIVE GUESTS** - VM141/144/145/146 are ZFS clones for probes and workloads, VM140 remains the exclusive reference; W2 released 2026-09-08, VM restored + oracle bytes verified; H released 2026-09-08, oracle reproduced + VM restored; N released 2026-09-08; Z released 2026-09-08; O-workloads released 2026-09-08 08:30 local, see the LANE O note at the end of this row; U-unlock released 2026-09-08 06:55 local; V-eventlive before it; F-fpucw before it, M-movefleet before that). 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. GOTCHA (lane V, third confirmation): the >60 s startup is REAL - the Kerberos intro was still on screen at t+30 s and the main menu appeared at ~t+70 s. GOTCHA (lane V): `type <text>` via the click helper did **NOT** register in the game's text fields this session (neither the Create Game name nor the Save Game file name took SendKeys); the workaround is to accept the default name and **rename the `.sav` on disk** - the Load dialog lists files by FILENAME. SAVEDGAMES IS NOW **8 FILES**: the pre-existing 7 plus `zuul-turn5.sav` (a species-5 save, see the Zuul row). Row positions are UNCHANGED for existing lanes because `zuul-turn5` sorts last: **ref-turn2 is still (400,436)**, zuul-turn5 is the new row 8 at (400,465), rows 29 px apart from y=262 - verified by screenshot `verify/results/shim/eventlive/load-dialog-8files.png`. The three autosaves now belong to the Zuul game. Lane V left `C:\SOTS\shimdist-v` + `C:\SOTS\ui\v{deploy,release,click}.ps1`. VM RESTORED: recap build `recap-7584bad-20260908T0615Z`, `hooks=trace`, main menu verified by screenshot. LANE U (2026-09-08): left `C:\SOTS\shimdist-u` + `C:\SOTS\ui\u{deploy,release}.ps1` in place. SavedGames is STILL 8 FILES (the three autosaves now belong to the Zuul game at turn 15) - **ref-turn2 is still (400,436)**, zuul-turn5 still (400,465). A QEMU-level screenshot (feed `screendump /tmp/vm140.ppm` to `qm monitor 140` on spicy, then scp the .ppm) is more reliable than the click helper's `shot` action and does not need the game window. FOURTH confirmation of the >60 s startup: the Kerberos intro was still on screen at t+45 s, the main menu at ~t+85 s. VM RESTORED: recap build `recap-7584bad-20260908T0615Z`, `hooks=trace`, main menu verified by screenshot. | **LANE O 2026-09-08.** **VM140 is FREE.** Holder was O-workloads; released 2026-09-08 after ~9 turns of Zuul play (turn 15 -> 23) and a fresh 3-turn Human game. **CORRECTION to lane V's `type` gotcha: the click helper's `type` DOES reach the game's text fields - you must CLICK THE FIELD FIRST.** `click 480 234` (the Save Game File Name box) then `type <name>` worked every time this session, and `click 500 363` + `type` worked for the Create Game name box; lane V's failure was almost certainly an unfocused field. Saves can therefore be named properly and no longer need renaming on disk (the file written is gameName + typedName). Coordinates confirmed this session at 1024x768: in-game menu = round button (1000,714) -> `Save` (937,679) / `Quit to Main Menu` (937,698) -> confirm OK (537,377); Save dialog `Save` (772,233), `Game Successfully Saved` OK (612,383), `Done` (772,542); map panel `Research` (55,124) `Design` (142,124) `Build` (221,124), `Manage Fleets` (60,464) `Move` (155,464) `Special` (229,464), `End Turn` (100,714); Build screen `Add To Queue` (125,714); **Esc leaves the Build and Research screens** - the bottom-left button on the Research screen is `Cancel research`, NOT back, and clicking it pops `Confirm Cancel`. Move mode: send `move X Y` FIRST to read the target's name / Range / ETA off the map, THEN `click`, because that readout is what tells you whether the route is a node route (2-5 turns) or a sublight crawl (37-86 turns). **Chained clicks in one `cmd.txt` DO work for repeated clicks on the SAME button** (`Add To Queue`) but drop about half at `sleep 250` and about 1 in 6 at `sleep 500` - count the result, never assume. End Turn takes 30-45 s under `hooks=trace` with ~25 ships. A mid-turn AI diplomacy popup can steal a scripted click sequence - screenshot after any batch that seems to have done nothing. **VM RESTORED and verified**: quit to the main menu (screenshot), `SavedGames` back to the ORIGINAL 8-file set with the three autosaves restored BYTE-IDENTICAL (sha256 prefixes 0630daaa / e0bf3485 / 1f0dc162), so `ref-turn2` is still (400,436) and `zuul-turn5` still (400,465); lane O's staging dir `C:\SOTS\o-stage` removed; `shim.cfg` still `hooks=trace`, build `recap-7584bad-20260908T0615Z` untouched. Lane O's 7 saves live in the REPO ONLY - push one back with `scp verify/results/saves/<f>.sav re@192.168.10.139:C:/SOTS/SavedGames/` when a compare needs it, and remember that adding a file shifts the Load dialog rows (`MyGame*` sorts BEFORE `MyGameverify1*`)
| Zuul double-roll (behavioural) | verify | verified | high | 100% | 2026-09-08 | **CLOSED by lane V 2026-09-08.** No species-5 save existed, so lane V MADE one on VM140: a custom game with only Zuul left in the Available Species pool (both players Zuul), 4 End Turns under `shim.cfg.recapb3`. **8 calls, 8 compared, 0 diverged, exit 0.** All four researching calls have `species=5` and the generator advances by TWO, not one: `left` 540->538 / 522->520 / 504->502 / 485->483, `next_index` +2 each, `mt` hash unchanged; `ours` reproduced every post-state bit-for-bit. Save `verify/results/saves/zuul-turn5-species5.sav` (48559ab5b719b332, 59,131 B, turn 5, homeworld Gallandro) and on the VM as `C:\SOTS\SavedGames\zuul-turn5.sav`. NOT yet exercised by it: a Zuul tech COMPLETION (node 144 was at ~4,700 of 5,000 after 5 turns - one more End Turn gets there), so `SetResearched` and the Zuul boarding-pod grant are still untouched. Report `findings/subsystems/eventlive-verification.md` §5 **EXTENDED by lane U 2026-09-08: the double roll and the COMPLETION PATH together.** `zuul-turn5` + 10 more End Turns (turn 5 -> 15) under the same config: **20 calls, 20 compared, 0 diverged, exit 0**, with TWO Zuul completions. Call 2 (turn 7, alloc {144,1376}) advances the generator by two (`left` 449->447, `next_index` 175->177) AND runs the unlock cascade in the same call - node 144 -> state 4 / order 21 / turn_researched 7, nodes 132 and 136 -> state 2 with cost_rp 10000 / 16000 and turn_available 7, `next_id` 10->12, `observed_techs` 396->440. Note the ZUUL tree unlocks only 132 and 136 from tech 144 where the HUMAN tree also unlocks 142, so this is an independent instance of the cascade, not a repeat. Trace `verify/traces/unlock-b3-zuul.jsonl.gz` |
| 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 | **LANE O 2026-09-08.** **PARTIALLY unblocked, and three of the remaining slots are now shown to be UNREACHABLE through the 1.8 UI.** Delivered: `[11] construction` and a large `[8] maintenance` are live in `zuul-turn17-orders2.sav` (build queue of 20 DEs) and `zuul-turn23-fleet23.sav` (27 DEs in service, `Maint` 14,500 vs 3,000/1,000 in every earlier save); the empire research slider is off default (`ResRate` 0.97 vs 0.25) in all five Zuul saves. NOT delivered, with reasons. **(a) `[12] expenses`**: `Nexp` is `[]` for EVERY player in ALL 11 saves across turns 1-23, human and AI alike, and no screen in the 1.8 UI (map panel, Build, Research, Ranking, Intel Analysis, Empire Population Manager, in-game menu) exposes a per-category expense slider - the only economic sliders are the empire Savings/Research split and the per-system `SRs/SRt/SRsc/SRtf/SRi/SRoh` rates, which land in `Sys`, not `Nexp`. Working hypothesis: slot 12 is dead in 1.8. **(b) `[10] debtInterest` / `[7] systemIncomeNeg`**: a debtor cannot be manufactured quickly - a ship's savings cost is deducted at QUEUE time so the queue cannot be over-committed, and `Maint` counts only ships IN SERVICE, which arrive at construction-budget/ship-cost per turn (~4.5 DE/turn here). With income floored at 78,570 (Planetary Budget pushed fully to Construction; measured 124,055 -> 78,570 on the panel) that is ~157 destroyers, i.e. ~35 more End Turns. Reachable, just not cheap. **(c) `[13]/[14]/[19]` aid**: a treaty offer DOES occur (the AI offered a non-aggression pact on turn 3 of the Human game and it was accepted - `human-turn3-noderoute.sav`), but the accepted pact leaves the player's `aid` count 0, and SotS 1.8 has no player-initiated treaty UI - proposals are AI-driven events. Research aid needs a longer game and luck, not a click path
| hook GetDifficultyMods | meta | backlog | — | 0% | 2026-09-08 | B1 derived the two difficulty rows from trace values (AI maintenance divisor 3, research x1.5) instead of snapshotting them; hook it properly so they stop being constants |
| section-loader compare crash | verify | backlog | — | 0% | 2026-09-08 | SectionDictionary compare crashes the engine while the identical weapon path succeeds -> fault is in re-running LoadSection, not the manifest reader. Next boundary: hook LoadSection itself. docs/M2.md has 3 ranked leads |
| P2-B4 colony + movement (behavioral) | phase2 | mapped | med | 70% | 2026-09-08 | LIVE: 36 calls compared, 0 divergences, tracecmp exit 0. Scout's headline: RNG left-delta 0 and mt hash identical on ALL 28 systems (only ProcessRebellion draws; none fired) - fpu_cw 0x127f confirmed. One real fleet move reproduced bit-for-bit; PlanFleetMovement's schedule matched. 3 prototypes + 6 helpers VERIFIED and written back to Ghidra; 22 formula corrections. THREE hook bugs found by reading the trace, not the verdict (stale args from describe_args-before-regions; StrategyServer has TWO bases 4 bytes apart; off_Fleets was a Ghidra-base number used as raw) - each would have given a clean compare that checked nothing. COVERAGE IS THIN: only 3 owned systems, 1 moving fleet, gate traffic all-zero; bats2, plague, rebellion, slaves, terraform, jumps, arrivals untested. No replace mode (input boundary). VM released: main menu, hooks=trace, build b4-fix2-20260908T0615Z . **QUALIFIED 2026-09-08 by lane R:** with 45 MoveFleet calls over 5 turns instead of 7 over 1, **8 of the 15 moving calls diverge by 1 ULP of position**. B4's clean verdict was a ONE-SAMPLE verdict - fleet 34 (its only mover) still matches bit-for-bit; the bug appears once other fleets move. ProcessTurn recaptured at 140 calls / 0 divergences, but only `ntdev` (15x) and `rcex` (7x) ever moved: everything else was byte-identical on all 140, so the verdict bounds two counters. Guard found the AI home system's fleet vector growing every turn (the build queue emitting a ship) |
| harness gap: undeclared side-effect lists | verify | verified | high | 100% | 2026-09-08 | FIXED STRUCTURALLY (engine 3f0721f+): compile-time-required Coverage on every descriptor (a hook without one does not compile); Guard regions that catch AND localise undeclared writes (names player+0x2b0, not 'the hash moved'); replace mode now emits records; tracecmp prints coverage on every report + --strict-coverage. A hook claiming 'complete' while a guard caught an undeclared write now counts as a DIVERGENCE. Audit found 23 undeclared side effects: docs/harness-audit.md |
| RollResearchEvent draw (behavioural) | verify | verified | high | 100% | 2026-09-08 | **CLOSED by lane R.** On turn 6 (`IND_TRKSTL`, tech 10094) `research_roll_pending` was true going in; the original drew exactly one word (rng `left` 375->374, `next_index` 249->250) and cleared the flag, and `ours` reproduced both bit-for-bit on the scratch generator. 0 divergences on that call. Needs 5 End Turns from ref-turn2 to reach - the reference turn has no completion at all | . RE-OBSERVED by lane V from the CALLER side 2026-09-08: on the B3 hook's own call 9 the same draw shows as an `rng` divergence (`left` 374 vs 375) plus guard spans `player+0x3b4` (`ResErrRoll`) and `player+0x196` (byte 2 of design mask B at `+0x194`). Still NO save that starts with `research_roll_pending` true - the flag has only ever been seen set and cleared inside one turn **LANE O 2026-09-08.** **The 'no save starts with the flag true' gap is CLOSED, and the exact condition is now a number.** `verify/results/saves/zuul-turn17-rollpending.sav` has player 16 `ResErrRoll=True`, `ResTNm='WEP_GrnLas'`, and that node at `TResDone 1083 / TResCost 5000` = ratio 0.217; `zuul-turn16-noderoute.sav` is the same state at ratio 0.000. The gate constant at **0x00a2c788 is a float 0.5**, not a double (read out of `dumps/sots.exe` by PE section walk; an 8-byte read gives 5.28e13 garbage and 0x00a2c78c is float 100.0) - recorded as `ResearchRollProgressThreshold` in `ghidra/addresses.d/lane-o.json`. So `ResErrRoll` survives into `ProcessResearch` exactly while progress/cost <= 0.5 AT THE START of the turn, and the `OnTechResearched` draw can only fire when ONE turn supplies more than half the target's cost. Confirmed live end to end: target set turn 15 (ratio 0), funded 1,083 RP/turn at 97% research, flag still true entering turns 16 and 17, tech completed on turn 21 with the flag ALREADY CLEARED - the ProcessTurn roll had fired when the ratio crossed 0.5. To make the draw fire you need a tech whose remaining cost is under one turn's RP; on this empire (1 colony, income 78-124k, 1,083 RP/turn, cheapest available tech 5,000 RP) that is ~2.3x the available RP, so it needs a bigger empire or a cheaper tech, not a different click path. The in-game **Boost Research** panel (Research screen, `Boost Research` twisty) spends savings on the current target and IS recorded in `TurnCommands_v5`, but its per-turn pool is only ~7.5% of Imperial Savings and decays geometrically within the turn (197,271 then 17,551 then 1,561 out of 2.9M), so it cannot bridge the gap either
| golden-trace recapture (post-guards) | verify | verified | high | 100% | 2026-09-08 | DONE on the live game, build `recap-7584bad-20260908T0615Z` (NO source change needed - the audit's machinery did all of it). **B3 ProcessResearch: the defect is VISIBLE** - `side.events.after.v.next_id orig=4 ours=3`, one divergent call of 3 on the reference turn and its ONLY divergent field; bit-for-bit the `EvNxID 4->3` that previously needed a 609 KB save diff. Over 5 turns 15 calls / 3 diverged, **RNG matched 15/15**, and the two completion calls miss TWO event ids each. **B1 ComputeBudget: verdict held** - 4284 compared, 0 diverged, exit 0, `budget_object` guard caught 0 undeclared writes (Budget+0x64 never changed value). **MoveFleet: 8 of 45 diverge by 1 ULP of position** (new; see its own row). First guarded captures for OnTechResearched (2 calls), ServerSystem::ProcessTurn (140 calls) and MoveFleet (45). Guards mapped SetResearched live (ConMod[0..2], OutMod, PopMod, ResTNm, TechTree+0x20 order counter) and found an UNDECLARED `vector<ObservedTech> otch` append at player+0x274. Oracle held on every run's first End Turn. Report `findings/subsystems/golden-trace-recapture.md`; engine `docs/R-recapture.md`; traces `verify/traces/recap-*`, reports `verify/results/compare/recap-*`. sots-engine branch `wip/recapture` e50d5e5 (merged with main 82ef52f; ctest 32/32, clean-room OK) |
| MoveFleet position rounding (1 ULP) | verify | verified | high | 100% | 2026-09-08 | **CLOSED by lane M.** Mechanism read off the instruction stream, not fitted: the engine's `Mars_Vec3_Normalize` (0x00422520, 123 callers) narrows to float32 FOUR times - `sumsq = f32(x*x+y*y+z*z)` (products/adds stay in 53-bit regs, only the SUM is stored), `len = f32(sqrt(sumsq))`, `inv = f32(1.0/len)` a RECIPROCAL that is MULTIPLIED through rather than three divides, and `dir.c = f32(delta.c*inv)`; and MoveFleet stores each `dest.c - pos.c` BACK TO A FLOAT32 SLOT before calling it, and takes the leg distance from that same call's return value. `ours` did all of it in double. The position tail (`f32(pos + f32(dir*move))`) was already right, which is exactly why the error was a constant ABSOLUTE ~1.2e-7. Confirmed OFFLINE first (an arrival copies the destination verbatim, so calls 115/155 hand you fleet 34's and fleet 50's exact float32 destinations = 8 fully determined legs; the 5-narrowing model reproduces the ORIGINAL bit-for-bit on all 8, the old double model reproduces `ours` on the 3 divergent ones), then LIVE: control run 8/45 diverged exit 1, fixed run **0/45 diverged exit 0**, with identical args, identical pos.before and identical ORIGINAL pos.after on all 45 calls. Report `findings/subsystems/movefleet-position-rounding.md`, engine `docs/M-movefleet.md`, branch `wip/movefleet` 2aa8cba |
| undeclared ObservedTech append | verify | verified | high | 90% | 2026-09-08 | NEW (lane R). A tech completion grows `vector<ObservedTech> otch` at ServerPlayer+0x274 (all three vector words move = a realloc). Seen as an undeclared write by BOTH the ProcessResearch `player` guard and the OnTechResearched `player` guard. It is serialized ServerPlayer state and it is in NO coverage note anywhere - a third list append in the same neighbourhood as the event list. B3's replace oracle never saw it because turn 1 of ref-turn2 has no completion **MODELLED AND LIVE-VERIFIED by lane U 2026-09-08.** `ours` now models the append DECISION - it scans the owner's otch element NAMES pre-call (the original appends during the call, so a scan taken afterwards would find the tech present and "agree" with a count it never computed) and reproduces `RecordObservedTech`'s de-duplication by name, then moves the SCRATCH header's byte span by one 0x2c element per append. `observed_techs.bytes` now matches on every call in all three runs (440->484, 484->528 on the five-turn; 396->440, 440->484 on the Zuul). STILL NOT MODELLED: the element's own fields (turn_first/turn_last/detected/name/with) - no region can see them | **APPEND SITE NOW NAMED (lane X): `RecordObservedTech` 0x007ba1a0, called from OnTechResearched; stride 0x2c.** Still needs a declared region + a model in ours. . PARTLY ADDRESSED (lane P + lane V 2026-09-08): it IS now a declared **Result** region (`observed_techs`, ServerPlayer+0x274), so it has left the guards' undeclared list and appears in the diff instead - lane V measured the span growing by exactly 44 on both completion calls (440->484, 484->528) and `ours` short by that 44 each time. STILL OPEN: `ours` does not append, so the region diverges on every completion. The element and the append site are fully pinned (lane X/S: sizeof 0x2c, RecordObservedTech 0x007ba1a0, DE-DUPLICATES BY TECH NAME), so what is left is modelling it - and that belongs with whoever takes SetResearched
| unnamed offsets from guard hits | verify | backlog | — | 0% | 2026-09-08 | NEW (lane R). Three spans the guards report every run and no addresses.json entry names: **ServerSystem+0xd8 (1 B)** and **ServerSystem+0x238 (4 B)** - written by the AI home system on every colony turn, alongside the fleet-vector growth; **StarFleet+0xdc (1 B)** - written on every moving MoveFleet call, just past Speed (FPsp2 @0xd8). Cheap wins for the contract |
| waypoint types 2-5 have no coverage | verify | mapped | — | 0% | 2026-09-08 | SUPERSEDED (superseded by lane P2 species table). NEW (lane M, promoted from a coverage line to its own row because it is now the biggest gap in `MoveFleet`). Types 2 (node line), 3 (node route), 4 (gate teleport) and 5 (probabilistic jump) have NEVER fired in any capture, and the node-line step is WRONG BY CONSTRUCTION - `sim::NodeLineStep` and `sim::BuildStutterSegments` are written and host-tested but are NOT wired into the hook, which steps every waypoint type as `speed x dt`. **`ref-turn2` structurally cannot exercise them**: lane M held the VM and tried. The only mover in that save is the AI, which travels straight runs; the player that would travel a node line has `DE 00 CR 00 DN 00` at its home system (screenshot `verify/results/shim/mf-human-home-no-ships.png`), so Move/Manage Fleets are greyed out on every turn. Needs a ship built over several turns, or - much cheaper - a PURPOSE-BUILT SAVE with a fleet already in orbit next to a node line. Same save would unblock the gate-traffic and probabilistic-jump rows. Also owed on that path: `sim::Distance` is still plain double, and `Mars_Vec3_Length` (0x004224b0) says every vector length in the engine is float32-narrowed twice, so the stutter geometry is probably 1 ULP out the same way the position update was - deliberately left alone by lane M because there is no behavioural evidence to correct it against | **LANE O 2026-09-08.** **TYPE 3 (NodeRoute) IS NOW COVERED - and type 2 is shown NOT to be produced by ordinary movement of EITHER node-drive race.** Waypoint census over the corpus (`Tp` values): turn1/turn2 none, turn3-state 1x type 1, `zuul-turn5-species5` **2x type 3** (already on disk since lane V - nobody had looked), and the new saves 6 / 8 / 11 / 11 / **16** x type 3 (`zuul-turn15-orders` through `zuul-turn23-fleet23`) plus 3x type 3 in `human-turn3-noderoute`. Includes the PLAYER'S OWN fleet: `zuul-turn16-noderoute.sav` has `Flt 688 HFPlan=True wpts` = 1 x (`Wpt 432`, `Tp 3`, `nrt` = (`nrp -1`, `nrf 384`, `nrt 432`)) - Gallandro to Octans, in nodespace - and `human-turn3-noderoute.sav` has (`Wpt 128`, `Tp 3`, `nrt` = (`nrp 37`, `nrf 224`, `nrt 128`)) where `nrp` is the `NdGr2` path id being ridden. **Type 2 negative result**: Zuul (rip-bore route over a natural node line) and Human (node drive over a single directly-connected node line, Downbelow->Delphi, and over multi-hop routes) BOTH emit type 3 - single-hop (`nrp -1`) and multi-hop alike, planned and in-transit alike, 20+ instances, zero type 2. So `WaypointKind::NodeLine` is not what a Move order produces for a node race; the remaining candidates are Hiver gate traffic (4/5) and some non-order path into node space. Types 4 and 5 still need a HIVER game. Mechanic worth knowing for the next lane: a Zuul fleet WITHOUT a Rip Bore is refused node travel outright (dialog: 'cannot reach X using nodes. Do you want to plot a sublight course?'), and node-connected destinations are identifiable from the Move-mode readout by a short ETA (2-5 turns) versus a 37-86 turn sublight crawl
| ref-turn2 has no tech completion | meta | verified | high | 100% | 2026-09-08 | TRAP for anyone writing a workload (lane R). The documented one-End-Turn recipe produces **zero** `OnTechResearched` calls - an empty log that still passes. It takes 5 End Turns (to turn 7) to reach a completion. Also: only the FIRST End Turn is reproducible - it hashed to the oracle on all four runs and its research calls reproduce docs/B3.md exactly, but from turn 4 the AI picks a different research target than B3 recorded while the point totals stay nearly identical. Treat anything past turn 1 as *a* run, not *the* run |
| 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) **§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 | verified | high | 90% | 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 **MODELLED AND LIVE-VERIFIED by lane U 2026-09-08.** `ours` now models the append DECISION - it scans the owner's otch element NAMES pre-call (the original appends during the call, so a scan taken afterwards would find the tech present and "agree" with a count it never computed) and reproduces `RecordObservedTech`'s de-duplication by name, then moves the SCRATCH header's byte span by one 0x2c element per append. `observed_techs.bytes` now matches on every call in all three runs (440->484, 484->528 on the five-turn; 396->440, 440->484 on the Zuul). STILL NOT MODELLED: the element's own fields (turn_first/turn_last/detected/name/with) - no region can see them | . PARTLY ADDRESSED (lane P + lane V 2026-09-08): it IS now a declared **Result** region (`observed_techs`, ServerPlayer+0x274), so it has left the guards' undeclared list and appears in the diff instead - lane V measured the span growing by exactly 44 on both completion calls (440->484, 484->528) and `ours` short by that 44 each time. STILL OPEN: `ours` does not append, so the region diverges on every completion. The element and the append site are fully pinned (lane X/S: sizeof 0x2c, RecordObservedTech 0x007ba1a0, DE-DUPLICATES BY TECH NAME), so what is left is modelling it - and that belongs with whoever takes SetResearched
| 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 | verified | — | 0% | 2026-09-08 | SUPERSEDED (lanes P/V/U: prediction held, cascade closed). 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 . HAPPENED AGAIN 2026-09-08 (lane V): lane D's commit d7ea0a0 swept lane V's in-flight `ghidra/addresses.json` edit (the ObservedTech LIVE CONFIRMATION prose) into it. No harm this time - the content was correct and is now on main - but it is the same failure mode a third time, and it means a lane can find its own work already committed under another lane's message. Check `git log -- <path>` before assuming your edit is still unstaged. |
| MoveFleet waypoint types 2-5 | verify | mapped | — | 0% | 2026-09-08 | SUPERSEDED (lane P2: type 2 = LIIR drive (naming error), 3 covered by lane O, 4/5 need a Hiver game). 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 | **LANE O 2026-09-08.** Superseded in part - see the `waypoint types 2-5 have no coverage` row: **type 3 now has 16 instances in one save** (`zuul-turn23-fleet23.sav`) and the player's own fleet is a type-3 mover in `zuul-turn16-noderoute.sav` and `human-turn3-noderoute.sav`. Type 2 was ATTEMPTED as a Human node-drive move over a single directly-connected node line and still came back type 3, so B4's node-line step is not what a player Move order exercises
| P2-P event posting in ours | phase2 | verified | high | 95% | 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) | . **LIVE-VERIFIED AGAINST AN ADVANCE PREDICTION (lane V 2026-09-08, build `eventlive-dd38117-20260908T0916Z`, main dd38117 unchanged).** Lane P wrote the expected numbers into `docs/P-events-wiring.md` §4 BEFORE the run; every one held. First End Turn: **3 calls, 3 compared, 0 diverged, exit 0** (was 1 diverged) with `turn=3`, `events_turn_bucket_exists=true`, `events_next_id_in=3`, `events_in_turn_bucket=1`, `events_dedup_risk=0`, no `events_scan_truncated`, `next_id` 3->4 both sides, `turns`/`turns_bytes` 2/48 unchanged, node[144] 2879->5768 + flag 1->2, rng identical. Five End Turns: exit 1 with **2 divergent calls instead of 3**, each short by EXACTLY 1 (`next_id` orig 7 ours 6, orig 12 ours 11) = the unmodelled `EVENT_TECHS_UNLOCKED`, exactly as forecast - 0 divergences there would have been suspicious, not good. §4.3 conversion held too: `player+0x274/0x278/0x27c` left the guard's undeclared list (13 spans in 2 calls -> 10) and appear in the diff as `observed_techs.bytes`. End-Turn oracle byte-identical to lane R's. clean_room_check OK, ctest 33/33 (separate commands). Report `findings/subsystems/eventlive-verification.md`; engine `docs/V-eventlive.md`; traces `verify/traces/eventlive-b3-*`, reports `verify/results/compare/eventlive-b3-*`
| EVENT_TECHS_UNLOCKED / the unlock cascade | verify | verified | high | 100% | 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 **CLOSED by lane U 2026-09-08 — by running the cascade, not by posting on completion.** `sots-engine src/game/sim/techgraph.{h,cpp}` implements `PrereqsMet` (0x0057d8e0, AND of ORs; zero groups = TRUE, an EMPTY group = FALSE), `SetResearched` (0x00581e10: stamps, child-cost sweep with a SIGNED min against an INT_MAX sentinel, sticky `turnAvailable`, zero-cost recursion) and the tail collector (0x00587cc3). Wired into the B3 hook in COMPARE MODE ONLY, over the scratch node copies. LIVE: **first End Turn 3/3/0 exit 0; five-turn 15/15/0 exit 0; Zuul 20/20/0 exit 0** — all 22 of lane V's divergent fields gone, `next_id` reaching 7 and 12, and the End-Turn oracle hashes UNCHANGED (`bb4fd9ac…` / `978041ac…`). The unlock list is still an INPUT and still `nullptr` when it could not be computed, so lane P's distinction survives. `findings/subsystems/unlock-cascade.md`; engine `docs/U-unlock.md` (§4 = the advance prediction, §5 = the outcome) |
| 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` | . **CONFIRMED LIVE (lane V 2026-09-08)**: on the running game the `observed_techs` Result region grew by **exactly 44 bytes** on each of the two tech-completion calls of the 5-End-Turn run (440->484, 484->528), and both non-researching players' vectors measured 880 = 20 x 44 and never moved. Behavioural confirmation of a purely static pin
| 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 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 | . **CONFIRMED LIVE (lane V 2026-09-08)**: on the running game the `observed_techs` Result region grew by **exactly 44 bytes** on each of the two tech-completion calls of the 5-End-Turn run (440->484, 484->528), and both non-researching players' vectors measured 880 = 20 x 44 and never moved. Behavioural confirmation of a purely static pin
| 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 |
| MY BRIEFING ERROR: fpu_cw triple was under-powered | meta | verified | high | 100% | 2026-09-08 | I specified the experiment as 0x027f / 0x127f / 0x137f. WRONG ON BOTH AXES: 0x027f is 53-bit (it differs from 0x127f only in bit 12, infinity control, ignored since the 387) and 0x137f is 64-bit EXTENDED, not a rounding change. Run literally it returns "all three identical" - TRUE, but it tests single precision not at all and rounding not at all, so the conclusion drawn would have been wrong. Lane F caught it and added the genuine probes 0x007f (24-bit) and 0x1a7f (round-up). RULE: derive experiment parameters from the ISA definition, never from assumed mnemonics - and brief lanes to challenge the parameters, not just execute them |
| serializer struct recovery (automated) | objects | verified | high | 90% | 2026-09-08 | **Lane D: struct recovery is now MECHANICAL.** `tools/serializers.py` decodes the `Mars::IStreamable` `Write` idiom for the whole binary in **0.35 s**; `tools/rtti_map.py` walks type-descriptor <- COL <- vftable[-1] (1,924 TDs = the inventory's count, 2,172 COLs/vftables) to attribute each serializer to its class and its COL this-adjustment. **VALIDATION RUN BEFORE ANY NEW CLAIM, four ways: (A) 305/307 field offsets+kinds exact across 17 already-known classes, ZERO WRONG** vs struct-recovery.md 1-4 (ServerSystem 79/79, StarShip 23/23, StarFleet 16/16, ServerPlayer 101/103, PlayerView 13/13, DiplomacyStats 14/14 - including every trap: ObservedTech+0x24 correctly NOT a field, Bats2/rcex int64, TShn/ETS/DipStats int16, ltis int, pswd string, TRM/CstR/E/T/shrm/RefCap/RepCap/PlayerView.Infra float); **(B) sizeof read off the VectorHelper element-stride divide** (invert MSVC's magic `M = ceil(2^(32+k)/s)`) reproduces ObservedTech **0x2c** independently of lane X's three proofs, plus MoraleEvent 0x50, PlayerReport 0x30, DiplomacyStats 0x24, ObservedDesign 0x10; **(C) 22 of save_reader.py's shapes, recovered tag order identical, 22 agree / 0 disagree** (Sys 78 tags, Player 104, CreateParams 25, Ship 22, Slot 18); **(D) automated Read/Write cross-check on every class, 437/437 field offsets agree**. SCALE: **386 classes with a Write, 1,682 member fields** - verified 87 (542 fields) / clean 77 (328) / unnamed 176 (471) / partial 31 / empty 15; **58 sizeofs corroborated** by a second line of evidence, the rest reported as LOWER BOUNDS and labelled as such (a serializer enumerates only *serialised* members - a non-streamed tail is invisible). ~10x more classes with a known layout, reproducible from the exe in under a second. FOUR ENABLERS each worth 10-170 classes: (1) the RTTI **ClassHierarchyDescriptor** is the only honest "is this an IStreamable" test - a 3-slot vftable also matches TacAISquadRule_*, the CSV row parsers and ~100 others, and using it dropped a bogus 116-class "empty" bucket to 11; (2) **mod=0 memory operands** carry no displacement so `tools/x86disp.py` cannot index them - every field at offset 0 was invisible (OutputRates.SRt, every container element read through a bare iterator); (3) the **member->id pointer idiom** behind every handle field (HomeSys/PlrID/DesID/FltID/SrnTo) - and the base's this-ness must be read BEFORE retiring the destination, because `mov edi,[edi+0x1c0]` overwrites the this-register with the member it is reading; (4) **sub-writers**, base-class (StarMapNode inside ServerSystem = `Pos`) and private-same-class (StrategyServer's six id lists live in FUN_00794cd0 - without it PlayerIDs/DesignIDs/SystemIDs/FleetIDs/ShipIDs/TradeIDs are simply absent), spliced at the call site so disk order survives. FAILURE CLASSES, enumerated: 176 anonymous-tag classes (writer passes NULL, so offsets+types are complete but there are NO on-disk names - a hard limit on names, not on layout; combat commands + network messages); 19 unsized nested types; 9 map/list-node-only; 3 untyped sub-writers; 2 fields lost to a value assembled ACROSS A BRANCH (`tech ? tech->name : ""`, `max(v,1)`) - **left unrecovered on purpose rather than patched with a backward-search heuristic that has no oracle on the other 1,600 classes**; std::map/list members recovered only as their `_Mysize` int; 1 runtime-built tag (Mars::ParticleSystem). Everything but the first is a bounded mechanical fix. WRITE-BACK: 288 structures + 328 labels into Ghidra (0 failures), +201 addresses.json entries, header regenerated with gen_addresses.py. `findings/objects/serializer-struct-recovery.md`, `objects/layouts.{json,md,h}` |
| Game::ShipDesign is NOT streamed through IStreamable | objects | mapped | high | 100% | 2026-09-08 | Lane D, from the serializer sweep: `ShipDesign`'s IStreamable slots are inert - `Write` 0x008747a0 makes no stream call at all. Same for `Game::ServerTradeManager` (Read and Write are BOTH the inherited no-op 0x924fb0) and 13 others incl. the whole `Mars::Particle*` family and `Mars::DecalFile`. So designs persist by some other path (`ShipDesignDef` is already verified separately and may be the whole answer) - worth confirming before anyone goes looking for a ShipDesign serializer that does not exist |
| SVSOJewelsOfTheCrown writes a DUPLICATE tag | objects | mapped | high | 100% | 2026-09-08 | Lane D: `SVSOJewelsOfTheCrown::Write` emits the tag `JEWELLOCATIONID` TWICE, at +0x8 and +0x10. Not a tool error - the Write really does it. Any save reader matching that shape BY NAME will bind the wrong field. Same shape as ServerPlayer's two `Team` members (int @0xac, PlayerAlliances @0x168), which is why the Read/Write cross-check has to exclude duplicated tags rather than report them as conflicts |
| serializer struct recovery (mass) | objects | verified | high | 95% | 2026-09-08 | Lane D industrialised lane S's enumeration rule: the ~179 Streamable serializers ARE complete field enumerations with 4-char tags. 386 classes with a Write, 1,682 member fields, whole binary in 0.35 s. Verified 87 (542 fields) / clean 77 (328) / unnamed 176 (471) / partial 31 (341) / empty 15 - roughly 10x more classes with a known layout. VALIDATED FOUR WAYS BEFORE ANY NEW CLAIM: (A) 305/307 fields exact vs layouts we already had, 2 unrecovered, ZERO WRONG, every known trap reproduced incl. ObservedTech+0x24 correctly NOT a field; (B) sizeof independently inverted from MSVC's container-stride magic M=ceil(2^(32+k)/s) - ObservedTech 0x2c falls out mechanically, plus MoraleEvent 0x50, PlayerReport 0x30, DiplomacyStats 0x24; (C) 22 on-disk tag orders vs save_reader.py, 22 agree 0 disagree; (D) automated Read/Write cross-check, 437/437 offsets agree, 0 conflicts. 58 sizeofs corroborated by a second line of evidence; the REST ARE REPORTED AS LOWER BOUNDS AND LABELLED - a serializer enumerates only SERIALISED members, so a non-streamed tail is invisible. Ghidra: 288 structures + 328 labels, 0 failures |
| serializer recovery: failure classes | objects | mapped | high | 100% | 2026-09-08 | What defeats the tool, i.e. whether it scales to the remaining ~1,600 classes. 176 anonymous tags (writer passes NULL - offsets/types complete, no on-disk names; combat commands + network messages; NOT fixable, the names do not exist in the binary, layouts still usable). 19 nested member of unknown size (fixable, one ctor size each). 9 map/list node bodies. 3 untyped sub-writer. 2 fields VALUE ASSEMBLED ACROSS A BRANCH (`tech ? tech->name : ""`, `max(v,1)`) - DELIBERATELY left unrecovered rather than patched with a backward-search heuristic that has no oracle on the other 1,600 classes. 1 runtime-built tag (Mars::ParticleSystem, not fixable). Four enablers each worth 10-170 classes: RTTI ClassHierarchyDescriptor as the only honest "is this IStreamable" test (a 3-slot vftable also matches TacAISquadRule_* and the row parsers; using it dropped a bogus 116-class "empty" bucket to 11), mod=0 memory operands (x86disp.py cannot index them; they hide every field at offset 0), the member->id pointer idiom behind every handle field, and sub-writers (StrategyServer's six id lists live in FUN_00794cd0) |
| Game::ShipDesign has NO serializer | objects | verified | high | 100% | 2026-09-08 | Lane D: Game::ShipDesign::Write (0x008747a0) MAKES NO STREAM CALL, and ServerTradeManager's Read and Write are both the inherited no-op. Designs persist some other way. Recorded so nobody hunts for a serializer that does not exist |
| SVSOJewelsOfTheCrown duplicate tag | objects | verified | med | 100% | 2026-09-08 | Its Write emits the tag JEWELLOCATIONID TWICE (+0x8 and +0x10). A name-matching save reader binds the WRONG field. Ours must bind by ordinal here, not by name |
| addresses.json fragment dir (structural fix) | meta | verified | high | 100% | 2026-09-08 | Three cross-lane sweeps today all traced to ONE shared file. `ghidra/addresses.d/<lane>.json` fragments are now merged by gen_addresses.py after addresses.json, in sorted order; a DUPLICATE NAME ACROSS FILES IS A HARD ERROR (exit 1), never last-wins, because two lanes disagreeing about an address is precisely what must not be papered over. Self-tested both ways (duplicate exit=1, clean exit=0). Integrator folds fragments back into addresses.json once a lane merges. This replaces the "stage by path" rule with a design that does not depend on remembering it |
| P2-P event posting LIVE-VERIFIED | phase2 | verified | high | 95% | 2026-09-08 | ADVANCE PREDICTION HELD FIELD FOR FIELD. Lane P wrote the expected result into docs/P-events-wiring.md 4 BEFORE the run; lane V tested it. First End Turn: 3 calls, 3 compared, 0 divergent, tracecmp exit 0 - turn=3, bucket_exists=true, next_id_in=3, in_turn_bucket=1, dedup_risk=0, no scan_truncated, next_id 3->4 both sides, node[144] and rng as forecast, observed_techs.bytes unchanged on all three. Five-turn: exit 1, 2 divergent calls (was 3), each short by EXACTLY 1 (orig 7/ours 6, orig 12/ours 11) = the deliberately unmodelled EVENT_TECHS_UNLOCKED. player+0x274/0x278/0x27c LEFT the guards' undeclared list (13 spans -> 10) and now show as `observed_techs`. STRENGTHENED BY LUCK RUNNING OUT: this was NOT lane R's run - from turn 4 the AI picked different techs - and the predicted id totals landed anyway. End-Turn oracle matched lane R's hashes byte for byte |
| sizeof(ObservedTech)=44 CONFIRMED LIVE | objects | verified | high | 100% | 2026-09-08 | observed_techs.bytes grew by EXACTLY 44 on both completion calls (440->484, 484->528); non-researching players measured 880 = 20 x 44 and never moved. Independent BEHAVIOURAL confirmation of a purely static pin (lane X's three proofs + lane D's magic-divide inversion). Static and dynamic now agree on this struct from four directions |
| CORRECTION: lane R's "RNG matched 15/15" was workload luck | verify | verified | high | 100% | 2026-09-08 | Lane V found rng diverges on call 9 (orig left 374/next_index 250, ours 375/249) = the RollResearchEvent draw in OnTechResearched, a boundary B3 and P-events-wiring 3 both declared out of scope. HONEST STATEMENT: ProcessResearch's rng matches on every call that does NOT complete a roll-triggering tech, and is short by one draw on every call that DOES. Lane R's 15/15 was a property of that workload, not of the model - exactly the kind of claim that survives only until the workload changes. The two "new" guard spans (player+0x196, player+0x3b4) are already-named fields (design-mask B, ResErrRoll) seen from the caller **EXPLAINED by lane U 2026-09-08, and it is stronger than "workload luck".** The draw is now MODELLED (`ServerPlayer::OnTechResearched` head: `if (ResT == def) { if (ResearchRollPending) RollResearchEvent(); pending=0; ResT=0; }`, one unconditional NextFloat), its two inputs are read PRE-CALL and reported in every record (`research_target`, `roll_pending_in`), and `region:rng` compared clean on all 35 of lane U's calls. BUT `roll_draws` was **0 on every one of them**: `ResearchRollPending` is normally consumed by `ServerPlayer::ProcessTurn` BEFORE ProcessResearch runs, because that call site fires once the progress ratio crosses its threshold - i.e. exactly the turns approaching completion. In lane U's Zuul run `roll_pending_in` is true for turns 8-12 and flips FALSE on turn 13, the turn before the tech completes on turn 14. So lane V's call-9 draw was the RARE case (a tech jumping from below the threshold to complete in one turn). Three sessions, three answers: 0 / 1 / 0. **The branch is inside the compare but has never been seen to fire** - needs a purpose-built save |
| Zuul double roll (behavioural) | verify | verified | high | 100% | 2026-09-08 | CLOSED by lane V. No species-5 save existed so it MADE one: custom game, only Zuul in the Available Species pool, 4 End Turns. 8 calls, 8 compared, 0 divergent, exit 0; all four species=5 researching calls advance the generator by TWO (left 540->538, 522->520, 504->502, 485->483) and ours reproduced every post-state bit-for-bit. Was disassembly-only since 2026-09-08 morning. Save: verify/results/saves/zuul-turn5-species5.sav (and zuul-turn5.sav on the VM). Does not yet contain a Zuul COMPLETION - one more End Turn gets there |
| VM click helper: `type` does not register | meta | verified | high | 100% | 2026-09-08 | The click helper's `type` action does not reach the game's text fields. WORKAROUND: accept the default name and rename the .sav afterwards. Costs a save-naming step in every workload-building lane |
| wire-schema channel (engine) | engine | verified | high | 95% | 2026-09-08 | Lane G REJECTED the brief's premise for good reason. objects/layouts.json is a MEMORY-layout projection and was the wrong input: serializers.py's build() sorts fields by off_abs (89 of 386 classes have offset order != WRITE order) and merges duplicate offsets into alt_tags - which is exactly the JewelsOfTheCrown double-tag trap. Lab.layout() already computed true disk order internally and it was being thrown away. New `tools/streams.py` is a SECOND projection keeping order and repeats and DROPPING EVERY MEMORY FACT (no off/size/sizeof/gaps/strides) - so the original's ABI never enters our runtime types, which was the design constraint. Offsets still have their home as `offset` entries in addresses.json for the shim, which legitimately needs them. gen_stream_schema.py -> include/generated/sots_stream_schema.h (386 classes, 2,042 items). SUBTLETY: the on-disk primitive is NOT layouts.json's `kind` - it comes from the stream vftable slot/helper, and a member held as int16/int8 is written by WriteInt and is FOUR BYTES ON THE WIRE |
| schema as CHECK not codec | engine | verified | high | 100% | 2026-09-08 | Lane G's key judgement: the table is a SPECIFICATION, not a program. The recovery is a LINEAR pass over Write, so it cannot see branches (StarShip's BQ2 is gated on hbq but listed unconditionally) and it flattens container loops - a codec driven off it DESYNCHRONISES ON THE FIRST BRANCH. So hand-written io() shapes stay the codec and a new SchemaProbe archive walks them WITH EVERY BRANCH TAKEN (the same view the recovery has), LCS-aligned against the table: 56 shapes bound, 657 items matched, 0 mismatches |
| NAMED coverage 38% -> 97% | engine | verified | high | 100% | 2026-09-08 | All four real saves round-tripped byte-identically BEFORE and AFTER - and lane G refused to call that a coverage claim, because `ar.any` bodies round-trip trivially by copying bytes nobody understands. New CoverageArchive separates items a FIELD NAMES from items a Node merely CARRIES: turn1/2/3 37.9/38.8/39.4% -> 97.1/97.2/97.2%; zuul-turn5 42.5% -> 97.6%. Ratchet at 95%. Newly typed: TechTree (both NumTechs sections), Events (3 nesting levels), ShipRecs, sprjs, civr, comms, spy2, spymgr, aid, Ojvs, AIEnf, FNG, trdmgr, Des section/gun-bank tree. STILL OPAQUE, honestly: CD custom data (744 items - TurnCommands_v5 is a NO-ORDERS snapshot, needs a save with issued orders), SvSctOb (147, eight EncObj variants), DOpts (94, read_elem lacks a std::string branch), spies2 (56, count 0 everywhere), RNG (2, correctly opaque) | **LANE O 2026-09-08.** The `CD` line is now actionable: three saves with issued orders exist (see the wire-schema row and `SAVE_FORMAT.md` section 11). `SvSctOb`, `DOpts`, `spies2` untouched
| FOUR reader defects invisible to round-trip | verify | verified | high | 100% | 2026-09-08 | Present in BOTH readers, none catchable by a round-trip test. (1) SystemParams field 1 is a STRING, not an int - it is the empty string in every save, four zero bytes, byte-identical to int 0; it round-tripped BY LUCK and a named planet would have desynced both parsers. (2) ObservedTech/ObservedWeapon `odet` is a BOOL not an int (lane D's own golden table already said so); byte-safe only because a 4-char tag makes bool and int items both 12 bytes. (3) SpeciesRatios::nv and (4) ShipRecords::srbd are COUNTS, not fields. Lane G deliberately did NOT patch save_reader.py: every fix is byte-neutral on available saves, and quietly editing the oracle mid-campaign is worse than recording the divergence. THE ENGINE IS NOW THE CORRECTED REFERENCE; the Python oracle carries a logged open item |
| RULE: interface stub -> look for the *Impl | objects | verified | high | 100% | 2026-09-08 | `trdmgr` RESOLVED, correcting the earlier "ServerTradeManager has no serializer" finding: the no-op Read/Write is real but THE CALL IS VIRTUAL - ServerTradeManagerImpl has the actual serializer. Same shape resolves IServerSpyManager -> ServerSpyManager. GENERAL RULE: when an interface's serializer is the inherited stub, look for the concrete *Impl. (Game::ShipDesign::Write genuinely still makes no stream call - but ShipDesignDef::Section IS recovered, so most of Des is typeable anyway) |
| B3 hook: the completion path is now inside the compare | phase2 | verified | high | 95% | 2026-09-08 | NEW (lane U 2026-09-08). `Game::TechTree::ProcessResearch` in compare mode now reproduces not only what the function itself writes but the whole `SetResearched` completion path: the turn/order stamps, the child-cost sweep, the availability sweep, the newly-available collector that decides EVENT_TECHS_UNLOCKED, the de-duplicating ObservedTech append and the one RNG word RollResearchEvent draws. **35 compared calls across three workloads, 0 divergences, tracecmp exit 0 on all three.** FOUR pre-call reads make it work and each is a trap that reads back a PLAUSIBLE wrong answer if taken after the original: TechTree+0x20 (order counter, post-incremented -> every `order` off by one), ServerPlayer+0x294 (`ResT`, zeroed by the callback -> the extra RNG draw never modelled), +0x3b4 (pending roll, cleared in the same block), and the otch element names (already appended -> the dedup check 'agrees' with a count it never computed). All four are reported as ARGUMENTS so a run is auditable without trusting `ours`. COMPARE MODE ONLY: in replace mode applying half of OnTechResearched (the append and the roll, but not the ~90 tech-effect field writes) would leave the player in a state no code path produces |
| CLEAN-RESULT DISCIPLINE: how a 0-divergence run was kept honest | meta | verified | high | 100% | 2026-09-08 | NEW (lane U). A clean result was EXPECTED here, which is exactly when a hook that silently models nothing slips through. Three things made that hard to hide and all three fired: (1) the 'did nothing' answer is KNOWN - it is `INT_MAX / 0 / -1`, precisely what lane V's report shows - so there is no null model that passes; (2) the collector ran on all 35 calls, not just the 4 completions, and had to come back EMPTY on the 31 quiet ones - an over-collecting transcription would have pushed `next_id` TOO HIGH and made those calls newly divergent; (3) the shim log prints per-call counters (`completions= unlocked= otch_appends= roll_draws= failures= depth= name_unreadable=`), so a clean compare with zeros on a completion call would have been visible as a clean compare of nothing. Reusable pattern for any lane whose expected outcome is 'no divergence' |
| Prereq semantics come from the CODE, not the tech files | subsystem | verified | high | 100% | 2026-09-08 | NEW (lane U). `TechTree::PrereqsMet` 0x0057d8e0 (whole function): a prerequisite set is an **AND over groups, each group an OR over techs**, held as a flat entry array at `TechDef+0x88` (stride 8, `TechDef*` at +0) plus a vector of `{int start; int count}` groups at `TechDef+0x98/+0x9c` (stride 8). A tech satisfies its group only when its node exists IN THIS TREE and is state 4. TWO EDGE CASES THE DATA FILES DO NOT SUGGEST: **zero groups -> TRUE** (a tech with no `requires` is gated only by its parent edge), and **a group with zero entries -> FALSE, failing the whole test** (the inner loop cannot break, so the outer one exits with that group uncounted) - a 'vacuously true empty OR' reading gets this exactly backwards. Also `TechDef+0xb0`: a byte that excludes a node from the availability sweep entirely; named `TechDef_off_NoAutoAvailable` for what it DOES - the tech-file keyword `unlock_explicitly` matches the behaviour but ParseTech 0x0058b050 shows no reference to 0xb0, so the link is a HYPOTHESIS. `findings/subsystems/unlock-cascade.md` |
| per-lane addresses.d fragment (second use) | meta | verified | high | 100% | 2026-09-08 | Lane U wrote `ghidra/addresses.d/lane-u.json` (12 entries; header 615 -> 627) rather than touching the shared `addresses.json`, and staged every sots-re change by explicit path. No sweep, no lost authorship. Duplicate-name detection did its job: `TechDef_off_Name` had to be checked against the shared file before being added |
| P2-U tech unlock cascade | phase2 | verified | high | 95% | 2026-09-08 | RESEARCH SLICE CLOSED. Prediction COMMITTED BEFORE THE BUILD (engine 405ba41, docs/U-unlock.md 4, incl. 4.6 listing five ways the model could be falsified and the symptom of each). Outcome: 35 compared calls across THREE workloads, 0 divergences, tracecmp exit 0 on all three (first End Turn 3/0, five-turn 15/0, Zuul turn 5->15 20/0). End-Turn oracle checked FIRST (bb4fd9ac.../978041ac..., lane R's and lane V's hashes) - a clean compare from a build that moved the game would be worthless. ANTI-VACUITY EVIDENCE, which is the point: call 9 was NOT lane V's call (from turn 5 the AI picked a different target) - it completed tech 9 and unlocked three nodes at costs 13000/35000/4000 that appear in NO earlier report and were predicted by nobody, reproduced with zero divergences, so the model cannot have memorised numbers that did not occur. The collector also ran on all 35 calls and had to come back EMPTY on the 31 quiet ones (over-collecting would have pushed next_id TOO HIGH). Guards 9 undeclared in 2 calls vs 10 predicted - the miss is WORKLOAD not code (player+0x3b4 gone because the pending-roll byte was already 0; +0x196 replaced by +0x130 because a different tech completed) |
| Zuul completion (double roll + cascade) | verify | verified | high | 100% | 2026-09-08 | Lane U took it: call 2, turn 7, species 5 - `left` 449->447 (DOUBLE ROLL) AND the cascade in the SAME call, next_id 10->12, observed_techs 396->440. Independent instance: the Zuul tree unlocks only 132/136 from tech 144 where the Human tree also unlocks 142 |
| RollResearchEvent draw - why it never fires | verify | mapped | high | 90% | 2026-09-08 | Lane U's honest limit: roll_draws was 0 on ALL 35 calls. The draw is modelled, its inputs are read pre-call and reported in every record, and region:rng compares clean - but THE BRANCH HAS NEVER BEEN SEEN TO FIRE. The reason is a real finding: ResearchRollPending is normally consumed by ServerPlayer::ProcessTurn BEFORE ProcessResearch, because that site fires when the progress ratio crosses its threshold - exactly the turns approaching completion. In the Zuul run it is true for turns 8-12 and flips FALSE on turn 13, the turn before completion on turn 14. So lane V's call-9 draw was the RARE case and lane R's "15/15" the common one. Three sessions: 0 / 1 / 0. Closing it needs a purpose-built save |
| VM: use QEMU screendump, not the click helper's shot | meta | verified | high | 100% | 2026-09-08 | Lane U: `qm monitor 140` screendump is more reliable than the click helper's `shot`. Also the >60 s startup got its FOURTH confirmation (menu at ~t+85 s) |
| wire sections closed (SvSctOb/DOpts/spies2) | engine | verified | high | 95% | 2026-09-08 | Lane W: named coverage 97.1 -> 98.0% (zuul 97.6 -> 98.4), conformance 56 shapes/657 items -> 74/769, STILL 0 MISMATCH, every new binding 0 wire-only 0 shape-only. Ratchet 95.0 -> 97.5. SvSctOb (147/156/156/126 -> 0): body is Game::SVSOSots writing two POLYMORPHIC lists keyed by the item before them (xscn->xsc, EncID->EncObj); NEITHER MAP IS ON THE WIRE - both came out of the binary: EncID from a 23-entry dword JUMP TABLE at 0x0052bf60 indexed by EncID-1 (factory 0x0052bf00, class names read off each ctor's vftable store), xscn from an exhaustive four-way _stricmp chain at 0x005a7050. The eight ids in our saves had been identified by tag-sequence match; the table AGREED on all eight and added four more. DOpts (94 -> 0) and spies2 (56 -> 0) are certain FROM THE HELPER'S OWN DECORATED TYPE - VectorHelper<Mars::String> and VectorHelper<int> - not inferred from bytes |
| spies2/SysMem/mts are HYPOTHESES not results | verify | backlog | — | 0% | 2026-09-08 | Lane W flagged as instructed: spies2's count is 0 in all 28 systems of all four saves, so the 56 items "closed" are 28 x (frame + count) and NO ELEMENT VALUE HAS EVER BEEN OBSERVED. Same for SysMem and mts. A layout you cannot exercise is a hypothesis - needs a workload |
| CD custom data: provable negative | objects | mapped | high | 80% | 2026-09-08 | Lane W's static read gave a NEGATIVE RESULT WORTH HAVING: the recovered 44-item Game::TurnCommands sequence CANNOT be aligned to the save's 35 items even as a SUBSEQUENCE. Item 4 is 8 bytes on disk, which for a 1-char tag can only be a BOOL where the recovery says i32; and the 27 trailing ints have only 22 i32 slots available after index 15. Confirms a save with issued orders is required (lane O). ALSO SPLIT INTO TWO PROBLEMS: TurnCommands_v5 (blocked on a workload) vs THREE AIAgent blocks (~236 items each = the BULK of the 744) which are Game::StrategyAIAgent::Streamable with NAMED tags, statically tractable, ~10 nested shapes - deliberately left for a following lane since it is AI cache state, not orders |
| SvSctOb side findings | objects | verified | high | 100% | 2026-09-08 | `indsys` = Game::SVSOIndependentSystems, whose Read AND Write are both the shared `ret 4` stub - its EMPTY FRAME IS CORRECT OUTPUT, not truncation. SVSOMonitor derives from SVSODerelict (its Write calls Derelict's first). SVSOSots::Read accepts NPCPlr/hastraps that Write NEVER EMITS - read-only back-compat, not a recovery hole. And THE RECOVERY IS WRONG about SVSOCrowDefenders::dsys - it is inside the ndsys loop, which no save can settle. EncIDs 7/8/14/21 and ScnObj have factory entries and recovered serializers but NO OCCURRENCE anywhere - deliberately carried as Nodes, not typed |
| four oracle fixes landed, byte-neutral | verify | verified | high | 100% | 2026-09-08 | Lane W fixed all four OPENLY with 12 new tests: 36 -> 48 tests passing, --strict exit 0 on all four saves, state_checksum coverage PROVED on all four with unchanged rebuilt byte counts. BYTE-NEUTRALITY PROVEN AT ITEM GRANULARITY: dumping every save before and after, the OFFSET SEQUENCES ARE IDENTICAL (38,933/39,843/40,300/35,771 offsets, no item boundary moved); the /CreateParams and /Sim/players digests DO change, correctly, because they hash typed values, and the value-byte deltas balance EXACTLY (odet x3 + p1 x4 = 292/295/295/238, to the byte). NUANCE: defects 3 and 4 were an ABSENCE in save_reader.py, not an error - ShipRecs and civr were both A(...,"any"), so the fix ADDED typed shapes rather than retyping a field, a bigger change than my brief implied. srbd turned out BEHAVIOURALLY CONFIRMED not inferred: it takes 0/1/3/4 across players and every non-zero count is followed by exactly srbd x 5 scalars (turn3 player 4: srbd==4, 20 items) - read as a field those trailing scalars have no explanation. `nv` is WEAKER: only ever 0 or 1 in these saves, so the multi-pair case is synthetic |
| save_reader silent-agreement failure mode | verify | verified | high | 100% | 2026-09-08 | Forced into the open by lane W's tests: save_reader.py's walker types "." items from its OWN kind catalog, not the schema passed in - so on the real saves the catalog and the old schema agreed with each other AND WERE BOTH WRONG. Its failure mode is SILENT AGREEMENT, not the framing desync the schema-driven engine would have suffered. Two independent-looking checks that share a hidden assumption are one check |
| AIAgent blocks typed | engine | verified | high | 95% | 2026-09-08 | Lane A: NAMED COVERAGE 98.0 -> 99.9% on all four saves; opaque items 748 -> 37 (turn1/2/3) and 521 -> 37 (zuul). Conformance 74 shapes/769 items -> 86/838, STILL 0 MISMATCH, all twelve new bindings 0 wire-only 0 shape-only. Round trip byte-identical throughout; state_checksum --strict still coverage PROVED, 0 error 0 warn, unchanged. Ratchet 97.5 -> 99.8. LOAD-BEARING FINDING: Streamable::Write (0x006c6f00) is UNCONDITIONAL - the decompiler shows a large `if` wrapping everything from `lnat` on, which reads exactly like the branch that defeated CD, but it is an INLINED std::vector DESTRUCTOR whose operator delete this database marks noreturn; the disassembly shows both paths converging at 0x006c72e8. So the recovery's all-branches view and a single record are THE SAME VIEW - the opposite of lane W's TurnCommands result, for a concrete reason |
| dsh is a map count, not a scalar | objects | verified | high | 100% | 2026-09-08 | Lane A: `dsh` is _Mysize at +0x240 of the std::map whose _Myhead is at +0x23c - the same head@0/size@4 layout the campaign pinned on ServerSystem::NVO. Node _Isnil at +0x21 makes the value pair<const int, vector<int>> i.e. pid/trns. THE RECOVERY LISTED IT AS A PLAIN FIELD; typing it that way DESYNCHRONISES THE READER. Also lnat/lat are scalars not elements (recovery marks both [element]); saves confirm - nalat is 0 everywhere and both are present anyway |
| CORRECTION to lane W: StreamableEnum<T> | objects | verified | high | 100% | 2026-09-08 | Lane A found StreamableEnum<T> writes a FRAME CONTAINING ONE INT, not a bare int, so VectorHelper<StreamableEnum<uint>> is a framed array of one-int frames. SysMem, mts and nalat had been typed as vector<int32_t>. BYTE-NEUTRAL - all three have count 0 in every save, which is EXACTLY WHY IT WAS INVISIBLE - but the previous typing was wrong. Lane W had flagged those two as hypotheses; this is what checking one looked like, and it is the argument for flagging them |
| Game::AISystem::Write never reads its object | objects | verified | high | 100% | 2026-09-08 | Lane A: it builds an EMPTY AISituation on the stack and writes that. Every AISys body on disk is two empty weight maps REGARDLESS OF GAME STATE. Worth knowing before anyone tries to read AI behaviour out of a save |
| ten AIAgent element layouts UNEXERCISED | verify | backlog | — | 0% | 2026-09-08 | Typed but hypotheses, flagged by lane A: AIAttr entries, all four weight maps, prs2, dsh, NBStab, NMBlst, nalat, CmbR, NumCL, apr - every one count 0 in all ELEVEN AIAgent blocks across our saves. They come from the WRITERS, not from bytes, and conformance agrees, but nothing exercises them. test_stream.cpp::test_aiagent_block populates every one and round-trips (and asserts the trap directly: a two-element nalat must produce two COMPLEX nodes) - that test is the only thing exercising them. Four containers ARE exercised: dnnc (0/1/2), NPrv (1/2), NTecS (2/3), AINumSys (28). Settling the rest needs a longer multi-AI save = turns played |
| Game::TacReport carried, not typed | objects | backlog | — | 0% | 2026-09-08 | Lane A left it deliberately: computed count plus three trailing scalar runs the linear pass cannot resolve, and it sits two levels under NCmbR which is 0 everywhere. Carried as Nodes rather than typed on a guess |
| turn driver map | control-flow | verified | high | 95% | 2026-09-08 | Lane T read BOTH drivers BYTE FOR BYTE from objdump over the raw image, not the decompiler: StrategyServer::ProcessTurn 0x007dc6c0-0x007dcd67 (32 phases) and ServerPlayer::ProcessTurn 0x00891340-0x00891783 (12 phases). CORRECTION TO MY BRIEF: ServerPlayer::ProcessTurn is NOT the phase driver - it is the per-player economy/research half; StrategyServer::ProcessTurn IS the spine. Verified call sites of our pieces: ProcessFleetMovement->MoveFleet phase 8; ServerSystem::ProcessTurn phase 11 (`mov ecx,[Systems+i*4]; call`, NO STACK ARGS, confirming B4); ServerPlayer::ProcessTurn(dt) phase 13 (dt forwarded by fstp [esp]); ComputeBudget player phase 1; ProcessResearch player phase 5. Also settled: the TWO StrategyServer bases 4 bytes apart (every StrategyServer_off_* in addresses.json is the S+4 frame EXCEPT off_RNG) and TWO per-turn counters - BeginProcessTurn bumps S+0xc (ModCount, what the research code stamps), ProcessTurn bumps S+0x8, which nobody has named |
| CORRECTION: turn-spine.md deferred tail was WRONG | control-flow | verified | high | 100% | 2026-09-08 | turn-spine.md 2.4 step 11 said the end-of-turn tail is deferred when encounters are pending. IT IS NOT. The `if` at 0x007dcb15 is an INLINED std::vector DESTRUCTOR - the `je` skips only the operator delete and both arms converge at 0x007dcb38. There is NO branch on the encounter snapshot anywhere in the function. And FUN_00794ad0 never built an encounter list: it builds the fifteen ship-action type ids. Encounter detection is a different function, dead last. THIS IS THE SECOND TIME TODAY the inlined-destructor-looks-like-a-branch trap produced a wrong published finding (lane A hit it on Streamable::Write and got it right). Treat any decompiler `if` wrapping a whole tail as suspect until the converge point is checked |
| ResearchRollPending SETTLED | verify | verified | high | 100% | 2026-09-08 | Lane T, by full displacement scan of +0x3b4 - only TWO consumers in the image. Site A ServerPlayer::ProcessTurn+0x2ac runs AFTER ProcessResearch (+0x165), NOT before as the board previously said: `if (ResT && ResErrRoll && 0.5f < progress/Cost) { roll; ResErrRoll = false; }` - threshold is a STRICT `<` against 0.5f at 0x00a2c788, and THE CLEAR IS INSIDE THE BRANCH, so a failed ratio test leaves the flag set. Site B OnTechResearched+0x51 clears unconditionally given ResT==def. So the flag survives into the next turn IFF progress/Cost <= 0.5 at the end of this one - which is exactly why three sessions gave 0/1/0. SAVE RECIPE: ResErrRoll is a serialized tag right after ResTNm so it can be EDITED INTO a save - set it true, point ResTNm at a high-cost tech with near-zero progress, fund research to >= 1.5x cost |
| REPO-WIDE CORRECTION: roll branch costs ONE OR TWO RNG words | verify | verified | high | 100% | 2026-09-08 | EVERY coverage note in the repo says the research-event roll draws "exactly one NextFloat". Lane T: RollResearchEvent itself draws one, but the branch it fires (ServerPlayer_OnResearchRollSucceeded 0x00889d60) draws a SECOND NextInt on the plague path and posts EVENT_PLAGUE_OUTBREAK; the rebellion path allocates an AIRebellion and CANCELS the research. Any RNG accounting that assumes one word is wrong the first time that branch fires - and it has never fired in three sessions, so nothing has caught it |
| ResErrRoll arming mechanism UNKNOWN | objects | backlog | — | 0% | 2026-09-08 | Lane T flagged rather than papered over: NOTHING was found that ARMS the flag during play. The only writes are the two clears, ServerPlayer::Read, the constructor (`mov WORD [esi+0x3b4],1` - born TRUE), and a virtual setter pair with no locatable caller. "Born true, consumed once" fits every observation but IS A HYPOTHESIS. The save recipe does not depend on it |
| unread driver: OnAllCombatDone_Tail | control-flow | verified | — | 0% | 2026-09-08 | SUPERSEDED (lane K read all 36 phases). THE BIGGEST UNREAD BLOCK IS NOT IN ProcessTurn AT ALL. No bankruptcy, no turn results, no turn events, no autosave run in the turn driver - they are all in StrategyServer::OnAllCombatDone_Tail 0x007d92a0, a SECOND driver no lane has read. Highest-value next control-flow target |
| player_turn hook prepared (boundary narrowed) | phase2 | mapped | high | 70% | 2026-09-08 | Lane T built the descriptor with compile-time coverage() (9 notes), Result regions for the three phases needing nothing but the player, roll_flags + rng as observations ours never writes, guards over the whole ServerPlayer and the TechTree header. DELIBERATELY NARROWED: phases 2/3/6 (savings, aid records, refund) depend on ComputeBudget's slots and ProcessResearch's overBudget, both STACK LOCALS of the original. The three ways to reach them are calling ComputeBudget ourselves (repairs ships - audit #6), reading the nested hooks (audit #5, SELF-FULFILLING), or inferring from the Sav delta. They are GUARDED, NOT CHECKED; formulas written and unit-tested but not in the verdict. docs/T-turn-driver.md carries the prediction incl. which checks are WEAK BY CONSTRUCTION on the reference save |
| INTEGRATOR NOTE: two gate failures caught on lane T merge | meta | verified | high | 100% | 2026-09-08 | Both gates failed on merge and both were real, which is the argument for running them SEPARATELY rather than &&-chained: (1) clean-room exit 1 - a raw FUN_00889d60 identifier had reached docs/ in the public-capable engine repo; fixed by using the name the lane itself had already put in addresses.json (ServerPlayer_OnResearchRollSucceeded). (2) The shim CROSS-BUILD failed -Werror=unused-function on player_turn.cpp - the file is WIN32-only and the lane could not build it on the notes host, exactly as it flagged. Host ctest passed both times and would have hidden both. A lane that cannot cross-build its own shim TU must say so, and the integrator must run the CT111 shim build before pushing |
| guides/method-rules.md | meta | verified | high | 100% | 2026-09-08 | The 15 rules this campaign PAID FOR, each traced to the finding that produced it, ranked by damage caused. Written because the integrator was pasting the same hard-won constraints into every lane brief, which invites drift and omission. Lane briefs now REFERENCE it instead of restating it. Covers: green verdict != evidence; predict before the run; static vs behavioural find different bugs; inlined dtor looks like a branch; size by enumeration; unexercised path = hypothesis; round-trip != coverage; two checks sharing an assumption are one check; rankers never filter; derive experiment params from the ISA; correct the record; fix the oracle openly; separate gates; never hand-resolve a generated header; report thin coverage as loudly as divergences |
| CORRECTION: click helper `type` DOES work | meta | verified | high | 100% | 2026-09-08 | Lane V's gotcha was WRONG and I propagated it in every VM brief since. The click helper's `type` DOES reach the game's text fields - YOU MUST CLICK THE FIELD FIRST. `click 480 234` then `type <name>` worked every time. Saves no longer need renaming on disk. Also: chained clicks on one button work but DROP ~HALF at sleep 250, so count the result; and a mid-turn AI diplomacy popup can swallow a scripted click batch. Full coordinate set for save/quit/build/research/move is in the VM140 row |
| workload saves built (lane O) | verify | verified | high | 100% | 2026-09-08 | SEVEN saves, all --strict clean and all coverage PROVED under state_checksum --tree. Repo now holds 11 .sav. Saves live in the repo only; scp one back to the VM when a compare needs it |
| ISSUED-ORDERS saves + TurnCommands layout | objects | verified | high | 90% | 2026-09-08 | THE BLOCKER LANE W PROVED IS NOW LIFTED. Three saves taken AFTER issuing orders, BEFORE End Turn. CD[0] measured by state_checksum --tree --depth 1: every prior save (turn1/2/3, zuul-turn5, human-turn3) is BIT-IDENTICAL at 3df7d93164fb1d7d, 35 leaves/122 B - which is why nothing could be learned from them. human-turn2-orders 41/143 B (1 fleet move); zuul-turn15-orders 61/226 B (research target + boost + 5 build + 1 move); zuul-turn17-orders2 129/498 B (20 build + system-rates + colonize x3). Layout in SAVE_FORMAT.md 11: playerId, researchRate float, per-command-type present-flags, then counted lists - build {ordinal, designId, systemId, 0}, system-rates {systemId, {SRs SRt SRsc SRtf SRi SRoh SRnr}} (the ONLY named sub-frame), colonize {shipId, 1}, fleet move {fleetId, 1, destSystemId, 0}. Ids cross-check WITHIN the same file: 384=Gallandro, 432=Octans, 688=the fleet the UI showed as Dest: Octans, 216383 = the exact Imperial-Savings drop the Boost panel caused. KEY INSIGHT: the block is the PENDING CLIENT->SERVER QUEUE - zuul-turn15-orders still shows ResRate 0.25 / ResTNm '' / Sav 2,902,722 on the ServerPlayer while the block already carries 0.97 and the boost |
| research_roll_pending save + the gate constant | verify | verified | high | 100% | 2026-09-08 | zuul-turn17-rollpending.sav: ResErrRoll=True, ResTNm='WEP_GrnLas', node at TResDone 1083 / TResCost 5000 = ratio 0.217. zuul-turn16-noderoute.sav same at ratio 0. AND THE GATE CONSTANT IS PINNED AS A TYPE: 0x00a2c788 is a FLOAT 0.5, not a double - read out of dumps/sots.exe; an 8-byte read gives 5.28e13 garbage and 0x00a2c78c is float 100.0. Confirmed live: flag true entering turns 16 and 17, tech completed turn 21 with the flag already cleared. FIRING the OnTechResearched draw needs ONE TURN TO SUPPLY >HALF the tech's cost = ~2.3x this empire's RP. The in-game Boost Research panel is recorded in the command block but its pool is only ~7.5% of savings and DECAYS GEOMETRICALLY INSIDE THE TURN (197,271 -> 17,551 -> 1,561 of 2.9M), so it cannot bridge it |
| budget tail: partial, three slots shown UNREACHABLE | verify | mapped | high | 60% | 2026-09-08 | DELIVERED: [11] construction and a large [8] maintenance (Maint 14,500 in zuul-turn23-fleet23.sav, 27 DEs, vs 1,000-3,000 in every earlier save); ResRate off default. NOT DELIVERED, WITH REASONS - this is the useful half: [12] expenses - `Nexp` is [] for every player in ALL 11 saves, turns 1-23, human and AI, and NO 1.8 screen exposes an expense slider (map panel, Build, Research, Ranking, Intel Analysis, Empire Population Manager, in-game menu all checked); LIKELY DEAD IN 1.8. [10] debtInterest / [7] systemIncomeNeg - savings cost is deducted at QUEUE time so the queue cannot be over-committed, and Maint counts only in-service ships arriving at ~4.5 DE/turn; income FLOORS at 78,570 (measured 124,055->78,570), so a debtor needs ~157 destroyers = ~35 more End Turns. Reachable, not cheap. aid [13]/[14]/[19] - the AI DID offer a non-aggression pact (accepted, in human-turn3-noderoute.sav) but it leaves aid count 0, and 1.8 has no player-initiated treaty UI |
| waypoint type 2: NEGATIVE RESULT | verify | verified | high | 100% | 2026-09-08 | Lane O: type 3 is now covered and TYPE 2 APPEARS NOT TO EXIST FOR MOVE ORDERS. Census: turn3-state 1x type 1; zuul-turn5-species5 ALREADY HAD 2x type 3 on disk (nobody had looked); lane O's saves have 6/8/11/11/16 type 3 plus 3 in the Human game. The PLAYER'S OWN fleet is a mover: Flt 688, Wpt 432, Tp 3, nrt{nrp -1, nrf 384, nrt 432} (Gallandro->Octans, in nodespace). A fresh HUMAN game was built specifically to chase type 2 - Human node drive, single directly-connected node line (Downbelow->Delphi) - and it came back Tp 3, nrt{nrp 37, nrf 224, nrt 128}. Across BOTH node-drive races, single-hop and multi-hop, natural and rip-bored lines, planned and in-transit: 20+ instances, ZERO type 2. WaypointKind::NodeLine is NOT what a Move order produces. Types 4/5 still need a Hiver game |
| OnAllCombatDone_Tail mapped | control-flow | verified | high | 90% | 2026-09-08 | Lane K: 36 phases, WHOLE 1587-byte body read from the instruction stream. Base is S (checked against FOUR independent reads). Signature `void __thiscall (S, vector<EncounterResults>*)`, ret 4, called from EXACTLY ONE site - StrategyHost::OnMessage 0x00784d07 on SNMAllCombatDone with msg+4. Strides enumerated from division magics: EncounterResults 0x178, Encounter 0x74, member record 0x44; the arity check between them LOGS AND CONTINUES, no early return. TRAP 1 AVOIDED AND ITS CONVERGE POINT FOUND: phase 7 is encounters.clear(), not a filter - the `if` is the empty-vector guard erase always carries, both arms meet 3 instructions later at 0x007d96bf, and the IDENTICAL four-argument shape appears inside vector<Encounter>::operator= on its empty-source path. Past that point the function is STRAIGHT-LINE: every jcc in phases 8-36 is a loop bound or one of three null tests. Three functions run TWICE per turn across the two drivers. UNREAD (the lane boundary, stated): FUN_007d5af0, 7499 B, the combat resolver under phase 6 |
| CRITICAL for the standalone milestone: the tail advances the RNG | verify | verified | — | 0% | 2026-09-08 | SUPERSEDED (lane Z: ledger closed, zero residual). LANE K'S MOST IMPORTANT FINDING. The tail draws from the strategic generator and NOTHING MODELS IT: phase 11 draws one NextFloat PER EXPIRED NODE LINE (instruction-verified at 0x007ae095) and phase 6's subtree draws NextInt/Twist. BOTH RUN BEFORE THE AUTOSAVE. So any reimplementation that reproduces ProcessTurn exactly STILL DIVERGES the first turn a node line expires. This directly threatens the "standalone runs one turn and byte-matches the autosave" milestone and must be modelled before that is attempted |
| autosave mechanism read | subsystems | verified | high | 100% | 2026-09-08 | The mechanism our determinism ORACLE rests on, finally read from bytes. StrategyHost::Autosave 0x00895210, called with endTurn=1 from SendEndTurn (pre-turn file) and endTurn=0 from the message handler (post-turn file). The (Autosave Backup) rotation fires ONLY on the post-turn call - the flag byte is (endTurn==0) because the je at 0x00895266 SKIPS the store of 0. FOUR PROPERTIES MAKE IT REPRODUCIBLE: a fixed point in the message sequence; NOTHING IDENTIFYING REACHES THE WRITER (the filename is built here and never passed on); per-player connection state is DETACHED via conn->vft[0x14] before the write and reattached after; and a deterministic gzip container. SaveGame_WriteFile 0x00877070 writes Summary/CreateParams/Sim/CDT + per-agent CD - exactly the root save_reader.py models |
| bankruptcy: 3 formula corrections | subsystems | verified | high | 100% | 2026-09-08 | ProcessBankruptcy 0x007c0a50 at phase 15, per-player UpdateBankruptcyLimits at phase 31 - which INSTRUCTION-VERIFIES formula-gaps.md Q1's ordering claim. Three corrections banked: the divisor is the double -0.15000000596046448 (= (double)(float)-0.15f), NOT -0.15; the per-system income term is max(rate,0) BEFORE summing; and 3.3 IS NOT A BINARY CONSTANT - it lives in .bss and is DB-loaded |
| turn results: half of lane T's note was wrong | control-flow | verified | high | 100% | 2026-09-08 | The records ARE filled here (phases 6, 11, 18 write S+0x2f4[PlyrIdx], found by a whole-image scan for the 0x11c stride) - but ApplyEncounterResults' tail then SWAPS S+0x2f4 into the outbox S+0x304 and refills the accumulator; SynchronizePlayer dispatches S+0x304[i] as strategy event 0x25; GenerateTurnEvents clears it. sizeof(SETurnResults)=0x11c, enumerated five ways. ALSO: DispatchTurnResults and SendTurnResultsToPlayers ARE MISNOMERS - they are vector::resize and _Ufill; NOTHING in that group sends anything |
| turn events are NOT per-turn | control-flow | verified | high | 100% | 2026-09-08 | GenerateTurnEvents is 122 bytes and GENERATES NOTHING (its copy loop at 0x007dc680 is provably dead). BuildTurnEvents is MISNAMED: its whole body is gated on a setup descriptor set in one place on the LOAD/REJOIN path, and it references NO EVENT_* string whatsoever. Also found: TurnEvents_Write/TurnEvents_Read are SWAPPED IN GHIDRA (layouts.json is right); sizeof(TurnEvents)=0x18 enumerated four ways |
| CORRECTION: S+0x8 advances TWICE per turn | control-flow | verified | high | 100% | 2026-09-08 | Lane K corrects lane T: OnAllCombatDone_Tail's FIRST INSTRUCTION increments the same word ProcessTurn does, so lane T's "lockstep" conclusion about the two per-turn counters does not hold |
| A CORRECTION WITHDRAWN BEFORE PUBLISHING | meta | verified | high | 100% | 2026-09-08 | Worth recording as method, not trivia. Lane K DRAFTED an entry claiming off_RNG was wrongly flagged as a frame exception, then checked and WITHDREW it: addresses.json already carries BOTH frames (off_RNG=0x16c for S, off_RNGPtr=0x168 for S+4), which is exactly lane T's rule, and a second call site confirmed them. The duplicate entry is gone and the near-miss is recorded in the finding. Also: lane K fixed the "exactly one NextFloat" claim across 4 notes + 4 engine files but DELIBERATELY LEFT the captured verify/results/compare/* artefacts alone - they are RUN RECORDS of what the hook said at the time, and editing them would falsify the record |
| SNMAllCombatDone on no-combat turns is an INFERENCE | verify | verified | — | 0% | 2026-09-08 | SUPERSEDED (lane Z: tail ran 8/8 turns incl. no-combat). Lane K's stated caveat: that the message is delivered on turns with NO combat is inferred from the determinism note (the post-turn autosave appears on every End Turn and this handler is its only reachable caller), NOT from the instruction stream. One VM run settles it |
| trade manager: nine unidentified virtual calls | subsystems | verified | — | 0% | 2026-09-08 | SUPERSEDED (lane V2 named all eleven). Lane K's stated blind spot (its tier 4): phase 23 is NINE CONSECUTIVE VIRTUAL CALLS on the trade manager and NONE is identified |
| TurnCommands_v5 typed - 100% NAMED COVERAGE | engine | verified | high | 95% | 2026-09-08 | Lane Q. All ELEVEN saves 99.7-99.9% -> 100.0%; opaque 37/40/43/63/132 -> 2 EVERYWHERE (only the MT19937 blob, correctly opaque). Ratchet 99.8 -> 99.99. Verified by the integrator with SOTS_SAVES_DIR set: 11 saves, `round trip: tree identical, typed identical`, `resyncs 0, hint-failures 0`. THE RECONCILIATION - lane W's arithmetic AND conclusion were right but the disagreement had THREE causes, only one a branch: (1) the sequence lane W aligned against was OFFSET-SORTED, not write-ordered - the writer is flag-then-payload with each gate bool AFTER the payload it gates, so TurnCommands is one of the 89 classes where offset order != write order, and "item 4 is 8 bytes, which can only be a bool where the recovery says i32" is an ARTIFACT OF THE SORTED VIEW. ALIGN SAVES AGAINST streams.json, NEVER layouts.md. (2) The tail is 27 container CALL SITES, not 27 scalars: twenty-seven std::list<T> members at 0x70..0x1a8 stride 0xc allocator-last, each writing WriteInt(size) then records; the linear recovery keeps one item per call site and DROPS THE COUNT WORD, so 44 = 17 members + 27 call sites and lane W's "27 trailing ints, 22 i32 slots" is the same 27 seen from both sides. (3) All 27 lists are ALWAYS written, so an empty one still costs a zero int: 8 prologue + 27 counts = 35, the bit-identical block every earlier save carried. Item arithmetic closes to the unit on all five workloads (35/38/41/61/123) |
| CORRECTION to lane O: fleet-move element | objects | verified | high | 100% | 2026-09-08 | Lane Q: the element is {fleetId, nHops, nHops x systemId} - a COUNTED ROUTE VECTOR - not {fleetId, 1, destSystemId, 0}. Only the three-item reading balances: a four-item element needs 26 lists on human-turn2 and 28 on zuul-turn15, and the writer has 27. ALSO: NEITHER noderoute save carries a fleet move, so the node-route UI does NOT queue through that list. SAVE_FORMAT 11 corrected in place |
| SECOND defect found at main: NVs PID tag | verify | verified | high | 100% | 2026-09-08 | Rule 6 exactly, and it was live at main. zuul-turn23-fleet23.sav is the FIRST save with a non-empty NVs list; its element's leading id had been typed positionally as "." when the real tag is PID. The typed round trip on that save DIFFERED AT 0x89c14 at engine c883a32, BEFORE any of lane Q's changes - i.e. a real round-trip defect that eleven saves' worth of green tests had never exposed because no earlier save populated the list. Independently corroborated: the recovered table for Game::ServerSystem names that item PID, and the Sys row moves 102 matched/1 wire-only/3 shape-only -> 103/0/2. ALSO: zuul-turn17-orders2 was BELOW the old coverage ratchet, so that gate was already failing on lane O's largest save |
| TurnCommands: what stays a hypothesis | verify | backlog | — | 0% | 2026-09-08 | Lane Q labelled honestly: 22 of the 27 lists are scalar sequences read off the helpers with NO SAVE EXERCISING THEM. Nested bodies not otherwise modelled (ShipDesignDef, FleetLayout, WeaponGroups, DefenceLayout, RaidTargets) are carried as opaque Nodes rather than guessed. Three prologue gates never seen set, one a whole CivilianRatios frame. List 14 is OBSERVED BUT NOT UNDERSTOOD: issuing one fleet move also queues {sameFleetId, 0, true} there. No version word exists - the version is in the CDT id and select() matches the exact `.TurnCommands_v5` suffix, so a _v6 falls back to the carried Node (unit-tested) |
| save_reader deliberately NOT mirrored (rule 8) | verify | verified | high | 100% | 2026-09-08 | Lane Q declined to mirror the CD shape into the Python oracle and wrote the reasoning down: it reads CD generically for the AIAgent bodies too; state_checksum's digest tree is built from the GENERIC tree so typing CD adds no named leaf to any diff; nothing consumes the typed dict; and RULE 8 POINTS STRAIGHT AT IT - a layout mirrored into both readers from ONE reading is not two checks. Instead test_save.cpp now asserts on every real save that each block is consumed with NOTHING LEFT OVER (extra.empty()) - the item-granular statement a wrong list count or element width breaks first |
| conformance check honest about what it cannot check | engine | verified | high | 100% | 2026-09-08 | Lane Q: the generic check<T>() could NOT be used, and that is the finding not an obstacle - every tag in this block is ".", so the LCS degenerates to a strict positional compare where any primitive disagreement is fatal, AND the table's tail describes ELEMENTS where the wire has COUNTS. The dedicated check states only what is checkable: prologue 17/17 ITEM FOR ITEM (SchemaProbe takes every branch, so this is real evidence that the conditional structure read from the instruction stream is the one the recovery flattened) and tail 27 lists vs 27 table items. Tail reported as WIRE-ONLY, never claimed as matched. 86 shapes/838 items -> 87/856, still 0 MISMATCH |
| INLINED RNG DRAWS ARE INVISIBLE TO CALL-GRAPH SWEEPS | verify | verified | high | 100% | 2026-09-08 | LANE J'S HEADLINE, and it invalidates a class of prior analysis. The combat resolver's NextFloat is INLINED, so its only call-graph edge is `caller -> RNG_Twist`, which reads as a bare Twist and is NOT one - it is the lazy twist INSIDE NextFloat. That is why lane K reported "no NextFloat in that subtree". An image-wide scan for the MT TEMPERING IMMEDIATES AT INSTRUCTION BOUNDARIES finds **14 game functions with inlined draws no RNG sweep can see**, and TWO OF THEM - FUN_004f7670 (84 bytes) and FUN_007aa240 - ARE IN ProcessTurn'S CLOSURE AT DEPTH 4. Any RNG accounting built from the call graph alone is a LOWER BOUND. Now rule 16 |
| CORRECTION: Ghidra's function size is not the function | meta | verified | high | 100% | 2026-09-08 | Ghidra reports 7,499 bytes for the combat resolver; the real body is 0x007d5af0..0x007d78c8 = 7,641 and Ghidra's end lands MID-INSTRUCTION. Lane J first read a callee as straight-line because it dumped at Ghidra's size and the outer BACK-EDGE FELL OUTSIDE - it is a loop over combatants, which changes the draw count of the very site it was measuring. A delegated sweep made the same mistake independently. Same defect lane X hit from the other side (clipping at fva+sizeInBytes lost 11% of functions; sweeping to the next function start took coverage 89% -> 100%). Now rule 17: never trust a function's end, disassemble to the next function start |
| combat resolver mapped | control-flow | verified | high | 85% | 2026-09-08 | Lane J read all 7,641 bytes. THE RESOLVER DRAWS NOTHING ITSELF - exactly three sites in its subtree, each behind a function whose callerCount is 1 (the resolver), so nothing else in the image can trigger them: R1 NextInt @0x007bb69b (node cannon, once per battle, gated on something having been flung AND a non-empty candidate list) mean 4/3 words; R2 an INLINED NextFloat @0x007a84bd, one per back-engineering candidate per combatant with a non-zero salvage slot, exactly 1 each; R3 NextInt @0x00852ec7, one per successful R2 roll if the project list is non-empty, mean 2^ceil(log2 n)/n. Structure: only EIGHT non-stack stores in the whole function and EXACTLY ONE indirect call (in a _CxxThrowException path) - it composes and posts events and delegates every mutation to its 70 callees. Also: the subtree DOES write the turn-results accumulator at S->+0x2f4[PlyrIdx*0x11c]+0x90 on the surrender arm (a DIFFERENT member from the +0x24 lane K found, and that writer has SEVEN callers); 23 EVENT_* keys in the closure vs lane K's 7, five new, INCLUDING PLAGUE BEING DECIDED INSIDE COMBAT; sizeof(Game::TacReport)=0x94 enumerated twice |
| resolver formula is UNTESTED (stated) | verify | backlog | — | 0% | 2026-09-08 | Lane J's honest limit: every encounter in lane Z's workload had res->+0x4 set, so ApplyEncounterResult was a no-op and THE RESOLVER HAS NEVER EXECUTED UNDER AN INSTRUMENT. The formula is untested. combat-resolver.md 10.3 specifies the workload; the first cheap PREDICTION is that a plain fleet battle with no node cannon and no salvage should cost the SAME 18-20 words as a peaceful turn, because the resolver has no unconditional draw |
| lane J errors caught before publishing | meta | verified | high | 100% | 2026-09-08 | Two, both corrected pre-publication and both worth keeping: (1) read a callee as straight-line from a truncated Ghidra range, hiding the outer loop that sets R2's count - now rule 17; (2) drafted a 0x11c site as a false positive after converting its address BY HAND, wrongly - re-disassembling from a known boundary showed a real SETurnResults write. THE SCANNER WAS RIGHT AND THE READER WAS NOT |
| inlined-draw inventory COMPLETE | verify | verified | high | 100% | 2026-09-08 | Lane I re-ran the tempering-immediate scan ITSELF at real instruction boundaries (all 41,089 functions decoded TO THE NEXT FUNCTION START, never to Ghidra's size). SIXTEEN functions carry the masks inside a decoded instruction, 67 occurrences - and RECALL IS PROVED COMPLETE: a brute byte scan over the executable sections finds the SAME 67 and ZERO ORPHANS. 70 functions desync mid-decode, every one inside int3 padding, none hiding a site. **LANE J'S 14 WAS WRONG IN TWO WAYS**: one is a FALSE POSITIVE (FUN_008cca30's four bytes are the rel32 displacement of a call, not a temper chain) and FOUR are RNG ENTRY POINTS, not game code - including 0x004f7670 which lane J counted as a game function with an inlined draw and which is actually Mars::RNG::NextUInt, a primitive. CORRECTED FIGURE: **eleven game functions, 28 sites**. Only TWO are reachable from the turn drivers by direct edges: FUN_007aa240 (StrategyServer::ProcessTurn, depth 4) and FUN_007a7f30 (OnAllCombatDone_Tail depth 3 / CombatResolver_Run depth 1). The nine others top out at vtable slots with no direct caller - and lane I states AS LOUDLY AS THE RESULT that "not in the closure" is proved FOR DIRECT EDGES ONLY |
| Mars::RNG::NextUInt read completely | objects | verified | high | 100% | 2026-09-08 | 0x004f7670, 84 bytes, EVERY INSTRUCTION READ: `if (left==0) Twist(&mt); y=*next++; --left; temper(y); return y`. EXACTLY ONE WORD, unconditional, no loop, no branch but the lazy twist, no scaling with game state. Ghidra's 84 is CORRECT here (body ends 0x004f76c3, 12 int3 to 0x004f76d0) - worth stating, since rule 17 is about not TRUSTING the size, not about it always being wrong. CONVENTION TRAP: ECX is the RNG OBJECT, not &mt - it does `lea esi,[ecx+4]` itself, where NextFloat/NextInt are entered at object+4, and ProbabilisticJump uses BOTH conventions 0x6b bytes apart |
| EncounterDetect_AssignContacts + "a doomed roll still moves the generator" | verify | verified | high | 95% | 2026-09-08 | FUN_007aa240 (Ghidra says 944; REAL BODY IS 953 - Ghidra's end lands inside a `push` at 0x007aa5ee, real end 0x007aa5f9; nine hidden bytes, only a throw stub, no draw-count change, but the range is wrong). Draws ONE INLINED NextFloat PER (contact, detector) TRIAL, BEFORE THE ACCEPT TEST. Threshold 0.25f if the detector's TechTree has tech 0x2728/0x2729 else 0.0f; accept iff thresh >= r, derived from fcompp/test ah,5/jp - EQUALITY ACCEPTS. words = sum over contacts of min(trials-to-first-accept, |detectors|); with no detector teched that is |contacts| x |detectors| EXACTLY and NOTHING IS EVER ASSIGNED - **a roll that can never succeed still advances the generator**. The outer repeat-until-no-progress back-edge (which a truncated dump HIDES) cannot redraw a pair: the tried-bitset is filled ABOVE the back-edge target and never cleared, so at most two passes |
| RNG entry points: SEVEN, not three | objects | verified | high | 100% | 2026-09-08 | Lane I: NextFloat, NextInt, Chance, plus NextUInt, FloatRange 0x0047d8a0 (1 word, NARROWS TWICE, in ProcessTurn's closure at depth 3 with two call sites), IntRangeBell 0x008e6d80 (triangular, >=2 words) and GaussianRange 0x008e6e30 (2 words PER ATTEMPT, UNBOUNDED, both draws inlined). AND IT SCALES BY 2^-32 WHERE NextFloat SCALES BY 1/(2^32-1) - TWO DIVISORS IN ONE IMAGE. GaussianRange documented but deliberately NOT modelled |
| RNG residual: 18-20 words STILL UNEXPLAINED | verify | verified | — | 0% | 2026-09-08 | SUPERSEDED (lane Z: every word attributed; 16 = trade-raid generation behind a virtual edge). THE HONEST HEADLINE. Lane J predicted the two inlined functions would explain lane Z's per-turn gap. **NOT CONFIRMED.** Both new sources are gated and neither has been measured: 0x007aa240 contributes 0..(|contacts|x|detectors|) and whether its +0xfc gate passed is not knowable statically; ProbabilisticJump's second word is 0 unless a type-5 waypoint fails its arrival test. RESIDUAL: 18-20 words, essentially ALL of it. WHAT IS NOW PROVABLE IS THE NEGATIVE: there is NO TWENTY-THIRD MECHANISM - the complete draw-site inventory of the ProcessTurn closure (1,426 functions) is 22 sites (21 entry-point calls + 1 inlined), so the 18-20 words are distributed among exactly those. THE SEARCH SPACE CLOSES; THE COUNT DOES NOT. One extra bracket on EncounterDetect_ProcessTeamRecord with |contacts|/|detectors| in the argument record turns the formula into a one-turn test |
| CORRECTION: no Mars MT19937 variant | objects | verified | high | 100% | 2026-09-08 | Lane I correcting ITSELF (rule 11). It had written into Ghidra that 0xff3a58ad/0xffffdf8c are a Mars variant of MT19937. THEY ARE NOT - they are the textbook masks applied BEFORE the shift: (y & 0xff3a58ad) << 7 == (y << 7) & 0x9d2c5680, verified over 200k words. `mars::rng` in sots-engine was NEVER WRONG. Corrected in place in Ghidra and in the fragment. NOTE FOR FUTURE SCANS: a scan for the TEXTBOOK constants finds NOTHING in this image, which is exactly why rule 16's scan must use these pre-shift values |
| STANDALONE SCAFFOLD RUNS | engine | verified | high | 100% | 2026-09-08 | THE NORTH STAR MADE CONCRETE. `sots_turn SAVE --roundtrip --phases --out POST.sav --metric m.json`: loads a real .sav through mars::stream, PROVES THE FOUNDATION FIRST (re-serialises the untouched parse and byte-compares against the inflated stream; if that fails the run STOPS), walks the published phase order of ALL THREE drivers (host steps, StrategyServer::ProcessTurn's 32 with ServerPlayer::ProcessTurn's 12 nested at phase 13, then OnAllCombatDone_Tail's 37), PRINTS EVERY PHASE IT DOES NOT RUN, writes post-turn state back through write_save + gzip, emits a completion metric. Driven over all 11 saves: 11/11 load, run, re-serialise and re-read cleanly. Integrator-verified on CT111 with real saves: ctest 38/38, and the trace output labels hypotheses INLINE (e.g. "3 judged stable by the owned/not-abandoned stand-in (HYPOTHESIS -- the original asks a callee)") |
| standalone divergence vs the oracle | verify | mapped | high | 100% | 2026-09-08 | Leaves localised by state_checksum.py, coverage PROVED on every save in every comparison. turn1->turn2: baseline 209 diverge -> 204, CLOSED 5, REGRESSED 0. turn2->turn3: 108 -> 103, closed 5, regressed 0. Closed the same five on both pairs: /Summary/Turn, /Sim/Frame, and ntdev on Gamma Cephei / Ke'Dolarra / Koa'Vo. Remaining 204: /Sim/players 82, /Sim/systems 80, /Sim/turnstats 24, /Sim/SvSctOb 8, plus 10 singletons (ModCount, RNG, Checksum, NMnx, cmbtid, four id lists, NumFlts). BYTE MATCH: NO, AND IT CANNOT BE YET - 0 of the 18-20 per-turn generator words are modelled and the generator is saved state |
| standalone completion metric | meta | verified | high | 100% | 2026-09-08 | verify/results/standalone/status.json, read by tools/dashboard.py's new section 6 (regenerate with tools/standalone_report.py). **14 of 44 turn-driver phases modelled, 7 committed; 2 of 37 tail phases. VERIFIED 0 - DELIBERATELY**: in that table `verified` means "compared against the live game", lane S2 held no VM, and `app_catalog` FAILS THE BUILD if that ever drifts upward silently. Implemented+committed (9): H00 BeginProcessTurn, H01 SaveWriterInvariants, S00 ModCount bump, S11 SystemTurn (tuning-free subset), P07 clear timed-research accumulators, P08 rebellion output decay, P09 timed research bonuses (last->first, ORDER LOAD-BEARING), P10 ResearchRollPending consume, T00 tail ModCount bump. Blocked - evaluated, reported, NOT written (8): P01 P02 P03 P05 P06 all behind the unresolved population->base-output term, plus P11, S31, T31. Stub - named no-ops visible in output: 65 |
| THE FINDING THAT SHAPED THE DESIGN: report regressed, never net it off | meta | verified | high | 100% | 2026-09-08 | Lane S2 first implemented AND COMMITTED S31's player-status restore. The comparison tool immediately reported TWO REGRESSED LEAVES on turn2->turn3: two Player.Status words that AGREED with the oracle before the turn and DISAGREED after. The phase writes 1, the file carries 4, a load resets to 0 - so a writer between phase 31 and the autosave is unaccounted. S31 is now blocked, and `regressed` is reported NEXT TO `closed` in every run, NEVER NETTED OFF. Consequent design rule: a phase whose FORMULA we hold but whose INPUTS we do not is EVALUATED AND REPORTED, NOT WRITTEN, unless --commit-blocked. Same for the generator (--commit-rng) |
| standalone: two things deliberately NOT implemented | verify | backlog | — | 0% | 2026-09-08 | (1) TShn/ltis - 18 of the remaining 204 leaves, moving 1->2 on 8-10 systems on BOTH pairs. A `+1` closes them in ten lines. NOTHING NAMES THEIR WRITER, so that is a hypothesis not a reading; named in the docs as the cheapest measured target. (2) RNG write-back - an advanced-but-incomplete state is WRONG DIFFERENTLY from an untouched one. One hypothesis IS under test and survived a changed workload: `stable = owned && !abandoned && !destroyed` (the original asks a callee) closed the same 3 ntdev leaves on both pairs - six agreements, zero disagreements - and is labelled a hypothesis in code, log AND docs |
| RANKED blockers to the byte-match | meta | mapped | high | 100% | 2026-09-08 | Lane S2's ranking, which is now the project's critical path: (1) THE RNG LEDGER - lane Z; nothing in src/app can close it. (2) The population->base-output term - ONE FORMULA GATING 5 OF THE 44 PHASES. (3) The 37-phase post-combat tail, which is the driver THE AUTOSAVE IS WRITTEN FROM. (4) The `nve` visibility record (32 leaves, one mechanism x 8). (5) The event pipeline. (6) Summary.Checksum. (7) The Player.Status writer. (8) ModCount - the real turn advances it 12-44 times from writers spread across BOTH drivers |
| RNG LEDGER CLOSED - zero residual | verify | verified | high | 100% | 2026-09-08 | THE BLOCKER IS GONE. 18-22 words per turn, EVERY ONE ATTRIBUTED TO A CALL SITE, summing to the independently-measured total with ZERO RESIDUAL on three turns. THREE INSTRUMENTS AGREE on turn 5's 20 words: boundary hook, return-address detours, and the two save files - and the file check has `twists = 0`, so it does NOT route through anyone's twist implementation. Two calibrated oracle pairs are in the repo for the standalone to test against. Also settled live: the tail RUNS ON EVERY End Turn (8/8, two saves), confirming lane K's inference (its stronger no-encounter form is narrowed, not closed); node-line decay fired on turn 64 for EXACTLY 1 WORD, matching a prediction committed THIRTY TURNS EARLIER, and exercised the twist path live on the same turn; and COMBAT IS FREE - the first instrumented battle cost 0 words |
| the dominant RNG consumer: trade-raid generation | subsystems | verified | high | 100% | 2026-09-08 | Sixteen of the ~20 words per turn are ServerTradeManager::GenerateTradeRaidEncounters (0x00893290) - ServerTradeManagerImpl vftable 0x00a31b74 SLOT 10, looping StrategyServer::Players and rolling per player: +0x196 player raid TRADE_RAID_ODDS_PLAYER 0.2f (8/8 fired), +0x283 NPC raid 0.05f (gate is player-INDEPENDENT so all-or-nothing per turn, 8/8), +0x33e refugee raid 0.05f (0/8). All three probabilities are strictly inside (0,1) so `Chance` takes NEITHER early-out - exactly one word each, matching the measurement. NO BACK-EDGE CONTAINS ANY OF THE THREE SITES, so one word per player per site is a HARD BOUND, not an observation. STILL UNBOUNDED, flagged: a SUCCESSFUL roll calls slot 17 (CreateRaidEncounter 0x008938a0) which draws a NextInt but returns without drawing on an empty candidate list, so 0 or 1 further word; it cost 0 on all three turns, consistent EITHER with no roll succeeding (~11% on the defaults) OR with the candidate list being empty every time - a word count cannot separate those |
| WHY NO SWEEP FOUND IT: the virtual edge | meta | verified | high | 100% | 2026-09-08 | ZERO direct calls to 0x00893290 exist in the image; its only reference anywhere is the vtable slot. The dispatch is `call edx` through slot 10 at 0x007d8469, inside DetectEncounters - ONE INSTRUCTION BEFORE the direct call lane I's closure DID follow. LANE I'S INVENTORY IS NOT WRONG: its tempering-scan recall claim is intact and its 22-site list is EXPLICITLY a direct-edge closure, with the indirect-call caveat stated as loudly as the result. This is the demonstration that the caveat was LOAD-BEARING - the single largest RNG consumer of a strategic turn hangs off a virtual edge inside a function the closure already contained. THE LESSON: a closure over direct edges is a lower bound, and saying so is what let the next lane find this in one step |
| the repo had already met this function | meta | verified | high | 100% | 2026-09-08 | strategic-turn-internals.md line 153 ALREADY listed 0x00893290 as "raid encounter generation" against these exact three StrategyVars. What was missing was not the identification but THE CONNECTION TO THE LEDGER - nobody knew it was where a turn's RNG goes. Worth remembering when the next "unknown" turns up: search the notes before the binary |
| rule 17, third independent instance | meta | verified | high | 100% | 2026-09-08 | Ghidra reports 1532 bytes for GenerateTradeRaidEncounters; the real body is 1546, ending 0x0089389a, and Ghidra's end lands MID-INSTRUCTION. Same failure lane I hit on 0x007aa240 and lane J on the combat resolver. Three lanes, three functions, same defect |
| standalone generator: NO MATCH, residual attributed | verify | mapped | high | 100% | 2026-09-08 | A CLEAN BINARY NO, which is the useful kind. On lane Z's two calibrated pairs: oracle 20 words / standalone 16 / RESIDUAL 4 (left 379 vs 375), and oracle 18 / standalone 16 / residual 2 (left 359 vs 357). mt[624] IDENTICAL both times, block byte-identical (no boundary crossed) - ONLY `left` differs. The residuals 4 and 2 are EXACTLY lane Z's per-site split for those turns (2 detection words both, +2 research on turn 5, +0 on turn 6), so subtracting a statically-bounded 16 from the FILE oracle corroborates the site ledger THROUGH A ROUTE TOUCHING NO HOOK. Stated caveat: a subtraction confirms the SUM, not each term; the static bound carries the 16. New instrument tools/rng_oracle_check.py. MODELLED: GenerateTradeRaidEncounters, 2 Chance rolls per player-vector entry, counted INSIDE the chance helper past both early-outs, so a data file pushing an odds value to 0 or 1 removes the draw and the ledger says so (one test pins that). NOT modelled and why: the 2 detection words (NO DERIVED RULE - fitting 2/turn to three observations is not a model), the 2 research words (downstream of lane N's blocked budget term), the 0-or-1 raid-success draw |
| T36 FinalizeTurnRecords recovered | control-flow | verified | high | 95% | 2026-09-08 | Lane Y: the archive it writes IS ON THE WIRE (/Sim/turnstats/.../stats[], one element per turn per player, every save carries its own frame). Six fields recovered and checked against bytes THE GAME WROTE: 11 saves, 80 player-records, 480 fields, 0 MISMATCHES, and the same check now runs inside every turn as a phase note. TWO CORRECTIONS to combat-done-tail.md 5: `inc`/`tdinc` are THE OPPOSITE WAY ROUND from the obvious reading, and lane K's open population term is `Pop + pbon` (112/112) not `Pop + dcs` (101/112). The block is now a MEASUREMENT: --commit-blocked closes 24 turnstats leaves and REGRESSES 17 (almem x8, inc x3, sav x3, 3 census, nothing outside the unmodelled set) - so it is NOT "committing makes it worse", it is right in six fields and wrong in five, and becomes a clean +24 once S04 and the budget land |
| BnkEl INVERTS: a per-save oracle for lane N's blocker | verify | verified | high | 100% | 2026-09-08 | THE HIGHEST-LEVERAGE FIND OF THE LANE. BnkEl is injective in maxIncome (slope ~6.67) so it inverts: 25 player-records recovered across the corpus (e.g. turn1-state player 16 -> 238592). That value IS `sum of max(ComputeMaxIncome(s),0)` - the exact population->output term lane N is chasing - available as a PER-SAVE ORACLE NEEDING NO VM, read from bytes the game itself wrote. Tool: tools/max_income_oracle.py. The same inversion independently recovers BANKRUPTCY_PROTECTION_LIMIT_FACTOR = 3.3 from saves (all 25 in [3.299994751, 3.299999872]), corroborating lane K's "3.3 is DB-loaded, not a binary constant" WITHOUT READING THE DATA FILES. Routed to lane N mid-flight |
| CORRECTION: the -0.15 divisor defect is much worse than noted | verify | verified | high | 100% | 2026-09-08 | The repo characterised the wrong bankruptcy divisor as roughly "one ulp on large empires". THAT UNDERSTATEMENT IS WRONG - and lane Y corrected its OWN prediction which had repeated it. `-0.15` and the real `-0.15000000596046448` differ for EVERY maxIncome DIVISIBLE BY 3, from m=3 (-20 vs -19), and the rate climbs: 33.3% below ~1.2M, 66.7% at ~1.4M, **100% above 3M**. SIX OF THE 25 CORPUS RECORDS would get a different BnkEl from the engine's current constant than the game wrote. One-line fix in economy.cpp wanting a test at maxIncome=3. Lane Y reported it and did NOT touch the file - it is lane N's |
| re-ranked path to the byte-match | meta | mapped | high | 100% | 2026-09-08 | Lane Y's ranking, superseding lane S2's now that the RNG ledger is closed: (1) PER-SYSTEM MONEY OUTPUT - now has a per-save oracle - unblocks P01/P02/P05/P06/T31 and 5 of 11 record fields, ~82 of 204 leaves and 2 of the 4 missing words; (2) S04 alliance mask - 8 of the 17 leaves stopping T36; (3) encounter detection - the last non-research generator residual, HOOK-SHAPED; (4) ship-design catalogue (hull size + 0x400) - census, ship records, part of T24; (5) ModCount - ONE leaf, but it feeds Summary.Checksum, so NO BYTE-MATCH WITHOUT IT |
| three hook-shaped questions for the VM holder | verify | in-progress | — | 0% | 2026-09-08 | SUPERSEDED (lane H holds VM140). Lane Y raised these per rule 18: bracket DetectEncounters with contact/detector counts (settles the last non-research generator residual); hook CreateRaidEncounter to separate "no roll succeeded" from "candidate list empty" (a word count CANNOT); and run one ZUUL End Turn - THE STANDALONE PREDICTS 14 WORDS THERE, NOT 16, which tests what the raid loop actually iterates over. All three routed to lane N mid-flight |
| CORRECTION: FUN_007d5a00 is RETREAT, not a combat pipeline | control-flow | verified | high | 95% | 2026-09-08 | Lane B5 corrects lane J's characterisation. It is the RETREAT pipeline - and THE LARGEST WRITER OF SAVED STATE ANYWHERE UNDER THE COMBAT RESOLVER. Lane J's "the resolver's own body writes nothing outside its stack context" STANDS; this is where a large part of "its callees write plenty" lives. Seven saved fields/containers move, ALL `verified` in struct-recovery.md so the autosave oracle sees all of them: StarShip.FltID (ships of a partly-retreating fleet re-parented); the StarFleet master list off S+0x64 (one fleet CREATED per partial group, every emptied fleet DESTROYED); **NMnx**, the object-id counter, `inc [ecx+eax*4+0x10]` at 0x008b8b16 once per created fleet; NShips/FtFlg (new fleet born with flag 0x400, retreaters flag 2); LocID/Pos/PrvPos/inline FlightPlan (gate retreat TELEPORTS, normal retreat installs a flight plan); ServerSystem VFlags/EFlags/AFlags/FFlags + NumFlts - AND RETREATING FROM AN UNEXPLORED SYSTEM MARKS IT EXPLORED FOR YOU; ServerPlayer.GTraf + per-system NVs/PlayerView debited and re-credited around every membership and plan change |
| OID allocation CLOSED - R2's x16 is a shl 4 | objects | verified | high | 100% | 2026-09-08 | struct-recovery.md 7's long-open "OID allocation (R2's x16)" is answered. The allocator is IDMap::AllocateID 0x008b8ae0; the counter is PER NETWORK NODE at IDMap->+0x08 + node*0x14 + 0x10, pre-incremented, never issues 0; an id is `(counter << 4) | (node & 0xF)`. R2's x16 IS THE shl 4. Found ONLY by following E9 TAIL-CALL THUNKS - an 8-byte jmp thunk hid the whole chain from the first closure pass |
| retreat destination rule + a predicted ORIGINAL BUG | control-flow | verified | high | 90% | 2026-09-08 | All six phases instruction-verified, dumped to the next function start. destinations -> group -> classify whole/partial -> split -> execute -> destroy. THE DESTINATION RULE: three INDEPENDENT nearest-system searches in ONE pass - nearest owned, nearest with no hostile presence, nearest at all - first non-null wins. The independence is LOAD-BEARING (each `best` store sits INSIDE its predicate arm); a single-loop version gives a different system. Squared distance, float32 deltas, strict `<` so exact ties go to the EARLIER system. New diplomacy rule: A SYSTEM CAPTURED ON THE CURRENT TURN LOSES ITS OWNER'S CEASEFIRE COVER. FALSIFIABLE PREDICTION WRITTEN BEFORE ANY RUN: on the first turn a battle produces a partial retreat, post-turn NMnx rises by exactly the number of groups with a split fleet - one per GROUP, not per ship or per fleet. PLUS A PREDICTED ORIGINAL BUG: the fleet is created BEFORE the ship loop, so a group flagged partial creates a fleet even when every ship turns out to belong to a wholly-retreating one - A ZERO-SHIP FLEET IN THE SAVE |
| retreat pipeline: events and RNG | verify | verified | high | 100% | 2026-09-08 | EXACTLY TWO EVENT_* keys in the 327-function closure - EVENT_FLEET_RETREATED_VIA_TELEPORT (depth 1, and gate retreat is species 1 + mode 1 + a destination, the only path to it) and EVENT_FLEET_INTERCEPT_ABORTED (depth 3, posted to the INTERCEPTOR'S owner when a split empties a fleet). Both were already in lane J's 23 at the right depth; what is NEW is the condition for each and that these two are the ONLY ones this pipeline can post. DRAW-FREE CONFIRMED INDEPENDENTLY: zero RNG calls, zero inlined MT tempering immediates, including the fleet-name generator. The one 0x11c hit is a FALSE POSITIVE (FtMS = -1 in the fleet ctor) - no turn-results write here |
| new failure mode: tail-call thunk in an argument list | meta | verified | high | 100% | 2026-09-08 | Lane B5 correcting ITSELF: it first read the fleet-creation call's arguments wrong, because a `ret`-N TAIL-CALL THUNK IN THE MIDDLE OF AN ARGUMENT LIST makes three pushes look like they belong to the wrong call. CHECKING `ret N` AGAINST THE PUSH COUNT CATCHES IT. Worth a rules line - and note the same thunks HID the OID allocation chain from a closure pass entirely |
| duplicate-name check working as intended | meta | verified | high | 100% | 2026-09-08 | Two live demonstrations today. (1) Lane B5 found StarFleet_SetFlag ALREADY in addresses.json from lane B4, same address and same prototype DERIVED FROM A DIFFERENT PATH - entry dropped, agreement recorded as corroboration. (2) The generator HARD-ERRORED on my merge: `duplicate address entry 'StrategyServer_off_TradeManager': in lane-t.json and lane-v2.json`, catching an in-flight collision between a landed lane and a running one. Exactly the designed behaviour: two lanes disagreeing about an address surfaces instead of being silently last-wins |
| vtable inversion tool | meta | verified | high | 100% | 2026-09-08 | Lane V2, `tools/vtable_map.py`. VALIDATED BLIND FIRST: 12/12, inputs only the PE + Ghidra function starts + the existing RTTI walk, nothing hand-fed - site 0x007d8469 classified virtual, slot 10 recovered, receiver typed as member +0x158, class Game::ServerTradeManagerImpl, target 0x00893290, zero direct call sites. Member typing came from the StrategyServer CONSTRUCTOR (0x007d78d0), corroborated independently by a method entered on the +4 SUB-OBJECT storing the same two ctor results exactly 4 bytes lower - TWO FRAMES, ONE ANSWER, and it re-derives lane T's 0x154 from a different direction. THE MAP IS EXACT: 2,172 vftables -> class -> sub-object offset -> slot -> target plus the inverse, a vftable accepted only with a COL at [vftable-4] pointing at a real type descriptor; no inference, no FPs. `ghidra/vtable-owners.json` |
| SCALE OF THE INDIRECT BLIND SPOT | meta | verified | high | 100% | 2026-09-08 | **5,045 of the 5,207 functions named by a vtable slot have ZERO DIRECT CALL SITES.** That is the size of what every call-graph result in this campaign was blind to. Additionally, sweeping for jumps into another function's start finds **14,958 MORE EDGES** - and three of the nine parked inlined-draw functions have NO E8 CALLER AT ALL and would read as dead code without them (the same tail-call-thunk effect that hid the OID allocation chain from lane B5's first pass) |
| indirect resolution: honest precision | meta | verified | high | 100% | 2026-09-08 | Lane V2 reported this as two things with very different precision rather than one number. Of 17,577 indirect sites: 7,415 are `call [disp32]` import thunks (not dispatch), **6,398 are PROVEN virtual with an exact slot**, 2,948 REPORT UNRESOLVED because the backward resolver refuses to cross a branch target or an unmodelled opcode. RECEIVER TYPING IS NOT SOLVED: only 163 of 6,398 (2.5%) get a class - but on those the falsification test fires on 1 in 163 (0.6%) against a 70% random baseline. THE DISPLACEMENT-ONLY ROUTE MEASURED 81% OUT-OF-RANGE VS 58% FOR A RANDOM VTABLE - WORSE THAN CHANCE - SO IT IS REJECTED OUTRIGHT, NOT FLAGGED (rule 9 with the ranker off). Three real bugs the falsification test caught: [ebp+8] typed as a member of `this`; constructors given a vftable's sub-object offset instead of 0; carrier spans not ending at the epilogue's pop esi |
| lane K tier-4 blind spot CLOSED | control-flow | verified | high | 100% | 2026-09-08 | The tool reproduces lane K's byte-level transcription of phase 23 EXACTLY AND INDEPENDENTLY, then names all eleven targets: trade slots 14/8/12/11/9/7/13/15 -> 0x008590d0 0x0088e8d0 0x0088e920 0x00848570 0x00868060 0x0088ad60 0x0088ef80 0x0082cca0; the ninth call is on +0x15c = ServerSpyManager slot 13 -> 0x008877b0; phase 33 = spy slots 14/15. All eleven have ZERO direct call sites. ALSO: Game::ServerSpyManager has NO *Impl - it is itself concrete over IServerSpyManager/ISpyManager/IStreamable, the opposite of the ServerTradeManager shape, so the *Impl rule is a pattern to CHECK, not to assume |
| CORRECTION: "the tail draws nothing" is true of 8 TURNS, not of the code | verify | backlog | — | 0% | 2026-09-08 | FOUR of the eleven phase-23/33 targets REACH A DRAW on the strategic generator (StrategyServer+0x16c), verified at the instruction: 0x00887c8a, 0x00840929, 0x00840a3c, 0x008409c7, 0x0088dc43, 0x0082cdb8, 0x00820e18, 0x0088b613. NONE HAS EVER BEEN OBSERVED FIRING - lane Z measured 0 tail words across 8 turns. So tail-rng-ledger.md's "the tail draws nothing" is a PROPERTY OF EIGHT TURNS. RULE-18 ITEM: hook those four callees with a save that has LIVE TRADE ROUTES AND A SPY PROGRAM. Our corpus has neither |
| RNG re-check over indirect edges: nothing contradicted | verify | verified | high | 100% | 2026-09-08 | Lane I's 22-site inventory is intact and lane Z's residual stays zero. CORRECTION to how the nine parked functions were recorded: NONE of the eleven inlined-draw functions is itself in a vftable (no dword equal to any of them exists in the image) - their TOPMOST DIRECT-CALL ANCESTORS are, and all nine resolved. CrowRuinsEncounter/SwarmEncounter are CombatEncounterBase (16 slots), NOT SVScriptObject, so ProcessTurn's slot-11 dispatch is not a route to them. NEW DRAW SURFACE: the SVScriptObject hooks on StrategyServer+0x1b4 (lane T's field), 30 derived classes, 11 of 14 slot-4 overrides / 4 of 7 slot-11 / 2 of 8 slot-25 reach a draw - with TWO LIMITS STATED: the generator arrives as an ARGUMENT at depth 2-4 so its identity is unproven, and +0x1b4's class is a HYPOTHESIS here (no ctor store found; lane T had it verified, lane V2 records the WEAKER claim). STRENGTHENED: over direct + every pinnable indirect edge, ProcessTurn's closure grows 1,430 -> 1,486 and the tail's 1,424 -> 1,668, and the only draws those ~300 functions add are the eight above. The MAXIMAL over-approximation puts half the image (19,697/41,089) in the closure - USELESS, AND LANE V2 SAYS SO rather than dressing it up |
| system output term CLOSED | subsystems | verified | high | 95% | 2026-09-08 | Lane N. OUTPUT POINTS ARE LINEAR IN POPULATION: outputPerHead = typeOutputModifier x 1.8 / 500000 = exactly 3.6e-6 per imperial head with no station. ServerSystem::ComputeTotalOutput (0x00750480, ret 8, SIDE-EFFECT FREE) is a SUM OF THREE TERMS, not a multiplicative chain: base = overHarvestDemand x SpeciesDef+0x50 + (TRes + resAvail) x stripMineFraction x 0.9 + imperial + civilian + slave; total = addictionMod x base x OutMod x sys.OutMod x setupOutMod x RebOutMod x ScOutMod. The station bonus scales ONLY the imperial term; morale ONLY the civilian one. InitPopTypeTable (0x00535ca0) builds the three-row population-type table IN CODE from x87 literals (imperial 1.0, civilian 0.33f, caps 50M/20M) - facts about the binary, not the data files. The data-file surface is a CLOSED LIST OF NINE VALUES, all measured live, incl. SpeciesDef +0x4c/+0x50 = 0/10 Human and Tarkas but 10/40 ZUUL, so a species table is NOT optional |
| output term: live verification | verify | verified | high | 90% | 2026-09-08 | Two builds, two species, both hooks in compare mode: GroupOutput 13,105 calls 0 diverged; ComputeTotalOutput 11,252 calls 1 diverged. **0 UNDECLARED WRITES IN 24,357 GUARDED CALLS** turns "side-effect free" from a claim into a MEASUREMENT - which is exactly why this and not ComputeOutputFromRates (repairs ships in orbit) was the target. COVERAGE IS THIN AND LANE N SAYS SO: 24,357 calls is THIRTEEN DISTINCT SYSTEM STATES. The first run's 4,957 divergences were ALL ONE ULP on the civilian row and ALL ONE DEFECT - a 32-bit x87 build skipping the double rounding the original performs; sim::Narrow fixes it. The one residual is a single ulp on Koa'Vo in a value THE CALLER ROUNDS TO AN INTEGER, so it cannot move anything stored - recorded, not fixed |
| THE FIVE PHASES ARE NOT UNBLOCKED - the blocker MOVED | verify | mapped | high | 100% | 2026-09-08 | Lane N's honest headline. ComputeBudget needs a system's **money**, not its **output** - a SECOND chain (TradePointsToMoney) with its own population law (incomeModifier / 14000, NO 1.8). Against lane Y's BnkEl oracle via new tools/max_income_predict.py: **6 of 25 player-records match EXACTLY**, and those six are every HUMAN-OWNED OR INDEPENDENT record, including turn1/2/3 on a moving workload. The 19 misses are ALL AI-OWNED; the single-system ones are short by exactly **x1.1 = DifficultyMods(owner)->+4**, which is NOT ON THE WIRE (formula-gaps.md Q3 already names it). Multi-system AI empires differ by more - trade routes, stations, or the suitability money cost. So the output term is CLOSED and the missing input for P01/P02/P03/P05/P06 is now the INCOME TAIL PLUS THE AI DIFFICULTY INCOME MULTIPLIER - a smaller, better-specified target with a 25-record oracle and NO VM NEEDED |
| corrections from lane N | subsystems | verified | high | 100% | 2026-09-08 | strategic-turn-internals.md 3.3 had the SHAPE of the output formula wrong, and 0x007483b0 is the over-harvest RESOURCE demand (its population is Res, not Pop; species +0x4c is ADDED, not a cap) - both corrected in place. AND LANE N CORRECTED ITSELF BEFORE VM TIME: its own 4.2 prediction mis-evaluated the strip-mine branch as a min when it is a SUBSTITUTION, so Gamma Cephei's fraction is 2.0 and its resource term 9000, not 4500 - caught by the FIRST HOST TEST, before any VM run. Static-only and labelled hypotheses (unexercised): the over-harvest branch and its max(v,1) floor (SRoh is 0 on every save), the station factor, the slave term, both morale branches, addiction, the capacity surplus |
| bankruptcy divisor FIXED | engine | verified | high | 100% | 2026-09-08 | kBankruptcyInterestDivisor = -0.15000000596046448 named in the header, with tests at maxIncome 3/6/9 (-19/-39/-59, WHERE IT FIRST BITES) and at 238592 and 3000001. Closes the defect lane Y found and correctly escalated from the repo's "one ulp on large empires" understatement to "6 of 25 corpus records get a different BnkEl than the game wrote" |
| VM AUTOSAVE CONTENTS OVERWRITTEN (recoverable) | meta | verified | high | 100% | 2026-09-08 | Lane N reported this rather than papering over it: its two End Turns overwrote the three autosave CONTENTS on VM140 (they now hold its turn-3 Human and turn-6 Zuul states). THE FILE SET - names and count - IS UNCHANGED, so every documented Load-dialog row position still holds. Lane O's byte-identical autosaves are gone from the VM AND LANE N DID NOT FAKE A RESTORE from lane F's older, differently-sized snapshot. NOT A REAL LOSS: all 11 curated saves live in the REPO (verify/results/saves/), and the determinism-oracle hashes are REPRODUCIBLE by loading ref-turn2 and ending one turn - any VM lane can regenerate them. Record the regenerated hashes when someone next does |
| FUN_007066c0 is NOT a path finder | control-flow | verified | high | 95% | 2026-09-08 | Lane P2, instruction-verified over raw .text to the next function start (Ghidra's size was RIGHT on all four functions this time - recorded because rule 17 exists, not because it was violated). THERE IS NO GRAPH SEARCH: no frontier, no visited set, no relaxation, no recursion. It is a PER-LEG CLASSIFIER AND VALIDATOR over a destination list THE CALLER ALREADY CHOSE - a helper classifies each consecutive pair and returns the waypoint type; this walks the chain, ORs the flags, records the first failing index, draws down fuel and resets it at refuelling systems. CDECL WITH 8 ARGS (not thiscall), and it ALWAYS RETURNS TRUE past the null checks - all outcome data is in out-params. The only graph structure in the whole subtree is a SINGLE-HOP ADJACENCY QUERY over a triangular per-player bit matrix; a multi-hop node route in a save is n waypoints, one per hop. NEW FAILURE MODE: two helpers READ ebx/esi LIVE-IN WITHOUT EVER WRITING THEM, so reading them as plain cdecl gives nonsense (adjacent to lane B5's tail-call-thunk-in-an-argument-list trap) |
| WAYPOINT TYPE 2 EXPLAINED - it is a NAMING ERROR | verify | verified | high | 100% | 2026-09-08 | THREE LANES CIRCLED THIS AND THE ANSWER IS THAT THE NAME WAS WRONG. A 7-entry jump table re-verified from raw bytes maps SPECIES to waypoint type: **Human->3, Hiver->0, Tarkas->1, Liir->2, NPC->0, Zuul->3, Morrigi->6**. TYPE 2 IS THE LIIR DRIVE. Lane O's 20+ observations were all on the two NODE-DRIVE races, both forced to 3 - so nothing is unreachable and NOTHING NEEDS FIXING. "Node line" is the wrong name for type 2 (its speed profile is the STUTTER_* curve, slowest at a star), which is exactly why IsNodeWaypoint accepts only 3 - CONSISTENT, NOT ANOMALOUS. This also puts B4's "the type-2 node-line step is wrong by construction" in doubt: that claim was made about a type it had misidentified. Exercising type 2 needs a LIIR FLEET, not a node line - far cheaper than the save lane M was contemplating |
| OrderFleetMove's three failure bits | control-flow | verified | high | 100% | 2026-09-08 | `test DWORD [ebp-0x10],0x418` at 0x865499 = 0x400|0x010|0x008, exactly lane B5's three. 0x008 destination is a fleet on a node route and no intercept could be solved (you must sit at one end of its line); 0x010 a ship's drive is destroyed, CLEARED on a successful gate leg (a gate ignores dead drives - the same rule B5 found from the retreat side); 0x400 the destination point is not one this player may use. The OTHER NINE BITS are advisory route-quality complaints the server commits anyway. PROOF OF THE SPLIT: the UI caller does a DRY RUN and tests `flags != 0` to raise a confirm dialog, while the server tests `& 0x418`. Notably 0x004 (gate traffic exceeded) DOES NOT REFUSE - a plan can be installed over capacity with a type-0 waypoint |
| nav precision: one float decides a failure | verify | verified | high | 100% | 2026-09-08 | Lane P2 ranked precision by CONSEQUENCE rather than listing it. FUN_006ffa00 is the only float that decides a failure: three f32 deltas, sum-of-squares narrowed ONCE, range = min(rangeLeft, tankCap) from f32 slots - and then `fmul st(0),st` computes r*r AND NEVER STORES IT. The comparison is `f32(sumsq) <= (double)r^2`, NON-STRICT. Writing f32(r*r) - the natural mirror of every other narrowing here - DISAGREES EXACTLY AT THE BOUNDARY. Leg length is Mars_Vec3_Length INLINED (lane M's five narrowings verbatim, plus a third narrowing on the subtraction). fpu_cw=0x127f throughout |
| P2 predictions: written first, then checked offline | verify | verified | high | 100% | 2026-09-08 | Three predictions written from the disassembly BEFORE checking, then run against all 11 saves: **58 waypoints, 46 flight plans, 0 failures**. nrt == Wpt on all 57 type-3 records; the nrf chain closes through `pnd` across the three-hop plans; nrp splits exactly as the branch structure says - Human (cannot bore) has only real indices, Zuul has a MIX of real indices and -1, which is what the bore path writes. AND A BONUS: turn3-state.sav's Tp=1 with nrt{-1,0,0} can only be a TARKA fleet by species elimination - a live confirmation of a SECOND table row from a save nobody was looking at. Also corrected: type 5 is a HIVER GATE THROW at a gateless system within CstR, NOT the Zuul bore; CstR now has a reader; GFlags(+0xdc) is the per-player GATE MASK (closing lane B5's open item); `pnd` is the node transit's ORIGIN id; FtTrans is a SECOND SAVED COPY of wpts[0].Tp |
| P4: a ~1-minute VM test of a predicted ORIGINAL bug | verify | backlog | — | 0% | 2026-09-08 | Lane P2, for whoever holds VM140: order a fleet to the system it is ALREADY AT, then onward. The leading-destination drop happens in the solver's frame only, so types[]/routes[] end up SHIFTED BY ONE and the last slot is never written. PREDICTED SYMPTOM: the final waypoint saves as Tp 0 with an empty route. Note the framing - a CLEAN result FALSIFIES the reading rather than merely failing to confirm it |
| P2 honest boundary | meta | verified | high | 100% | 2026-09-08 | Fully read: FUN_007066c0, three siblings and 20 helpers. NOT read: FUN_00705d60's tanker fold past ~0x90 bytes (the input to EVERY range decision, the largest remaining hole in the NUMBERS), the node bore, the ranking term and hash walk inside FindNodeLine, the relation scale, and MoveFleet's two classifier call sites. THREE INDIRECT EDGES ON THE MAIN LINE UNRESOLVED, the most important being `(fleet->galaxy)->vft[1]()`, the node-graph getter the whole node branch hangs off - vtable_map.py plus an image-wide E8/E9 sweep confirm the function has exactly two direct callers and no tail-call thunks, but those three are OUTBOUND edges lane V2's inversion does not reach. AND NOT ONE LEG HAS EVER BEEN OBSERVED EXECUTING UNDER AN INSTRUMENT - only its saved output has. Also caught internally: one test check was VACUOUS (the float32-squaring probe never fired) - found and fixed with an unconditional witness |
| MULTIPLAYER: GameSpy is NOT required to join | subsystems | verified | high | 95% | 2026-09-08 | Lane G2's lead answer - THIS IS A WEEKEND, NOT A PROJECT. Three GameSpy-free paths into a session, all verified from the binary and all reachable in the shipped UI: (1) MANUAL JOIN / FAVORITES - the accept handler at 0x0076ef90 calls App_StartJoin(params, address, serverBrowser = **NULL**), and a NULL browser makes StrategyJoin's ctor take its DIRECT ARM (state +0x80 = 1) straight to App_ConnectToStrategyHost 0x0089a7e0 and the game's own UDP connection - NO availability check, NO peerInitialize, NO browser, NO NatNeg; (2) COMMAND LINE `/join <a.b.c.d[:port]>`; (3) LAN BROWSE - ServerBrowserNew(lanBrowse=1) with NO availability gate, broadcasting UDP 3369-3370, and LAN HOSTING takes a branch that calls qr2_init_socket(ispublic=0) and SKIPS GSIStartAvailableCheck ENTIRELY at 0x007c5088. CROSS-CHECK: NNBeginNegotiationWithSocket has exactly two call sites image-wide, NEITHER on the direct-address arm. CORROBORATED OUTSIDE THE BINARY: Kerberos staff 2012-12-08, "host needs to start their game in LAN mode, then other can join direct connect. It will not work in Internet mode" |
| GameSpy identity constants + hosts-redirect list | subsystems | verified | high | 100% | 2026-09-08 | From the .rdata constant table at 0x00a35cd4/0x00a35cd8: gamename **swordots**, secret key **Z5gR9Z**, game id **1381** - INDEPENDENTLY CONFIRMED by OpenSpy/UniSpy/333networks/gslist, which all carry row `1381 swordots Z5gR9Z`. Hosts-redirect targets: swordots.available.gamespy.com:27900/UDP, swordots.master.gamespy.com:27900/UDP, **swordots.ms5.gamespy.com:28910/TCP** (the `ms5` DERIVED by re-implementing the binary's own hash fold `h = tolower(c) - h*0x63306ce7`, % 20), natneg1/2.gamespy.com:27901/UDP, peerchat.gamespy.com:6667/TCP. Game's own ports: HostPort 3369, CombatHostPort 3370. LINKED AND CALLED: availability check, serverbrowsing SB v2, QR2, NatNeg, Peer SDK, peerchat, ghttp (MOTD only). ABSENT ENTIRELY - no string, no host, no code: GP/GPCM/GPSP login, motd/gamestats/sake/atlas, keymaster, and **CD-KEY VALIDATION OF ANY KIND** (no CDKEY substring in 49,781 strings) |
| multiplayer revival plan, ranked | subsystems | mapped | high | 90% | 2026-09-08 | TIER 0 (hours): VPN/LAN + manual join, NO SERVER AT ALL. TIER 1 (afternoon): availability responder, specified BYTE-FOR-BYTE - request `09 00 00 00 00 "swordots" 00`, reply `FE FD 09 00 00 00 00` - and LIKELY A NO-OP, because the check FAILS OPEN on DNS failure (socket == -1 returns GSIACAvailable); MATCHINGSERVICE_UNSUPPORTED was GameSpy's KILL-SWITCH, not self-detection. TIER 3 COLLAPSES TO CONFIGURATION, NOT CODE: OpenSpy and UniSpyServer both implement the exact service set and both already ship the swordots row; adding a title is one DB row plus SyncToRedis. CAVEATS, STATED: swordots is SEEDED not on OpenSpy's 132-title tested list; the public openspy.net availability responder returns "available" for ANY gamename so it PROVES NOTHING - self-host; the seeded row says queryport 6500 while SOTS reports on 3369; and its keylist omits SOTS's 16 custom QR2 keys (slot0..7, numslots, mapshape, numsys, turn, scenario, settings, slot_, ranks - ids 50-65, recovered here). 333networks RULED OUT: GameSpy v0 only, no SB v2/NatNeg/peerchat |
| two clients on ONE guest: /concurrent | meta | verified | high | 100% | 2026-09-08 | THE GAME SHIPS THE TWO-INSTANCE SWITCH. WinMain compares argv against `/concurrent` on ERROR_ALREADY_EXISTS and CONTINUES on a match - so `/concurrent /join 127.0.0.1:3369` gives two clients on one guest, NO CLONED VM NEEDED. Address parsing is DOTTED-QUAD ONLY (`localhost` is rejected). Full switch list: /join, /concurrent, /startup:, /motd_, /tell |
| CORRECTIONS from lane G2 | meta | verified | high | 100% | 2026-09-08 | ui-screen-map.md corrected in place on two counts: there are 13 NETERROR_* strings and NONE IS CD-KEY (the "21 incl. 6 CD-key" and "CD-key strings survive" claims were WRONG), and the "2017 build knows GameSpy is dead" inference is replaced with the FAIL-OPEN finding. Also: **SOTS1's backend died December 2012, not the May 2014 general shutdown** - its agreement ran through Lighthouse Interactive |
| alliance mask rule + ModCount writers | control-flow | verified | high | 95% | 2026-09-08 | Lane A2. THE ALLIANCE RULE, byte for byte at 0x007dc871-0x007dc8c7: `rec->almem = 0; rec->almem |= 1<<i; if (p->ALid != -1) rec->almem |= p->AL;` - THREE SEPARATE STORES with the record pointer reloaded before each, which is what makes the term an OR and is INVISIBLE IN ANY SUMMARY. The shift count is the LOOP COUNTER, so the bit is the vector position, not PlyrIdx. Measured against bytes the game wrote: 11 saves, 80 player-records, **560 fields, 0 mismatches** (was 480/6). ITS FALSIFIER FIRED AND WAS THE MODEL, NOT A MISS: all 8 almem on turn1-state are 0 because FinalizeTurnRecords ALSO RUNS ON LOAD and the load path never runs the spine, so every save's earliest archived turn carries a zero mask - true on all 11 - and the engine states it as a POSITIVE prediction (spineRan) so all 80 records compare. MODCOUNT: StrategyServer::Write tags both words itself, so S+0x8 IS ModCount and S+0xc is Frame; RTTI explains the two bases (Game::StrategySim is a base sub-object at offset 4). **29 writer sites**, each with the base PROVED a StrategySim by an independent fingerprint: 20 command handlers bumping UNCONDITIONALLY ON ENTRY BEFORE VALIDATION, 6 more inlined in ApplyTurnCommandBatch, the two turn drivers, and the abandon/chaos check (gated on Abdn, false on all 28 systems of all 11 saves) |
| ModCount is NOT derivable from a pre-turn save | verify | verified | high | 100% | 2026-09-08 | THE RULE: ΔModCount = 2 + one per command applied out of every player's TurnCommands block (+ one per abandoned system). That is why the delta is not a function of the board - human-turn2->3 is 28 and zuul-turn16->17 is 16 on IDENTICAL 28-system/7-player boards. And the TurnCommands block in turn1/2/3-state is the EMPTY 35-item block, so the 10 non-driver bumps were issued AFTER the autosave. **ModCount cannot be derived from the pre-turn save**, S00+T00 are all the standalone can produce, and IT IS THE WRONG LEAF TO CHASE BEFORE THE AI. Independently confirms lane Q's block layout: the flush walks a vector<TurnCommands> at S+0x174 with stride 0x1b4 (last list at 0x1a8, stride 0xc) |
| A2 reported what the corpus CANNOT separate | verify | verified | high | 100% | 2026-09-08 | Rule 15, applied without prompting. THREE parts of the alliance rule this corpus cannot distinguish: every player has PlyrIdx == i (0 of 80 records differ), every observed AL already contains its own member's bit, and AL == 0 exactly when ALid == -1. So `1<<i` vs `1<<PlyrIdx`, the OR vs an assignment, and the ALid guard vs an `AL != 0` guard are INSTRUCTION-STREAM READINGS ONLY. A unit test pins all three with the separating inputs no save provides, and the corpus test PRINTS THAT IT COULD NOT SEPARATE THEM |
| T36 cost measured: net positive on BOTH pairs now | verify | mapped | high | 100% | 2026-09-08 | Default run UNCHANGED at closed 5 / regressed 0 - S04 writes no save leaf of its own, the mask reaches the wire only through T36 which stays blocked. What moved is T36's COST: the 8 almem regressions are GONE, and the remainder is exactly inc x3 + sav x3 (the budget) plus 3 census leaves (lane D2's). T36 now closes ALL 24 /Sim/turnstats leaves on the reference pair, so it becomes a clean +24 the moment those two land. CORRECTION to lane Y: "which way the net falls depends on the save" is NO LONGER TRUE - --commit-blocked is now net positive on BOTH pairs (+20, +2). It stays blocked anyway, because 9 leaves would be CONFIDENTLY WRONG |
| ship designs DO have a serializer - misattributed address | objects | verified | high | 100% | 2026-09-08 | Lane D2 overturns lane D's finding. **Game::ShipDesign::Write exists at 0x008325e0 and makes four stream calls.** The published "ShipDesign::Write (0x008747a0) makes no stream call at all" was A MISATTRIBUTED ADDRESS, not a fact about the class - lane V2's inversion is decisive: `vtable_map.py who 0x008747a0` says it is IN NO VFTABLE, and its body pops a parser stack. WHY THE SWEEP MISSED IT: RTTI shows Game::ShipDesign : Game::ShipDesignDef, Mars::IStreamable, ... and **ShipDesignDef is the base and is NOT itself IStreamable** - it declares its own three virtuals in a different order; ShipDesign overrides all three and the IStreamable sub-object at +0x9c holds three ADJUSTOR THUNKS back to the primary vftable. A slot-indexed sweep finds the thunk. THE *Impl RULE WITH A TWIST WORTH RECORDING: the concrete serializer can be a DERIVED CLASS, not a separate Impl - lane V2's "check the shape, do not assume it" was right |
| ship design: three sections, not five | objects | verified | high | 100% | 2026-09-08 | THREE CORRECTIONS FORCED. (1) THREE sections, not five - the campaign's "slots 3-4 reserved" was save_reader.py's trailing Rest("sections") sweeping Dtc/Dwgv into the section list and stock_designs.py decoding them as empty slots. **RULE 8 EXACTLY: reader and consumer agreed and were both wrong.** Two independent enumerations say three (straight-line writer; ctor eh_vector_constructor_iterator(this+0x24, 0x28, 3) closing at 0x9c = sizeof(ShipDesignDef)). (2) DWep/Dwgv are BOOLS, byte-indistinguishable at a 4-char tag - same class as odet. (3) Offset order != write order: array [+0x24,+0x4c,+0x74] = mission, command, engine; WIRE = command, mission, engine. Oracles fixed openly (rule 12): save_reader.py 49/49, stock_designs.json regenerated with the diff ONLY raw_slots 5->3 and dWep int->bool across all 127 designs, design rules still 32/32 and 127/127 |
| hull size and the 0x400 flag | objects | verified | high | 90% | 2026-09-08 | **0x400 = defence_platform**, read off the .shipsection parser's own bit setter at 0x005749b7 directly after the strcmp against the literal; the design's word is the OR across its <=3 sections. **NOT lane B5's 0x400** - that is a FLEET flag, on the wire as FtFlg (turn1-state fleet 0 has FtFlg 1024). Different word, different object. HULL SIZE = section_class via ParseShipClassName 0x0056e1c0, _stricmp against Destroyer/Cruiser/Dreadnought -> 0/1/2; absent or unrecognised -> **0 WITH A LOG LINE, NOT AN ERROR** (16 shipped sections have no section_class); ASSIGNMENT NOT MAX - last resolved section in MEMORY ORDER wins. Both live on ShipDesign, NEITHER ON THE WIRE, both rebuilt by UpdateDerivedStats. Independent corroboration: the same bit picks default hull health (500/3000/15000 without, 100/500/1000 with) - platforms, not ships. CENSUS VERIFIED: 11 saves, 503 designs, 480 leaves, 0 mismatched, computed TWICE independently (Python off save_reader, C++ off the engine's shapes reader - they DISAGREE about section count and still agree on all 480) |
| census coverage: 3 of 6 leaves UNEXERCISED | verify | backlog | — | 0% | 2026-09-08 | Rule 15 again, unprompted. Only 32 of 480 census leaves are NONZERO: cls0 shpt 18, cls0 satt 3, cls1 shpt 0, cls1 satt 0, cls2 shpt 11, cls2 satt 0. **Three of the six census leaves are UNEXERCISED, NOT VERIFIED** - no cruiser of any kind and no DN platform in the corpus. ONE SAVE WITH A CRUISER CLOSES TWO OF THEM. Also 0 ships with an unresolvable design, 0 unclassifiable designs, 0 designs where first- and last-resolved section disagree on hull size |
| T36 census wired; a NEW blocker found by a wrong prediction | verify | mapped | high | 100% | 2026-09-08 | Lane E2. Default run UNCHANGED at closed 5 / regressed 0 on both pairs, exactly as predicted (T36 does not commit by default). --commit-blocked regressions fell 9 -> **7** (pair 1) and 11 -> **9** (pair 2). PREDICTION WAS 6 AND 8, AND THE MISS IS THE FINDING: falsification hypothesis 1 fired - two census leaves closed (shpt[2]=5 dreadnoughts, satt[0]=10 platforms) but **shpt[0] did not, and the archived value is higher than ours by EXACTLY ONE DESTROYER ON BOTH PAIRS** (0 vs 1, then 1 vs 2). The self-check on the INPUT turn is exact, so this is NOT a classification error - it is a NEW NAMED BLOCKER: **no phase we run builds a ship** (S11's build-queue sub-pass is the declared input boundary). Census itself reproduced through the app's own path: 11 saves, 80 records, 1040 fields, 480 census leaves, 0 mismatches - and it visits slots in the ORIGINAL'S IN-MEMORY ORDER (mission, command, engine) rather than the wire's, so it is an independent route to lane D2's 480/480 |
| T36 remains blocked on TWO named things | verify | backlog | — | 0% | 2026-09-08 | Neither is lane E2's: (1) sav/inc x3 = the budget, lane E1's income chain; (2) shpt[0] x1 = unmodelled ship construction. **The briefed --commit-blocked-except=<fields> knob is NOT IMPLEMENTABLE and lane E2 did not fake one**: the archived record is ONE STRUCT ON THE WIRE, so sav/inc cannot be omitted while the other eleven fields are written - the format has no hole. It implemented the same convention at PHASE granularity instead (--commit-blocked=IDS / --commit-blocked-except=IDS), which is what isolated the T36 rows and cleanly separated S31's two Player/Status regressions out of the pair-2 number |
| standalone data root | engine | verified | high | 100% | 2026-09-08 | The standalone grew `--data DIR` / $SOTS_DATA_DIR. NO GAME DATA EMBEDDED; absence costs exactly the six census counters and SAYS SO. Note for integrators: with a data root set, `game_data_realdata` fails - and it FAILS IDENTICALLY ON UNMODIFIED MAIN, because the extracted sots.gob is a different data version than the counts that test pins. Not a regression; worth pinning the data version in that test |
| THE STRATEGIC AI IS A StrategyClient, not part of ProcessTurn | control-flow | verified | high | 90% | 2026-09-08 | Lane AI1, instruction-verified. One extra Game::StrategyClient (0x708 B) is built PER AI PLAYER at SNMRunAI (net msg 0x3d) by StrategyApp::RunAI 0x008706f0, carrying a Game::StrategyAIAgent at StrategyClient+0x12c, and it receives the same SE* events the human client does. THE PER-TURN TRIGGER IS SEResumePlaying, client event 0x26, raised by StrategyServer::ResumePlaying 0x007ddc90 to every player with Status==0; it lands in StrategyClient::OnResumePlaying 0x00777480 which does `if (this->+0x12c) agent->vt[1](0x26, ev)` - and THAT CALL RUNS THE AI'S WHOLE TURN SYNCHRONOUSLY ON THE MAIN THREAD. Order: ProcessTurn -> OnAllCombatDone_Tail -> "(Autosave EndTurn)" [the save is written HERE] -> SNMResumePlaying -> each AI client's ~30 phases -> cl_EndTurn -> human End Turn -> SNMUpdate -> ApplyTurnCommands (ModCount++ per command) -> ProcessTurn. EXACTLY THE SHAPE LANE A2 MEASURED. CORRECTION to turn-spine.md 2.3: RaiseAIPrepareTurn/SEAIPrepareTurn (type 9) is CONSTRUCTION, not a turn hook - two callers, RunAI and CreateGame, both one-shot |
| how an AI decision becomes a command | control-flow | verified | high | 95% | 2026-09-08 | THERE IS NO SEPARATE AI ORDER CHANNEL - the join to everything we already own. StrategyClient+0x160 is an ACCUMULATING TurnCommands; +0x4d8 is the send buffer. ~21 client order methods in 0x00762ca0..0x00763f60 each do `lea ecx,[this+0x160]` and call a TurnCommands::Add* helper. At End Turn, StrategyClient::BuildTurnCommands 0x00783780 does Clear(+0x4d8); +0x4d8 = +0x160; playerId; SetResearchRate - WHICH INCIDENTALLY EXPLAINS why every save's block has the research-rate gate set and the other five clear. SendEndTurn copies +0x4d8 into SNMEndTurn. **11 of the 21 order methods are called directly from the AI module; 7 of those are also UI-reachable.** So ModCount is downstream of StrategyClient+0x160, and RUNG B REDUCES TO FILLING THAT OBJECT THE WAY THE AI DOES |
| AI determinism: every client seeded with ZERO | verify | mapped | high | 85% | 2026-09-08 | All AI randomness uses a PER-CLIENT Mars::RNG at StrategyClient+0x134 (operator_new(0x9cc) + RNG_Seed(ctorArg) in the client ctor); all six direct NextInt sites in the AI band reach it, as do the two facade helpers cl_Chance and cl_RandRange. **ZERO AI CALLS TO THE STRATEGIC GENERATOR** - which closes lane Z's zero residual FROM THE OTHER SIDE, and neither lane has to weaken anything. The seed comes from NextUInt() on a static g_GlobalRNG at 0x00af6e58 that is **NEVER SEEDED** (its only static init writes the IStreamable vftable; none of the six RNG_Seed call sites targets it), and AN ALL-ZERO MT STATE IS A FIXED POINT OF THE TWIST - so every AI client is seeded with 0. GOOD: constant and reproducible. BAD: **the AI's generator state is in NO SAVE** - the StrategyAIAgent::Streamable block's 35 tags carry none. UNVERIFIED LIVE: P1/P2 routed to the VM holder |
| P2: the test that decides whether Rung B is stated correctly | verify | in-progress | — | 0% | 2026-09-08 | ROUTED TO LANE H (holds VM140). Load one save twice IN TWO FRESH PROCESSES, End Turn in each, diff the autosaves. Predicted byte-identical incl. ModCount. IF IT HOLDS, Rung B stays as written (implement the AI, the byte-match is reachable from a save alone). **IF IT FAILS, Rung B is UNREACHABLE FROM A SAVE ALONE** and must be restated as "reproduces a recorded command stream", making --turn-commands injection the primary path instead of writing an AI. A roadmap-level answer for ten minutes of VM time, and it must be TWO SEPARATE PROCESS LAUNCHES to catch per-process seeding - distinct from the oracle restoration, which re-runs the same path |
| CORRECTION: fuzzy::IAIRule is the TACTICAL AI | subsystems | verified | high | 100% | 2026-09-08 | A framing error the roadmap and several briefs carried: Game::fuzzy::IAIRule is the tactical/COMBAT AI - its only four implementors are TacAISquadRule_*. The STRATEGIC AI is 34 IAITask goals + 29 IAIRelationScale/Shift diplomacy terms + AIRulesDB (mostly SHIP DESIGN, and AIRulesDB::SelectRules_T is the ship-design selector, NOT the task selection loop). Only the strategic half is on the Rung-B path |
| AI module boundary: lane AI1 read ~3% | meta | verified | high | 100% | 2026-09-08 | The module is **1,629 functions / ~360 KB** (dense core 0x680000-0x6d1000) and lane AI1 read the spine, maybe 3%, and says so. NOT DONE: the 34 IAITask bodies - NOT ONE - and IAITask's 8 pure virtuals are unnamed; THE TASK SELECTION LOOP IS UNFOUND; 19 of the 30 AI Process-Turn phases are unclassified addresses; the order-method -> list mapping (mechanical, one pass); and g_CurrentClientIndex 0x00ae4808 is uninstrumented - who sets it and in what ORDER AI clients are stepped matters, BECAUSE ModCount IS A COUNT. ONE HOP IS INFERRED NOT READ: StrategyAIContext::Broadcast 0x006b3840 delivering to the queued callback that reaches OnAIPacket - the identification is safe (one caller, registered by one function, jump table indexed by exactly the codes that function emits) but a next lane should finish it. ALSO UNSETTLED: AIProcessMinTime + Game::AIProcessingDialog hint at a think-time throttle; no deferral seen in OnAIPacket but no per-frame driver was looked for, and if an AI turn CAN span frames the clean "all AI orders in before the human's End Turn" ordering needs revisiting |
| nve visibility CLOSED - 46 leaves, 0 regressed | engine | verified | high | 95% | 2026-09-08 | THE BIGGEST SINGLE MOVE THE STANDALONE HAS MADE. closed 46 / regressed 0 on turn1->turn2 (204 -> 158) and closed 16 / regressed 0 on turn2->turn3 (103 -> 87); against the do-nothing baseline that is 209 -> 158 (51 closed) and 108 -> 87 (21 closed). The brief's 32-leaf nve target closed IN FULL (8 NVE counts, 8 EPid, 8 ETS, 8 Eid) plus 8 ltis and 6 EFlags on pair 1. **PREDICTION COMMITTED IN 55ea86f BEFORE ANY ENGINE FILE EXISTED AND MATCHED LINE FOR LINE.** VALIDATED ON THREE PAIRS IT WAS NEVER FITTED TO, all zero regressions: human-turn2->3 353->311 (42 closed, a DIFFERENT game/map/species mix, 21 systems), zuul15->16 276->264 (12), zuul16->17 341->329 (12). **128 leaves across five pairs, 0 regressed** |
| THE GATE IS AFlags - and the near-miss is the finding | verify | verified | high | 100% | 2026-09-08 | NVE is a std::map<playerIdx, {int16 ETS, int32 Eid}> - what this player LAST SAW at this system: turn of sighting + the encounter type that was there. Writer 0x00756300 (ETS = server->Frame), called from tail phase 17 under one gate, BYTE-DECODED at 0x007cf7a7..0x007cf7ce, not taken from the decompiler. **THE GATE IS `AFlags`** - the DERIVED, NON-STICKY union FFlags|GFlags|isOwner - NOT VFlags, NOT EFlags. That is load-bearing: **all three masks agree on nearly every system of every save, so a model on the wrong one LOOKS RIGHT**. zuul-turn23-fleet23.sav's Bismol separates them, and the engine reproduces the freeze on the save itself (ETS and ltis stay at 22 while every other system moves to 24) - with a host test that asserts BOTH the freeze AND what the wrong gate would have produced |
| ltis writer NAMED (was an unnamed offset) | objects | verified | high | 90% | 2026-09-08 | Lane E3 names a writer this board recorded as unnamed: ltis's writer is 0x00743ec0, `if AFlags != 0: ltis = Frame` (decompiler-level, not byte-decoded). Also EFlags |= AFlags in tail phase 21. AND lane B5's flagged indirect edge is RESOLVED: SetExploredBy's vft[0x1c] is ServerSystem vtable slot 7 = 0x007480b0, and it writes only an UNSERIALISED runtime mask |
| visibility: what remains and the workloads | verify | backlog | — | 0% | 2026-09-08 | (1) **Eid's source is a HYPOTHESIS** - the original reads StarSystem+0x184, set once at map generation and NOT ON THE WIRE; the engine recovers it from the encounter fleet's FtEnc and agrees on all six encounter fleets in the corpus. NEEDS: a save where an encounter fleet is DESTROYED at a system that stays visible. (2) **NVO.TShn** (10 leaves/pair) EVALUATED AND REPORTED, NOT WRITTEN - its gate is demonstrably NOT AFlags (Spica has AFlags==0 and TShn moves anyway); needs a watchpoint on the +0x274 map, with Spica vs Bismol as the discriminating pair. (3) **rcex** (6 leaves/pair) toggles 0 -> 1<<16 -> 0 with this cluster; UNEXPLAINED, UNASSIGNED. (4) NEVER EXECUTED BY ANY SAVE: the map's multi-entry ordering (every corpus system has a single-bit AFlags) and the alliance intel-sharing rule 0x00754d90 (no save has an alliance) - needs a two-empire-contact save |
| income chain 25/25 - and the roadmap's item 1 was WRONG | subsystems | verified | high | 90% | 2026-09-08 | Lane E1 took the BnkEl oracle from **6/25 to 25/25**. THE MULTIPLIER RESOLVES FULLY: StrategyServer::IncomeDifficultyMod 0x0080f470 returns f32(f32(triple[1]) x f32(server->IncMod)) where IncMod is the Sim block's own tag, ON THE WIRE; DifficultyMods::Select 0x0059b490 returns the AI triple iff p->[0xf9] && !p->NPC; ServerPlayer+0x36c is an UNNAMED UNSAVED pointer filled by ServerPlayer::Read via LoadDifficultyRow. **THE TABLE IS BUILT IN CODE** (BuildDifficultyTable 0x005a3870) from .rdata float literals - no data file, no GlobalConst key, the same shape lane N found for the pop-type table; id 1 (which EVERY corpus save carries) is AI {3.0, 1.1, 1.5}. Q3 WAS WRONG about the third column: it is a RESEARCH multiplier, not a trade one, and the first is a fleet-maintenance DIVISOR. **BUT P01/P02/P03/P05/P06 ARE NOT UNBLOCKED AND MY ROADMAP ITEM 1 WAS WRONG**: ComputeBudget at 0x008631fd BRANCHES - projected mode uses ComputeMaxIncome, THE TURN PATH USES ComputeOutput 0x00751fb0 WITH THE SYSTEM'S OWN Rts SLIDERS, where the repair pass runs and the unspent-industry/terraform cascades into the money channel are live. A strictly larger function, and nothing in the corpus states its answer. The RNG reason narrowed from "a system's money is unmodelled" to "the PROJECTED-RATE money is" |
| E1's own prediction was wrong, and recorded as wrong | verify | verified | high | 100% | 2026-09-08 | Lane E1 predicted the 12 remaining Zuul misses were the suitability cost. THEY WERE NOT - every corpus colony sits EXACTLY at its species' ideal, so CalcSuitMod is 0 and that whole term is UNEXERCISED. The misses were in the OUTPUT half: SpeciesDef +0x4c/+0x50 are PER SPECIES (0/10 Human & Tarkas, 10/40 Zuul) and the predictor carried them as one global pair; 400 output points = 2000 money per Zuul colony, and the observed 4400 and 5566 deltas fall out TO THE UNIT. FALSIFICATION ACTUALLY RUN: model 25/25; "nobody is AI" 14/25 (the 11 AI records break); "both real players AI" 14/25 (the 11 human records break, each by x1.1); human species pair forced on Zuul 0/2 |
| T31 unblocked in substance, still listed blocked | verify | mapped | high | 90% | 2026-09-08 | closed 0 / regressed 0 - it self-checks every run against the save's own BnkEl (8/8, 7/7 ... on ALL 11 saves) and closes nothing because the limits move between turn1 and turn2 from CIVILIAN POPULATION GROWTH, which is not committed, so our value equals the input's. Still blocked on two named things, NEITHER THE FORMULA: ServerPlayer+0xf9 (is-AI) is a game-setup input not on the wire (--ai-player N supplies it), and BnkPr needs BANKRUPTCY_PROTECTION_LIMIT_FACTOR from the data files - committing it from a zero constant REGRESSED a leaf in the first measurement, so it is now gated on haveTuning |
| NEW WIRE FACT: ISsp/ISsu is server->IdealSuit[] | objects | verified | high | 100% | 2026-09-08 | Lane E1: the Sim block's ISsp/ISsu IS server->IdealSuit[] (0x0080f4b0, raw base +0xf8), randomised per game by the map generator, cross-checking against every ServerPlayer.IdealSuit in all 11 saves. **So the suitability cost's ideal needs no data file.** struct-recovery.md 5 lists the tags without naming them |
| income chain coverage - read before quoting 25/25 | verify | backlog | — | 0% | 2026-09-08 | Lane E1's own caveat list: UNTESTED are the suitability cost (x0 everywhere - every corpus colony sits at its species' ideal), slaves, addiction, morale, stations, the civilian capacity surplus, difficulty levels 0 and 2, and the maintenance/research columns of all three difficulty rows. `aidf` is 1 on all 25 records |
| determinism oracle REPRODUCED after everything changed | verify | verified | high | 100% | 2026-09-08 | Lane H, first task. ref-turn2 + one End Turn wrote (Autosave EndTurn) = **bb4fd9ac89f41e3b...** and (Autosave) = **978041acd168b56e...** - IDENTICAL to the historical values, with on-screen savings matching too (289,688 -> 532,369). This revalidates the oracle across EVERYTHING that changed since 2026-09-07: nineteen template hooks and seven RNG detours instead of one asm stub, the x87 module, eight save files instead of four, and a Zuul game played to turn 64 in the same directory. Reproduced TWICE MORE with hooks=off, two fresh processes |
| THE INSTRUMENT PERTURBED THE GAME (rule 19) | verify | verified | high | 100% | 2026-09-08 | **NO PREDICTION COVERED THIS.** Three End Turns from ref-turn2 gave 19/18/19 words where lane Z recorded 19/18/20 - from a BYTE-IDENTICAL input - producing an autosave 4 bytes different. Six single-End-Turn runs bisected it (lane H added a `probes=` config key so the set is switchable): hooks=off, all template hooks + 7 draw-site detours with probes=0, +probes 0-3, +probes 4-7 ALL give b2124798...; **+probe 8, a MinHook detour on GenerateTradeRaidEncounters 0x00893290, gives 058656...**. TWO CONSEQUENCES: **lane Z's ledger is behaviour-neutral and its published numbers STAND** (never checked before - it came out right by luck, not design), and every number in this lane was re-taken at probes=8. MECHANISM UNDETERMINED: the 5 patched bytes land on a clean boundary (push ebp; mov ebp,esp; push -1) with no branch target inside, and the SUPPRESSED draw is RollResearchEvent's, which runs EARLIER IN THE TURN than the hooked function. Now rule 19 |
| P2 HELD: Rung B stays as written | verify | verified | high | 100% | 2026-09-08 | Lane AI1's priority insert, run by lane H before release. ref-turn2 loaded in **TWO SEPARATE PROCESS LAUNCHES** (pids 3400, 2028) with hooks=off, one End Turn each -> **BYTE-IDENTICAL autosaves, equal to the oracle**. Byte-identity subsumes ModCount and Summary.Checksum. **So the AI is fully reproducible from a save alone and Rung B does NOT need restating** - implement the AI and the byte-match is reachable. Seven fresh processes across two saves agreed today in total. P1 NOT ANSWERABLE AS SPECIFIED: session.log after a full load + End Turn with an AI player is 22 lines with no RunAI:/random seed/AI Process Turn - level-2 logging is not emitted by this build; one suggestive counter-indication to the all-zero seed is recorded as a hypothesis with the decisive test named. P3 not run (no watchpoint armed) |
| tail callees: ENTERED every turn, gated INSIDE | verify | verified | high | 100% | 2026-09-08 | A much stronger negative than the ledger had. All four outer callees (0x008877b0, 0x0088db80, 0x0088ef80, 0x0082cca0) are entered **1x per End Turn**, 3 turns, 2 saves; all three inner draw-bearing functions are entered **0x**. No draw-site row at any of the eight return addresses; tail cost 0. So "never observed firing" was about A GATE, not reachability. Reading the bodies: slot 15 loops the trade-route vector and spy slot 14 the spy-program vector (empty containers) - but **slot 13 loops the FLEETS vector**, so lane H committed a competing prediction that a fleet-rich save would draw. **FALSIFIED**: on zuul-turn23-fleet23 (23 fleets) slot 13 ran and the inner function still ran zero times, so the gate is the PER-FLEET TEST CHAIN, not the loop bound |
| CreateRaidEncounter: hypotheses separated (rule 20) | verify | verified | high | 100% | 2026-09-08 | **Entered 2x on Zuul turn 24 and 1x on turn 25, drawing 0 words every time.** So rolls succeed at about the predicted rate and **THE CANDIDATE LIST IS EMPTY** - the two hypotheses a word count could not separate. Lane H also corrected lane Z's prior: three quiet turns at that rate is **1-in-720, not "unremarkable"**. CAVEAT FOUND AND REPORTED: 0x007d5150 ALSO dispatches slot 17, so lane Z's caller list is incomplete and an entry count alone cannot attribute |
| Zuul word count: 14, exactly as predicted | verify | verified | high | 100% | 2026-09-08 | Lane Y's standalone predicted **14 words on a Zuul turn, not 16**. Both Chance sites read **7 calls / 7 words** on two consecutive Zuul turns (vs 8/8 on Human), turn total 18. **The raid loop iterates StrategyServer::Players and nothing else** - lane Y's trade_raid.cpp is right. A NUMBER NO EARLIER RUN PRODUCED |
| DetectEncounters residual is NOT where anyone thought | verify | verified | high | 95% | 2026-09-08 | EncounterDetect_ProcessTeamRecord recorded **ZERO calls** over five End Turns while an entry probe on EncounterDetect_Run recorded **one per turn** - Run calls it once per record, so **the record vector is empty**. The +0xfc gate is never even evaluated; lane I's |contacts| x |detectors| bound is INTACT AND HAS NOTHING TO BOUND. A call-graph walk (every edge single-caller) puts both residual sites in ONE 744-byte function with one call site: DetectEncounters -> 0x007d5150 -> {0x0078cc70 -> NextFloat; 0x00792750 -> NextInt}. It loops players; **one word per player that passes a per-player gate**, and on both corpus saves exactly one passes - so "2/turn" is A COINCIDENCE OF THESE SAVES, not a constant. A real handle for the standalone |
| trade-route + spy workload NOT BUILT, with reasons | verify | backlog | — | 0% | 2026-09-08 | Lane H could not build it and said exactly why: from ref-turn2 (turn 2, 1 colony, 0 techs) trade income needs trade-station construction PLUS its tech - tens of 30-60 s End Turns with a Build/Research click path each; **the corpus's newest Zuul save (turn 23) STILL has no trade routes** (slot 15's body did not execute on it); and NO LANE HAS IDENTIFIED WHICH UI produces an entry in the spyManager+0x10 vector, so there is no click path to write down. WHAT IT GOT INSTEAD IS BETTER FOR THE NEXT ATTEMPT: two named vectors to watch fill, so a workload can be CONFIRMED BEFORE a turn is spent measuring |
| VM menu detection: use Exit-button redness | meta | verified | high | 100% | 2026-09-08 | The intro screens are bright white so brightness-based menu detection fires EARLY. Detect **redness in the Exit button**: `crop 100x14+462+674`, `mean.r > 0.2 && mean.g < 0.15`. With that, all ten of lane H's launches hit the menu first try at t+48-54 s |
| lane H self-report: touched a shared worktree | meta | verified | high | 100% | 2026-09-08 | Recorded because it was self-reported. Lane H ran `git checkout -- include/generated/sots_addresses.h` in the SHARED ~/sots-engine main worktree, discarding an uncommitted regeneration another lane had in flight. It is a generated file and was regenerated, so no content was lost - but it was not that lane's tree to touch. Reinforces: lanes work in their OWN worktree, and generated files are regenerated by the integrator at merge |
| AI TASK SELECTION LOOP FOUND - it is not a scorer | control-flow | verified | high | 95% | 2026-09-08 | Lane AI2. **0x006cf630**, Process-Turn phase 20, which AI1 had listed only as an address. THERE IS NO SEARCH: PruneTasks -> `switch (player->Species)` (7-entry table @0x006cf880, FOUR distinct arms) building a FIXED, SOURCE-ORDERED list of per-family task creators -> PruneTasks -> **sort TaskList DESCENDING by task->vt[10]() using MSVC std::list::sort, which is STABLE** -> two passes of `task->vt[5](agent, pass)` -> PruneTasks. And vt[10] is, for 26 of 31 task classes, a CONSTANT LOOKUP on the task's type id through **0x00691f00** - 33 arms, each `mov eax,imm32; ret`. **THAT TABLE IS THE AI'S ENTIRE STRATEGIC ORDERING POLICY** and it is in the findings verbatim (DeployGateAt 1400 down to AdvanceIdleShips 0). Five overrides; the two artifact tasks IGNORE their table entries and return 1260/1261. The switch is on player+0x5c = Species, VERIFIED THREE WAYS: arm 1 is the only one building gate families (Hiver), arm 5 the only one building AITNodeBore (Zuul), and arm 4 - species 4, the engine's own Species::NPC - **BUILDS NOTHING AT ALL**. Both defensive families sit behind player+0x2d8 = the save field `plcy` |
| order-method -> TurnCommands mapping COMPLETE | objects | verified | high | 95% | 2026-09-08 | Done image-wide (94 `lea ecx,[r32+0x160]` sites in 76 functions). **ALL 27 of lane Q's lists and ALL SIX prologue gates now have a NAMED PRODUCER.** AI1's 21-row table was incomplete - there are **26** order methods, and the missed ones include list 5 (system rates, AI-called) and list 7 (colonize, AI-called). **LANE Q'S UNEXPLAINED LIST 14 IS THE AI'S FLEET ORDER**: 0x007634d0(fleetObj, mode, flag) -> {fleetId, mode, flag}, adder 0x00842a00 at +0x10c keyed on (fleetId, mode); the AI bridge 0x006987e0 calls it **TWICE**, (f,0,true) then (f,1,true), while the UI emits only mode 0. 24 of 31 tasks' Execute reach it. **That is a ModCount prediction: TWO bumps per AI fleet order, not one** - against lane A2's measured 10 |
| AI throttle SETTLED - Rung B is safe | control-flow | verified | high | 100% | 2026-09-08 | The open question from lane AI1 (if an AI turn can span frames, the "all AI orders in before the human's End Turn" ordering needed revisiting). **StrategyApp::RunPendingAITurns 0x00838c60 is called EVERY FRAME from Update 0x007842b0**, walks the pending-AI vector at StrategyApp+0x1c IN INDEX ORDER raising SEResumePlaying on each - NO early exit, NO yield - and THEN does Sleep(AIProcessMinTime - elapsed). **AIProcessMinTime is a TRAILING MINIMUM-DURATION SLEEP FOR THE PROGRESS DIALOG, not a compute budget.** An AI turn CANNOT span frames; AI1's ordering holds |
| g_CurrentClientIndex is a STACK POINTER | objects | verified | high | 100% | 2026-09-08 | Not an index. Two writers only, Push/PopCurrentClient 0x00578020/40, and StrategyAIAgent::OnEvent BRACKETS THE WHOLE AI TURN in them - that is how every cl_* call lands on the right client AND the right per-client RNG. Also: AI1's one inferred hop is now READ TO THE CALL at 0x006b395b (Broadcast -> OnAIPacket), push order matching the thunk exactly; lane-ai1.json's Broadcast and g_CurrentClientIndex upgraded mapped -> verified in place |
| AI2 corrections to AI1 | meta | verified | high | 100% | 2026-09-08 | **31 task classes, not 34** - slot-1 x slot-8 across 31 vtables gives a gapless type enum 0..0x20 with two retired ids. **26 order methods, not 21.** **7 pure virtuals, not 8** - named: GetTypeId, GetTargetA/B, Execute(agent,pass), IsFinished(agent), GetTypeName, Describe. Also a test expectation was wrong on first run (ColonizeAt 1300 outranks NodeBore 1275) and lane AI2 FIXED THE TEST, NOT THE CODE |
| AI2 boundary | meta | verified | high | 100% | 2026-09-08 | Slots 11/12/13 UNNAMED. The nine tasks with no reachable order method are INFERRED to be goal/planner tasks - bodies unread. The two-pass meaning is INFERENCE. The reachability table is a DIRECT-CALL CLOSURE = lower bound (rule 16 bites hard in a vtable system). **The StrategyApp+0x1c ENQUEUE SITE WAS NOT FOUND, so AI-stepping order is still open** - and that matters because ModCount is a count. The .data invade tunables 0x00a1795c/60 have no traced loader. NOTHING RAN UNDER AN INSTRUMENT; four falsifiable predictions with symptoms are in the finding |
| INTEGRATOR NOTE: do not share a build dir with a background gate | meta | verified | high | 100% | 2026-09-08 | I read "98% tests passed, 1 failed" and started hunting a flaky test in a DETERMINISM project - a serious thing to chase. It was my own error: I ran an ad-hoc ctest against the same CT111 build directory while the BACKGROUND gate job was still writing binaries into it. The gate itself reported 47/47 and the shim built; 13 further clean runs (5 serial, 8 at -j4) confirm. RULE: one build directory per gate invocation, and do not query a dir while a background job owns it - the failure mode looks exactly like nondeterminism in the engine, which is the one thing this project must never misdiagnose |
| ComputeOutput turn path modelled | subsystems | verified | high | 90% | 2026-09-08 | Lane C3. **+1 leaf closed, 0 regressed, on BOTH pairs** (reference 209 -> 157, closed 52; pair 2 108 -> 86, closed 22); the leaf is /Sim/players/Player[576 "Independent Colony"]/Sav both times. THE FUNCTION IS THIN: 0x00751fb0 zeroes 12 slots, calls ComputeOutputFromRates(out, &sys->Rts) with the system's OWN, UNNORMALISED sliders, then fills three reporting slots - and **out[3], the only slot ComputeBudget reads (confirmed at 0x008631fd), is written entirely inside ComputeOutputFromRates and never touched again**. THE LOAD-BEARING FACT NOBODY HAD: **construction points the build queue and the ship-repair pass do NOT spend are REDISTRIBUTED over trade/terraform/infra, and the trade share is added to the money channel** - and unspent infra cascades into the TERRAFORM pool, which cascades into money. TWO HOPS, NOT ONE. So a colony with an empty build queue earns the same money whichever way its sliders point. Which float decides an output value: **SRsc and the build-queue demand**; everything else in the corpus is suppressed by the normaliser or rounds to nothing |
| the two-path cross-check decomposes TO THE UNIT | verify | verified | high | 100% | 2026-09-08 | Stronger than "the two paths agree". The engine now runs BOTH the projected path (BnkEl-constrained) and the turn path on the same colony state every load and reports the difference - and on all 11 saves **every delta decomposes to the unit into the build queue's points priced through the money chain**: 5 saves agree exactly (all queues empty); human-turn3 differs by 37 = one five-point block x 5 x IncMod 1.48; zuul-turn23 by 2035 = 370x5x1.1; zuul-turn5 by 39160 = 7120x5x1.1; zuul-turn17 by 21010; zuul-turn16 by 36438 (within the 1 unit two truncations of a .5 allow). The two paths differ by EXACTLY what the disassembly says |
| S11 CIVILIAN GROWTH is the highest-value next item | verify | backlog | — | 0% | 2026-09-08 | Lane C3's headline for the roadmap: the residual on the metric is UPSTREAM, not in the output formula. ComputeBudget runs AFTER S11 SystemTurn (strategic phases 11 then 13), and our S11 DOES NOT COMMIT CIVILIAN GROWTH. The human's savings come out 289,092 against the oracle's 289,688 - the growth gap (597) minus one trade point. **ONE INPUT (imperial carrying capacity) GATES the human's Sav, PvSav, BnkEl AND BnkPr on both pairs.** The AI's are wrong for a different reason: its ResRate/ResTNm are its own orders, i.e. Rung B |
| the last unmodelled output input: ship repair demand | verify | backlog | — | 0% | 2026-09-08 | Nine inputs to the turn path; EIGHT ARE ON THE WIRE (Rts, dsu, IdealSuit, TerraMod, Infra/ibon, BQ/ords/conleft, NumSnF, the output total). The ninth is **the repair demand of damaged ships in orbit**, needing Ship::RepairCost 0x00815180, which is UNREAD - it is taken as 0, so **a colony with a damaged fleet reads HIGH**. Also note this is the same repair pass that makes ComputeOutputFromRates unsafe to call for harvesting (the B1 replace double-run defect) |
| the two RNG words: the blocker MOVED again | verify | mapped | high | 90% | 2026-09-08 | They still cannot enter the generator model and THE REASON HAS CHANGED AGAIN. The allocation is now computable - the money input was the whole gap. What blocks them is one step further out: **the only corpus player reaching ProcessResearch with a target is the AI**, whose research rate and target are generated during the same turn (Rung B). On turn1-state NO player has a target, so both words cost zero on the reference pair - **but rule 20 applies to that sentence**: a count cannot separate "not entered" from "entered and drew nothing". AN ENTRY PROBE ON ProcessResearch SETTLES IT IN MINUTES. Ledger unchanged at 16 words modelled |
| two callees with NON-STANDARD conventions | objects | verified | high | 100% | 2026-09-08 | Recorded because they read WRONG as __thiscall and would silently mis-model: **0x0074c6f0 takes `this` in ESI**, and **0x007460b0 takes the system in EBX**. Same class of trap as lane P2's two helpers that read ebx/esi live-in without ever writing them |
| shpt[0] CANNOT close from the build queue - the blocker moves to game/ai | verify | verified | high | 100% | 2026-09-08 | Lane B6 closed 0 and regressed 0 **and predicted exactly that before writing code**. MEASURED, NOT ARGUED: turn1-state and turn2-state each carry three BQ frames (players 16, 32, 576) and **ALL THREE HOLD ZERO ORDERS**; every `hbq` in both files is false; the only TurnCommands_v5 block is player 16's byte-identical empty one, the other three CDT blobs being AIAgent caches. **AT LOAD THERE IS NO BUILD ORDER ANYWHERE IN THE GAME**, so a faithful pass has nothing to advance. The destroyer is the AI's, created inside the turn: NumDes 5->6, NMnx 106->109 with ids issued design(1712), ship(1728), fleet(1744) IN THAT ORDER, FNGNum 0->1, Maint 0->500, srb[0] 0->1. **DECISIVE: PvSav 50,000 -> 38,100** - 11,900 left the treasury BETWEEN THE SAVE AND SPINE PHASE 0, the queue-time deduction of an order the input file does not contain. So lane E2's new blocker is Rung B, not S11 |
| build queue read; prototype corrected | control-flow | verified | high | 95% | 2026-09-08 | S11 -> ServerSystem::ProcessTurn -> ProcessBuildQueue -> BuildQueue::ProcessTurn 0x00890d50, between the plague pass and population growth. Ghidra says 1230 bytes; the real body is **1264** and Ghidra's end lands INSIDE THE EPILOGUE (rule 17 again). **CORRECTED PROTOTYPE: arg0 is NOT the ServerSystem - it is [sys+0x10]-4, the StrategyServer S frame**, so the build-completed event goes on the SIM (S+0x2b0) and the hull's turn stamp is S+0xc = Frame. Per completion: create hull -> tblt = Frame -> vft[10](sim, ship) fleet routing -> owner->ShipRecords.built[hullClass]++ -> find-or-append the per-design record -> post event -> points -= conleft; conleft = 0. **`inc [owner + class*4 + 0x1b4]` has EXACTLY ONE WRITER in the whole image** (image-wide byte scan), and the encounter spawner that also creates ships touches no ShipRecords - so `srb` cleanly discriminates PLAYER-BUILT hulls |
| CORRECTION to lane B5: FtFlg 0x400 is not retreat's | objects | verified | high | 100% | 2026-09-08 | **FtFlg |= 0x400 is set by EVERY fleet born through the create path, not by retreat** (build adds 0x20 on top). Also from the birth read: StarShip is 176 B, id from lane B5's allocator (model reproduced EXACTLY at the instruction level), FltID born NULL; a hull joins its system's CACHED HOME FLEET at ServerSystem+0x238 and a fleet is created only when that slot is empty; there is no scalar ship count (NShips is the vector length); FNGNum bumps once per GENERATED name |
| NEW BLIND SPOT: stack-built function-pointer tables | meta | verified | high | 100% | 2026-09-08 | A third way this binary hides call edges, after inlining and vtables. The ship-borne build-queue wrapper 0x00789500 has **zero call sites AND no vtable slot** - its address is written into a **STACK-BUILT function-pointer table** at 0x007b9c4b. `tools/vtable_map.py` cannot see that class, so **"no vtable caller" is ALSO a lower bound**, not just "no direct caller". (Related: the base BuildQueue is pure from slot 4-10 and SystemBuildQueue binds slot 10 to the function that is literally the "next function start" bounding ProcessTurn - which is why the fleet half looked absent) |
| AN ORACLE THE CAMPAIGN ALREADY OWNED | verify | verified | high | 100% | 2026-09-08 | Lane B6 noticed that **zuul-turn16-noderoute -> zuul-turn17-rollpending is a real CONSECUTIVE-TURN PAIR** - six completions and one partial advance across two systems - which nobody had used. Its test **SOLVES** for the point total (0..200,000) rather than assuming it, and **asserts the solution is UNIQUE** (4182 and 9782): a per-order budget, a non-FIFO drain, or skip-instead-of-stop each make the solution set come back EMPTY. The completions then reproduce the ShipRecs deltas both files carry. That refutes its falsification hypothesis 3 AND independently confirms lane B4's ProcessBuildQueue on all four rules the corpus can reach |
| build-queue hypotheses (unexercised) | verify | backlog | — | 0% | 2026-09-08 | Labelled by lane B6: the slot-9 MONEY CHARGE (no design in the corpus costs money), a ship-borne queue WITH an order, srl/srk/sri (all zero in 11 saves), the four non-default routing handlers, and a corrected colony rule (**with points <= 0 the original skips the removal sweep too**) that is unobservable in this corpus. THE WORKLOAD THAT CLOSES ALL OF IT: load zuul-turn16-noderoute.sav on VM140, End Turn, keep the AUTOSAVE - that converts the fitted point totals into a full before/after oracle for the pass, the ship birth and the fleet birth at once |
| treaty turn stamps (H02) - 26 leaves | engine | verified | high | 95% | 2026-09-08 | Lane T2. **Reference pair 157 -> 131 (closed 52 -> 78), pair 2 86 -> 72 (closed 22 -> 36), regressed 0** - +26 and +14, **EXACTLY the numbers predicted in writing before building**. Three further pairs the model was NEVER FITTED TO - a different game, species 5, turns 2/15/16 - each closed **+12, regressed 0**, which is PRECISELY the number of ordered treaty pairs each save holds. **76 leaves closed, 0 regressed, 0 RNG words, across five pairs**, and the deltas are identical against both 0f1c007 and 2fd0852 so it is orthogonal to C3's and B6's work. StampTreatyTurns 0x007898c0, instruction-verified: the diplomacy ledger's "this treaty was last in force on turn N" stamp over every ordered player pair, creating the entry on demand; sole caller is ApplyTurnCommands so it is a HOST STEP, before both drivers. Reproduces the ledger of 10 of 11 saves ENTRY-FOR-ENTRY INCLUDING ORDER (the 11th is turn1-state, whose ledger no turn has written) |
| THREE corrections to strategic-turn-internals 5.2 | subsystems | verified | high | 100% | 2026-09-08 | All flagged in place by lane T2: (1) relation codes are **3 = ally, 2 = NAP, 1 = cease-fire**, NOT the reverse - and 5.2 had self-labelled MEDIUM on exactly this point while combat-done-tail.md 2A implied the right answer independently; (2) the stamped value is **Frame, not ModCount** (`mov eax,[edi+0xc]`), and the stamp equals the save's own Frame on 10 of 11 saves while ModCount runs 0/12/24; (3) the bit is the **PlyrIdx field, not the vector position** - THE OPPOSITE CONVENTION from the shared-vision mask two files away, and no save separates them |
| rule 17, seventh instance - a clipped sweep would call it DEAD | meta | verified | high | 100% | 2026-09-08 | Lane T2's sole-caller claim EXISTS ONLY because it disassembled past Ghidra's function end. ApplyTurnCommands is sized 2965 B ending 0x007b2445; **its real body runs to `ret 0x10` at 0x007b248d and the call sits at 0x007b2461**. A sweep clipped at fva+size finds NO CALLER AND CALLS THE FUNCTION DEAD. Seven functions, seven lanes |
| ROADMAP ITEM 6 WAS WRONG: there is no such writer | verify | verified | high | 100% | 2026-09-08 | backlog item 6 had Player.Status blocked on "the writer between tail 31 and the autosave - watchpoint". **THERE IS NO SUCH WRITER.** A whole-image scan for immediate stores to ServerPlayer+0x164 finds THREE: ProcessTurn's encounter loop writes 1; ResumePlaying writes 0 on load (the 4->0 the determinism note recorded); and StrategyServer_MarkPlayerTurnEnded 0x00821a40 writes 4 from the three End-Turn SUBMISSION paths, BEFORE the turn runs. **Item 6 needs one predicate, not a VM.** Lane T2 did NOT write it: only two corpus saves carry a non-zero Status at all, and `Species != 4` fits 8 observations on a 1-BIT PREDICATE THAT NINE SAVES DO NOT EXERCISE - rule 20. An entry probe on that function names the set directly |
| the tail's own list is correct and UNACTIONABLE | meta | verified | high | 100% | 2026-09-08 | Lane T2 ranked the tail by leaves-closed-per-effort against the 158-leaf residual and found **every tail phase that moves a leaf in this corpus is blocked on someone else**: T24 (Maint/ShipRecs, 9+5 leaves) waits on ship construction; T31 and T36 are already Blocked on the budget; T05 writes deadhome/bty* and **NO BATTLE OCCURS ON EITHER PAIR** (every encounter result has res->+0x4 != 0); T02/T30 move no leaf at all; the SvSctOb cluster is the tail's tier-4 vtable blind spot; TShn/rcex are lane E3's named residual with a watchpoint already specified. So it took a HOST STEP instead and said so plainly. The ranked table is in findings/subsystems/treaty-turn-stamp.md 5 |
| MULTIPLAYER TIER 0 WORKS - verified end to end | subsystems | verified | high | 95% | 2026-09-08 | Lane W2. **A two-player game was created, joined, launched and played for TWO TURNS IN LOCKSTEP on one Windows guest, with zero GameSpy traffic.** Capture was tcpdump on spicy's tap140i0 - OUTSIDE the guest, so it cannot perturb it - **44,319 packets, EXACTLY 0** to UDP 27900/27901 or TCP 28910/28900/6667/6500. Instrument removed (hooks=off) per rule 19. Host: Host Multi-Player -> Custom + LAN -> Create Game; joiner: `/concurrent /join 127.0.0.1:3369`. Joiner appeared in slot 2, Ready -> Launch -> Turn 1, both ended turn -> Turn 2 -> Turn 3, distinct treasuries. SOCKETS: host binds UDP 3369 + ephemeral, client binds ONLY ephemeral, **no TCP anywhere**, and **3370 is NOT bound at lobby time**. All five of lane G2's predictions confirmed, restated in docs/W2-predictions.md BEFORE the run. Also: LAN discovery listed the game on first open with no Refresh, browser-mediated join works and makes NO NatNeg attempt, and **G2's own caveat (a) is FALSIFIED** - Join Manually is enabled with every gamespy.com name dead |
| the developer shipped the answer in the client | subsystems | verified | high | 100% | 2026-09-08 | Two things nobody had. The **MOTD is fetched live on every launch** from www.kerberos-productions.com (STILL ANSWERING, Last-Modified 2017-06-16, User-Agent GameSpyHTTP/1.0) - and its text is Kerberos telling players to **host in LAN mode and use direct connect**, i.e. the developer's own statement of lane G2's `+0x2c == 1` predicate, shipped inside the binary. Also: the Windows firewall prompt fires at **Create Game, on the host**, not at launch. AND a correction to the Tier-1 hosts list: the browser resolves **THREE** names, not one (available, ms5, peerchat); master/natneg are host-path only; exactly one DNS query each (the SDK's 2 s retry is a SOCKET retry and never runs). **Tier 1 is confirmed unnecessary** |
| multiplayer: what is NOT covered | verify | backlog | — | 0% | 2026-09-08 | Stated loudly by lane W2: **loopback only** (no two-box or VPN test), **NO COMBAT** - 3370 and the combat lockstep are untested and are the likeliest remaining breakage - two turns only, two Human players, and sots_server.exe not run |
| watchpoint module + ModCount CONFIRMED to the address | verify | verified | high | 100% | 2026-09-08 | New `src/shim/hooks/watchpoints.{h,cpp}`: DR0-DR3 4-byte write watchpoints armed from a SINGLE MinHook detour on ApplyAllTurnCommands, a VEH recording EIP/value/DR6/EBP-chain/stack-scan, a **CANARY SELF-TEST** (arm a word we own, write it once, require exactly one trap), and `watch=on|off` so the two configs differ only in that key. **RULE 19 CONTROL PASSED: with all four armed, one End Turn reproduced the oracle BYTE FOR BYTE - the armed run IS the oracle run.** ModCount: **exactly 12 per End Turn, twice** (13->24, 25->36), lane A2's prediction confirmed TO THE ADDRESS - both predicted sites exact, zero at the third, whole chain confirmed live. REFINEMENT: the ten command bumps PRECEDE ProcessTurn; the two driver bumps BRACKET it (A2's falsifier (c) fires as a refinement, not a refutation). **Four of the ten handler EIPs are unnamed** - 0x00821a84 fires 4x/turn and is the one to disassemble first. **And 12 is NOT a constant**: other corpus games run 15-25/turn |
| CORRECTION to lane T2: the Player.Status writer EXISTS | verify | verified | high | 100% | 2026-09-08 | Lane T2 concluded from a whole-image scan that no writer sits between tail 31 and the autosave. **It does.** Full measured order per turn: MarkPlayerTurnEnded+0x35 writes 4 to each player -> Frame++ -> 10 ModCount -> ProcessTurn+0x5ca writes **1** (local player only) -> **StrategyNetworkClient::OnMessage+0xa15 writes 4** -> OnAllCombatDone_Tail -> AUTOSAVE -> ResumePlaying+0xb1 writes 0. A static scan for immediate stores missed it; the watchpoint found it. **Roadmap item 6's original premise was right and my correction of it was wrong** - restore it. (Lane AI3 separately notes 0x007ddd3f is also a Status writer, at the resume boundary AFTER the autosave) |
| ModCount vs Frame settled | objects | verified | high | 100% | 2026-09-08 | S+0x8 took 12 writes; **S+0xc took exactly ONE**, from BeginProcessTurn+0x2a, value = the turn number. So S+0x8 IS ModCount and S+0xc is Frame, confirming lane A2 by measurement. `addresses.json` has the name on the wrong word and lane T's `PhaseCounter` IS ModCount - fix at the next fragment fold |
| rcex CLOSED - with no VM time at all | objects | verified | high | 100% | 2026-09-08 | It is **sixteen 4-bit per-player counters**. Nibble p is set to **1** on the turn the system enters player p's AFlags, and the rcex tick in ServerSystem::ProcessTurn decrements it to 0 next turn. **7/7 across the corpus**, including human-turn2-orders.sav - a DIFFERENT game, different system, different player index (AFlags bit 3 <-> nibble 3). Falsifiers listed. `Bats2` is the same shape and remains a rule-6 hypothesis (0 everywhere) |
| MY BRIEF GAVE THE WRONG ADDRESS for TShn | meta | verified | high | 100% | 2026-09-08 | I briefed the TShn watchpoint at `player+0x274`. **That is `observed_techs`; the TShn map is `ServerSystem+0x274`.** Lane W2 caught it - arming the briefed address would have watched the wrong object and **reported a confident nothing**. The real obstacle is separate: a std::map's nodes are heap-allocated so there is no fixed leaf to arm; the fix is to arm the MAP HEADER to trap the insertion, which is the better question anyway, ~20 lines. ALSO: both blocked containers are one add from the existing arming point - `*(S+4+0x154)+0x40` (trade routes) and `*(S+4+0x158)+0x14` (spy programs) - and for the spy vector the trapped return addresses would **name the UI nobody has found** |
| P11 event posting - 4 leaves, and the bucket now VERIFIES | engine | verified | high | 95% | 2026-09-08 | Lane EV. Pair 1 **131 -> 127 (closed 82)**, pair 2 **72 -> 69 (closed 39)**, **regressed 0** - exactly the paths its prediction named (committed BEFORE the build), nothing else moved. STRONGER THAN THE COUNT: the bucket exists in both saves after the run, **so the checksum DESCENDS INTO IT and reports nothing** - the posted record matches the oracle on ALL EIGHT FIELDS, text included. FLT_MAX vs +inf and the EvAct rule are therefore ACTIVELY DISCONFIRMED as failure modes, not merely untriggered. WHICH EVENTS A TURN POSTS, verified across all 11 saves: turn1->2 posts two (player 0 EVENT_NO_RESEARCH id 1; player 1 EVENT_SHIPS_BUILT id 1); turn2->3 posts three (NO_RESEARCH id 2; SHIPS_BUILT id 2 then RESEARCH_OVERBUDGET id 3). **Order is readable off the ids: the build pass posts BEFORE the research pass.** Ids are per player, so no cross-player order is observable and none is assumed. Only TWO players in the entire corpus ever hold an event |
| TWO corrections to events.md | subsystems | verified | high | 100% | 2026-09-08 | (1) **events.md said the turn handed to PostEvent is ModCount. IT IS THE FRAME** - turn2-state has Frame 2, ModCount 12, and buckets EvTurn=2. (2) **The EVENT_NO_RESEARCH gate was read BACKWARDS**: 0x00584e50 is TechTree::CollectResearchedTechs (lane T's name; argument order re-read), NOT a ListAvailableTechs, and the middle test is *"nothing was researched on this turn or later"*, not *"no affordable tech"*. zuul-turn23-fleet23 exercises it live: RESEARCH_COMPLETE on turn 22 with NO no-research event that turn, NO_RESEARCH again on turn 23. The engine's P11 had only two of the three tests and OVER-FIRED |
| a CONTROL measurement worth copying | verify | verified | high | 100% | 2026-09-08 | Lane EV did not just report that its phase needs the AI roster - it MEASURED the cost of withholding it: with the roster withheld the phase over-fires and **REGRESSES 16 leaves on pair 1** - and ONLY on pair 1, because pair 2's input already carries the AI targets. That is why the phase stays Blocked and phase-granular. The AI research-selection gate itself is labelled a hypothesis with the workload named: across ~90 AI player-turns no AI ever posts NO_RESEARCH while the human posts it on every target-less turn |
| tail phases post NO events (measured negative) | control-flow | verified | high | 100% | 2026-09-08 | Confirming lane K from the other side: T18/T21/T32 post nothing on either reference pair - and this is a MEASURED negative, not an absence of evidence, since other corpus saves DO exercise them. No tail phase manufactures a turn's events |
| standalone_report gains --engine-arg | meta | verified | high | 100% | 2026-09-08 | Lane EV added a repeatable `--engine-arg` pass-through so a lane can feed the standalone the OPERATOR INPUTS A SAVE DOES NOT CARRY (e.g. --ai-player N, --data DIR) instead of hard-coding them; every run now records what it was given in the header AND in status.json. That is the right shape - the provenance travels with the number |
| headline number: default 131, with operator inputs 127 | meta | verified | high | 100% | 2026-09-08 | Worth keeping distinct rather than quoting the better one. `standalone_report.py` with NO flags reports **209 -> 131, closed 78** - that is the number for "a save and nothing else". Lane EV's P11 closes 4 more (127) but ONLY when handed `--data DIR` and `--ai-player N`, operator inputs the save does not carry, because a save does not record which players are AI-controlled (lane AI3: ServerPlayer+0xf9/+0xfa sit in a HOLE in the serialised layout). Both numbers are real; the dashboard tracks the default because it is the one that needs no human. Lane EV's --engine-arg pass-through records in status.json exactly what a run was given |
| civilian growth: MY BRIEF'S PREMISE WAS WRONG | subsystems | verified | high | 95% | 2026-09-08 | Lane G3. **+3 closed on each pair, 0 regressed**, by default with no flags (both colonies' Pop2/PopG/PopC and Player[16]/Sav); pair 1 131 -> 128, pair 2 72 -> 69. With --commit-blocked=T31 --ai-player 1 it is 83/0 and 41/0 - the extra two per pair being the human's and the AI's BnkEl, which T31 could not close before. **GROWTH IS NOT GATED ON IMPERIAL CARRYING CAPACITY** as I briefed: the whole system's civilian delta is CLAMPED TO 20,000,000 (POPTYPE[1]+0x08, an int64 literal in the executable), the uncapped delta is 150,000,005 (7.5x) and the capacity headroom 500,000,000 (25x), **so the clamp decides the value and the pass commits with NO TUNING TABLE AT ALL**. PRECISION BY CONSEQUENCE: the deciding float is neither the growth curve nor the capacity but the rescale `trunc(applied x (clamped/total))` - the product's error exceeds half an ulp of 20,000,000, so landing exactly on the cap is A MEASUREMENT, NOT A THEOREM. The growth fraction, which everyone looks at first, is the one term that CANNOT matter here: any value in [0.04, 1.0] gives the same answer. Also pinned the imperial capacity at exactly Size x 1e8 FROM THE CORPUS ALONE via two independent behaviours of one colony over three turns |
| A LIVE-VERIFIED MODULE WAS WRONG (rule 23) | verify | verified | high | 100% | 2026-09-08 | THE MOST IMPORTANT FINDING OF THE ROUND. Lane G3 predicted Sav closes on both pairs; it closed on pair 2 and came out **ONE MONEY HIGH on pair 1**. Growth had closed 596 of the 596-money gap - the residual was **ComputeBudget's interest literals, which are WIDENED FLOATS in the image** ((double)0.01f at 0x009e31c0, (double)0.15f at 0x009ed188) and then truncated, so a treasury of exactly 50,000 earns **499, not 500**. The engine used exact decimals; sixteen hand-computed test expectations moved by one. **That module had been compared on 4,437 LIVE CALLS WITH 0 DIVERGENCES and missed it, because it presented only 20 DISTINCT STATES, NONE ON A BOUNDARY** - exactly the thin-coverage caveat lane R recorded against it at the time, now vindicated by a different lane doing arithmetic the compare never exercised. Now rule 23 |
| Ship::RepairCost read; a stronger negative | objects | verified | high | 100% | 2026-09-08 | `max(0, design+0xcc - (ConCap + (flag ? design+0xd0 : 0)))`, plain integers; Ship::ApplyRepair 0x008151c0 names the fields - **ConCap is INVESTED CONSTRUCTION, not a per-turn capacity**, and +0xcc is its ceiling. Closes 0 leaves as predicted and the demand stays 0 (the design fields are cached stats off the wire) - but **the zero is now EVIDENCED, not silent**: the independent colony parks TEN SHIPS over Koa'Vo on both pairs and its Sav closes exactly at zero demand. Rule 20's stronger negative |
| CORRECTION: out[6] is not a repair function | subsystems | verified | high | 100% | 2026-09-08 | output-turn-path.md had 0x007460b0 as a repair function. **It is SHIP-CARRIED POPULATION INCOME** - its summand 0x0081f8c0 gates on the design's carried-population bit and computes GroupIncome over the ship's own Population, a slot the engine already had. Corrected in place; lane C3's three unread stubs superseded. ALSO: GrowCivilianPops' real end is 0x00754b59 (its group-type loop's back edge is OUTSIDE every decompiler `if` - rule 17, SEVENTH lane); MaxPopGeneric ends 0x0074a6cd so lanes N and E1's 0x0074a6d0 is A DIFFERENT FUNCTION; and **InitPopTypeTable NEVER WRITES the group ceiling** - the CRT static initialiser at 0x009abe20 does, to INT64_MAX, and read as zero it would cap every capacity in the game at nothing |
| growth hypotheses (nothing exercises them) | verify | backlog | — | 0% | 2026-09-08 | Named by lane G3: one species per colony everywhere so the proportional rescale is only exercised DEGENERATELY; nothing shrinks (the -50,000,000 floor and negative rescale unexercised); pbon2 empty everywhere so the headroom/write-back asymmetry is invisible; haltv[1] false everywhere; **GFlags 0 everywhere so the 1.5x growth BONUS never fires**; no species ever seeded; the morale-event half not modelled; and imperial growth DELIBERATELY not committed - a no-op here, and committing it needs a capacity the corpus bounds from below but not above |
| ModCount DECOMPOSED EXACTLY - zero residual | control-flow | verified | high | 95% | 2026-09-08 | Lane AI4. The ten command bumps lane W2 measured now decompose with **ZERO RESIDUAL**, and **all four of W2's unnamed handler EIPs are named plus the two it recorded as inlined**: 0x00821a87 x4 (research-rate gate, once per submitted block), 0x0086c3e9 (list 5 system rates), **0x0084946e (list 3 build order - THE DESTROYER LANE B6 COULD NOT FIND)**, 0x0088bf01 (list 10), **0x0089046f x2 (list 14, INLINED, the AI's fleet order)**, 0x008657ad (list 8 fleet route). The join came from reading ApplyTurnCommandBatch as an INSTRUCTION SEQUENCE: a flat run of 27 per-list loops in a fixed order (neither list nor offset order), the six prologue gates split across THREE separate per-player loops at three different points, and **four bumps INLINED INTO THE BATCH rather than in a handler - which is why call-graph sweeps under-count them**. THE COST RULE HAS A SHARP BOUNDARY: **lists 1-16 cost one bump per element, lists 17-27 cost NOTHING**, no exception either way; four gates bump, one is free, and CivilianRatios has NO APPLIER anywhere in the apply path - reported as UNKNOWN, not zero |
| AI2's two-elements-per-fleet-order CONFIRMED from the counter side | verify | verified | high | 100% | 2026-09-08 | At zero VM cost: list 14's applier fired exactly **TWICE** on a turn that moved exactly **ONE** fleet (one list-8 bump), and the UI control (human-turn2-orders.sav) carries **ONE** list-14 element for its one move. Lane AI2 predicted this from the AI bridge calling the order method twice, (f,0,true) then (f,1,true), where the UI emits only mode 0 |
| THE REFERENCE GAME IS NOT WHAT THE RECORD SAID | meta | verified | high | 100% | 2026-09-08 | Three corrections from the saves plus W2's log. (1) **`ref-turn2.sav` IS `turn2-state.sav`** - same 66,739 bytes, same sha256 - so W2's watchpoint measurement is of THE REFERENCE GAME and is directly comparable to the standalone's reference pair. (2) **THREE AI players, not one**: 32 (Fane Lao) and the two "dormant" Singularity players 496/512, which DO run - all three set ResRate 0.25->0.8 and picked a research target on turn 1. (3) **The four Species==4 monster factions submit NO COMMAND BLOCK AT ALL** - exactly four players go Status 0->4 and exactly four rate-gate bumps fire, the first direct evidence they have NO CLIENT rather than an empty task list. That third point also gives the Player.Status predicate a candidate shape (has-a-client vs not) with four positive instances |
| AI order emission scaffold + the submit latch | engine | verified | high | 90% | 2026-09-08 | `src/game/ai/orders.{h,cpp}` + `agent.{h,cpp}`, 233 -> **423 checks**, ctest 51/51 -> **53/53**: the command block, the order API with (fleetId, mode) dedup, the AI fleet-order bridge, BlockModCountCost/TurnModCountDelta, the 34-phase turn spine, and the two-pass walk with **pass-0 silence enforced AT THE CLIENT, not by trusting the caller**. **THE SUBMIT LATCH**: EndTurn sets client+0x15c BEFORE BuildTurnCommands, so **phases 29-33 are DEAD** - a literal port would emit AN EXTRA COLONIZE ORDER PER AI PER TURN. Two more cross-checks: **phase 2 is Hiver-only** (`cmp [player+0x5c],1` - a FIFTH independent confirmation of the species reading, and the reason gate +0x3c has never been seen set), and cl_SetResearchTarget is AI-ONLY surface with exactly one caller in the image. IT DECIDES NOTHING - which tasks exist and what each wants are board questions this module does not model (standing convention) |
| AI4's falsifiable turn1 prediction (probe routed) | verify | in-progress | — | 0% | 2026-09-08 | Committed BEFORE the build: turn1-state should also give 12, out of a DIFFERENT command set - 4 rate gates + 3 research targets + list 1 (new design) + list 3 (build) + list 5 (rates) + 2 drivers, with NO fleet order on turn 1. **The predicted trap multiset CONTAINS TWO EIPs W2 HAS NEVER SEEN AND OMITS THREE IT DID**, so the SET discriminates and a wrong model fails visibly rather than coincidentally landing on 12. One save swap on W2's unchanged watchpoint module; routed to lane W3. Specific falsifier: does 0x0088291a (list 1) fire? That is the difference between "the engine must emit a design COMMAND" and "a design OBJECT the build order merely names" |
| LAB EXPANDED: five Windows guests, not one | meta | verified | high | 100% | 2026-09-08 | **The single-VM constraint was shaping the work.** With one guest, VM lanes serialised and I defaulted to static analysis because static parallelises - which is exactly the habit rules 18 and 19 exist to counter, and the user had to say so twice. FIXED STRUCTURALLY: `zfs snapshot rpool/data/vm-140-disk-0@clone-base` then `zfs clone` per guest (instant, copy-on-write, ~0 extra space until divergence) + `qm create` with a fresh MAC. **VM140 (16c/12G, the reference, holds the oracle autosaves) + VM141, 144, 145, 146 (8-12c/10-12G each)**. Host has 40 cores and had 101 GB free. NOTE 142/143 were already CT ids - check `qm list` AND `pct list` before choosing. THE ORACLE GUEST IS STILL EXCLUSIVE: VM140 keeps the 8-file SavedGames set with the reference autosave bytes and the exclusivity rule; the clones are for probes, workload manufacture and multiplayer peers. A clone can be destroyed and re-cloned from the snapshot in seconds, which makes destructive experiments cheap for the first time |
| /Sim/players DECOMPOSED - unmodelled, not mismodelled | verify | verified | high | 100% | 2026-09-08 | Lane PL, and this reframes the biggest remaining block. Re-measured (status.json said 55; the real figure is **54** on pair 1, **24** on pair 2): **53 of the 54 leaves on pair 1 and 23 of the 24 on pair 2 are values our turn WRITES BACK UNCHANGED from the input save.** Exactly ONE - player 32's Sav - is a number we compute and get wrong. **A lane hunting a formula error in /Sim/players will not find one.** Mechanism split: M1 AI research orders 12/2 (Rung B), M2 AI ship construction 15/9 (Rung B), M3 observation records refreshed by the AI's own new design 9/1 (Rung B, downstream of M2), M4 events 8/6 (half Rung A, written, wants --data), M5 BnkEl+BnkPr 4/4 (BnkEl CLOSED here), M6 Status 4/0, M7 PvSav 0/2 (CLOSED here), M8 an unexplained one-off 2/0. **Rung B is 44 of 54 on pair 1** - and FOUR FIFTHS OF THE BLOCK HANGS OFF TWO DECISIONS BY ONE AI PLAYER: pick a research target, queue one destroyer |
| M3 is not an intel pass | objects | verified | high | 90% | 2026-09-08 | Lane PL: player 32's new `odes` entry is odid=18, opid=32 - **the design IT ITSELF created**; `owep` is that design's weapon and the three `otch` entries whose otnL moves are that design's techs. AND THE LISTS REFRESH ON DIFFERENT EVENTS: on pair 2 odes/.[1]/otnL moves 2->3 while every otch stays at 2, so **`odes` re-stamps on BUILD and `otch` on CREATION**. Both flagged as hypotheses - no corpus save has a player observing someone ELSE'S design |
| T31's "self-check" was not one | verify | verified | high | 100% | 2026-09-08 | Lane PL correcting lane E1's catalogue claim: T31 compared its POST-turn result against the PRE-turn stored value, so its "6 of 8" covered only the six players whose limit never moves - **and printed the same number regardless of --ai-player**. The catalog claimed "8 of 8 with the flag supplied", **which the binary never printed**. Now taken at load and passing for EVERY LIVE PLAYER of all 11 saves. Also: T31 now **recovers its difficulty column from the save with NO OPERATOR INPUT** - BnkEl has a 6.67x slope and the AI column is worth 10%, so recomputing under both columns against the stored value identifies it (1 AI + 1 non-AI on all eleven saves). Blocked -> Partial |
| rule 23 again: two float widths in ONE routine | verify | verified | high | 100% | 2026-09-08 | Lane PL: BANKRUPTCY_PROTECTION_LIMIT_FACTOR is read `fmul dword ptr` (D8 /1 = m32fp) while **the divisor two instructions earlier is `fld qword`** - different widths in the same routine. Engine now narrows it. 0 leaves on this corpus (all seven BnkPr records land where the two agree) but 3 test expectations moved, and **disagreement is at EVERY max income divisible by ten, from 10 upward** - lane PL predicted ~1.1% and measured 10%, recorded as a prediction miss |
| the published 128/69 OVERSTATES what is unmodelled | meta | verified | high | 100% | 2026-09-08 | 4+3 of those leaves belong to P11, which is WRITTEN and only wants a game-data root - `standalone_report.py` passes no `--data`. Reproduced end to end on CT111 against /srv/re-lab/gob-extract: **87 closed / 0 regressed (pair 1, residual 122)** and **46 / 0 (pair 2, residual 62)**. Ranked remainder by leaves-per-effort: (1) give the metric harness a data root - 7 leaves, ONE LINE; (2) wire StrategyVars.txt into a TuningTable and set opt.haveTuning - 4 leaves, and **TurnOptions::haveTuning is READ IN THREE PLACES AND NEVER ASSIGNED, the field is dead**; (3) game/ai research selection 14; (4) game/ai build order 24 + M3's 10; (5) T34 10; (6) Status predicate 4, one VM session; (7) M8 2, unexplained |
| Status: eighteen fields split the roster identically | verify | backlog | — | 0% | 2026-09-08 | A negative result lane PL deliberately did NOT fit: **eighteen scalar fields of the player record split the roster exactly the same way** (ReqCL, AMine, CnTrd, cdp, hgs, TerraMod, pddm, ...) because the two sets are **perfectly correlated with species**. One observation, one transition, one roster. ReqCL is the most plausible on name and meaning but IS A HYPOTHESIS. **The probe is an entry hook on lane W2's writer at OnMessage+0xa15 capturing the player index per call** - one VM session |
| TShn CLOSED - writer trapped live, gate named, 158/158 | verify | verified | high | 95% | 2026-09-08 | Lane W3, and **MY ADDRESS WAS RIGHT AFTER ALL**: ServerSystem+0x274 is NVO._Myhead; the three-way disagreement was ServerSystem::Write running on the **IStreamable subobject at +0x8**. The arming code did not depend on the reconciliation - it probed BOTH bases and logged +0x274 validating on 9 systems, +0x26c on 0. CHAIN CONFIRMED FRAME BY FRAME from the trapped ebp chain, not inferred: RefreshVisibleOwnerIfKnown 0x0075bd70 -> RecordVisibleOwner -> SetVisibleOwner -> NVO::operator[] 0x0075a890 (**exactly one caller in the image, so the writer list is COMPLETE**) -> store 0x0075b961. **THE GATE IS NOT A SPECIES RULE**: `AFlags bit || (PlyrIdx < 15 && (SnLv >> 2*idx) & 3 && HasResearched(CCC_AdvSens))` - and Spica vs Bismol resolves on the SECOND conjunct, because every Species-4 pseudo-player carries CCC_AdvSens in every save while **NO REAL EMPIRE HAS IT IN ANY SAVE**. Model tested against every NVO record in all 11 saves: **158 predictions, 0 mismatches** - 103 AFlags-refresh, 54 sensor-branch, and 1 frozen (Bismol@23) PREDICTED RATHER THAN EXCUSED. Four traps not two because **the refresh runs TWICE per End Turn** (driver phase 24 and combat-done phase 25) - a free live confirmation of both phase attributions. CAVEAT ON THE RECORD: SnLv is NOT SERIALISED, so "the sensor branch refreshes Spica" is a hypothesis; one arming line settles it |
| Player.Status predicate SETTLED - and "Species != 4" was wrong | verify | verified | high | 100% | 2026-09-08 | `Status == 4 <=> ReqCL(+0xfd) != 0 && Elim(+0xf8) == 0`. **NO species, NPC or RebAI field is read on ANY path that touches Status** - the game states it itself in StrategyNetworkServer::AllPlayersTurnEnded 0x007cfd00, and the _NPC script binding clears ReqCL explicitly. Settled STATICALLY, so the entry probe was not needed and the VM slot went to lane AI4's insert instead. Lane PL had found eighteen fields that split the roster identically and correctly refused to fit one; ReqCL was its most plausible guess and is now confirmed by reading the code that reads it. Also corrects treaty-turn-stamp.md by three more Status=4 sites |
| AI4's prediction PASSED, and the falsifier answered | verify | verified | high | 100% | 2026-09-08 | Same binary, one config key, one save swap. **12 traps, contiguous 1->12, and THE SET MATCHED**: two EIPs never seen before appeared and three previously-seen ones were absent - exactly the discriminating shape AI4 committed before building. New: 0x0088fe0d x3 = OnCommand_SetResearchProject, **a SECOND inlined bump site** in ApplyTurnCommandBatch distinct from W2's; 0x0088291d x1 = OnCommand_CreateDesign. **AI4'S FALSIFIER: 0x0088291a FIRES - list 1 emits a DESIGN COMMAND**, not just an object the build order names, which changes what Rung B must implement. CROSS-CHECK NEITHER MODEL WAS FITTED TO: 4 rate gates but **3** research projects - the three AI empires picked targets, the human did not, which is why the map banner reads "No Research Project Assigned" - and that 4-vs-3 population is the SAME SET the ReqCL predicate produces, reached independently |
| all ten ModCount handlers NAMED | control-flow | verified | high | 100% | 2026-09-08 | 0x00821a84 (x4) = OnCommand_SetResearchRate; plus SetSystemRates, TransferShips, AddBuildOrder. Lane W2's four `StrategySim_ModCountBump_unresolved_*` entries should be RETIRED in favour of these - lane W3 left W2's fragment alone rather than editing another lane's file, correctly. W2's caveat stands: **12 is this save's command count, not a constant** |
| trade/spy workload: ABSENCE MEASURED, not assumed | verify | verified | high | 100% | 2026-09-08 | Both containers read live: `trade routes count=0`, `spy programs count=0`, both managers non-null, **all three vector pointers ZERO - default-constructed, not emptied** - and zero traps on either _Mylast. Three lanes had failed to build this workload; now the absence is a measurement. The spy-UI question stays open because the vector never grew, **but the instrument that would name the UI now exists and is proven to arm** |
| CORRECTION: layouts.md ServerSystem NVO rows are wrong | objects | backlog | — | 0% | 2026-09-08 | Lane W3: `Game::ServerSystem` rows `PID@0x274` and `NVO@0x278` are wrong - **+0x274 is _Myhead, +0x278 is _Mysize, and the "PID" tag belongs to the NODE KEY**. Same shape for the NVE/NVs rows. Documented but not fixed because it is a GENERATED file - the fix belongs in the recovery, not the output |
| gen_addresses now detects SAME-ADDRESS duplicates | meta | verified | high | 100% | 2026-09-08 | Lane SV caught by hand what the tooling could not see: it reached 0x004271c0 independently and would have filed `RNG_NextIntInclusive`, but addresses.json already carries it as **RNG_NextInt** - and **the merger only checked duplicate NAMES**, so it would have merged silently and **forked the vocabulary for the campaign's most-used RNG primitive**. Fixed: gen_addresses.py now errors on same-address-different-name too (offsets excluded - they are only meaningful per owning struct). IT IMMEDIATELY FOUND **13 MORE across 8 lane fragments**, including two AI lanes naming one function differently, a boundary marker colliding with the function it bounds, and several lane-suffixed aliases of existing entries. All resolved with the agreement recorded in each fragment's _note. **AND THE SWEEP BROKE THE SHIM BUILD** - the engine referenced a dropped alias - which the CT111 cross-build caught and the host build could not. Rule 13 earning its keep again |
| standalone: 209 -> 124, closed 85, regressed 0 | engine | verified | high | 100% | 2026-09-08 | Corrected figure after finding a stale-build error of mine (rule 24). Lanes PL and SV had each independently reported 126 from a 128 baseline while main also read 128 - arithmetically impossible, which is what exposed it. Both lanes' work was in the tree all along; the binary I measured predated both merges because the gate rsync excludes `build*` and rsync preserves mtimes, so cmake relinked nothing. **NO ERROR, NO WARNING, AND A PLAUSIBLE NUMBER** - the same shape as the build-dir race earlier today that looked exactly like engine nondeterminism. Fresh build dir per measurement from now on |
| THE REFERENCE PAIR IS NOT DETERMINISTIC | verify | verified | high | 100% | 2026-09-08 | **Lane L5, unlooked for, and it qualifies the metric every standalone number is measured against.** Three runs of `turn1-state -> turn2` on VM146 gave **three different post-turn autosaves, differing in EXACTLY FOUR LEAVES** - one Singularity shadow empire's research pick plus the derived checksum. **RULE 19 IS DISCHARGED**: two of the three runs carried IDENTICAL hooks and still differed, and the hooks=off run was a THIRD value - so this is the game, not the instrument. The determinism oracle **stands for ref-turn2 -> turn3** (the byte-identical hashes, reproduced many times today including in two fresh processes for AI1's P2) but **DOES NOT GENERALISE TO ITS NEIGHBOUR**. Most likely locus: lane AI4 established there are THREE AI players including the two dormant Singularity empires, all of which pick a target on turn 1, and lane AI1's reading has the AI's per-client RNG seeded from an unseeded global - never confirmed live because this build does not emit the log line. Routed to lane L1, which is on this exact pair |
| interest literals VERIFIED LIVE - with a control that FAILED | verify | verified | high | 100% | 2026-09-08 | Lane L5 did what rule 23 asks. Prediction: the game pays **499, not 500**, on a treasury of exactly 50,000. HELD - the shipped widened-float build ran **3,895 calls, 0 diverged, 0 undeclared writes**. **AND THE CONTROL BUILD FAILED AS REQUIRED**: exact decimals gave **1,359 divergences of 2,718 calls**, every one on a treasury that is a multiple of 100, while the three non-boundary treasuries diverged ZERO times. **Coverage: 5 distinct treasury states, 2 ON THE BOUNDARY** - versus the green run this replaces, 4,437 calls over 20 states, NONE on a boundary. Fewer calls, far more evidence. BLAST RADIUS IS BIGGER THAN G3 STATED: the one-money error propagates into `available` and `researchMoney` on 1,317 of the 1,359 calls, so it moves the player's RESEARCH SPEND, not just the treasury. The control also resolved a contradiction L5 flagged BEFORE running: slot 5 IS diffed and the harness CAN see it, so the old green run was thin workload, not a broken instrument |
| "widen every literal" is ITSELF a defect | verify | verified | high | 100% | 2026-09-08 | Two MORE rule-23 defects found by operand sweep in ComputeBudget, both corrected and both **marked unverified because no reference turn can see them**: the research-yield factor is a widened 0.85f (needs research money a multiple of 40,000 - the run presented 9 distinct values, NONE is) and the three research modifiers are summed in SINGLE precision (needs shrm/TRM non-zero; the corpus has both at 0). Both pinned by tests that fail with decimals. **CRITICALLY: three constants BESIDE them are genuinely exact doubles** - so blanket-widening is its own bug. Read the four bytes, every time |
| BANKRUPTCY factor measured, not assumed | objects | verified | high | 100% | 2026-09-08 | = 3.29999995 = (float)3.3. **Its file image is ZERO - the loader fills it at run time** - so lane PL had read the operand width and been forced to ASSUME the value. Now measured on the running game: the assumption was right and the narrowing is real. **No static reading could have produced this** |
| L5 kept `verified` at 0, and defended it | meta | verified | high | 100% | 2026-09-08 | The column has read 0 all campaign and L5 held it there ON PURPOSE. Every phase its compare touches is `Partial` for reasons UPSTREAM of the compare: P01's repair demand is taken as 0, P02 needs the AI's research orders, T31's BnkPr needs an unwired tuning constant. **None is fixable by a VM.** Promoting one because part of it was checked is exactly the drift app_test_catalog asserts against - the ladder's two conditions, COMPLETE and COMPARED, are blocked by different things and the column measures the conjunction. Catalog notes now carry the live evidence and its coverage instead of the older call counts. Also two predictions of its own falsified and recorded: the difficulty record is a HEAP POINTER at ServerPlayer+0x36c not an inline record (correcting the hook's standing "not reachable" note; the fitted pair stays a hypothesis, deliberately not dereferenced), and it predicted 2 divergences and got 1,359 because it counted players where the harness counts calls |