Commit graph

53 commits

Author SHA1 Message Date
alex
c24bf6e7e0 Z: reconcile with lane J's rules 16/17, add the Zuul ledger table
Lane J landed rule 16 (inlined draws are invisible to call-graph sweeps) while
this run was in flight, and it bears directly on three claims here that rested
on direct-call sweeps. Those claims now rest on the behavioural measurement
instead, which is immune to it: ProcessNodeSpaceTravel moved the generator by
0 words on 16 observations and node-line decay on 8. The instrument does not
ask which function drew, only whether the generator moved.

Lane J's resolver map and this ledger pair up: the resolver has no
unconditional draw, so its prediction -- a plain fleet battle costs the same
18-22 words as a peaceful turn -- is testable with these hooks as soon as
someone builds a save where two hostile fleets meet.
2026-09-08 09:47:11 -04:00
alex
1d50f1edda lane Z: the RNG ledger for one strategic turn, measured end to end
We consume 18-22 generator words per turn and model none of them as a count.
All of it is inside StrategyServer::ProcessTurn; OnAllCombatDone_Tail costs 0
on every turn observed; the residual outside the two drivers is exactly 0. The
generator does not move between turns at all, so the interval a standalone has
to reproduce is closed at both ends.

The instrument reads generator STATE, not calls, and that choice paid: the
image has four draw entry points, not three (NextUInt 0x004f7670 is in no
lane's primitive set) plus inlined draws in twelve functions, two reachable
from the turn roots. A primitive-counting hook would have undercounted
silently.

Checked against the save files independently: the turn-6 autosave pair gives
18 words read from the two Sim.RNG blobs, and with twists == 0 that number
never passes through a twist implementation -- so the two instruments do not
share the hidden assumption they could have.

Corrections to combat-done-tail.md, in place:
  * the node-line 0x20000-fleet check runs AFTER the Chance(0.5f) call and
    cannot gate the draw; the expiry test is NodePath::RemainingLife 0x006e2130
    and is now a formula rather than a description
  * StrategyHost::Autosave is ret 8 and returns the std::string* in EAX
  * SNMAllCombatDone IS delivered every End Turn (8 of 8) -- lane K's inference
    was right; the stronger no-encounter reading is narrowed, not closed
  * S+0x8 advances 12-14 times per turn, not twice

Node-line decay still has not fired. The hook reports the distance instead of
the absence: 51 of 53 lines are permanent, the mortal ones are dug ~1/turn by
the Zuul, each ~40 turns from expiry. It stays a labelled hypothesis.
2026-09-08 09:45:06 -04:00
alex
8dda49cfae lane J: the combat resolver, read from the instruction stream
Closes lane K's #1 ranked gap: FUN_007d5af0 under phase 6 of
OnAllCombatDone_Tail, the second RNG source in the strategic tail.

RNG inventory (the deliverable that matters). The resolver draws nothing
itself. Three sites in its subtree, each behind a function with exactly
one caller:

  R1  RNG_NextInt @0x007bb69b (node cannon)   1 word w.p. 3/4, mean 4/3
  R2  an INLINED RNG_NextFloat @0x007a84bd    1 word per back-eng candidate
  R3  RNG_NextInt @0x00852ec7 (project pick)  >=1 word per successful roll

R2 corrects combat-done-tail.md 3, which reported no NextFloat in the
subtree: the draw is inlined, so the only call-graph edge it leaves is
FUN_007a7f30 -> RNG_Twist, which reads as a bare Twist and is not one.
An image-wide instruction-boundary scan for the MT tempering immediates
finds 14 game functions with inlined draws; two of them, FUN_004f7670
(84 B) and FUN_007aa240, are reachable from StrategyServer::ProcessTurn
and are candidate mechanism for part of lane Z's unexplained 18-20
words per turn.

Also: the real body is 7641 B, not Ghidra's 7499 (which ends
mid-instruction); only 8 non-stack stores and 1 indirect call in the
whole function; 23 EVENT_* keys in the closure, five of them new to the
campaign's combat picture (plague is decided inside combat); the
resolver's subtree DOES write the SETurnResults accumulator at
S+0x2f4[PlyrIdx]+0x90 via FUN_007baef0 -> FUN_007b9df0; and
sizeof(Game::TacReport) = 0x94, enumerated twice.

Two structural errors made and corrected before publication are recorded
in 7.3 and 4 rather than quietly fixed.

