Commit graph

45 commits

Author SHA1 Message Date
alex
38a13139d4 regenerate header (805 entries) 2026-09-08 11:00:24 -04:00
alex
3d5f83414a Z: the dominant RNG consumer is trade-raid generation, behind a virtual call
FUN_00893290 is ServerTradeManager::GenerateTradeRaidEncounters --
ServerTradeManagerImpl vftable slot 10 -- rolling Chance(TRADE_RAID_ODDS_PLAYER
= 0.2) and Chance(TRADE_RAID_ODDS_NPC = 0.05) once per player. Both are
strictly inside (0,1) so each is exactly one word, and no back-edge contains
either site, so one word per player per site is a hard bound.

Why no sweep found it: zero direct calls to it exist in the image and its only
reference is a vtable slot. The dispatch is a "call edx" through slot 10 at
0x007d8469 inside DetectEncounters -- one instruction before the DIRECT call
that lane I's closure did follow. Lane I's inventory is not wrong; its stated
caveat about indirect edges was load-bearing, and this is what it was hiding.

strategic-turn-internals.md line 153 had already named 0x00893290 "raid
encounter generation" against these exact StrategyVars. What was missing was
that it is where a turn's RNG goes.

Ghidra's size is wrong again: real body 1546 bytes ending 0x0089389a, reported
1532, ending mid-instruction. Rule 17, third time.
2026-09-08 10:57:12 -04:00
alex
9c37635ca3 Z: the unnamed counter at S+0x8 is ModCount, and my players flag was my own error
StrategyServer::Write tags both words itself: S+0x8 is ModCount and S+0xc is
Frame. addresses.json has the name on the wrong word and lane T's
PhaseCounter is the one the wire calls ModCount. The saves confirm it
independently -- ModCount 0/12/24 across turn1/2/3-state, 241/412 across Zuul
16/23 -- and those deltas are exactly the 12-44 per turn measured live. So the
'writer nobody has identified' question dissolves: it is a modification
counter, it scales with the empire, and there is no single writer to find.

The players=8 flag is withdrawn. The offset is right, pinned by the ctor's
four-vector enumeration at 0x0085b120 with no frame arithmetic needed, and the
count is right: the vector is empires + one rebel-AI per empire species + four
NPC pseudo-players, so 8 on the Human saves and 7 on the Zuul ones against a
lobby that says 2 in both. My draft claimed the hook read 8 on both saves. It
read 7 on the Zuul one. I generalised from one run without re-reading the
other, and a check aimed at something else caught it.
2026-09-08 10:04:21 -04:00
alex
888f318d8c lane I: the complete inlined-draw inventory, and the seven RNG entry points
Re-ran lane J's image-wide tempering-immediate scan at real instruction
boundaries and audited it site by site.  Sixteen functions carry the two masks
inside a decoded instruction, 67 occurrences, and a brute byte scan finds zero
orphans -- recall is complete.  Of the sixteen:

  * ONE IS A FALSE POSITIVE.  0x008cca30 has no temper chain at all; the four
    bytes read as the second mask are the rel32 displacement of a call.
  * FOUR ARE RNG ENTRY POINTS, not game code -- the tempering there is the
    primitive's own.  Two were known; 0x004f7670 and 0x008e6e30 were not.

