lane DT: T34 RecordObservedDesigns -- mechanism read from the instruction stream, prediction committed before the build
The phase is FUN_007c2350 (tail phase 34): for every player, over every ship of every fleet, gated on a two-bit-per-player field at Ship+0x54 that is NOT on the wire, call RecordObservedDesign(player, ship.design). That function dedups on odid alone, and on a hit it ERASES and PUSH_BACKS rather than updating in place -- so the list is ordered by last observation and otnF survives the move -- then caps the list at 20 entries per DESIGN-OWNER, counted from the back, after every single record call. sizeof(ObservedDesign) = 0x10. Predictions, computed from a Python simulation of that mechanism over the corpus, committed before any engine code is written: P1 rich turn odes: 55 -> 21, i.e. 34 closed / 0 regressed P2 otch+owep (24 leaves): 0 closed / 0 regressed, blocked (see the finding) P3 rich-turn total 1092 -> 1058 P4 canonical pair: 1 closed / 0 regressed P5 the NPC-owner guard is worth 4 leaves of regression on the canonical pair
This commit is contained in:
parent
b6018a0f19
commit
ea7b771c76
1 changed files with 248 additions and 0 deletions
248
findings/subsystems/t34-observed-designs.md
Normal file
248
findings/subsystems/t34-observed-designs.md
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
# `T34 RecordObservedDesigns` — the phase, its gate, and what it closes
|
||||
|
||||
- **Type:** subsystem / engine implementation
|
||||
- **Owner / date:** lane DT · 2026-09-09 · **HOST ONLY**. No VM was touched; VM140/141/145/146
|
||||
were not approached, not pinged and not logged into. The corpus was read, never written.
|
||||
- **Trigger:** `campaign/backlog.md` §2/§3 Track 2, and
|
||||
`findings/subsystems/rung-b-rich-turn.md` §5 rank 4 — lane CV's ranked worklist names
|
||||
`T34 RecordObservedDesigns` (stub) as "79 leaves, no upstream dependency", the largest
|
||||
independently closable item measured against a rich turn.
|
||||
- **Consumes:** CV's measurement (`verify/results/standalone/cv/*`), lane BR's deep block
|
||||
`verify/results/turncommands/br2-turn27-deep.tcb`, lane X/S's `observedtech-append.md`
|
||||
(the sibling `ObservedTech` record and its 0x2c element), lane PL's `players-residual.md`
|
||||
§M3, lane T's `combat-done-tail.md` phase table.
|
||||
- **Touches:** `sots-engine`: `src/game/sim/observed.{h,cpp}` (new),
|
||||
`src/app/observed_phase.{h,cpp}` (new), `src/app/turn.cpp`, `src/app/phase_catalog.cpp`,
|
||||
`tests/game_sim/test_observed.cpp` (new), two `CMakeLists.txt`.
|
||||
`sots-re`: this file, `ghidra/addresses.d/dt.json`, `verify/results/standalone/dt/*`.
|
||||
**`src/mars/stream/` was not touched** — that is lane DW's file set this round.
|
||||
|
||||
---
|
||||
|
||||
## 1. The prediction, written and committed before the engine was built (rule 2)
|
||||
|
||||
This is a *computed* prediction, not a guess: the mechanism below was read out of the
|
||||
instruction stream first, then simulated in Python against the save corpus, and the numbers
|
||||
below are that simulation's output. What the build then tests is whether the C++ in
|
||||
`sots_turn` — reading the save through the engine's own typed shapes, in the engine's own
|
||||
iteration order — reproduces the simulation. Any disagreement is an engine/order defect, and
|
||||
that is exactly the thing this prediction is for.
|
||||
|
||||
| # | prediction | why |
|
||||
|---|---|---|
|
||||
| **P1** | On the rich turn the **`odes` sub-group closes 34 of its 55 leaves and regresses 0**; 21 remain. | The mechanism is fully determined by the wire (§2). The 21 that remain are the shadow of two designs — 1826 and 1522 — whose **first ships are built during turn 27→28**; this engine builds no ship, so it cannot observe them, and their absence changes which entries the 20-per-owner cap evicts. |
|
||||
| **P2** | The **`otch` + `owep` sub-group (24 leaves) closes 0 and regresses 0.** | Not implemented: the design→tech and design→weapon lists the phase feeds to `RecordObservedTech`/`RecordObservedWeapon` are built by three `std::set` builders in the original that are not decoded, and the wire's own `DOpts` list covers only 13 of the 18 tech names this turn touches (§4). Named as a dependency, not attempted. |
|
||||
| **P3** | Rich-turn total: **1092 → 1058**. | P1 + P2, with nothing else moving. |
|
||||
| **P4** | On the **canonical pair the phase closes 1 leaf and regresses 0** (`Player[32]/odes/.[1]/otnL: 2 → 3`). | The whole canonical-pair `odes` residual is that one stamp; the simulation reproduces it exactly, including the eight players whose lists must stay empty. |
|
||||
| **P5** | The **NPC guard is load-bearing and its absence is a 4-leaf regression on the canonical pair**, not on the rich turn. | Without it, player 528 ("Alien Menace", NPC) gains four `odes` entries the target does not have. This is the one place the two pairs disagree about which rule is needed, and it is why both pairs are run. |
|
||||
|
||||
**Falsifiers, each with its symptom.**
|
||||
|
||||
1. *The engine's fleet/ship iteration order is not the save's order.* Symptom: the surviving
|
||||
`odid` sequence is a different permutation and the rich-turn residual is **worse than 21**.
|
||||
2. *A player observes ships it does not own.* The gate in the original is a two-bit-per-player
|
||||
field on the ship that is **not on the wire**; this implementation stands in for it with
|
||||
ownership (§3.3). Symptom: entries appear for foreign designs — a regression, on either pair.
|
||||
3. *`sim.frame` is not the post-increment turn at tail time.* Symptom: every `otnL` is off by
|
||||
one and the residual **grows** rather than shrinks.
|
||||
4. *The cap is not 20, or is not per design-owner.* Symptom: the list length is wrong, which
|
||||
is a `LEN` leaf plus four per extra element.
|
||||
|
||||
---
|
||||
|
||||
## 2. What the phase is — read from the instruction stream
|
||||
|
||||
**`T34` is `0x007c2350`**, 222 bytes, called from `OnAllCombatDone_Tail` `0x007d92a0` at
|
||||
`0x007d98aa` (lane T already had it as tail phase 34: "record observed designs into
|
||||
`ServerPlayer+0x254/+0x258`"). Its two other callers are `BuildTurnEvents` `0x007db780` and
|
||||
`LoadGame` `0x007dd530`.
|
||||
|
||||
### 2.1 The phase body — a triple loop with one gate
|
||||
|
||||
```
|
||||
for pi in 0 .. |S->Players|: ; S+0x54 / S+0x58
|
||||
p = S->Players[pi]
|
||||
for fi in 0 .. |S->Fleets|: ; S+0x64 / S+0x68
|
||||
f = S->Fleets[fi]
|
||||
for si in 0 .. |f->ships|: ; f+0xa4 / f+0xa8
|
||||
sh = f->ships[si]
|
||||
if (p->PlyrIdx(+0x28) >= 15) continue ; 0x007c23bf cmp eax,0xf ; jge
|
||||
cl = 2 * p->PlyrIdx
|
||||
if (((sh->+0x54 >> cl) & 3) != 3) continue ; 0x007c23ce and eax,[edx+0x54]
|
||||
RecordObservedDesign(S, p, sh->design(+0x14)) ; 0x007c23e2
|
||||
```
|
||||
|
||||
Two things to carry away. The **outer** loop is players and the **inner** two are the whole
|
||||
fleet list, so the phase is O(players × ships) and every player is offered every ship in the
|
||||
game. And the gate is a **two-bit-per-player field at `Ship+0x54`** — index `2*PlyrIdx`, both
|
||||
bits required. `Ship+0x54` is **not serialised**: the ship's on-disk field list runs
|
||||
`… +0x4c Dep, +0x4d Atq, +0x5c LCT, +0x60 tsd …` with `0x54` and `0x58` absent
|
||||
(`objects/layouts.json`, `Game::StarShip`). The cap at 15 players is the same cap
|
||||
`src/game/sim/visibility.h` already records for the runtime companion of the system masks.
|
||||
|
||||
### 2.2 `RecordObservedDesign` — `0x007be340`, `ret 8`
|
||||
|
||||
`this` is the `StrategyServer`; arg 0 is the observing `ServerPlayer*`, arg 1 the `Design*`.
|
||||
|
||||
```
|
||||
if (!player || !design) return;
|
||||
owner = design->+0x130 ; the design's owning ServerPlayer
|
||||
if (owner->+0xfb != 0 && owner->+0xfc == 0) return; ; 0x007be389 / 0x007be392
|
||||
if (!Design::vt-ish 0x0080baf0(design)) return; ; 0x007be3a1
|
||||
if (Design::HasFlag(design, 0x400, 0)) return; ; 0x007be3b7 -> 0x00813ab0
|
||||
|
||||
; --- dedup, on odid ALONE (not on the (odid, opid) pair) ---
|
||||
for (e = player->odes._Myfirst; e != _Mylast; e += 0x10) ; *** stride 0x10 = 16 ***
|
||||
if (e->+0x8 == design->id) break; ; +0x8 is `odid`
|
||||
|
||||
if (e == _Mylast) { ; APPEND 0x007be3f5
|
||||
push_back(ObservedDesign{}); ; ctor 0x0080da30, push_back 0x00799d80
|
||||
back->+0x4 = S->turn(+0xc); ; otnF (16-bit)
|
||||
back->+0x6 = S->turn; ; otnL (16-bit)
|
||||
back->+0x8 = design->+0xa0->+0x4; ; odid
|
||||
back->+0xc = owner->+0x4; ; opid
|
||||
if (owner != player) <notify list at S->+0x284 + 0x10*player->PlyrIdx>
|
||||
} else { ; MOVE-TO-BACK 0x007be618
|
||||
tmp = *e; ; otnF/otnL/odid/opid copied out
|
||||
odes.erase(e); ; 0x00795c40
|
||||
odes.push_back(tmp); ; 0x00799d80
|
||||
}
|
||||
back->+0x6 = S->turn; ; otnL, common to both paths
|
||||
|
||||
; --- the cap: 20 entries per DESIGN-OWNER, counted from the BACK 0x007be67f ---
|
||||
kept = 0
|
||||
for (i = count-1; i >= 0; --i)
|
||||
if (odes[i].opid == owner->+0x4) {
|
||||
if (kept >= 0x14) { <notify if odes[i].opid != player id>; erase odes[i]; }
|
||||
else ++kept;
|
||||
}
|
||||
```
|
||||
|
||||
`sizeof(Game::ObservedDesign) = 0x10`, and the element is
|
||||
`{vptr @+0x0, uint16 otnF @+0x4, uint16 otnL @+0x6, int odid @+0x8, int opid @+0xc}` — the
|
||||
wire record `otnF otnL odid opid` exactly, with the vptr accounting for the difference between
|
||||
12 on disk and 16 in memory. (Compare its sibling `Game::ObservedTech` at `0x2c`, whose extra
|
||||
0x1c is the embedded tech-name `std::string` — `observedtech-append.md` §4.)
|
||||
|
||||
**Three facts here are the whole finding, and none of them is guessable from the wire:**
|
||||
|
||||
- **the dedup key is `odid` alone.** A design id is globally unique in this game, so this only
|
||||
matters if two owners could ever share one — but it is what the code does, and modelling it
|
||||
as a `(odid, opid)` pair would be a different program.
|
||||
- **re-observation is `erase` + `push_back`, not an in-place update.** The list is therefore
|
||||
ordered by *last* observation, and `otnF` survives the move. This is why the target's list is
|
||||
a permutation of the input's rather than an append.
|
||||
- **the list is capped at 20 entries per design-owner**, counted from the most recent end, and
|
||||
the cap is enforced *after every single record call*, not once per turn. On a player with more
|
||||
than 20 designs in service this thrashes: entries are evicted and re-created inside one sweep,
|
||||
and each re-creation resets `otnF` to the current turn. That is measured, not inferred — see
|
||||
§3.2.
|
||||
|
||||
### 2.3 The tech and weapon arms
|
||||
|
||||
The same function then builds three lists off the design and feeds two of them to
|
||||
`RecordObservedTech` `0x007ba1a0` (lane X's function) and one to `RecordObservedWeapon`
|
||||
`0x007be1b0`:
|
||||
|
||||
```
|
||||
0x007be4af FUN_00862c90(&v, design) -> for each: RecordObservedTech(player, owner, x) 0x007be4e1
|
||||
0x007be50d FUN_008629b0(&v, design) -> for each: RecordObservedTech(player, owner, x) 0x007be535
|
||||
0x007be561 FUN_008626a0(&v, design) -> for each: RecordObservedWeapon(player, owner, x) 0x007be591
|
||||
```
|
||||
|
||||
All three are ~600–700-byte `std::set` builders (`operator new 0x14` red-black nodes) walking
|
||||
the design's section array at `Design+0x2c`/`+0xac` and `+0x3c`. They are **not decoded**, and
|
||||
they are the dependency §4 names.
|
||||
|
||||
---
|
||||
|
||||
## 3. The gate, and the two guards the corpus forced
|
||||
|
||||
### 3.1 The visibility gate is unexercised on both reference pairs — say so out loud (rule 6, rule 28)
|
||||
|
||||
`Ship+0x54` is not on the wire, so the phase's real gate cannot be read from a save. It does not
|
||||
have to be, on these two pairs, because **no player observes another player's ship on either of
|
||||
them**:
|
||||
|
||||
- rich turn: player 16's twelve `opid = 32` records all keep `otnL = 27`, and player 32's one
|
||||
`opid = 16` record keeps `otnL = 25`. Every record that moves is a player's own design.
|
||||
- canonical pair: the single moving record is player 32's own design 18 (lane PL's M3).
|
||||
|
||||
So this implementation stands the gate in for **`ship.PlrID == player.id`**, and that is a
|
||||
**hypothesis with a named falsifier**: the first save in which one empire's fleet stands in
|
||||
sensor range of another's and a foreign `odes` record moves. It is not a reading of the gate and
|
||||
must not be quoted as one. What it *is* is the smallest rule that is correct on every state the
|
||||
corpus contains — and lane PL's M3 said the same thing from the other end ("not an intel pass").
|
||||
|
||||
### 3.2 The NPC guard — `owner->+0xfb && !owner->+0xfc`
|
||||
|
||||
The two bytes are consecutive `ServerPlayer` bools next to `RebAI`, which lane T pinned at
|
||||
`+0xfc` (`ProcessTurn` phase 8: `if (RebAI(+0xfc)) RebOutMod = clamp(...)`). The wire writes
|
||||
`Elim, NPC, RebAI, ReqCL` consecutively, so `+0xfb` is `NPC`. Read as
|
||||
**"a design owned by an NPC that is not a rebel AI is never recorded"**, and the corpus agrees:
|
||||
|
||||
| save | player | kind | own designs | own `odes` records |
|
||||
|---|---|---|---:|---:|
|
||||
| `ad-turn27` | 32 "The Eternal Empire" | not NPC | 46 | 20 |
|
||||
| `ad-turn27` | 16 "re" | not NPC | 6 | 1 |
|
||||
| `ad-turn27` | 528 "Alien Menace" | **NPC, not rebel** | 19 | **0** |
|
||||
| `ad-turn27` | 576 "Independent Colony" | **NPC, not rebel** | 1 | **0** |
|
||||
|
||||
Every `odes` record in the corpus, on every player, names a design owned by 16 or 32 — the two
|
||||
non-NPC empires. The four NPC factions own 26 designs between them and not one of them has ever
|
||||
been observed by anybody.
|
||||
|
||||
**Without this guard the canonical pair regresses by 4 leaves** (player 528 gains four records),
|
||||
which is P5 and is the reason the canonical pair is not a formality here.
|
||||
|
||||
### 3.3 What is *not* modelled
|
||||
|
||||
`0x0080baf0(design)` and `Design::HasFlag(design, 0x400)` are two further early returns that
|
||||
this implementation does not evaluate — the first is an unnamed predicate on the design, the
|
||||
second a design flag. Both are **assumed false** for every design in the corpus, on the evidence
|
||||
that the simulation reproduces the target list without them. That is an argument from a
|
||||
sufficient model, not from having read the flags, and it is recorded as such.
|
||||
|
||||
---
|
||||
|
||||
## 4. Named dependency: the tech and weapon arms are NOT independently closable
|
||||
|
||||
CV's ranking says rank 4 has "no upstream dependency". That is right for the `odes` half and
|
||||
**wrong for the other 24 leaves in the group**, in two separate ways. Both are named here and
|
||||
neither is attempted (scope discipline).
|
||||
|
||||
**(a) `otch` / `owep` need three undecoded set builders.** The obvious wire candidate is the
|
||||
per-section `DOpts` list, which `sots-engine`'s own `game::design::applied_techs` already
|
||||
documents as "exactly the save's `DOpts` list: hull-class tech, `requires_tech` in file order,
|
||||
then the chosen option". Measured against the reference turn, the union of `DOpts` over the
|
||||
designs player 32 observes covers **13 of the 18 tech names that move** and misses five —
|
||||
`WEP_GrnLas`, `WEP_PlsmCan`, `WEP_Dsrptr` (three weapon techs), `CCC_BtlCmp`, and the one
|
||||
**newly appended** record, `BIO_TerBac`. So `DOpts` is one of the three lists and not the whole
|
||||
of them; the other two (`0x008629b0`, `0x008626a0`) are `std::set<std::string>` builders over
|
||||
the design's weapon banks and would have to be decoded, and their **set ordering is
|
||||
load-bearing** because it decides the append order of any new `otch` element.
|
||||
|
||||
**(b) The `odes` half itself is capped by ship construction.** Two of the designs the target
|
||||
observes — 1826 "Egg Thief Mk 3" and 1522 "Bravestar Mk 2" — have **zero ships in the input and
|
||||
their first ships in the target** (4 and 1). No phase in this engine builds a ship (CV rank 8=,
|
||||
`M2` in `players-residual.md`), so those two observations cannot happen here, and because the
|
||||
list is capped at 20 per owner their absence also stops the two evictions (546 "Warrior Mk 6",
|
||||
770 "Beaten Shield Mk 2") that the target performs. That is the whole of the 21-leaf residual.
|
||||
|
||||
**The oracle run proves the mechanism is not the residual.** Feeding the same implementation the
|
||||
**target's** post-turn fleet/ship state instead of the input's leaves **1 leaf, not 21** —
|
||||
`Player[32]/odes/.[18]/otnF`, where this model writes 28 (the entry was evicted and re-created
|
||||
inside the sweep) and the target holds 27. So on the rich turn the mechanism is exact on 20 of
|
||||
21 records and the gap is ship construction, not `T34`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Results
|
||||
|
||||
*(filled in after the run — see §5.1 onward)*
|
||||
|
||||
---
|
||||
|
||||
## 6. Reproducing
|
||||
|
||||
*(filled in after the run)*
|
||||
Loading…
Add table
Reference in a new issue