Gates run separately: clean-room check OK; host ctest 36/36.
2026-09-08 09:37:02 -04:00
alex
f9b744e2ed lane Q: TurnCommands_v5 reconciled and typed; SAVE_FORMAT section 11 corrected
findings/objects/turncommands-block.md -- why lane W's 44-item recovery and the
save's 35/38/41/61/123 items disagreed, in mechanism terms, plus the per-list
element records read off all 22 distinct helper writers.

Three separate causes, only one of which was a branch:
 * layouts.md is OFFSET-SORTED and Game::TurnCommands is one of the 89 classes
   whose offset order is not write order, because each gate bool sits after the
   payload it gates. Align saves against streams.json, never layouts.md.
 * the writer's tail is 27 std::list members, each written by its own helper as
   WriteInt(size) then size element records. The linear recovery keeps one item
   per CALL SITE, guesses its kind from an element field and drops the count.
   44 - 17 members = 27 = the list count.
 * 8 prologue items + 27 zero counts = the 35-item empty block, which is why
   every earlier save was bit-identical here.

Corrects lane O's section 11 in place: the fleet-move element is
{fleetId, nHops, nHops x systemId} -- a counted route vector, not a fixed
{fleetId, 1, destSystemId, 0}. Only the three-item reading makes the item totals
close (a four-item element needs 26 lists on human-turn2 and 28 on zuul-turn15;
the writer has 27). Also records that neither noderoute save carries a fleet
move, so the node-route UI does not queue through that list.

22 of the 27 lists are labelled hypotheses: read off the instruction stream, but
no save exercises them and no element value has ever been observed. List 14 is
observed but not understood -- issuing one fleet move also queues
{sameFleetId, 0, true} there.

save_reader.py deliberately left alone, with the reasoning written down: it reads
CD generically for the AIAgent bodies too, state_checksum's digest tree is built
from the generic tree so typing CD would add no named leaf to any diff, dispatch
by CDT ordinal would need new machinery in the oracle, and rule 8 is pointed at
exactly this move -- a layout mirrored into both readers from one reading is not
two checks. coverage: PROVED, 0 error 0 warn on all eleven saves, unchanged.

ghidra/addresses.d/lane-q.json: 6 entries (the writer, its Read pair, and the
four observed list helpers). Validated by generating to a scratch path; the
tracked header was not regenerated.
2026-09-08 09:03:26 -04:00
alex
612bfe6e84 lane K: two section headings that read as stronger claims than the text supports 2026-09-08 08:51:42 -04:00
alex
bb0f990de9 lane K: field offsets belong in the offset schema, and off_RNG was already right
gen_addresses.py distinguishes "addr" (VA -> RVA) from "offset" (a member
offset). Three lane-K entries used "addr" for offsets and generated negative
RVAs, which broke the engine build. Two are re-expressed as offsets; the third,
StrategyServer_off_RNGPtr_S_frame, is withdrawn - addresses.json already has
StrategyServer_off_RNG = 0x16c for the S frame alongside off_RNGPtr = 0x168 for
S+4, which is precisely lane T's rule. The second call site found this lane
confirms it rather than correcting it.
2026-09-08 08:48:25 -04:00
alex
bb1f8b4692 lane K: StrategyServer::OnAllCombatDone_Tail mapped, 36 phases from the instruction stream
The second turn driver, 0x007d92a0, read byte for byte. Corrects turn-driver.md
section 5: of the four subsystems it said live here, only bankruptcy does.

- 36-phase map with strides enumerated (EncounterResults 0x178, Encounter 0x74,
  member 0x44) and the arity check that logs but does not return.
- Phase 7 is encounters.clear(), not a filter: the erase pair is the same
  four-argument shape vector<Encounter>::operator= uses, and both arms converge
  three instructions later.
- Bankruptcy: ProcessBankruptcy at phase 15, UpdateBankruptcyLimits at phase 31.
  Three corrections to formula-gaps Q1 - the divisor is the double
  -0.15000000596046448 not -0.15, the per-system income term is clamped at 0
  before summing, and the 3.3 factor lives in .bss and is DB-loaded.
- Turn results are FILLED here (phases 6, 11, 18 write S+0x2f4[PlyrIdx]) but
  rotated by ApplyEncounterResults and dispatched by SynchronizePlayer as event
  0x25 afterwards. sizeof(SETurnResults) = 0x11c, enumerated five ways.
- BuildTurnEvents is misnamed: it is the setup/load/rejoin resync push, gated on
  a pending descriptor, and references no EVENT_ string at all.
