sots-re/findings/objects/aiagent-block.md
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

16 KiB

Typing the AIAgent custom-data blocks

Lane A, 2026-09-08. Host/static only — VM140 was held by lane O and the game was never run. Picks up findings/objects/wire-schema-closeout.md §4b, the job lane W left deliberately.

Headline: named coverage 98.0 % → 99.9 %

The honest metric is CoverageArchive's split of items a field names from items a generic Node merely carries. Byte-identical round trip held throughout, on all four saves.

save before after opaque before → after still opaque after
turn1-state 98.0 % 99.9 % 748 → 37 <rest:CD> 35, RNG 2
turn2-state 98.0 % 99.9 % 748 → 37 <rest:CD> 35, RNG 2
turn3-state 98.0 % 99.9 % 748 → 37 <rest:CD> 35, RNG 2
zuul-turn5-species5 98.4 % 99.9 % 521 → 37 <rest:CD> 35, RNG 2

Conformance: 86 shapes bound, 838 items matched, 0 MISMATCH (was 74 / 769 / 0). Twelve new bindings, every one of them a full match with zero wire-only and zero shape-only items — the recovered sequence and the shape agree item for item, which is unusual and is the point of §2.

ctest 35/35. tools/clean_room_check.sh OK (run as a separate command). test_save still skips cleanly with SOTS_SAVES_DIR unset. verify/state-checksum/state_checksum.py --strict still reports coverage: PROVED on all four saves with 0 error, 0 warn — unchanged, because nothing on the Python side was touched (§6).

The remaining CD items are the one TurnCommands_v5 body per save, which is lane W's proven blocker and still needs a save with issued orders. RNG is correctly opaque. Attrib closed as a side effect (§3).

1. The block is one unconditional sequence — there is no branch to read

Game::StrategyAIAgent::Streamable::Write is at 0x006c6f00. The decompiler presents it with a large if wrapped around everything from lnat onwards, which looks exactly like the conditional the campaign method warns about. It is not one. The if is an inlined std::vector destructor for the temporary vector nalat is written from, and this database has operator delete marked noreturn, so Ghidra could not join the two paths and hoisted the continuation into the null branch. The disassembly settles it:

0x006c72ce  cmp  [ebp-0x50],ebx      ; _Myfirst == 0 ?
0x006c72d1  je   0x006c72e8          ; skip the delete
0x006c72d3  ...  _Orphan_all / operator delete
0x006c72e8  ...  push "lnat"; call WriteInt

Both paths converge at 0x006c72e8. Every one of the 36 items is written unconditionally. That is why this block, unlike TurnCommands_v5, aligns perfectly: the recovery's "all branches" view and the actual single-record view are the same view. All eleven AIAgent blocks across the four saves have byte-for-byte the same top-level item order.

This is the opposite outcome to lane W's §4a and for a concrete reason, not luck.

2. What each item is, and how the count/element structure was settled

The recovery gives tags and disk primitives; it does not give the count/element structure, because a linear pass flattens loops. Each of these came from reading the loop in Write.

item what it is evidence
AIAttr StreamableHelper<Game::AttribMap> §3
AITurnPris AIWeightMap<StreamableEnum<AIPurposeID>> §4
AISit Game::AISituation two weight maps, 0x0069a220
AIPlyHat AIWeightMap<StreamableEnum<uint>> §4
prs2 VectorHelper<Game::AIPlayerRequestStamp> §5
dsh a count, not a scalar §2a
pid, trns the dsh element: an int and a VectorHelper<int> §2a
NBStab count = span >> 3 over the vector at +0x38..+0x3c loop writes BStabPl at elem+0, BStabTn at elem+4
NMBlst count = span >> 3 over +0x48..+0x4c loop writes MBlstSy at elem+0, MBlstTn at elem+4
AIDNG one nested DesignNameGen, not a loop body single helper call at 0x006c710f
AIHivJ, SDFlT plain ints at +0x104, +0x368 —
nalat VectorHelper<StreamableEnum<uint>> over a temporary vector built at the call site §4a
lnat, lat plain scalar members at +0x58, +0x5c §2b
AINumSys a computed count of the systems the following loop actually emits both loops walk the same list under the same *(x+8) == 3 test
AISysID, AISys the AINumSys element pair —
NCmbR count = span >> 2 over the pointer vector at +0x94's +0x28..+0x2c each element writes one CmbR frame
NumCL count = span / 0xc over +0x1ac..+0x1b0 loop writes CLTn(+0), CLSyID(+4), CLPlID(+8)
NPrv count = span >> 3 over +0x32c..+0x330 loop writes NPrvId(int, +0), NPrvVa(float, +4)
NTecS count = span >> 2 over +0x13c..+0x140 loop writes TecS
fct plain int at +0x1f0 —
apr VectorHelper<Game::AIAutoPeaceRun> at +0x1f8 —

Two of these are also confirmed by the data, not only by the disassembly: NPrv is 1 or 2 in our saves and is followed by exactly that many (NPrvId, NPrvVa) pairs; NTecS is 2 or 3 and is followed by exactly that many TecS. dnnc inside AIDNG is 0, 1 or 2 and is followed by exactly that many (dnid, dnnm) pairs — the zuul save's two-entry case is what makes it a count rather than a coincidence.

