From 805409ec1187435605b27f59a0078123e49eed0b Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 07:33:33 -0400 Subject: [PATCH] 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 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. --- findings/objects/aiagent-block.md | 260 ++++++++++++++++++++++++++++++ ghidra/addresses.d/lane-a.json | 236 +++++++++++++++++++++++++++ 2 files changed, 496 insertions(+) create mode 100644 findings/objects/aiagent-block.md create mode 100644 ghidra/addresses.d/lane-a.json diff --git a/findings/objects/aiagent-block.md b/findings/objects/aiagent-block.md new file mode 100644 index 0000000..61a63b1 --- /dev/null +++ b/findings/objects/aiagent-block.md @@ -0,0 +1,260 @@ +# 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 | `` 35, `RNG` 2 | +| turn2-state | 98.0 % | **99.9 %** | 748 → 37 | `` 35, `RNG` 2 | +| turn3-state | 98.0 % | **99.9 %** | 748 → 37 | `` 35, `RNG` 2 | +| zuul-turn5-species5 | 98.4 % | **99.9 %** | 521 → 37 | `` 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` | §3 | +| `AITurnPris` | `AIWeightMap>` | §4 | +| `AISit` | `Game::AISituation` | two weight maps, `0x0069a220` | +| `AIPlyHat` | `AIWeightMap>` | §4 | +| `prs2` | `VectorHelper` | §5 | +| **`dsh`** | **a count, not a scalar** | §2a | +| `pid`, `trns` | the `dsh` element: an int and a `VectorHelper` | §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>` 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` 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("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>` — 4 + 16 = 0x14 bytes, +landing `_Color` exactly on `+0x20`. So the container is `std::map>` 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 `WriteInt`s 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`, 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` is a frame, and that corrects two of lane W's shapes + +`AIWeightMap` (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>::Write` is `0x0054b6e0` and writes **exactly one anonymous + int**. So a `StreamableEnum` member is a *frame containing an int*, never a bare int. +* `StreamableHelper` writes two anonymous ints (`0x0056ed50`), which is the + key for the `AIWeightMap` instantiation and matches the `dnid` frames on disk + (`{2, 44}`, `{5, 37}`, …). + +The consequence reaches outside this block. `VectorHelper>::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` — 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`, 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` (`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 `Node`s 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` + 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. diff --git a/ghidra/addresses.d/lane-a.json b/ghidra/addresses.d/lane-a.json new file mode 100644 index 0000000..644315d --- /dev/null +++ b/ghidra/addresses.d/lane-a.json @@ -0,0 +1,236 @@ +{ + "entries": [ + { + "name": "Game_StrategyAIAgent_Streamable_Write", + "addr": "0x006c6f00", + "convention": "thiscall", + "prototype": "void (Game::StrategyAIAgent::Streamable* this, Mars::Stream* s) // the body of every `Player..AIAgent` CD block. 36 wire items, NO conditionals: the only `if` the decompiler shows around `lnat` is an inlined std::vector destructor whose operator delete is marked noreturn, and both paths converge at 0x006c72e8. The agent object is *(this+4)", + "status": "verified", + "source": "findings/objects/aiagent-block.md (lane A 2026-09-08)" + }, + { + "name": "Game_StrategyAIAgent_Streamable_Read", + "addr": "0x006c8fd0", + "convention": "thiscall", + "prototype": "bool (Game::StrategyAIAgent::Streamable* this, Mars::Stream* s)", + "status": "verified", + "source": "findings/objects/aiagent-block.md (lane A 2026-09-08)" + }, + { + "name": "Game_StrategyAIAgent_DesignNameGen_Write", + "addr": "0x006c77a0", + "convention": "thiscall", + "prototype": "void (Game::StrategyAIAgent::DesignNameGen* this, Mars::Stream* s) // `dnnc` = (this+8 - this+4)/0x24, then dnnc x { dnid: StreamableHelper at elem+0, dnnm: WriteString at elem+8 }. Element stride 0x24 = ShipSectionID(8) + Mars::String(0x1c)", + "status": "verified", + "source": "findings/objects/aiagent-block.md (lane A 2026-09-08)" + }, + { + "name": "Game_StrategyAIAgent_DesignNameGen_Read", + "addr": "0x006ccc70", + "convention": "thiscall", + "prototype": "bool (Game::StrategyAIAgent::DesignNameGen* this, Mars::Stream* s)", + "status": "verified", + "source": "findings/objects/aiagent-block.md (lane A 2026-09-08)" + }, + { + "name": "Game_AISituation_Write", + "addr": "0x0069a220", + "convention": "thiscall", + "prototype": "void (Game::AISituation* this, Mars::Stream* s) // AISitSecs = AIWeightMap at this+0, AISitWeapFams = AIWeightMap> at this+0x10. sizeof >= 0x20", + "status": "verified", + "source": "findings/objects/aiagent-block.md (lane A 2026-09-08)" + }, + { + "name": "Game_AISituation_Read", + "addr": "0x0069a160", + "convention": "thiscall", + "prototype": "bool (Game::AISituation* this, Mars::Stream* s)", + "status": "verified", + "source": "findings/objects/aiagent-block.md (lane A 2026-09-08)" + }, + { + "name": "Game_AISystem_Write", + "addr": "0x006bda30", + "convention": "thiscall", + "prototype": "void (Game::AISystem* this, Mars::Stream* s) // writes ONE `AISysSit` frame and nothing else, and the AISituation it writes is constructed empty on the stack -- `this` is never read. Every AISys body on disk is therefore two empty weight maps regardless of game state", + "status": "verified", + "source": "findings/objects/aiagent-block.md (lane A 2026-09-08)" + }, + { + "name": "Game_AISystem_Read", + "addr": "0x006bd960", + "convention": "thiscall", + "prototype": "bool (Game::AISystem* this, Mars::Stream* s)", + "status": "verified", + "source": "findings/objects/aiagent-block.md (lane A 2026-09-08)" + }, + { + "name": "Game_AttribMap_Write", + "addr": "0x0059ee60", + "convention": "thiscall", + "prototype": "void (Game::AttribMap* this, Mars::Stream* s) // count = *(this+8) (the tree's _Mysize; _Myhead is at this+4), then a red-black walk writing two NULL-named strings per node -- key at node+0x0c, value at node+0x28, _Isnil at node+0x45. So AttribMap is a std::map", + "status": "verified", + "source": "findings/objects/aiagent-block.md (lane A 2026-09-08)" + }, + { + "name": "Game_AttribMap_Read", + "addr": "0x005a8a60", + "convention": "thiscall", + "prototype": "bool (Game::AttribMap* this, Mars::Stream* s)", + "status": "verified", + "source": "findings/objects/aiagent-block.md (lane A 2026-09-08)" + }, + { + "name": "Game_AIWeightMap_ShipSectionID_Write", + "addr": "0x00696b40", + "convention": "thiscall", + "prototype": "void (Game::AIWeightMap* this, Mars::Stream* s) // NULL-named count = span/0xc, then n x { NULL-named StreamableHelper frame at elem+0, NULL-named float at elem+8 }", + "status": "verified", + "source": "findings/objects/aiagent-block.md (lane A 2026-09-08)" + }, + { + "name": "Game_AIWeightMap_ShipSectionID_Read", + "addr": "0x006bff00", + "convention": "thiscall", + "prototype": "bool (Game::AIWeightMap* this, Mars::Stream* s)", + "status": "verified", + "source": "findings/objects/aiagent-block.md (lane A 2026-09-08)" + }, + { + "name": "Game_AIWeightMap_StreamableEnumUInt_Write", + "addr": "0x00696c50", + "convention": "thiscall", + "prototype": "void (Game::AIWeightMap>* this, Mars::Stream* s) // NULL-named count = span>>3, then n x { NULL-named StreamableHelper> frame at elem+0, NULL-named float at elem+4 }", + "status": "verified", + "source": "findings/objects/aiagent-block.md (lane A 2026-09-08)" + }, + { + "name": "Game_AIWeightMap_StreamableEnumUInt_Read", + "addr": "0x006b57c0", + "convention": "thiscall", + "prototype": "bool (Game::AIWeightMap>* this, Mars::Stream* s)", + "status": "verified", + "source": "findings/objects/aiagent-block.md (lane A 2026-09-08)" + }, + { + "name": "Game_AIWeightMap_AIPurposeID_Write", + "addr": "0x0069b590", + "convention": "thiscall", + "prototype": "void (Game::AIWeightMap>* this, Mars::Stream* s) // same wire shape as the uint instantiation; `AITurnPris`", + "status": "verified", + "source": "findings/objects/aiagent-block.md (lane A 2026-09-08)" + }, + { + "name": "Game_AIWeightMap_AIPurposeID_Read", + "addr": "0x006c01a0", + "convention": "thiscall", + "prototype": "bool (Game::AIWeightMap>* this, Mars::Stream* s)", + "status": "verified", + "source": "findings/objects/aiagent-block.md (lane A 2026-09-08)" + }, + { + "name": "Game_AIWeightMap_WeaponFamilyID_Write", + "addr": "0x0069b4c0", + "convention": "thiscall", + "prototype": "void (Game::AIWeightMap>* this, Mars::Stream* s) // same wire shape as the uint instantiation; `AISitWeapFams`", + "status": "verified", + "source": "findings/objects/aiagent-block.md (lane A 2026-09-08)" + }, + { + "name": "Game_AIWeightMap_WeaponFamilyID_Read", + "addr": "0x006bffe0", + "convention": "thiscall", + "prototype": "bool (Game::AIWeightMap>* this, Mars::Stream* s)", + "status": "verified", + "source": "findings/objects/aiagent-block.md (lane A 2026-09-08)" + }, + { + "name": "Game_ShipSectionID_Write", + "addr": "0x0056ed50", + "convention": "thiscall", + "prototype": "void (Game::ShipSectionID* this, Mars::Stream* s) // two NULL-named ints, at this+0 and this+4", + "status": "verified", + "source": "findings/objects/aiagent-block.md (lane A 2026-09-08)" + }, + { + "name": "Game_ShipSectionID_Read", + "addr": "0x0056ed10", + "convention": "thiscall", + "prototype": "bool (Game::ShipSectionID* this, Mars::Stream* s)", + "status": "verified", + "source": "findings/objects/aiagent-block.md (lane A 2026-09-08)" + }, + { + "name": "Mars_StreamableHelper_StreamableEnumUInt_vftable", + "addr": "0x009e24b8", + "convention": "data", + "prototype": "void* [3] // { 0x004f7230 scalar-deleting dtor, 0x00697ae0 Read, 0x0054b6e0 Write }", + "status": "verified", + "source": "findings/objects/aiagent-block.md (lane A 2026-09-08)" + }, + { + "name": "Mars_StreamableEnum_Write", + "addr": "0x0054b6e0", + "convention": "thiscall", + "prototype": "void (Mars::StreamableHelper>* this, Mars::Stream* s) // writes exactly ONE NULL-named int. So a StreamableEnum member is a FRAME on the wire whose body is a single int -- not a bare int. This is what makes a VectorHelper> a framed array of one-int frames", + "status": "verified", + "source": "findings/objects/aiagent-block.md (lane A 2026-09-08)" + }, + { + "name": "Mars_StreamableEnum_Read", + "addr": "0x00697ae0", + "convention": "thiscall", + "prototype": "void (Mars::StreamableHelper>* this, Mars::Stream* s) // ReadInt through stream slot +0x10; stores only when the read succeeds", + "status": "verified", + "source": "findings/objects/aiagent-block.md (lane A 2026-09-08)" + }, + { + "name": "Mars_VectorHelper_StreamableEnumUInt_vftable", + "addr": "0x009e2438", + "convention": "data", + "prototype": "void* [3] // { 0x004f7230 dtor, 0x00431610 Read, 0x0042f7f0 Write }", + "status": "verified", + "source": "findings/objects/aiagent-block.md (lane A 2026-09-08)" + }, + { + "name": "Mars_VectorHelper_StreamableEnumUInt_Write", + "addr": "0x0042f7f0", + "convention": "thiscall", + "prototype": "void (Mars::VectorHelper>* this, Mars::Stream* s) // NULL-named count = span>>2, then n NULL-named FRAMES each written by Mars_StreamableEnum_Write. Backs `nalat`, `SysMem` and `mts`, all of which have count 0 in every save we hold", + "status": "verified", + "source": "findings/objects/aiagent-block.md (lane A 2026-09-08)" + }, + { + "name": "Mars_VectorHelper_AIPlayerRequestStamp_vftable", + "addr": "0x00a1a700", + "convention": "data", + "prototype": "void* [3] // { 0x004f7230 dtor, 0x006b5600 Read, 0x0069b310 Write }; backs `prs2`", + "status": "verified", + "source": "findings/objects/aiagent-block.md (lane A 2026-09-08)" + }, + { + "name": "Mars_VectorHelper_AIPlayerRequestStamp_Write", + "addr": "0x0069b310", + "convention": "thiscall", + "prototype": "void (Mars::VectorHelper* this, Mars::Stream* s) // NULL-named count = span>>3, then n NULL-named frames written by Game_AIPlayerRequestStamp_Write. Element stride 8", + "status": "verified", + "source": "findings/objects/aiagent-block.md (lane A 2026-09-08)" + }, + { + "name": "Mars_StreamableHelper_AIPlayerRequestStamp_vftable", + "addr": "0x00a1a730", + "convention": "data", + "prototype": "void* [3] // { 0x004f7230 dtor, 0x006953b0 Read, 0x00695400 Write }", + "status": "verified", + "source": "findings/objects/aiagent-block.md (lane A 2026-09-08)" + }, + { + "name": "Game_AIPlayerRequestStamp_Write", + "addr": "0x00695400", + "convention": "thiscall", + "prototype": "void (Mars::StreamableHelper* this, Mars::Stream* s) // two named ints: `pid` at +0 and `trn` at +4. Game::AIPlayerRequestStamp is a POD with no RTTI class of its own, reached only through this specialised helper, so tools/serializers.py reports 'no serializer' for it and it has no entry in the generated wire table", + "status": "verified", + "source": "findings/objects/aiagent-block.md (lane A 2026-09-08)" + } + ] +}