- TurnEvents_Write and TurnEvents_Read are swapped in Ghidra (layouts.json is
  right). sizeof(TurnEvents) = 0x18, enumerated four ways.
- The autosave: StrategyHost::Autosave 0x00895210, its four localized paths, the
  rotation that fires only on the post-turn call, the connection detach around
  the write, and why the payload carries nothing time-, name- or machine-derived.
- Two RNG sources in the tail that nothing models: one NextFloat per expired node
  line, plus draws inside the combat resolver. Both run before the autosave.
- S+0x8 advances twice per turn, not once.

Repo-wide correction: the research-event roll costs one or two RNG words, not
one. Fixed in unlock-cascade.md, addresses.json and lane-u.json; the captured
compare artefacts under verify/results are left alone as run records.
2026-09-08 08:47:08 -04:00
alex
834eb09a19 T: read ServerPlayer::ProcessTurn and StrategyServer::ProcessTurn from the instruction stream
Phase map for both turn drivers, byte-for-byte from objdump rather than the
decompiler. Corrections that matter:

- the end-of-turn tail is UNCONDITIONAL. turn-spine.md 2.4 step 11 said it is
  deferred when encounters are pending; that came from reading an inlined
  std::vector destructor as a branch (both arms converge at 0x007dcb38). There
  is no branch on the encounter snapshot in the function, and 0x00794ad0 does
  not build an encounter list -- it builds the 15 ship-action type ids.
- ServerPlayer::ProcessTurn takes one float argument and never reads it.
- the ResearchRollPending site runs AFTER ProcessResearch, not before, and
  clears the flag only when the roll actually fires. Threshold is a strict
  0.5f < progress/Cost. Both consumers located by a full displacement scan.
- EVENT_NO_RESEARCH's gate is 'completed nothing this turn', not 'nothing
  available' -- 0x00584e50 collects RESEARCHED techs, not available ones.
- a fired research roll costs one or two RNG words, not one: the plague branch
  draws a second NextInt and posts EVENT_PLAGUE_OUTBREAK.
- two StrategyServer bases four bytes apart, and two per-turn counters.
- new: the research refund at 0x008914aa, previously unmodelled.

45 addresses in ghidra/addresses.d/lane-t.json (669 -> 722 entries, no dupes).
2026-09-08 08:08:36 -04:00
alex
805409ec11 A: the AIAgent CD blocks - derivation, addresses, and what stays a hypothesis
Game::StrategyAIAgent::Streamable typed end to end. The writer is entirely
unconditional; the branch the decompiler shows is an inlined vector destructor
whose operator delete is marked noreturn. dsh is a std::map's _Mysize, so it is
the (pid, trns) count and not a scalar; lnat and lat are scalars, not elements.
AISystem::Write never reads its object, so every AISys body on disk is empty
regardless of game state. StreamableEnum<T> is a frame holding one int, which
makes SysMem/mts/nalat arrays of frames rather than of ints.

Named coverage 98.0% -> 99.9% on all four saves; state_checksum.py unchanged
and still PROVED. Section 7 lists the ten element layouts no save exercises and
says what workload would settle each.

29 addresses in the per-lane fragment; merges to 669 with no duplicate name.
2026-09-08 07:33:33 -04:00
lane-w
2308b6edb9 lane W: SvSctOb variant factories; fix the four save_reader.py defects openly
Part 1 (notes side): findings/objects/svsctob-variants.md records the two maps
that are nowhere on the wire and were read out of the game -- EncID -> class
from a 23-entry dword jump table at 0x0052bf60 indexed by EncID-1 (0x0052bf00),
and xscn -> class from an exhaustive four-way _stricmp chain at 0x005a7050.
Twelve live EncIDs, four scenario names, and "indsys" =
Game::SVSOIndependentSystems whose Read and Write are both the shared `ret 4`
stub at 0x005f8ac0, so its empty frame is correct output rather than a
truncation. Also: SVSOSots::Read accepts NPCPlr and hastraps, which Write never
emits -- read-only backward compatibility, not a hole in the recovery. And a
correction to the recovery itself: SVSOCrowDefenders writes `dsys` INSIDE the
ndsys loop; layouts.json calls it a plain member and no save can settle it
because both counts are 0 everywhere. 13 addresses in ghidra/addresses.d/
lane-w.json; gen_addresses.py merges to 640 with no duplicate name.