2a. dsh is a std::map's _Mysize, so it is the element count

The recovery lists dsh as a scalar member and pid/trns as elements of nothing. Taking that at face value would desynchronise the reader, because there is no other count before the pairs. What Write actually does is:

local = *(agent + 0x240) ; WriteInt("dsh", &local)
node  = *(agent + 0x23c) ; red-black walk until back at the head
    WriteInt("pid",  node + 0x0c)
    VectorHelper<int>("trns", node + 0x10)

+0x23c is _Myhead and +0x240 is _Mysize — the same head@0 / size@4 layout the campaign already pinned on ServerSystem::NVO (0x274 / 0x278). The node's _Isnil is at +0x21, which puts _Myval at +0x0c and makes it pair<const int, std::vector<int>> — 4 + 16 = 0x14 bytes, landing _Color exactly on +0x20. So the container is std::map<int, std::vector<int>> and dsh is its size. Structurally it has to be: a reader has no terminator and no other number.

2b. lnat and lat are scalars, and the data agrees

The recovery marks both [element]. They are two ordinary WriteInts of agent+0x58 and agent+0x5c, immediately after the nalat frame. The saves confirm it independently: nalat's count is 0 in all eleven blocks and lnat/lat are present anyway, which no element reading survives.

More generally the [element] flag is unreliable for this class — it is also set on AIDNG, nalat, AINumSys and apr, which are not loop bodies. It does not affect the conformance check, which aligns on tag and disk primitive only, so objects/streams.json was left alone; this is the same class of recovery artefact as lane W's open item 4 (SVSOCrowDefenders::dsys).

3. Game::AttribMap — and Attrib fell out with it

AttribMap::Write (0x0059ee60) writes *(this+8) as an anonymous count, then walks the tree at *(this+4) writing two anonymous strings per node — key at node+0x0c, value at node+0x28, _Isnil at node+0x45. Key and value are both Mars::String (0x1c), so _Myval is 0x38 bytes and _Color lands on +0x44: std::map<Mars::String, Mars::String>, exactly.

Lane W recorded Attrib in Game::StrategyServer as "an empty frame in every save; there is nothing to type". It is not empty — it is a frame holding a count of 0, which is why it showed as 2 opaque items. Typing it as AttribMap closes both, on all four saves.

The entry pair is a hypothesis in the behavioural sense: the map is empty everywhere we can look. The type is certain, from the writer.

4. Mars::StreamableEnum<T> is a frame, and that corrects two of lane W's shapes

AIWeightMap<K> (four instantiations, one wire shape) writes an anonymous count then, per entry, an anonymous key frame and an anonymous float. The key frame's body is what matters:

  • StreamableHelper<StreamableEnum<T>>::Write is 0x0054b6e0 and writes exactly one anonymous int. So a StreamableEnum member is a frame containing an int, never a bare int.
  • StreamableHelper<Game::ShipSectionID> writes two anonymous ints (0x0056ed50), which is the key for the AIWeightMap<ShipSectionID> instantiation and matches the dnid frames on disk ({2, 44}, {5, 37}, …).

The consequence reaches outside this block. VectorHelper<StreamableEnum<uint>>::Write (0x0042f7f0) writes a count and then n anonymous frames, one int each. Lane W typed Game::SVSOSwarmQueen::SysMem and Game::SVSOVonNeumann::mts as std::vector<int32_t> — a framed array of plain ints — reasoning from the decorated helper name. The name is right; the element framing is not. Both are now std::vector<StreamEnum>, along with nalat here.

This changes no bytes and closes no items: all three have count 0 in every save, which is exactly why the error was invisible. It is a correction to an unexercised layout, and it stays an unexercised layout — see §7. Lane W's own note flagged these three as hypotheses; this is what checking one of them looked like.

4a. nalat is computed, not stored

nalat's vector is built on the stack at the call site (FUN_006c0360 over the members at +0x18 and +0x1c) and destroyed immediately after. It is a derived list, not a member, so its content is not recoverable from a static read of the agent's fields.

5. Game::AIPlayerRequestStamp has no wire-table entry, by construction

prs2 is VectorHelper<Game::AIPlayerRequestStamp> (0x0069b310): a count of 8-byte elements, each an anonymous frame. The element writer is 0x00695400 and emits two named ints, pid and trn. tools/serializers.py reports "no serializer" for the class because it is a POD with no RTTI class of its own — it is reached only through a specialised StreamableHelper thunk, one of the 38 such types §3 of serializer-struct-recovery.md describes. So the shape is typed from the writer directly and is not bound in test_wire_schema.cpp: there is nothing in the table to bind it to.

6. Game::AISystem::Write never reads its object

0x006bda30 takes only the stream. It constructs an empty AISituation on the stack — two 16-byte vectors, both zeroed — writes it as AISysSit, and destroys it. this is never touched.

