sots-engine/docs/PL-players-residual.md

13 KiB
Raw Blame History

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.