Part 2: lane G found four defects in both readers and deliberately did not
patch the oracle mid-campaign. Fixed now, with tests, and byte-neutral.
  1. Game::SystemParams field 1 is a string, not an int (empty string == four
     zero bytes == int 0, so it round-tripped by luck).
  2. ObservedTech/ObservedWeapon odet is a bool, not an int (byte-safe only
     because a 4-char tag makes both items 12 bytes; 3 chars would not).
  3. SpeciesRatios nv is a count, not a field.
  4. ShipRecords srbd is a count, not a field -- and this one is behaviourally
     confirmed, not inferred: srbd takes 0, 1, 3 and 4 across the players and
     every non-zero count is followed by exactly srbd x 5 scalars.
Note that 3 and 4 were an ABSENCE in save_reader.py, not an error: ShipRecs and
civr were both A(..., "any"), so the fix had to add the shapes rather than
retype a field.

Byte-neutrality: every item's inflated offset is unchanged on all four saves
(38,933 / 39,843 / 40,300 / 35,771 offsets, sequences identical), so no item
boundary moved. state_checksum.py still reports coverage: PROVED on all four
with the same rebuilt byte counts. The /CreateParams and /Sim/players digests do
change, because they hash typed VALUES and two fixes change what a value is --
and the value-byte deltas balance exactly: odet items x 3 plus p1 items x 4.
--strict exit 0 on all four saves; tests 36 -> 48.

findings/objects/wire-schema-closeout.md carries the whole account, including
the proof that CD/TurnCommands_v5 cannot be typed without a save that has
issued orders.
2026-09-08 07:05:09 -04:00
Alex
5a3f986f38 lane U: the unlock cascade, implemented and live-verified
Reads: TechTree::PrereqsMet 0x0057d8e0 (AND of ORs; zero groups TRUE, an empty
group FALSE), the prerequisite layout at TechDef+0x88/+0x98, the tail collector
0x00587cc3, and the head of OnTechResearched (RecordObservedTech unconditional;
the research-event roll gated on ResT == def && ResearchRollPending).

Live: 35 compared calls over three workloads, 0 divergences, tracecmp exit 0,
End-Turn oracle hashes unchanged. The EVENT_TECHS_UNLOCKED residual lane P
predicted and lane V measured is closed by running the cascade, not by posting
on completion.

12 new addresses in ghidra/addresses.d/lane-u.json (header 615 -> 627).
2026-09-08 06:57:37 -04:00
alex
2bb7b4debb lane G: record that save_reader.py still carries the four defects, and why it was left alone 2026-09-08 06:33:53 -04:00
alex
48fcc3ff3a lane G: wire-schema channel — layouts.json -> generated stream schema for sots-engine
objects/layouts.json is a memory-layout projection: 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. Both losses are the substance of the on-disk format.

tools/streams.py is a second projection of the same recovery that keeps the
program order Lab.layout() already computes and the repeated tags, and drops
every memory fact — no off, size, sizeof, gaps or strides. The engine must read
and write the format, not inherit the original's ABI.

tools/gen_stream_schema.py emits sots-engine's include/generated/sots_stream_schema.h
under the same discipline as gen_addresses.py: generated, provenance header,
never hand-edited.

386 classes, 2042 wire items.
2026-09-08 06:29:30 -04:00
alex
7eabfefff2 lane V: live verification of the event-posting model against an advance prediction
Lane P wrote the expected numbers into sots-engine docs/P-events-wiring.md §4 before
this run existed. Every one held.

First End Turn (ref-turn2 -> Launch -> End Turn, shim.cfg.recapb3 unchanged, build
eventlive-dd38117-20260908T0916Z, main dd38117 with no source change): 3 calls, 3
compared, 0 divergent, tracecmp exit 0 — where lane R's run exited 1 on
side.events.after.v.next_id orig=4 ours=3. Call 0 read turn=3,
events_turn_bucket_exists=true, events_next_id_in=3, events_in_turn_bucket=1,
events_dedup_risk=0, no events_scan_truncated, next_id 3->4 on both sides,
turns/turns_bytes 2/48 unchanged, node[144].progress 2879->5768, flag 1->2, rng
identical — the whole predicted list.

Five End Turns: exit 1 with 2 divergent calls instead of 3, each short by exactly 1
(next_id orig 7 ours 6, orig 12 ours 11) = the deliberately unmodelled
EVENT_TECHS_UNLOCKED. A 0-divergence result there would have been suspicious.