So every AISys body on disk is two empty weight maps regardless of game state, which is what the saves show: 28 identical AISys frames per block, in every block, in every save. The AINumSys/AISysID pairing still carries real information (which systems the AI tracks); the situation payload does not. Worth knowing before anyone tries to reconstruct AI behaviour from it.

The recovery graded AISystem "partial — only container elements / computed values recovered". That grade is right, and this is why.

7. What is still a hypothesis, and what would settle it

Every container in the block has count 0 in all eleven AIAgent blocks of all four saves, apart from AIDNG's dnnc, NPrv and NTecS. So the frames are exercised and most element layouts are not. Labelling it honestly:

item element layout exercised?
AIDNG / dnnc (dnid: ShipSectionID, dnnm: string) yes — 0, 1 and 2 entries observed
NPrv (NPrvId: int, NPrvVa: float) yes — 1 and 2 entries observed
NTecS TecS: int yes — 2 and 3 entries observed
AINumSys (AISysID: int, AISys: frame) yes — 28 entries in every block
AIAttr entries (string, string) no — map empty everywhere
AITurnPris, AIPlyHat, AISitSecs, AISitWeapFams entries (key frame, float) no — count 0 everywhere
prs2 (pid: int, trn: int) no
dsh (pid: int, trns: int[]) no
NBStab, NMBlst two ints no
nalat one-int frame no
NCmbR / CmbR Game::AICombatReport no
NumCL three ints no
apr Game::AIAutoPeaceRun no

The unexercised rows are typed from the writers, not guessed from bytes, and the conformance check agrees with the recovery on every one. They are still hypotheses in the behavioural sense and tests/mars_stream/test_stream.cpp::test_aiagent_block is the only thing that exercises them: it populates every container, round-trips, and asserts the bytes are stable. It also asserts the trap in §4 directly — that a two-element nalat produces two complex nodes, which a bare-int typing would get wrong.

What would settle them: a save from a longer game with several AI players in contact. NBStab and NMBlst need the AI to have border-stability and menace-blacklist entries, NCmbR needs the AI to have fought a battle it kept a report of, prs2 and dsh need multi-player AI diplomacy and design sharing, and AIAttr needs whatever sets AI attributes (possibly a scenario). None of that needs anything the shim cannot produce; it needs turns played, which is lane O's machine.

Two things stay carried on purpose:

  • Game::TacReport (CombatPlayerStats::TacReports) has a computed count TRnc and three trailing scalar runs (TRships, TRsats, TRshipsL) whose relationship the linear recovery cannot resolve, and no save we hold contains one. Its body is carried as Nodes rather than typed on a guess. It sits two levels under NCmbR, which is 0 everywhere.
  • TurnCommands_v5, unchanged from lane W §4a — a proven blocker, not a suspected one.

8. Artifacts

  • sots-engine branch wip/aiagent (off f4f3a7a, never committed to main): src/mars/stream/shapes.h (StreamEnum, AttribMap, AIWeightMap<K> + two aliases, AISituation, AISystem, DesignNameGen, AIAutoPeaceRun, CombatPlayerStats, AICombatReport, AIRequestStamp, StrategyAIAgent, CustomDataBlock; Sim::Attrib retyped; SysMem/mts element type corrected; SaveGame now selects each CD body by the id at the same ordinal in CDT), tests/mars_stream/test_wire_schema.cpp (+12 bindings), tests/mars_stream/test_stream.cpp (+2 unit tests, no saves needed), tests/mars_stream/test_save.cpp (ratchet 97.5 → 99.8).
  • sots-re: ghidra/addresses.d/lane-a.json (29 entries, merges to 669 with no duplicate name), this note.
  • include/generated/sots_stream_schema.h was not regenerated: tools/streams.py and tools/gen_stream_schema.py were re-run and the output is identical to the committed header apart from the provenance line, which carries the notes repo's HEAD and so changes on every commit. objects/streams.json is unchanged.
  • ghidra/generated/sots_addresses.h was not regenerated either — same reasoning as lane W: the fragment carries this lane's addresses, and regenerating in place would sweep other lanes' in-flight fragments into this branch. The engine consumes none of these addresses.
  • verify/save-reader/save_reader.py was not changed. Its CD handling is an absence (the blocks are any), not a defect, so there was nothing to correct mid-campaign; state_checksum.py is byte-identical and still PROVED. Mirroring the AIAgent shape into the Python oracle is available as follow-up and would change the /CD subsystem digests (and only those).

9. Open items for the next lane

  1. A save with issued turn commands, for TurnCommands_v5 — still the one blocker of size.
  2. A longer multi-AI save would exercise ten of the thirteen element layouts in §7 at once.
  3. Game::TacReport's TRnc / TRships / TRsats / TRshipsL structure needs a combat report on disk before it is worth resolving.
  4. objects/streams.json's [element] flags are unreliable inside Game::StrategyAIAgent::Streamable (§2b) and in Game::SVSOCrowDefenders (lane W's item 4). The flag is not consumed by the conformance check, so this is a cleanliness item, not a correctness one — but anyone reading the table as documentation should know.