sots-re/campaign/board.md

128 KiB

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 in-progress — 0% 2026-09-07 .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 backlog — 0% 2026-09-07 ~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
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 backlog — 0% 2026-09-07 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 backlog — 0% 2026-09-07 choke point: bool __cdecl gobio::ReadFile(const char*, IBuffer**) 0x008d5140; FileSystemSet ctor 0x008d6d60 pushes NativeFileSystem(".") FIRST then sots.ini [Modules] Mount (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
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; 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
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: Z-tailrng (claimed 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-, stage dist-, deploy C:\SOTS\shimdist-. 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.
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
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
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
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 backlog — 0% 2026-09-08 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
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 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
fpu_cw sensitivity experiment verify verified high 100% 2026-09-08 DONE (lane F): 53-bit vs 64-bit x87 makes NO difference — the SSE port has no double-rounding budget to preserve, STATE_CHECKSUM.md §3.5 CLOSED. 7 End Turns from ref-turn2, 6 control words, whole-state checksum on each. stock / 0x027f / 0x127f / 0x137f (64-bit) all give 978041ac… identical across all 35,394 leaves. Two settings DO move state, each reproduced on a repeat run: 0x007f (true 24-bit) -> /Sim/systems/Sys[112 "Gamma Cephei"]/Pop2/PopG/PopC 540000000->540000002 (+derived Summary/Checksum); 0x1a7f (53-bit, round-UP) -> /Sim/fleets/Flt[34 "Beta Fleet"]/Pos/.[0] and /Pos/.[2], 1 ULP each. So the port must hold intermediates at 53 bits (never compute a chain in float) and use round-to-nearest — both SSE defaults, now measured. THE BRIEFED TRIPLE WAS UNDER-POWERED: 0x027f is 53-bit (differs from 0x127f only in bit 12, infinity control, ignored since the 387) and 0x137f is 64-bit, not a rounding change — run as written, all three come back identical and would have 'proved' something false. PC=bits 8-9, RC=bits 10-11. Setting PROVEN to hold: read-back at each force + 38 in-pipeline hook samples per run spanning turn phases 4/6/8, all at the forced value (verify/results/fpu-cw/cw-census.txt). Mars::Application::Run calls _controlfp(0x50000,0x3070300) at 0x0089f606 EVERY FRAME = 0x127f, so forcing at EndTurn is wiped before BeginProcessTurn; BeginProcessTurn is the point that works. TRAP: under 24-bit the CRT's own %g rendering degrades, so trace TEXT is not a comparison surface — use verify/fpu-cw/trace_bitdiff.py. findings/subsystems/fpu-precision-sensitivity.md
Summary.Checksum algorithm objects blocked — 0% 2026-09-08 Lane C RULED OUT two candidates so nobody repeats them: NOT a byte sum over the inflated stream, NOT a sum over the int leaves. Each is consistent with the -16 re-save delta but leaves no constant residual across turns
event posting in ours phase2 in-progress — 0% 2026-09-08 Lane P: make ours actually post events so ProcessResearch's side.events.after.v.next_id 4->3 divergence closes. Converts harness-audit row 1 from known-defect to checked, and unbounds B2/B3 whose clean compares currently cover economy fields only
LAB RULE: no git add -A in sots-re while lanes run meta verified high 100% 2026-09-08 MY error, caught by lane M: an integrator git add -A in the SHARED sots-re clone swept a running lane's in-progress files into commit 9d385a7 mid-run (remainder landed in f5b37c2). Nothing was lost, but authorship and atomicity were. RULE: while any lane is live, the integrator stages sots-re by explicit PATH only (git add campaign/board.md campaign/DASHBOARD.md), never -A. Lanes own their own subtrees. sots-engine is unaffected - lanes work in per-lane worktrees there, which is exactly why that repo has not had this problem . 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 backlog — 0% 2026-09-08 Still ZERO behavioural coverage after lane M. Not for lack of trying: the only mover in ref-turn2 is the AI (straight runs only), and the player that would travel a node line has DE/CR/DN all 00 at its home system, so Move/Manage Fleets are greyed out every turn - there is nothing to send along the node lines the map draws. Needs a ship built over several turns or a purpose-built save. The type-2 node-line step is still WRONG BY CONSTRUCTION (B4). Also: sim::Distance deliberately left in double (only stutter geometry uses it); Mars_Vec3_Length says it is probably 1 ULP out the same way, but there is zero behavioural evidence to correct it against - do not "fix" it blind
P2-P event posting in ours phase2 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)
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
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
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 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, 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)
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 - VectorHelperMars::String and VectorHelper - 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> 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 objects verified high 100% 2026-09-08 Lane A found StreamableEnum writes a FRAME CONTAINING ONE INT, not a bare int, so VectorHelper<StreamableEnum> 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 backlog — 0% 2026-09-08 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::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 backlog — 0% 2026-09-08 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 backlog — 0% 2026-09-08 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 backlog — 0% 2026-09-08 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 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() 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