sizeof(Game::ObservedTech) = 44 measured live: observed_techs.bytes grew by exactly
44 on both completion calls (440->484, 484->528), confirming lane X's static pin
behaviourally. Non-researching players measured 880 = 20 x 44 and never moved.

One deviation from lane R, and it is a workload effect, not a defect: rng diverged on
call 9 because the completed tech had research_roll_pending set and the
OnTechResearched callback drew one word ours does not (left 374 vs 375). Lane R's
"RNG matched 15 of 15" was workload luck; the honest statement is that the rng region
matches on every call that does not complete a roll-triggering tech.

Zuul double roll CLOSED. No species-5 save existed, so this lane made one: a custom
game with only Zuul in the Available Species pool, 4 End Turns under the same compare
config. 8 calls, 8 compared, 0 divergent, exit 0; all four researching calls have
species=5 and advance the generator by two, not one (left 540->538, 522->520,
504->502, 485->483), and ours reproduced every post-state bit-for-bit. Save added as
verify/results/saves/zuul-turn5-species5.sav.

End-Turn oracle byte-identical to lane R's on the first turn, so none of this
perturbs the game. clean_room_check OK and host ctest 33/33, run as separate commands.
VM140 restored to the recap build at the main menu and released.
2026-09-08 05:59:22 -04:00
alex
d7ea0a048c lane D: automated struct recovery from the IStreamable serializers
Every serializable class carries an enumeration of its own fields -- its
Write(Stream&), walking the members in order with a 4-char tag. This decodes
that idiom mechanically for the whole binary in 0.35 s.

Validation first (tools/serializers.py validate), against answers the campaign
already had before the tool existed:
  A  305/307 field offsets+kinds exact across 17 classes, 0 WRONG, vs
     struct-recovery.md 1-4 and observedtech-append.md
  B  sizeof from the container-stride divides: ObservedTech 0x2c, MoraleEvent
     0x50, PlayerReport 0x30, DiplomacyStats 0x24 -- all matching
  C  22 of save_reader.py's shapes, tag order identical (Sys 78 tags,
     Player 104, CreateParams 25, Ship 22): 22 agree, 0 disagree
  D  Read/Write cross-check on every class: 437/437 field offsets agree

At scale: 386 classes with a Write, 1,682 member fields.
  verified 87 (542 fields) | clean 77 (328) | unnamed 176 (471)
  partial 31 (341) | empty 15
  58 classes with a sizeof corroborated by a second line of evidence
  (45 container stride, 13 enumeration meeting the embedding bound); the rest
  report a lower bound and say so.

