diff --git a/docs/PL-players-residual.md b/docs/PL-players-residual.md new file mode 100644 index 0000000..055e30a --- /dev/null +++ b/docs/PL-players-residual.md @@ -0,0 +1,210 @@ +# PL — decomposing the `/Sim/players` residual + +Every lane so far attacked a named subsystem and reported which leaves fell out. This one runs +the other way: start from the residual the metric reports, group it by **mechanism**, and say +what the remaining leaves actually are. Closing leaves is secondary to the decomposition. + +Binary re-measured, not inherited. `sots_turn` built from `main` (`aabd8a3`) in this worktree, +`tools/standalone_report.py --binary …`: + +| pair | baseline | after | closed | regressed | `/Sim/players` share | +|---|---:|---:|---:|---:|---:| +| `turn1-state -> turn2-state` | 209 | **128** | 81 | 0 | **54** | +| `turn2-state -> turn3-state` | 108 | **69** | 39 | 0 | **24** | +| pair 1, `--commit-blocked=T31 --ai-player 1` | 209 | 126 | 83 | 0 | 52 | +| pair 2, same | 108 | 67 | 41 | 0 | 22 | + +The stale `status.json` breakdown said `/Sim/players` 55. It is **54** on pair 1 and **24** on +pair 2. + +--- + +## 1. The decomposition + +The first fact, and the one that reframes the rest: **53 of the 54 leaves on pair 1 and 23 of +the 24 on pair 2 are values our turn writes back unchanged from the input save.** Only one leaf +in the whole block — player 32's `Sav` — is a number we computed and got wrong. The residual is +almost entirely *unmodelled*, not *mismodelled*. + +| # | mechanism | pair 1 | pair 2 | rung | blocked on | +|---|---|---:|---:|---|---| +| M1 | **AI research orders** — `ResRate`, `ResTNm`, `ResErrRoll`, and the tech-tree state they move (`St[n]`, `TResDone[n]`, `Tbd[n]`) | 12 | 2 | **B** | `game/ai`: the AI picks a target *during* the turn | +| M2 | **AI ship construction** — `ShipRecs/*`, `Maint`, `FNG/FNGNum`, `NumDes` + the new `Des[…]` frame, `lboid`, and player 32's `Sav`/`PvSav` | 17 | 8 | **B** | lane B6: no build order exists at load | +| M3 | **Observation records refreshed by the AI's own new design** — `odes`, `owep`, `otch/*/otnL` (phase T34 `RecordObservedDesigns`, stub) | 9 | 1 | **B** | downstream of M2 — the observed design *is* the design the AI just made | +| M4 | **Events** — `Events/EvNxID` + the bucket frames | 8 | 6 | A (4/3) + B (4/3) | half is the human's `EVENT_NO_RESEARCH`, implemented in P11 and gated only on a **game-data root**; half is the AI's `EVENT_SHIPS_BUILT`/`EVENT_RESEARCH_OVERBUDGET` (M2/M1) | +| M5 | **Bankruptcy limits** — `BnkEl`, `BnkPr` | 4 | 4 | **A** | `BnkEl`: T31 is implemented and blocked on an operator flag. `BnkPr`: needs one tuning constant from the data files | +| M6 | **`Status`** — 0 → 4 on four of eight players | 4 | 0 | A? | the **predicate** is not identifiable from this corpus (§4) | +| M7 | **`PvSav`** — the S00 snapshot, for players M2 does not touch | 0 | 2 | **A** | nothing — closed here | +| M8 | **A monster faction's one-off design** — player 528 `NumDes` 18→19 + `Des[1712 "Refugee Trade Ship"]` | 2 | 0 | B? | unexplained; a non-empire faction creates a design on turn 1→2 and never again | +| | **total** | **54** | **24** | | | + +Per-mechanism evidence is in §2–§5. + +### Rung split of the block + +| | pair 1 | pair 2 | +|---|---:|---:| +| Rung B (needs the AI's own orders) | 42 | 14 | +| Rung A, implemented, gated on a **game-data root** (`--data`) | 6 | 5 | +| Rung A, closable, no blocker | 2 | 5 | +| not identifiable from the corpus (`Status`) | 4 | 0 | + +**Three quarters of the `/Sim/players` residual is Rung B**, and it all hangs off two AI +decisions on one player: pick a research target, and queue one destroyer. The Rung-A part is +small, and most of it is *already written* — it needs the harness to be handed the game's data +directory, not more reverse engineering. + +--- + +## 2. M1 / M2 / M3 — the three faces of one AI turn (Rung B) + +Player 32 "Fane Lao" is the only AI empire that owns colonies, and it does two things in the +turn that our engine cannot: it assigns research, and it builds a ship. Everything in M1, M2 +and M3 is a consequence. + +Measured, from the state-checksum tree over the three saves: + +* `ResTNm` `''` → `IND_Waldo` and `ResRate` `0.25` → `0.800000011920929` — the AI's own order. + The two "Singularity" shadow empires do the same (`DRV_PlsFiss`, `BIO_GnMod`), and their + `ResErrRoll` flips `False` → `True`. `St[106]` 2 → 3 and `TResDone[106]` 0 → 2879 → 5768 then + follow from the allocation. +* `PvSav` 50,000 → 38,100 and again 92,651 → 80,751: **11,900 leaves the treasury before the + first spine phase on both turns.** That is lane B6's queue-time deduction of a build order the + input file does not contain, and it is why player 32's `PvSav` does not close in §5. +* The new design is `Des[18 "Honor Lance"]`, and the observation records name it exactly: + `odes/.[1]` holds `odid=18, opid=32` — **the AI observes its own new design**. `owep/.[1]` is + `bal_gauss`, that design's weapon, and the three `otch` entries whose `otnL` moves from 1 to 2 + are `DRV_Fissn`, `WEP_GsDrvr`, `DRV_Hyper` — that design's technologies. So T34 + `RecordObservedDesigns` is not an intel pass over enemy fleets at all on this workload; it is + a self-registration triggered by design creation. +* On pair 2 the same design is built again (`srb[0]` 1→2, another `EVENT_SHIPS_BUILT`), and this + time `odes/.[1]/otnL` moves 2→3 while the `otch` entries stay at 2. **`odes` is re-stamped on + build; `otch` is re-stamped on design creation.** One workload, two different refresh rules — + worth recording before anyone models T34 from the pair-1 numbers alone. + +None of this is closable without `game/ai`. It is listed here so the next lane does not +re-derive it: **42 of the 54 leaves on pair 1 are one AI player's research pick and one +destroyer.** + +## 3. M4 — events + +The corpus posts exactly two events on pair 1 and three on pair 2, and the split is clean: + +| player | event | rung | +|---|---|---| +| 0 (human) | `EVENT_NO_RESEARCH`, every turn | **A** — P11 implements it; blocked on the string table | +| 1 (AI) | `EVENT_SHIPS_BUILT` (both turns), `EVENT_RESEARCH_OVERBUDGET` (turn 3) | B — M2 and M1 | + +P11's own note is precise about it: *"4 event(s) NOT posted: no string table."* Lane EV measured +`--commit-blocked=P11 --data ROOT --ai-player 1 --ai-player 2 --ai-player 3` at **4 closed on +pair 1, 3 on pair 2, 0 regressed**. Those leaves are in today's residual only because +`tools/standalone_report.py` invokes the binary with no `--data` root. + +## 4. M6 — `Status`, and why it is not identifiable here + +`Status` goes 0 → 4 on turn 1→2 for players 16, 32, 496 and 512, and stays 0 for 528, 544, 560 +and 576. It then stays 4 on turn 2→3, which is why the leaf does not appear in pair 2's residual +at all. + +The trouble is that the two sets are **perfectly correlated with species**: the four that take +the 4 are species 0 and 2, the four that do not are all species 4. Sweeping every scalar field +of the player record on `turn1-state`, **fourteen** of them split the roster exactly the same +way — `ReqCL`, `AMine`, `CnTrd`, `CnVItl`, `cdp`, `hadvs`, `harcc`, `hgs`, `PvMA`, `CstE`, +`CstR`, `CstT`, `MinRate`, `MaxOH`, `NPTrk`, `PrGtTrf`, `TerraMod`, `pddm` — eighteen of them. +Any one of them "fits". Only two corpus saves carry a non-zero `Status` at all, so there is one observation of one +transition on one roster: **any predicate chosen here is a one-bit fit on eight correlated +players, which is exactly the shape rule 23 was written for.** + +`ReqCL` is the most plausible on name and on meaning — `Status = 4` is written by +`MarkPlayerTurnEnded` from the End-Turn submission paths (W2), and `ReqCL` reads as "a client +must submit for this player" — but it is a hypothesis with a workload attached, not a finding. + +**The probe that settles it** (rule 18, rule 20): an entry probe on the writer W2 located at +`OnMessage+0xa15`, capturing the player index on every call for one turn. It names the set +directly and costs one VM session. Instrument the *entry*, not the count. + +## 5. What this lane changed + +See the prediction block below; measured results follow it. + +--- + +## 6. Predictions, written before the build (rule 2) + +### PL-1 — `PvSav` is S00's snapshot of `Sav` + +`PvSav(turn N+1) == Sav(turn N)` holds exactly for players 16 and 576 across all three saves +(50,000 → 50,000 → 289,688 against `Sav` 50,000 → 289,688 → 532,369; and 0 → 0 → 98,871 against +`Sav` 0 → 98,871 → 198,730). For player 32 it is `Sav(turn N) − 11,900`, the M2 deduction. + +**Predicted:** pair 1 — 0 closed, 0 regressed (every player's `Sav` already equals its `PvSav` +in `turn1-state`, so the snapshot is a no-op there). Pair 2 — **2 closed** (`Player[16]/PvSav`, +`Player[576]/PvSav`), **0 regressed**; player 32 stays open, short by exactly 11,900. + +**Falsification.** If the snapshot were taken later than S00 — say after P02 writes savings — +player 16's `turn3` `PvSav` would be its *turn 3* `Sav` (532,369), not its turn 2 `Sav` +(289,688). It is 289,688. If the snapshot were per-player conditional, some player other than 32 +would show an offset; none does. If it regresses anything, the symptom is a player whose +`PvSav` and `Sav` differ in the input for a reason that is not M2 — there is none in the corpus, +so a regression here means the field is not a snapshot at all. + +### PL-2 — T31's difficulty column is recoverable from the input save + +T31 is blocked on `ServerPlayer+0xf9`, the per-player "is AI" flag, which the save does not +carry and which is worth ×1.1 on that player's max income. The operator supplies it with +`--ai-player N`. + +But the input save **already carries the answer**: its `BnkEl` was written by the original at +the end of the previous turn from the colony state the save holds. Computing `BnkEl` from that +state under both columns and comparing against the stored value identifies the column — for +every player whose max income is non-zero, since the two columns differ by 10%, far more than +one truncation. + +**Predicted:** on `turn1-state`, the column resolves to non-AI for player 0 and AI for player 1; +it is *ambiguous* (both columns give `BnkEl = 0`) for the five players that own nothing, where +the value is 0 either way. Player 7's column is unknown before measuring. With the column +identified from the save and `BnkEl` committed, the phase closes **2 leaves per pair, 0 +regressed** — the same as `--commit-blocked=T31 --ai-player 1` measures today, but with no +operator input. + +**Falsification.** (a) If the AI flag does more than select a ×1.1 income column, the identified +column will still reproduce the *stored* value while the *post-turn* value diverges; symptom — +`BnkEl` closes on pair 1 and regresses on pair 2, or the other way round. (b) Player 576's +`BnkEl` is **constant** at −665,806 across all three saves although its `Sav` grows; if its +column is identified and its post-turn max income has moved, committing regresses that leaf. +Symptom — 2 closed and 1 regressed. (c) If the pre-turn computation reproduces *neither* +column's value for a player, the phase must abstain for that player, and the leaf stays +pass-through — no regression, but no close either. + +### PL-3 — the protection factor is a widened float (rule 23, read the four bytes) + +`ComputeBankruptcyLimits` multiplies `BANKRUPTCY_PROTECTION_LIMIT_FACTOR` in as a `double`. The +image does not. In `ServerPlayer::UpdateBankruptcyLimits` the two constants are loaded by +**different opcodes**, and that is the whole point: + +``` +DB 45 FC fild dword [ebp-4] ; maxIncome, still on the stack afterwards +DD 05 30 EC A2 00 fld qword [0x00a2ec30] ; DD /0 = m64fp -- a DOUBLE in .rdata +D8 F9 fdivr st(0), st(1) ; maxIncome / that double +E8 … call ftol ; -> BnkEl, then the -2e9 clamp +8B 0D DC DF AE 00 mov ecx, [0x00aedfdc] ; the tuning pointer slot +D8 09 fmul dword [ecx] ; D8 /1 = m32fp -- a FLOAT32 +E8 … call ftol +F7 D8 neg eax ; -> BnkPr +``` + +The `.rdata` double at `0x00a2ec30` reads `00 00 00 40 33 33 C3 BF` = **−0.15000000596046448**, +i.e. `(double)(float)-0.15` — already handled. The protection factor is the other case: it is +read `dword ptr`, so whatever decimal the data file carries is **narrowed to float32 before the +multiply**, and our `double` multiply is wrong at every boundary. + +**Predicted:** 0 leaves move on this corpus — all seven non-zero `BnkPr` records invert to a max +income where `3.3` and `(double)3.3f` truncate to the same integer. Three hand-written test +expectations move by one or twenty (`-3300 → -3299`, `-330 → -329`, +`-1320000000 → -1319999980`). The two constants disagree on roughly **1.1% of max-income values +at the corpus's empire size**, rising with empire size, so this is a fix the corpus cannot see — +report it as thin coverage, not as verified. + +**Falsification.** If the factor were a `double` in the image the opcode would be `DC /1`, not +`D8 /1`. If some *other* call site multiplies the same global as a qword, the storage is a +double and this is wrong; the check is a cross-reference sweep on `0x00aedfdc`.