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.
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>>::Writeis0x0054b6e0and writes exactly one anonymous int. So aStreamableEnummember is a frame containing an int, never a bare int.StreamableHelper<Game::ShipSectionID>writes two anonymous ints (0x0056ed50), which is the key for theAIWeightMap<ShipSectionID>instantiation and matches thednidframes 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 countTRncand 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 asNodes rather than typed on a guess. It sits two levels underNCmbR, which is 0 everywhere.TurnCommands_v5, unchanged from lane W §4a — a proven blocker, not a suspected one.
8. Artifacts
sots-enginebranchwip/aiagent(offf4f3a7a, 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::Attribretyped;SysMem/mtselement type corrected;SaveGamenow selects eachCDbody by the id at the same ordinal inCDT),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.hwas not regenerated:tools/streams.pyandtools/gen_stream_schema.pywere 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.jsonis unchanged.ghidra/generated/sots_addresses.hwas 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.pywas not changed. ItsCDhandling is an absence (the blocks areany), not a defect, so there was nothing to correct mid-campaign;state_checksum.pyis byte-identical and stillPROVED. Mirroring theAIAgentshape into the Python oracle is available as follow-up and would change the/CDsubsystem digests (and only those).
9. Open items for the next lane
- A save with issued turn commands, for
TurnCommands_v5— still the one blocker of size. - A longer multi-AI save would exercise ten of the thirteen element layouts in §7 at once.
Game::TacReport'sTRnc/TRships/TRsats/TRshipsLstructure needs a combat report on disk before it is worth resolving.objects/streams.json's[element]flags are unreliable insideGame::StrategyAIAgent::Streamable(§2b) and inGame::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.