Four things each worth 10-170 classes: the RTTI class hierarchy descriptor as
the only honest "is this an IStreamable" test (a 3-slot vftable also matches
TacAISquadRule_* and the row parsers); mod=0 memory operands, which x86disp.py
cannot index and which hide every field at offset 0; the member->id pointer
idiom behind every handle field; and sub-writers, both base-class and private
(StrategyServer's six id lists live in FUN_00794cd0).

Failure classes are enumerated in the finding -- 176 anonymous-tag classes are
a hard limit on names but not on layout, and the other 64 are bounded
mechanical fixes. Two fields lost to a value assembled across a branch were
left unrecovered rather than patched with an unverifiable heuristic.

Write-back: 288 structures + 328 labels into Ghidra (0 failures), +201
addresses.json entries, header regenerated with tools/gen_addresses.py.

Note: ghidra/addresses.json also carries lane V's already-written live
confirmation text on ObservedTech_sizeof and ServerPlayer_off_ObservedTechs --
their edit, swept in only because we share the file.
2026-09-08 05:51:37 -04:00
alex
f965c8c769 lane S: std::string is 0x1c binary-wide; ObservedTech element fully mapped
Settles the 0x18-vs-0x1c contradiction lane X raised. 0x1c is right, everywhere,
and there is exactly one std::string instantiation in this binary:
_Bx@0, _Mysize@0x10, _Myres@0x14, _Alval@0x18.

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

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

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

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

Standing rule this produced: never size a struct member from the offsets the code
touches. This build's STL puts the empty allocator LAST in both string (0x1c) and
vector (0x10), and an empty allocator is never loaded or stored, so a touch-scan
undercounts by exactly 4 every time. Size from an enumeration instead.
2026-09-08 05:09:33 -04:00
alex
849c5069fc lane F: x87 precision sensitivity measured; STATE_CHECKSUM 3.5 closed
Seven End Turns from ref-turn2.sav on VM140, six control words, whole-state
checksum on every post-turn autosave.

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

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

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

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

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

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

New tools: verify/fpu-cw/cw_census.py, verify/fpu-cw/trace_bitdiff.py (the
latter exists because under a forced 24-bit word the CRT's own %g rendering
degrades, so trace text is not a valid comparison surface).
2026-09-08 05:07:53 -04:00
alex
460cb7ca2b lane X: x86 displacement xref scanner; pin sizeof(ObservedTech) and its append site
Ghidra does not index ModRM displacements, so `lea reg,[reg+disp]` -- the MSVC
idiom for taking a member's address -- is invisible to find-constant-uses. That
blind spot parked ServerPlayer+0x274 and covers every non-trivial member of the
~1,600 classes still to map.

tools/x86disp.py: full x86-32 length decoder (prefixes, 1/2/3-byte opcodes,
ModRM, SIB, sign-extended disp8, disp32, every immediate form) swept from
Ghidra's 41,089 function starts so decodes begin on real instruction boundaries.
2,174,504 instructions, 612,166 displacement sites, 100.0% code coverage, 70
desyncs (0.17%), zero unknown opcodes. Excludes no-base disp32 forms
(mod=0/rm=5, sib.base=5) which are absolute globals, not member offsets.
Commands: build/query/cohort/func/dis/stats/brute. Works off a gitignored local
cache in dumps/ rather than hammering CT111.

Validated before use: re-finds lea eax,[ecx+0x29c] in ServerPlayer::GetEventStorage
(0x0080db00) and both known OnTechResearched +0x29c sites, plus a new one in
ProcessTurn. Positive control: the ServerPlayer serializer scores 50/50 known
offsets.

sizeof(Game::ObservedTech) = 0x2c (44), proven three ways: the exact magic
divide 0x2e8ba2e9 sar 3 at 0x0087239f, imul reg,reg,0x2c at 0x0087243a and
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
RecordObservedTech (0x007ba1a0) is a direct callee of OnTechResearched and
de-duplicates by tech name before appending. The realloc through 0x007b5820 is
why lane R's guard saw all three vector words move. Element carries a vptr
(RTTI .?AVObservedTech@Game@@) at +0 and a 0x18-byte std::string at +0x0c; the
four on-disk ints map onto +0x04/+0x06/+0x08/+0x24/+0x28 in an order this read
does NOT determine, and is not guessed.

Also corrects harness-audit row 11: ComputeBudget has no store to Budget+0x64
(its only +0x64 accesses are loads off a different base), and ProcessResearch's
int* overbudget arg is a ProcessTurn stack local, not Budget+0x64. Agrees with
lane R's guard seeing 0 changes in 4284 calls.

Honest limits are recorded in the note and the board: this is a recall tool, not
an oracle. Class-level precision at 0x274 is ~13% by function, i.e. a ~900x
search-space cut that still needs one call-graph check. Cohort ranking must not
be used as a hard filter -- it would have discarded the correct answer here.

Ghidra writeback: labels + plate comments on RecordObservedTech,
vector_ObservedTech_push_back, ObservedTech_ctor, vector_ObservedTech_assign,
vector_44B_grow, vftable_ObservedTech.
2026-09-08 04:44:18 -04:00
lane P
0cd84690af lane P: SetResearched cascade read; 10 new addresses.json entries
Read TechTree::SetResearched (0x00581e10) end to end while wiring the research event
posts into ours (sots-engine wip/post-events). Two things it settles:

- SetResearched invokes the owner callback as vft+0x10(def, (flags>>2)&1), so bit 2 of
  flags IS OnTechResearched's "silent" argument. ProcessResearch passes flags = 2, so
  silent is FALSE and the completion event IS posted. Previously this was only
  inferable from lane R's observation that a completion call moved EvNxID by two.
- The unlock cascade in full: the node's child edges set child state 0->1 and
  child.costRP = min(child.costRP, edge.costRP); then a whole-tree sweep sets state 2
  and stamps turnAvailable from ModCount ONLY when it currently reads -1 (so it is
  sticky, and a node re-entering state 2 is not re-announced), and recurses into any
  zero-cost available node. Both loops test the node itself, not a parent -- there is
  no parent clause anywhere in the function, confirming lane E's correction.

Also records what is and is not known about vector<ObservedTech> at ServerPlayer+0x274:
the on-disk element shape is confirmed against turn3-state.sav (its string holds a tech
name), while sizeof(ObservedTech) and the append call site are still unpinned. The
cheapest route to the stride is now a measurement from the new observed_techs region,
not a search -- find-constant-uses does not index lea displacements.

New entries: ServerPlayer_off_ObservedTechs, TechNode_off_TurnAvailable /
TurnResearched / Order / Children, TechEdge_off_CostRP / ChildDef,
TechTree_off_OrderCounter, TechTree_SetResearched_flag_Force / _flag_Silent.
Generated header regenerated with tools/gen_addresses.py (386 -> 396 entries).
2026-09-08 04:16:59 -04:00
alex
f5b37c2b34 lane M: MoveFleet position rounding closed; VM140 released; types 2-5 still uncovered
The mechanism, read off the instruction stream rather than fitted: Mars_Vec3_Normalize
(0x00422520, 123 callers) narrows to float32 four separate times, 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. Live 8/45 -> 0/45 with a control run of the unchanged
build, identical inputs and identical original outputs on all 45 calls.

Board: the two MoveFleet rows go verified; VM140 exclusivity back to FREE with two new
click-path gotchas; a new backlog row for waypoint types 2-5, which this lane attempted
and could not reach - ref-turn2 structurally cannot produce a node-line move because the
only player that would travel one has no ships.

(Most of this lane's files were swept into 9d385a7 by another lane's `git add -A` on the
shared repo; this commit carries what was left.)
2026-09-08 03:51:39 -04:00
alex
9d385a7683 board: lane C state-checksum landed; fpu_cw experiment queued; event-posting lane 2026-09-08 03:47:21 -04:00
alex
05095e93da lane R: golden-trace recapture with guards on the live game
Five hooks recaptured, build recap-7584bad-20260908T0615Z, no source change.

- ProcessResearch: the over-budget event is now a compare divergence
  (side.events.after.v.next_id orig=4 ours=3) instead of a 609 KB save diff.
  15 calls over 5 turns, 3 diverged, RNG matched 15/15.
- ComputeBudget: 4284 compared, 0 diverged, guard quiet - but only 20 distinct
  states and 13 of 22 slots always zero.
- MoveFleet: 8 of 45 calls diverge by 1 ULP of position. B4's clean verdict was
  a one-sample verdict.
- First guarded captures for OnTechResearched (RollResearchEvent draw fired and
  matched), the colony turn and MoveFleet.
- Guards mapped SetResearched live and found an undeclared ObservedTech append.

VM140 released at the main menu, hooks=trace.
2026-09-08 03:06:43 -04:00
alex
e0c3e648d3 events: 161 call sites are spread over 113 functions, not 110 2026-09-08 02:47:42 -04:00
alex
0dff4941bd events: recover the player event-posting API (lane E)
Closes B3's oracle gap and B2's known gap. Both milestones write into the
owner's event list; nothing modelled it, so their clean compares bounded the
economy fields only.

Container: EventStorage embedded at ServerPlayer+0x29c (0x1c bytes), verified by
ServerPlayer::GetEventStorage 0x0080db00 whose whole body is 'lea eax,[ecx+0x29c];
ret'. EvNxID at +0x14 == player+0x2b0, exactly the byte run the harness guard
reports. The list is a vector<TurnEvents{int EvTurn; vector<PlayerEvent>}> --
bucketed by turn, not flat, which the save-editor struct note had wrong.

Entry point: int __thiscall EventStorage::PostEvent(this, std::string BY VALUE,
std::string BY VALUE, void* obj, Vector3* pos, int turn, const char* img,
int act), 0x008862b0, RET 0x4c. 161 call sites in 110 functions: this is the
whole simulation's event API, not a research helper. Dedup is per turn bucket on
message/image/location/position/action but NOT summary; act 0 with no subject and
no position is stored as 2; EvNxID starts at 0 and is promoted to 1 on first post;
PruneOldTurns drops buckets older than turn-50 with an off-by-one that always
leaves one stale bucket behind.

Record: 0x74 bytes, EvEID(+4) EvDsc(+8) EvMsg(+0x24) EvLoc(+0x40) EvPos(+0x44)
EvImg(+0x50) EvAct(+0x6c) EvCID(+0x70) -- confirmed field by field against
turn3-state.sav, which contains the EVENT_RESEARCH_OVERBUDGET record itself.

B3's defect is fully explained: 0x00587b97, reached only from the
completion-roll-FAILED branch, under !wasDone && nowDone && owner.

Three note corrections: EvPos defaults to FLT_MAX, not infinity (writing +inf
changes the save bytes); the save array is turn-bucketed, not flat;
EVENT_TECHS_UNLOCKED has no 'parent researched' clause.

56 entries added to ghidra/addresses.json (header regenerated); 11 prototypes,
13 labels, 12 comments and 2 structs written back to the Ghidra project.
2026-09-08 02:47:11 -04:00
alex
ff67ec0d9a b4 live artifacts; two-base StrategyServer hazard in the contract 2026-09-08 02:07:28 -04:00
alex
4d535028ed b4 static findings: 22 formula corrections, 3 verified signatures, contract -> 324 entries 2026-09-08 00:57:55 -04:00
alex
8a6c0e8404 b3 live: fpu_cw settles x87 (53-bit); correct Q8 - research target is state 3 so it never decays 2026-09-08 00:29:44 -04:00
alex
db9b561964 b2 static findings: tech effects corrections, full g_TechIdNames dump; contract 176->211 2026-09-08 00:01:04 -04:00
alex
3a3e167242 b1 corrections: budget out-param is int[22], researchMoneyKept gated on a research target 2026-09-07 23:24:58 -04:00
alex
6600382e12 b3 static findings: rng draw mapping, NextInt inclusive, float32 odds; address contract +17 2026-09-07 23:14:51 -04:00
alex
de7a2bef04 tech effects (196-entry TechId table, OnTechResearched), 8 formula gaps answered, std::string settled 2026-09-07 22:05:49 -04:00
alex
254a077d6c docs: annotate R1 tag spellings; dashboard refresh 2026-09-07 17:57:21 -04:00
alex
1eb967756b verified: End-Turn oracle byte-identical cross-process; stream tag corrections 2026-09-07 17:46:20 -04:00
alex
078dbbbcc7 loader prototypes M1-M4 + app spine signatures; address contract 80 entries; first-wins parity bug logged 2026-09-07 17:45:24 -04:00
alex
f1c7f55a89 data-model: fix example section path (CRAIC, caught by vfs oracle) 2026-09-07 17:41:45 -04:00
alex
d76f0fd491 strategy: phase 2 decisions (two repos, c++ shim, accrual order) 2026-09-07 17:10:21 -04:00
alex
2d298b1e98 phase 2: address contract + generator, turn-internals filed, M0-M4 rows; sibling repo sots-engine 2026-09-07 17:08:40 -04:00
alex
f644b83581 battle-load: profile filed, bug parked; sampler + resume recipe kept 2026-09-07 16:43:14 -04:00
alex
a1852bbf5b objects: save-schema gaps resolved from serializers; StrategyServer complete 2026-09-07 16:24:54 -04:00
alex
afbfdf5ce8 verified: real saves parse; ServerPlayer/StarFleet/StarShip confirmed, ServerSystem conditional gaps listed; game-run recipe 2026-09-07 16:09:28 -04:00
alex
bb01d8e655 control-flow: full app/turn spine with addresses; types written into Ghidra; threads cleared for battle-load 2026-09-07 15:55:02 -04:00
alex
fc1d703ce7 objects: ServerPlayer/ServerSystem/StarFleet/StarShip member layouts via save-name xrefs; stream API 2026-09-07 15:24:18 -04:00
alex
7139dee219 verify: mars data parsers proven 100% on 1595 files; catalogs; tech-tree graph 2026-09-07 15:22:55 -04:00
alex
9378cec2fa ui/flow: 36-screen catalog, turn state machine; screens are C++ not data 2026-09-07 15:18:14 -04:00
alex
3bdc83fe29 recon: RTTI hierarchy + vftables, affinity pin has no topology branch, save-struct reference 2026-09-07 15:01:50 -04:00
alex
994b182f7a strings/config: battle-load smoking gun (affinity), lockstep sim, net map, Mods override 2026-09-07 14:58:36 -04:00
alex
ef8d557499 data-model: SOTS content is data-driven; parser-first reimpl lever (from .gob harvest) 2026-09-07 14:57:31 -04:00
alex
bf55a66cb5 map: RTTI class inventory (1924 types, engine=Mars); reseed board with real targets 2026-09-07 14:48:43 -04:00
alex
9ad30bffce scaffold RE campaign: board, backlog, findings template, verify tree 2026-09-07 14:40:44 -04:00
lab
a80cbbc6de P0 fingerprint: MSVC2010, DX9, LAA already on 2026-09-07 18:27:12 +00:00