So the figure is ELEVEN game functions with inlined draws over 28 sites, not
fourteen.  Exactly ONE of the eleven is reachable from StrategyServer::ProcessTurn
(0x007aa240, depth 4) and one more from OnAllCombatDone_Tail (lane J's R2).  The
other nine are map setup, the lobby, the network layer and two scripted
encounters, all reached through vtable slots with no direct caller -- so their
absence from the turn closure is proved for direct edges only, and that caveat
is stated as loudly as the result.

Both functions lane J handed over are read completely from the instruction
stream:

  0x004f7670 (84 B) is Mars::RNG::NextUInt -- ONE WORD, UNCONDITIONAL, no loop,
  no branch but the lazy twist.  ECX is the generator OBJECT, where NextFloat
  and NextInt take the object PLUS FOUR; ProbabilisticJump uses both conventions
  0x6b bytes apart.  Ghidra's size is right here, which is worth saying.

  0x007aa240 (Ghidra says 944; the body is 953 and ends past the reported range)
  is the encounter-detection roll.  ONE INLINED NextFloat PER (contact, detector)
  TRIAL, drawn BEFORE the accept test, so a detector holding neither of two
  specific techs still burns a word and can never succeed.  The accept test is
  `thresh >= r` -- equality accepts -- derived from the fcompp/test ah,5/jp
  encoding rather than the mnemonic.  The outer repeat-until-no-progress loop
  cannot redraw a pair: the "tried" bitset is filled above the back-edge target
  and never cleared, so the whole call is bounded by |contacts| x |detectors|.

Three more draw entry points nobody had listed: a float range (one word, and it
NARROWS TWICE), a triangular integer range (at least two words), and a
truncated-normal range whose two draws are BOTH inlined and which costs two
words per attempt with an unbounded attempt count -- and which scales by 2^-32
where NextFloat scales by 1/(2^32-1).  Two divisors, one image.

RECONCILIATION, stated honestly.  The complete draw-site inventory of the
ProcessTurn closure is 22 sites: 21 entry-point calls plus the one inlined site.
This lane adds two previously-uncounted sources to that list and accounts for
NONE of lane Z's 18-20 words per turn with certainty, because both new sources
are gated and neither has been measured.  Lane J's prediction that these two
functions would explain the gap is NOT confirmed.  What is now provable is the
negative: there is no twenty-third mechanism, so the 18-20 words are distributed
among exactly these 22 sites.  The search space closes; the count does not.

Also corrects, in place: my own Ghidra comment claiming the masks are a Mars
variant of MT19937.  They are the textbook masks applied before the shift
instead of after -- (y & 0xff3a58ad) << 7 == (y << 7) & 0x9d2c5680, verified
over 200k words -- so mars::rng was never wrong, but a scan for the textbook
constants finds nothing in this image.

Fragment validated by generating to a scratch path (797 entries, no duplicate
name); the tracked header is untouched while lane Z is in flight.  Eight
prototypes, nine plate comments and eight pre-comments written back to Ghidra.
2026-09-08 10:03:33 -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
94e8530a4a regenerate header (756 entries) 2026-09-08 09:07:15 -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
8131a9e30e regenerate header (750 entries) 2026-09-08 08:54:33 -04:00
alex
4a73eb8f74 regenerate header at the lane-K fragment commit (750 entries) 2026-09-08 08:48:59 -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
117d641d74 regenerate header (751 entries, lane K provenance) 2026-09-08 08:47:15 -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
9de77f7e5e lane O: 7 purpose-built saves - issued turn commands, node routes, research-roll-pending
Produces the game states several board rows were blocked on, and reads each
one back out of the save before claiming it.

- CD/TurnCommands_v5 decoded: three saves with issued orders (research target,
  research boost, build orders, system rates, colonize, fleet move). Layout in
  SAVE_FORMAT.md section 11; ids cross-checked against the same file's Sys/Flt.
- Waypoint type 3 (NodeRoute) now has 16 instances in one save and the player's
  own fleet is a mover in two. Type 2 is shown NOT to be produced by a Move
  order for either node-drive race (Zuul and Human both emit type 3).
- research_roll_pending true at load in two saves; the ProcessTurn gate constant
  at 0x00a2c788 is a float 0.5, so the flag survives into ProcessResearch only
  while progress/cost <= 0.5 at the start of the turn.
- Budget tail: construction and a large maintenance are live; Nexp is empty in
  all 11 saves and the 1.8 UI has no expense slider, so slot 12 looks dead.

VM140 restored to the original 8-file SavedGames set (autosaves byte-identical)
and left at the main menu; VM140 row set FREE.
2026-09-08 08:35:13 -04:00
alex
c092046966 regenerate header (722 entries) 2026-09-08 08:14:50 -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
77abd388c7 regenerate both headers (669 addresses); track objects/generated so the shared clone stops sitting dirty 2026-09-08 07:36:16 -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
9834b3d175 gen_addresses: per-lane fragment dir; duplicate names are a hard error 2026-09-08 05:55:03 -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
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
9d385a7683 board: lane C state-checksum landed; fpu_cw experiment queued; event-posting lane 2026-09-08 03:47:21 -04:00
alex
bd9b8f2e10 events: regenerate header (380 entries) 2026-09-08 02:50:38 -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
619edf97f4 regenerate header 2026-09-08 02:07:28 -04:00
alex
ff67ec0d9a b4 live artifacts; two-base StrategyServer hazard in the contract 2026-09-08 02:07:28 -04:00
alex
ee43e96404 regenerate address header with b2 offsets 2026-09-08 01:19:05 -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
ef3858c2ca regenerate address header 2026-09-08 00:29:44 -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
cea76e2771 rng: verify Seed/Twist/NextFloat signatures; lazy twist confirmed; flag x87 float-parity risk 2026-09-07 22:28:56 -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
b24f13c613 addresses: GlobalConst/map layouts + float-scale global (M1 recon) 2026-09-07 22:01:49 -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
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
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
aa895dcfe6 init commit 2026-09-07 12:31:56 -04:00