Compare commits

..

No commits in common. "5d245e4c8b38bb82dfc1c53504e13fd19d66ff0c" and "e0f80a1bf6c222d9161b8990df20cefd176d42f4" have entirely different histories.

18 changed files with 90 additions and 1230 deletions

View file

@ -1,299 +0,0 @@
# 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 …`:
| run | pair 1 residual | pair 2 residual | `/Sim/players` share |
|---|---:|---:|---:|
| `main` (`aabd8a3`), no options | **128** | **69** | **54 / 24** |
| `main`, `--commit-blocked=T31 --ai-player 1` | 126 | 67 | 52 / 22 |
| **this lane**, no options | **126** | **65** | **52 / 20** |
| this lane, `--data ROOT --commit-blocked=P11 --ai-player 1 --ai-player 2 --ai-player 3` | **122** | **62** | **48 / 17** |
`closed` / `regressed` on the reference pairs, never netted: this lane closes **2 on pair 1 and
4 on pair 2 with no options at all, and regresses 0**; with a data root, **87 / 0** and **46 /
0** against the 209 and 108 baselines.
Two corrections to the record before anything else:
* The stale `status.json` breakdown said `/Sim/players` 55. It is **54** on pair 1 and **24**
on pair 2.
* **The published 128 / 69 overstates what is unmodelled.** Seven of those leaves belong to a
phase that is already written and only wants the game's data directory — see §3. The metric
harness invokes `sots_turn` with no `--data`, so it measures the engine *plus* that missing
argument. Handing it a root is a one-line harness change worth 4 + 3 leaves today, and more
once the tuning table is wired (§6, PL-3).
---
## 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` | 15 | 9 | **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`: **closed here** (PL-2, 2 per pair). `BnkPr`: needs one tuning constant from the data files, and the loader that would fill it is not wired |
| 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** (PL-1) |
| 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–§4; what this lane closed is §5, the ranked remainder §8.
### Rung split of the block
| | pair 1 | pair 2 |
|---|---:|---:|
| Rung B (needs the AI's own orders): M1, M2, M3, half of M4, M8 | 44 | 15 |
| Rung A, implemented, gated on a **game-data root** (`--data`): half of M4 | 4 | 3 |
| Rung A, closed by this lane: `BnkEl` ×2, `PvSav` | 2 | 4 |
| Rung A, blocked on a named unread/unwired thing: `BnkPr` | 2 | 2 |
| not identifiable from the corpus: `Status` | 2 | 0 |
(`Status` is 4 leaves in the block, but two of them — players 496 and 512 — are on shadow
empires that also have M1 leaves; the row above counts each leaf once, under the *first*
mechanism that has to land. `Status` for the human and for the AI empire is the irreducible
pair.)
**Four fifths of the `/Sim/players` residual is Rung B**, and it all hangs off two decisions by
one AI player: pick a research target, and queue one destroyer. The Rung-A part is small, and
most of what is left of it is *already written* — it needs the harness 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: **36 of the 54 leaves on pair 1 are one AI player's research pick and one
destroyer**, and four more are the events those two decisions post.
## 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`, this many 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 — measured
Predictions are in §6, written and committed before the build (`c852965`). All three held; the
one thing that came out differently is recorded in §7 rather than quietly corrected.
| change | pair 1 closed | pair 2 closed | regressed | leaves |
|---|---:|---:|---:|---|
| **PL-1** S00 stamps `PvSav` from `Sav` | 0 | 2 | 0 | `Player[16]/PvSav`, `Player[576]/PvSav` |
| **PL-2** T31 recovers its difficulty column from the input save | 2 | 2 | 0 | `Player[16]/BnkEl`, `Player[32]/BnkEl` on both pairs |
| **PL-3** the protection factor is narrowed to float32 | 0 | 0 | 0 | none on this corpus, by construction |
| total | **2** | **4** | **0** | |
Independently of the leaf count, PL-2 turns T31's self-check into a real one. It used to report
"BnkEl reproduced for 6 of 8" on `turn1-state` **regardless of the AI roster**, because it was
comparing its *post-turn* computation against the *pre-turn* stored value — so the only players
it could ever "reproduce" were the six whose limit does not move at all. That is thin coverage
of exactly the shape rule 15 describes, and the phase's own catalog text claimed "8 of 8 with
the AI flag supplied", which the binary never printed. The check is now taken at load, against
the colony state the save was written from, and it passes **for every live player of all eleven
corpus saves** — 7/7 or 8/8 on each. The whole max-income chain is now verified on eleven saves
instead of two.
The column identification resolves to **1 AI and 1 non-AI on every one of the eleven saves**,
with the rest ambiguous, and needs no `--ai-player`.
## 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`.
---
## 7. Where the predictions were wrong
**"Ambiguous" is not a synonym for "owns nothing."** PL-2 predicted the ambiguous bucket would
be the five players with no colonies. It is six, and the extra one is `Player[576] "Independent
Colony"`, which owns a system and carries a non-zero `BnkEl` of −665,806. The reason is in the
difficulty table itself: the AI row is selected by `isAI && !npc`, and player 576 is an NPC, so
both columns hand it the same row. The note the phase prints and the header comment in
`game/sim/player_turn.h` were both corrected to say so — an ambiguous player is one where the
two columns *agree*, which happens for two different reasons.
That also disposed of falsification (b): player 576's limit is constant across all three saves,
and had the identification been wrong about it the commit would have regressed that leaf.
It did not; 576's post-turn max income reproduces the same −665,806.
Falsification (a) and (c) did not fire: no player of any corpus save came out `Unidentified`,
and no leaf regressed on either pair.
---
**The float/double disagreement rate is 10%, not "roughly 1.1%".** PL-3 estimated the rate from
the size of the gap (`3.3 - (double)3.3f` is 4.768e-8, which at a max income of 240,000 is about
0.011 of a unit, so ~1.1% of values should straddle an integer). Measured over every integer max
income from 230,000 to 250,000: **2,000 of 20,000 disagree — exactly 10%, and exactly the
multiples of ten.** The estimate was wrong because it assumed the products are uniformly
distributed against the integer grid and they are not: `3.3 x 10k` is an exact integer in
decimal for every k, so the decimal lands *on* the boundary at every tenth value and the
image's float lands just below it. The first disagreement is at `maxIncome = 10`
(33.0 against 32.99999952), not somewhere out in the hundreds of thousands.
That makes the fix meaningfully larger than predicted and the corpus' silence about it
correspondingly more suspicious: seven records, none of them a multiple of ten.
---
## 8. The ranked remainder
Ranked by leaves per unit of effort, both pairs summed, with the blocker named. Nothing here is
"more analysis of `/Sim/players`" — every row is a named thing somewhere else.
| rank | item | leaves (p1 + p2) | effort | what it needs |
|---|---|---:|---|---|
| 1 | **give the metric harness a data root** | 4 + 3 = **7** | one line in `tools/standalone_report.py` | `--data <extracted gob> --commit-blocked=P11 --ai-player 1 --ai-player 2 --ai-player 3`. Already measured end to end on CT111 against `/srv/re-lab/gob-extract`: **87 closed / 0 regressed** on pair 1 and **46 / 0** on pair 2, residual 122 and 62. It closes nothing new in the engine; it stops the metric reporting an argument as if it were a gap |
| 2 | **wire `StrategyVars.txt` into a `TuningTable` and set `opt.haveTuning`** | 2 + 2 = **4** | small, contained | `TurnOptions::haveTuning` is **read in three places and never assigned** — the field is dead. `game/config`'s loader exists and the data root already parses; nothing else is missing. Unblocks `BnkPr` in T31 (and, per rule 23, it must be narrowed to float32 on the way in — that is done, PL-3). Also unblocks whatever `RunCivilianGrowth` does with the flag |
| 3 | **`game/ai`: research target selection** | 12 + 2 = **14** | a module | `ResRate`/`ResTNm` are the AI's own orders, and every tech-tree leaf follows from them. Three players do it per turn (the AI empire and both Singularity shadows) |
| 4 | **`game/ai`: the build order** | 15 + 9 = **24**, plus M3's 10 | a module | one destroyer, queued during the turn, deducted 11,900 from the treasury *before* spine phase 0. It is the single largest cause in the block and lane B6 already proved it cannot come from the save |
| 5 | **T34 `RecordObservedDesigns`** | (10, downstream of 4) | small once 4 lands | the mechanism is now readable off the corpus (§2): a design's creator registers the design in its own `odes`, its weapons in `owep`, and re-stamps `otnL` on the design's techs in `otch`; `odes` re-stamps on *build*, `otch` on *creation*. Two different refresh rules from one workload — treat both as hypotheses until a second workload exists |
| 6 | **`Status`'s predicate** | 4 + 0 = **4** | one VM session | an entry probe on W2's writer at `OnMessage+0xa15`, capturing the player index per call (§4). Not a reading problem — eighteen fields fit the one observation the corpus offers |
| 7 | **M8: player 528's one-off design** | 2 + 0 = **2** | unknown | a non-empire faction ("Alien Menace") creates `Des[1712 "Refugee Trade Ship"]` on turn 1→2 and never again, and the id it takes is the first of the three `NMnx` issues that turn. Nothing in the corpus explains what triggers it. Lowest rank because two leaves is not worth a hunt until something else brings us into that code |
**What is NOT on this list, deliberately:** more decomposition of `/Sim/players`. The block is
now fully attributed — every one of the 54 and 24 leaves belongs to a row above.

View file

@ -1,143 +0,0 @@
# W3 — predictions, committed before the build
Method rule 2: the expected result is written down before the instrument is built, with a
falsification section for each claim. Lane W3, 2026-09-08. Companion to `docs/W2-watchpoints.md`,
whose module this extends.
## 0. What is being armed
A second watch mode, `watch.mode=tshn`, in `src/shim/hooks/watchpoints.{h,cpp}`. Same single arming
detour on `StrategyServer::ApplyAllTurnCommands`, same canary self-test, same VEH. Four 4-byte
data-write watchpoints:
| slot | target | how it is reached from `S` |
|---|---|---|
| 0 | the `NVO` map's **root node + 0x10** on the target system — the dword `{touched:int16, TShn:int16}` | `systems = *(S+4+0x40)`; pick the system with `AFlags == 0` and `NVO._Mysize >= 1`; `head = *(sys+0x274)`; `root = head->_Parent` |
| 1 | that system's `NVO._Mysize` = `sys + 0x278` | same |
| 2 | trade-route vector `_Mylast` = `*(S+4+0x154) + 0x40` | W2 §8.3 |
| 3 | spy-program vector `_Mylast` = `*(S+4+0x158) + 0x14` | W2 §8.3 |
Offsets used, and where they come from (all re-derived this lane from the instruction stream of
`ServerSystem::Write` `0x00749630`, because two earlier accounts disagreed):
`Write` is entered on the **IStreamable subobject at `ServerSystem+0x8`** (the RTTI COL offset for
vftable `0x00a2043c` is `+0x8`), so every displacement in its body is 8 less than the
`ServerSystem`-relative offset. Reconciled:
| field | in `Write` | `ServerSystem`-relative |
|---|---|---|
| `NVO._Myhead` | `[edi+0x26c]` (`0x0074a195`) | **+0x274** |
| `NVO._Mysize` | `[edi+0x270]` (`0x0074a07d`) | **+0x278** |
| `NVE._Myhead` / `_Mysize` | `[edi+0x27c]` / `[edi+0x280]` | +0x284 / +0x288 |
| `NVs._Myhead` / `_Mysize` | `[edi+0x28c]` / `[edi+0x290]` | +0x294 / +0x298 |
| `AFlags` | `[edi+0xcc]` (`0x00749aec`) | **+0xd4** |
| `Name` (`std::string`) | `[edi+0xa0]` (`0x00749aa9`) | +0xa8 |
| `ltis` | `[edi+0x2c0]` (`0x0074997c`) | +0x2c8 |
So the brief's `ServerSystem+0x274` is right and is `NVO._Myhead`; `objects/layouts.md` and lane E3
are both right; and the third reading (`+0x26c`) is the un-adjusted one. **The arming code does not
rely on this reconciliation**: it validates both candidate bases at runtime and logs which one
passed, because an arm that lands on the wrong object reports a confident nothing (rule 1).
`NVO` node layout, instruction-verified from the same loop (`0x0074a0b0..0x0074a195`):
`_Left +0x0`, `_Parent +0x4`, `_Right +0x8`, key = **player index** `+0xc`, `touched:int16 +0x10`,
`TShn:int16 +0x12`, `OID:int32 +0x14`, `isind:bool +0x18`, `indi` inline from `+0x1c`,
`_Isnil` at **`+0x8d`** — so an `NVO` node is about `0x90` bytes, not the `0x20` an `NVE` node is.
## 1. Prediction P1 — `TShn`
Workload: load `ref-turn2.sav` (Frame 2), one End Turn. Target system: the run picks it by
predicate, and on this save the predicate has **exactly one** solution.
**P1.0** The arming log names **Spica**, and reports `AFlags == 0`, `NVO._Mysize == 1`,
`NVE._Mysize == 0`. It is the only system in the ref game with `AFlags == 0` and a non-empty `NVO`
(measured across `turn1/2/3-state.sav`), so if the picker names anything else the picker is wrong.
**P1.1** Slot 0 takes **at least one** trap in the End Turn, and the value after the last trap has
`TShn == 3` in its high half-word (`(value >> 16) == 3`). `turn3-state.sav` carries `TShn = 3` for
Spica, so this is a prediction against a known post-state, not against the run.
**P1.2** The trapping EIP is **not** in `ServerSystem::UpdateLastObservedTurn` `0x00743ec0` (the
`ltis` writer, driver phase 29) and
**not** in `ServerSystem::RecordObservation 0x00756300` (which writes `NVE`, at `+0x284`). Spica's
`ltis` is `0x7fffffff` in all three ref saves — never written — while its `TShn` moves every turn,
so the two cannot share a writer.
**P1.3** Slot 1 (`NVO._Mysize`) takes **zero** traps. `NVO` counts are constant across every
consecutive pair in the corpus (1/1/1 at Spica, 2/2/2 at Kea'Pono), so nothing should insert or
erase. This slot is not decoration: it is the check that the node armed in slot 0 is still the node
being written. If slot 1 fires, slot 0's silence means nothing.
**P1.4** The prior this is testing. Across all 11 corpus saves, **every** `NVO` entry has
`TShn == Frame`, with exactly one exception: Bismol in `zuul-turn23-fleet23.sav`
(`TShn = 22` at `Frame = 23`, `AFlags = 0`, `VFlags = 2`, `FFlags = 0`, `ltis = 22`). The systems
whose `TShn` tracks `Frame` despite `AFlags == 0` — Spica, and Altair / Galifrey / Kithrup /
Alversi / Dosadi / Hyrakius in the Zuul game — all key their single `NVO` entry to a **Species-4
NPC pseudo-player** (`NPC=true, RebAI=false`: "Alien Menace", "Peacekeeper Enforcer", "Von Neumann",
"Independent Colony"). Bismol's entry is keyed to a real empire (index 1). So the model under test
is: **`TShn` is refreshed to `Frame` unconditionally for entries belonging to NPC/monster players,
and gated for real empires** — and the point of the watchpoint is that the trapped EIP and return
addresses name the loop, after which the gate is a static read.
### Falsification of P1
| symptom | what it would mean |
|---|---|
| slot 0: **0 traps**, slot 1: 0 traps, canary PASS, picker named Spica | `TShn` is written from a thread other than the turn thread — debug registers are per-thread and this instrument would be blind to it (W2 §1). Cheapest next step: arm from a second thread, or bracket with a code hook instead. |
| slot 0: 0 traps, slot 1: **fires** | the node was freed and reallocated; the armed address is stale and says nothing. Re-arm on `_Myhead`'s successor after the insert. |
| picker names a system other than Spica | `AFlags` is not at `+0xd4`, or the systems vector is not at `S+4+0x40`. The arming log prints both candidate bases and the system count (expect **28**), so this is diagnosable from the log alone. |
| slot 0 fires but `(value>>16) != 3` | either the write is not `TShn` (offset wrong) or `TShn` is not the Frame. Compare against `(Autosave).sav`'s Spica record. |
| slot 0 fires **many** times | more than one writer, or a per-player loop that touches the same node once per player. That is itself the answer to "what is the gate" and is a better outcome than one trap. |
## 2. Prediction P2 — the trade / spy containers
**P2.1** At arming time, on `ref-turn2.sav`, both vectors are **empty** (`_Myfirst == _Mylast`), so
the arming log reports `trade routes: 0` and `spy programs: 0`. Two lanes have failed to build this
workload; nobody has ever printed these two counts from a live game, and printing them is the
cheapest possible confirmation instrument.
**P2.2** Slots 2 and 3 take **zero** traps during the End Turn. Nothing in a turn with no trade
station and no spy program should grow either vector.
**P2.3** If either slot *does* fire, the trapped EIP plus the two `ebp`-chain return addresses name
the function that grew it. For the spy vector that is the answer to a question no lane has been able
to answer — which UI creates a spy program — because the return addresses name the caller.
### Falsification of P2
- If the arming log reports a **non-zero** count for either vector on `ref-turn2`, then the
offsets `S+4+0x154` / `S+4+0x158` do not name what W2 §8.3 says they name, *or* the game creates
entries this campaign has never seen. Either is news; the arming log prints the manager pointer
and the raw `_Myfirst`/`_Mylast`/`_Myend` triple so the two cases are separable.
- If a slot fires with `_Mylast` unchanged, the watch is on the wrong word of the vector.
- A zero here does **not** mean "the container is never filled" (rule 20). It means: on this save,
in this turn, nothing grew it. The claim being made is only that the *instrument* works and that
the workload is *absent*, which is exactly what a workload confirmation is for.
## 3. Prediction P3 — the rule-19 control
The armed run is the oracle run. One End Turn from `ref-turn2.sav` with four watchpoints live
reproduces the determinism oracle byte for byte:
| file | sha256 prefix |
|---|---|
| `(Autosave EndTurn).sav` | `bb4fd9ac89f41e3b` |
| `(Autosave).sav` | `978041acd168b56e` |
W2 took this control with a different set of four addresses and it passed; the mechanism (a `#DB`
on a data write is a trap taken after the store retires, and no code is modified except the single
arming detour that W2 already proved neutral) is unchanged, so this should hold. **If it does not,
every number in this lane is void** and the difference between W2's run and this one is only the
four addresses — which would be a much more interesting finding than anything above.
## 4. What this run cannot establish, stated in advance (rule 15)
- One save, one game, one turn. Nothing here is a claim about a turn with combat, an alliance, or
two players observing one system.
- **One** system watched, not 28. Spica is chosen because it is the discriminating case; the other
27 systems' `TShn` writes are unobserved and the "every entry is refreshed" half of the model
rests on the corpus, not on this run.
- Per-thread blind spot: a write from a thread other than the turn thread is invisible.
- The gate itself is **not** measured by this run. The run names the writer; the gate is then a
static read of that writer's enclosing branch, which is a different kind of claim and will be
labelled as one.

View file

@ -1,70 +0,0 @@
# W3 — a second watch mode, and what the two runs answered
Companion to `docs/W3-predictions.md` (written and committed before the build) and to
`sots-re/findings/subsystems/nvo-tshn-visible-owner.md` +
`sots-re/findings/control-flow/w3-containers-status-handlers.md` (the full reports).
Builds on lane W2's module without changing how it works.
## What was added
`watch.mode=modcount|tshn` in `src/shim/hooks/watchpoints.{h,cpp}`. **No second hook, no second
detour, no change to the VEH or to the arming mechanism** — only different arithmetic on the `S`
the existing `ApplyAllTurnCommands` detour already holds. `modcount` is W2's set and stays the
default so its run remains reproducible; `tshn` arms:
| slot | target |
|---|---|
| 0 | the `TShn` word of an `NVO` map record on a system with `AFlags == 0` |
| 1 | that map's `_Mysize` |
| 2 | trade-route vector `_Mylast` (`*(S+4+0x154) + 0x40`) |
| 3 | spy-program vector `_Mylast` (`*(S+4+0x158) + 0x14`) |
Two things about the `tshn` arming are worth keeping if the module is extended again:
- **The target is chosen by predicate at arm time, not hard-coded**, and all 28 systems are logged
with name, `AFlags` and both map sizes. A hard-coded pointer that lands on the wrong object
produces the same empty hit list as "nothing writes this" (method rule 1).
- **The `ServerSystem` base is probed, not assumed.** Two published accounts disagreed by 8 about
where the `NVO` map lives, because `ServerSystem::Write` runs on the IStreamable subobject at
`+0x8`. The code tries both candidate bases across every system and logs how many validated under
each (`+0x274`: 9 systems; `+0x26c`: 0). That turned a documentation dispute into a measurement.
Configs: `shim.cfg.w3tshn`, `shim.cfg.w3mod`, `shim.cfg.w3control`. `w3tshn` and `w3control` differ
in exactly one key (`watch=`), which is what makes the rule-19 control real.
## Rule 19: the control was taken again, and it passed again
One End Turn from `ref-turn2.sav` with the four `tshn` watchpoints armed reproduced the determinism
oracle byte for byte — `(Autosave EndTurn).sav` `bb4fd9ac…`, `(Autosave).sav` `978041ac…`, both
identical to the pre-run files. So the four *new* addresses are as neutral as W2's four were. That
mattered: W2 proved a data breakpoint is neutral for one address set, not for all of them, and this
lane's set includes a heap node that the game reallocates freely.
The second run (turn-1 workload, `watch.mode=modcount`) has **no oracle of its own** and says so.
## What the two runs answered
1. **`NVO.TShn`'s writer, trapped live**, with its whole call chain confirmed frame by frame from
the recorded `ebp` chain: `RefreshVisibleOwnerIfKnown 0x0075bd70` → `RecordVisibleOwner
0x0075bca0` → `SetVisibleOwner 0x0075b880` → `NVO::operator[] 0x0075a890` → the store
`0x0075b961`. The gate is `ServerSystem::IsKnownTo 0x00746390`, which is `IsVisibleTo` **or**
(sensor contact ∧ `CCC_AdvSens`) — not `AFlags` alone, which is why lane E3 could not fit it.
The resulting model predicts all 158 `NVO` records in the 11-save corpus with zero mismatches,
including the single frozen one.
2. **Four traps, not one, and the extra pair is a result**: the refresh runs twice per End Turn, in
driver phase 24 and again in combat-done phase 25. Both phase attributions confirmed for free.
3. **The trade and spy containers, read out of a live game for the first time.** Both managers
non-null, both vectors default-constructed with all three pointers zero, zero traps on either
`_Mylast` across the turn. The workload is *absent*, measured — which is the confirmation
instrument two earlier lanes needed and did not have.
4. **All ten command `ModCount` handlers are now named**, including two that only exist on a turn-1
workload (`OnCommand_SetResearchProject`, inlined at `0x0088fe0a`, and
`OnCommand_CreateDesign 0x00882910`).
## Reusing it
`watch.mode` is the extension point. To watch something else, add a mode and one arming function;
everything else — the canary, the VEH, the flusher, the hit format — is unchanged. Keep the canary
self-test and keep the `ref-turn2` oracle control: together they cost about two minutes and they
are what makes the numbers evidence rather than output.

View file

@ -57,14 +57,8 @@ constexpr PhaseDesc kHost[] = {
constexpr PhaseDesc kStrategic[] = { constexpr PhaseDesc kStrategic[] = {
{Driver::Strategic, 0, "S00", "SnapshotPreviousTurn", PhaseStatus::Partial, {Driver::Strategic, 0, "S00", "SnapshotPreviousTurn", PhaseStatus::Partial,
"bumps the modification counter and stamps PvSav from Sav on every live player -- the " "bumps the modification counter; the previous-turn shadow-word snapshot is not modelled "
"treasury's previous-turn shadow, taken before any phase of the turn can move it, which " "(the shadow words are not all identified on the wire)"},
"is what makes the tail's Sav-PvSav the turn's own net. Measured on the reference pairs: "
"0 closed on turn1->turn2 (the snapshot is a no-op there, every treasury is untouched) "
"and 2 closed on turn2->turn3, 0 regressed on both. It is short by exactly 11,900 on the "
"one AI empire, whose queued build order is deducted before this phase and exists nowhere "
"in the input file (Rung B). The REST of the shadow words are still not modelled: they "
"are not all identified on the wire"},
{Driver::Strategic, 1, "S01", "SystemPrePassMoraleAndAbandon", PhaseStatus::Stub, {Driver::Strategic, 1, "S01", "SystemPrePassMoraleAndAbandon", PhaseStatus::Stub,
"per-system morale event + the abandon/chaos check below the minimum chaos population"}, "per-system morale event + the abandon/chaos check below the minimum chaos population"},
{Driver::Strategic, 2, "S02", "TradeManagerTurn", PhaseStatus::Stub, {Driver::Strategic, 2, "S02", "TradeManagerTurn", PhaseStatus::Stub,
@ -274,24 +268,18 @@ constexpr PhaseDesc kTail[] = {
{Driver::Tail, 28, "T28", "UpdateNodeLineSightingMasks", PhaseStatus::Stub, ""}, {Driver::Tail, 28, "T28", "UpdateNodeLineSightingMasks", PhaseStatus::Stub, ""},
{Driver::Tail, 29, "T29", "AbortInvisibleInterceptOrders", PhaseStatus::Stub, ""}, {Driver::Tail, 29, "T29", "AbortInvisibleInterceptOrders", PhaseStatus::Stub, ""},
{Driver::Tail, 30, "T30", "RebuildCommunicationMasks", PhaseStatus::Stub, ""}, {Driver::Tail, 30, "T30", "RebuildCommunicationMasks", PhaseStatus::Stub, ""},
{Driver::Tail, 31, "T31", "UpdateBankruptcyLimits", PhaseStatus::Partial, {Driver::Tail, 31, "T31", "UpdateBankruptcyLimits", PhaseStatus::Blocked,
"BnkEl is modelled and committed; BnkPr is not. The chain is the sum over owned, " "the whole chain is now modelled: sum over owned, non-abandoned systems of "
"non-abandoned systems of max(ComputeMaxIncome, 0). CORRECTION (lane PL): this phase used " "max(ComputeMaxIncome, 0), and the phase self-checks it every run against the BnkEl the "
"to claim it self-checked against the BnkEl the input save carries, and it did not -- it " "input save already carries -- 8 of 8 players on turn1-state with the AI flag supplied. "
"compared its POST-turn computation against the PRE-turn stored value, so the only " "Two things keep it blocked and neither is the formula. First, `ServerPlayer+0xf9` (is "
"players it could ever 'reproduce' were the six whose limit does not move, and its " "this player AI?) is a game-setup input the save does not carry, and it selects a "
"6-of-8 was thin coverage of exactly the shape rule 15 describes. The check is now taken " "difficulty column worth x1.1 on an AI empire; --ai-player N supplies it. Second, BnkPr "
"at LOAD, from the colony state the save was written from, and it passes 8 of 8 on the " "needs BANKRUPTCY_PROTECTION_LIMIT_FACTOR from the data files, so it is offered only "
"reference saves and for every live player of all 11 corpus saves. That same check " "with a tuning table loaded. It used to close NOTHING because the limits move with the "
"replaces the operator flag: `ServerPlayer+0xf9` (is this player AI?) is not on the wire, " "CIVILIAN population and that growth was not committed; now that S11 commits it, measured "
"but it is worth 10% of the max income and BnkEl has a 6.67x slope, so recomputing the " "with --commit-blocked=T31 --ai-player 1: 2 closed, 0 regressed on EACH reference pair "
"stored limit under both difficulty columns identifies which one was used -- 1 AI and 1 " "-- BnkEl for the human and for the AI. BnkPr still needs the tuning factor"},
"non-AI on every corpus save, the rest ambiguous because both columns agree there (zero "
"income, or an NPC, whose row is gated on `isAI && !npc`). A player neither column "
"reproduces abstains and keeps its pass-through leaf. Measured with NO operator input: 2 "
"closed, 0 regressed on EACH reference pair. What is left is BnkPr, which needs "
"BANKRUPTCY_PROTECTION_LIMIT_FACTOR from the data files -- it is read `fmul dword ptr`, "
"so it is a float32 in the image and the engine now narrows it (rule 23)"},
{Driver::Tail, 32, "T32", "PostIncomingFleetWarnings", PhaseStatus::Stub, ""}, {Driver::Tail, 32, "T32", "PostIncomingFleetWarnings", PhaseStatus::Stub, ""},
{Driver::Tail, 33, "T33", "ShipManagerEndOfTurnHooks", PhaseStatus::Stub, ""}, {Driver::Tail, 33, "T33", "ShipManagerEndOfTurnHooks", PhaseStatus::Stub, ""},
{Driver::Tail, 34, "T34", "RecordObservedDesigns", PhaseStatus::Stub, ""}, {Driver::Tail, 34, "T34", "RecordObservedDesigns", PhaseStatus::Stub, ""},

View file

@ -18,7 +18,6 @@
#include "game/sim/colony.h" #include "game/sim/colony.h"
#include "game/sim/economy.h" #include "game/sim/economy.h"
#include "game/sim/numeric.h" #include "game/sim/numeric.h"
#include "game/sim/player_turn.h"
#include "game/sim/rng.h" #include "game/sim/rng.h"
#include "game/sim/tuning.h" #include "game/sim/tuning.h"
@ -585,151 +584,81 @@ std::vector<PlayerBudgetFeed> BuildBudgetFeeds(const SaveGame& game, const TurnO
return feeds; return feeds;
} }
// A system table keyed by the handle id a player's `OwnId` list carries. The list is short void RunUpdateBankruptcyLimits(SaveGame& game, const TurnOptions& opt, PhaseRecord& rec) {
// enough that a linear probe is cheaper than a map, and keeping it a value type means both MaxIncomeInputs ctx;
// callers below build it the same way. ctx.serverIncomeMod = game.sim.incMod;
class SystemIndex { for (const auto& sp : game.sim.species) ctx.idealSuit.push_back(sp.issu);
public:
explicit SystemIndex(const SaveGame& game) { // The system table, keyed by the handle id a player's `OwnId` list carries.
std::vector<const Sys*> byId;
std::vector<std::int32_t> ids;
for (const auto& e : game.sim.systems) { for (const auto& e : game.sim.systems) {
ids_.push_back(e.sysID); ids.push_back(e.sysID);
sys_.push_back(&e.sys); byId.push_back(&e.sys);
} }
} const auto find = [&](std::int32_t id) -> const Sys* {
const Sys* Find(std::int32_t id) const { for (std::size_t i = 0; i < ids.size(); ++i)
for (std::size_t i = 0; i < ids_.size(); ++i) if (ids[i] == id) return byId[i];
if (ids_[i] == id) return sys_[i];
return nullptr; return nullptr;
} };
private: int players = 0, dangling = 0, matches = 0, compared = 0, aiOwned = 0;
std::vector<std::int32_t> ids_; std::string firstMiss;
std::vector<const Sys*> sys_; for (auto& pe : game.sim.players) {
}; Player& p = pe.player;
if (p.elim) continue;
// `sum over owned, non-abandoned systems of max(ComputeMaxIncome(s), 0)` for one player. ++players;
int PlayerMaxIncome(const Player& p, bool isAI, const SystemIndex& index, const bool isAI = opt.IsAIPlayer(p.plyrIdx);
const MaxIncomeInputs& ctx, int* dangling = nullptr) { if (isAI) ++aiOwned;
int maxIncome = 0; int maxIncome = 0;
for (std::int32_t id : p.owners) { for (std::int32_t id : p.owners) {
const Sys* s = index.Find(id); const Sys* s = find(id);
if (!s) { if (!s) {
if (dangling) ++*dangling; ++dangling;
continue; continue;
} }
if (s->abdn) continue; // an abandoned colony is skipped, not counted as zero if (s->abdn) continue; // an abandoned colony is skipped, not counted as zero
maxIncome += SystemMaxIncomeFromWire(*s, p, isAI, ctx); maxIncome += SystemMaxIncomeFromWire(*s, p, isAI, ctx);
} }
return maxIncome; const sim::BankruptcyLimits lim =
} sim::ComputeBankruptcyLimits(maxIncome, ctx.tuning);
// The limits the save already carries were computed by the ORIGINAL at the end of the
// Which difficulty column each player's income was computed under, recovered from the input // previous turn from the same colony state, so comparing against them is a check of
// save before any phase mutates it. See `game/sim/player_turn.h` for why this is possible at // the whole income chain that needs no running game -- the same "testable on load"
// all; the short version is that `BnkEl` is a 6.67x-slope function of the max income, so the // property the turn-record phase has.
// 10% the AI column adds cannot hide inside a truncation.
//
// Taken at load, and it has to be: T31 runs at the end of the turn, by which point the colony
// state is the POST-turn one and no longer the state the stored limit was written from.
std::vector<sim::DifficultyColumnEvidence> IdentifyDifficultyColumns(const SaveGame& game) {
MaxIncomeInputs ctx;
ctx.serverIncomeMod = game.sim.incMod;
for (const auto& sp : game.sim.species) ctx.idealSuit.push_back(sp.issu);
const SystemIndex index(game);
std::vector<sim::DifficultyColumnEvidence> out;
out.reserve(game.sim.players.size());
for (const auto& pe : game.sim.players) {
const Player& p = pe.player;
out.push_back(sim::IdentifyDifficultyColumn(
p.bnkEl, PlayerMaxIncome(p, false, index, ctx),
PlayerMaxIncome(p, true, index, ctx), ctx.tuning));
}
return out;
}
void RunUpdateBankruptcyLimits(SaveGame& game, const TurnOptions& opt, PhaseRecord& rec,
const std::vector<sim::DifficultyColumnEvidence>& columns) {
MaxIncomeInputs ctx;
ctx.serverIncomeMod = game.sim.incMod;
for (const auto& sp : game.sim.species) ctx.idealSuit.push_back(sp.issu);
const SystemIndex index(game);
int players = 0, dangling = 0, reproduced = 0, compared = 0;
int identifiedAI = 0, identifiedNonAI = 0, ambiguous = 0, unidentified = 0, overridden = 0;
std::string firstMiss;
for (std::size_t i = 0; i < game.sim.players.size(); ++i) {
Player& p = game.sim.players[i].player;
if (p.elim) continue;
++players;
++compared; ++compared;
if (lim.eliminationFloor == p.bnkEl) {
// The column, from this player's own record where the save could settle it, and from ++matches;
// the operator's roster only where it could not. } else if (firstMiss.empty()) {
const sim::DifficultyColumnEvidence ev = firstMiss = fmt("player %d: BnkEl ours %d, save %d (maxIncome %d)", p.plyrIdx,
i < columns.size() ? columns[i] : sim::DifficultyColumnEvidence{}; lim.eliminationFloor, p.bnkEl, maxIncome);
bool isAI = false;
switch (ev.column) {
case sim::DifficultyColumn::AI: isAI = true; ++identifiedAI; break;
case sim::DifficultyColumn::NonAI: ++identifiedNonAI; break;
case sim::DifficultyColumn::Ambiguous:
++ambiguous;
isAI = opt.IsAIPlayer(p.plyrIdx);
break;
case sim::DifficultyColumn::Unidentified:
++unidentified;
isAI = opt.IsAIPlayer(p.plyrIdx);
if (isAI) ++overridden;
break;
} }
if (ev.Reproduced()) ++reproduced;
else if (firstMiss.empty())
firstMiss = fmt("player %d: the input save's BnkEl is %d and neither column "
"reproduces it (non-AI %d, AI %d) -- this player abstains",
p.plyrIdx, ev.storedLimit, ev.nonAiLimit, ev.aiLimit);
const int maxIncome = PlayerMaxIncome(p, isAI, index, ctx, &dangling);
const sim::BankruptcyLimits lim = sim::ComputeBankruptcyLimits(maxIncome, ctx.tuning);
++rec.invocations; ++rec.invocations;
// BnkPr's factor is a data-file constant; with no tuning table its computed value is // BnkPr's factor is a data-file constant; with no tuning table its computed value is
// -0 for every player, which is a confidently wrong leaf rather than a missing one. // -0 for every player, which is a confidently wrong leaf rather than a missing one.
// It is therefore only offered when the table is loaded. // It is therefore only offered when the table is loaded.
const bool prModelled = opt.haveTuning;
int would = lim.eliminationFloor != p.bnkEl ? 1 : 0; int would = lim.eliminationFloor != p.bnkEl ? 1 : 0;
const bool prModelled = opt.haveTuning;
if (prModelled && lim.protectionLimit != p.bnkPr) ++would; if (prModelled && lim.protectionLimit != p.bnkPr) ++would;
if (opt.CommitBlocked("T31")) {
// Committing is per player, and the gate is this player's own record: write only
// where the same chain, run on the state the save was written from, reproduced the
// limit the save carries. Where it did not, the phase leaves the leaf alone -- a
// pass-through leaf that may still be right beats a computed one that is known to be
// wrong. `--commit-blocked=T31` remains the operator's override for the rest.
const bool commit = ev.Reproduced() || opt.CommitBlocked("T31");
if (commit) {
rec.leafWrites += would; rec.leafWrites += would;
p.bnkEl = lim.eliminationFloor; p.bnkEl = lim.eliminationFloor;
if (prModelled) p.bnkPr = lim.protectionLimit; if (prModelled) p.bnkPr = lim.protectionLimit;
if (would) rec.committed = true; rec.committed = true;
} else { } else {
rec.wouldWrite += would; rec.wouldWrite += would;
} }
} }
rec.notes.push_back(fmt("%d player(s); the input save's own BnkEl was reproduced for %d " rec.notes.push_back(fmt("%d player(s); BnkEl reproduced for %d of %d from the input save's "
"of %d from the colony state it carries", "own colony state",
players, reproduced, compared)); players, matches, compared));
rec.notes.push_back(fmt("difficulty column recovered from the save: %d AI, %d non-AI, "
"%d ambiguous, %d unidentified",
identifiedAI, identifiedNonAI, ambiguous, unidentified));
if (ambiguous)
rec.notes.push_back("ambiguous means both columns produce the SAME limit, so the flag "
"cannot matter for that player -- either the max income is zero, "
"or the player is an NPC, and the difficulty table's AI row is "
"gated on `isAI && !npc`");
if (overridden)
rec.notes.push_back(fmt("%d unidentified player(s) took the operator's --ai-player "
"roster instead", overridden));
if (!firstMiss.empty()) rec.notes.push_back(firstMiss); if (!firstMiss.empty()) rec.notes.push_back(firstMiss);
if (dangling) rec.notes.push_back(fmt("%d owned-system id(s) absent from the system table", if (dangling) rec.notes.push_back(fmt("%d owned-system id(s) absent from the system table",
dangling)); dangling));
if (aiOwned == 0)
rec.notes.push_back("no player was declared AI (--ai-player N); every player therefore "
"takes the non-AI difficulty column, which is 1/1.1 low on an AI "
"empire at difficulty level 1");
// BnkPr's factor is a data-file constant (BANKRUPTCY_PROTECTION_LIMIT_FACTOR), so with no // BnkPr's factor is a data-file constant (BANKRUPTCY_PROTECTION_LIMIT_FACTOR), so with no
// tuning table loaded the protection limit is not modelled even though BnkEl is. // tuning table loaded the protection limit is not modelled even though BnkEl is.
if (!opt.haveTuning) if (!opt.haveTuning)
@ -961,12 +890,6 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
const TurnRecordAudit recordAudit = const TurnRecordAudit recordAudit =
AuditTurnRecordsAgainstSave(game, opt.catalog ? &inputCensus : nullptr); AuditTurnRecordsAgainstSave(game, opt.catalog ? &inputCensus : nullptr);
// Also taken before any phase runs, and for the same reason: T31's difficulty column is
// recovered by recomputing each player's bankruptcy limit from the colony state the save
// was written from, which stops existing the moment S11 commits growth.
const std::vector<sim::DifficultyColumnEvidence> difficultyColumns =
IdentifyDifficultyColumns(game);
// The event text. The engine holds keys; the text comes from the operator's own installed // The event text. The engine holds keys; the text comes from the operator's own installed
// string table, which arrives with the data root or not at all. // string table, which arrives with the data root or not at all.
const EventTextTable eventText(opt.catalog && opt.catalog->strings_loaded const EventTextTable eventText(opt.catalog && opt.catalog->strings_loaded
@ -1042,26 +965,6 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
rec.notes.push_back("the real turn advances this counter 12-44 times, from " rec.notes.push_back("the real turn advances this counter 12-44 times, from "
"writers spread across both drivers; only this one is " "writers spread across both drivers; only this one is "
"modelled, so the leaf will not match yet"); "modelled, so the leaf will not match yet");
// The treasury's previous-turn shadow. It is a copy of `Sav` taken here,
// before any phase of the turn can move it, which is what makes the tail's
// `Sav - PvSav` the turn's own net.
int stamped = 0, moved = 0;
for (auto& pe : game.sim.players) {
Player& p = pe.player;
if (p.elim) continue;
const int was = p.pvSav;
p.pvSav = sim::SnapshotPreviousTurn(p.sav);
++stamped;
if (p.pvSav != was) ++moved;
}
rec.invocations += stamped;
rec.leafWrites += moved;
rec.notes.push_back(fmt("PvSav stamped from Sav on %d player(s); %d leaf(s) "
"moved", stamped, moved));
rec.notes.push_back("a player whose treasury is spent between the file being "
"written and this phase -- the AI's queue-time build "
"deduction -- lands 11,900 high here, and that order is "
"not in the input save (Rung B)");
break; break;
} }
case 4: { // S04 RebuildAllianceMasks case 4: { // S04 RebuildAllianceMasks
@ -1328,7 +1231,7 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
rec.committed = v.leafWrites > 0; rec.committed = v.leafWrites > 0;
rec.notes = v.notes; rec.notes = v.notes;
} else if (tp[i].index == 31) { } else if (tp[i].index == 31) {
RunUpdateBankruptcyLimits(game, opt, rec, difficultyColumns); RunUpdateBankruptcyLimits(game, opt, rec);
} else if (tp[i].index == 36) { } else if (tp[i].index == 36) {
RunFinalizeTurnRecords(game, opt, rec, recordAudit, allianceMasks); RunFinalizeTurnRecords(game, opt, rec, recordAudit, allianceMasks);
} }

View file

@ -9,8 +9,7 @@ add_library(sots_game_sim STATIC
colony.cpp colony.cpp
movement.cpp movement.cpp
visibility.cpp visibility.cpp
techgraph.cpp techgraph.cpp)
player_turn.cpp)
target_include_directories(sots_game_sim PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../..) target_include_directories(sots_game_sim PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../..)
target_compile_features(sots_game_sim PUBLIC cxx_std_17) target_compile_features(sots_game_sim PUBLIC cxx_std_17)
if(NOT MSVC) if(NOT MSVC)
@ -20,7 +19,7 @@ endif()
option(SOTS_GAME_SIM_TESTS "Build the game/sim unit tests" OFF) option(SOTS_GAME_SIM_TESTS "Build the game/sim unit tests" OFF)
if(SOTS_GAME_SIM_TESTS) if(SOTS_GAME_SIM_TESTS)
enable_testing() enable_testing()
set(_sim_tests economy research colony movement techgraph visibility construction player_turn) set(_sim_tests economy research colony movement techgraph visibility construction)
foreach(_t IN LISTS _sim_tests) foreach(_t IN LISTS _sim_tests)
add_executable(game_sim_test_${_t} ${CMAKE_CURRENT_SOURCE_DIR}/../../../tests/game_sim/test_${_t}.cpp) add_executable(game_sim_test_${_t} ${CMAKE_CURRENT_SOURCE_DIR}/../../../tests/game_sim/test_${_t}.cpp)
target_link_libraries(game_sim_test_${_t} PRIVATE sots_game_sim) target_link_libraries(game_sim_test_${_t} PRIVATE sots_game_sim)

View file

@ -192,21 +192,7 @@ BankruptcyLimits ComputeBankruptcyLimits(int maxIncome, const TuningTable& t) {
std::max(Ftol(static_cast<double>(maxIncome) / kBankruptcyInterestDivisor), std::max(Ftol(static_cast<double>(maxIncome) / kBankruptcyInterestDivisor),
-kTreasuryLimit); -kTreasuryLimit);
// Protection: the tuned factor times the maximum income, never below the floor. // Protection: the tuned factor times the maximum income, never below the floor.
// l.protectionLimit = std::max(-Ftol(t.BANKRUPTCY_PROTECTION_LIMIT_FACTOR * static_cast<double>(maxIncome)),
// The factor is NARROWED TO FLOAT32 first, and that is not a guess about the data file:
// the two constants in this routine are loaded by different opcodes. The divisor above is
// `DD /0` -- `fld qword`, a double in .rdata. The factor is
//
// mov ecx, [PTR_g_BANKRUPTCY_PROTECTION_LIMIT_FACTOR]
// fmul dword ptr [ecx] ; D8 /1 == m32fp
//
// so whatever decimal the data file carries is a 4-byte float by the time it multiplies.
// A `double` multiply here truncates to a different integer for roughly 1% of max-income
// values at the corpus' empire size, and for more as the empire grows. The corpus cannot
// see it -- all seven of its non-zero `BnkPr` records land where the two agree -- so this
// is an instruction-stream reading, not a measured leaf (rule 23).
l.protectionLimit = std::max(-Ftol(F32(t.BANKRUPTCY_PROTECTION_LIMIT_FACTOR) *
static_cast<double>(maxIncome)),
l.eliminationFloor); l.eliminationFloor);
return l; return l;
} }

View file

@ -211,11 +211,7 @@ struct BankruptcyLimits {
// Limits from the sum of every owned system's maximum money output: // Limits from the sum of every owned system's maximum money output:
// eliminationFloor = max(ftol(maxIncome / kBankruptcyInterestDivisor), -2e9) // eliminationFloor = max(ftol(maxIncome / kBankruptcyInterestDivisor), -2e9)
// (the debt at which 15 %/turn interest eats the whole maximum income) // (the debt at which 15 %/turn interest eats the whole maximum income)
// protectionLimit = max(-ftol(float32(BANKRUPTCY_PROTECTION_LIMIT_FACTOR) x maxIncome), // protectionLimit = max(-ftol(BANKRUPTCY_PROTECTION_LIMIT_FACTOR x maxIncome), eliminationFloor)
// eliminationFloor)
// The factor is narrowed to float32 first: the image reads it `fmul dword ptr` while the
// divisor above is `fld qword ptr`, so the two constants of this one routine are stored at
// different widths. See the note at the call site.
// The limits a turn's check uses are the ones computed at the end of the previous turn // The limits a turn's check uses are the ones computed at the end of the previous turn
// (and on load); the caller keeps them on the player. // (and on load); the caller keeps them on the player.
// CONFIDENCE: high -- the factor is on the protection limit, the elimination limit is // CONFIDENCE: high -- the factor is on the protection limit, the elimination limit is

View file

@ -1,27 +0,0 @@
#include "game/sim/player_turn.h"
namespace sots::sim {
DifficultyColumnEvidence IdentifyDifficultyColumn(int storedEliminationFloor,
int nonAiMaxIncome, int aiMaxIncome,
const TuningTable& t) {
DifficultyColumnEvidence e;
e.storedLimit = storedEliminationFloor;
e.nonAiLimit = ComputeBankruptcyLimits(nonAiMaxIncome, t).eliminationFloor;
e.aiLimit = ComputeBankruptcyLimits(aiMaxIncome, t).eliminationFloor;
const bool nonAiFits = e.nonAiLimit == storedEliminationFloor;
const bool aiFits = e.aiLimit == storedEliminationFloor;
if (nonAiFits && aiFits) {
e.column = DifficultyColumn::Ambiguous;
} else if (nonAiFits) {
e.column = DifficultyColumn::NonAI;
} else if (aiFits) {
e.column = DifficultyColumn::AI;
} else {
e.column = DifficultyColumn::Unidentified;
}
return e;
}
} // namespace sots::sim

View file

@ -1,77 +0,0 @@
// Per-player steps of the strategic turn that belong to the player record itself, rather
// than to a colony or a fleet.
//
// Two things live here today, and they are unrelated except that both are about a player's
// own words:
//
// * `SnapshotPreviousTurn` -- spine phase S00's shadow of the treasury.
// * `IdentifyDifficultyColumn` -- recovering, from the save, which difficulty column a
// player's income was computed under, so that tail phase T31 does not need to be told.
#pragma once
#include "game/sim/economy.h"
#include "game/sim/tuning.h"
namespace sots::sim {
// ---- S00 SnapshotPreviousTurn ----------------------------------------------------------
//
// The first thing the strategic spine does is stamp the treasury into its previous-turn
// shadow, so that the turn's own income can later be reported as a delta (the tail's turn
// record carries `Sav - PvSav`). It is a copy, taken BEFORE any phase of the turn runs.
//
// Measured over the corpus: `PvSav(turn N+1) == Sav(turn N)` exactly, for every player whose
// treasury is not touched between the save being written and the first spine phase. The one
// player in the corpus where it does not hold is the AI empire whose queued build order is
// deducted before phase 0 -- an order that exists nowhere in the input file (see
// `docs/B6-ship-construction.md`), which is a Rung-B gap and not a defect of this step.
inline int SnapshotPreviousTurn(int savings) { return savings; }
// ---- T31's difficulty column ------------------------------------------------------------
//
// `ServerPlayer+0xf9` -- "is this player AI?" -- is copied from the game-setup record and
// never serialised, and it selects a column of the difficulty table that is worth a x1.1 on
// an AI empire's income at the corpus' difficulty level. That made T31 need an operator flag.
//
// It does not have to. The input save already carries `BnkEl`, which the original wrote at
// the end of the previous turn from exactly the colony state the save holds. Recomputing it
// from that state under BOTH columns and comparing with the stored value identifies the
// column: the two columns differ by 10% of the max income, which is far more than a
// truncation, so at most one of them can match on a player with any income at all.
//
// Two kinds of player make the two columns collapse onto one value, and both are reported as
// `Ambiguous` rather than as an identification -- the column is genuinely unknown, and it
// also genuinely cannot matter, and those are two different statements:
//
// * a player who owns nothing: max income is zero under either column;
// * an NPC: the difficulty table's AI row is gated on `isAI && !npc`, so an NPC takes the
// non-AI row whatever the flag says. Six of the corpus' eight players are NPCs, and one
// of them ("Independent Colony") owns a colony and carries a non-zero limit -- so
// `Ambiguous` is NOT a synonym for "owns nothing".
enum class DifficultyColumn {
Unidentified, // neither column reproduces the stored limit -- the model is off somewhere
Ambiguous, // both columns reproduce it (max income is zero); the choice cannot matter
NonAI,
AI,
};
struct DifficultyColumnEvidence {
int storedLimit = 0; // the `BnkEl` the input save carries
int nonAiLimit = 0; // what the non-AI column computes from the input colony state
int aiLimit = 0; // what the AI column computes from it
DifficultyColumn column = DifficultyColumn::Unidentified;
// Whether the identification says to run this player through the AI column. `Ambiguous`
// and `Unidentified` answer false, and the caller is expected to fall back to whatever
// the operator supplied in those two cases rather than to trust this.
bool IsAI() const { return column == DifficultyColumn::AI; }
// True when the model reproduced the value the save already carries -- i.e. when the
// whole max-income chain is verified against this player's own record on load.
bool Reproduced() const { return column != DifficultyColumn::Unidentified; }
};
DifficultyColumnEvidence IdentifyDifficultyColumn(int storedEliminationFloor,
int nonAiMaxIncome, int aiMaxIncome,
const TuningTable& t);
} // namespace sots::sim

View file

@ -19,19 +19,16 @@ bool g_enabled = false;
int g_players = 2; int g_players = 2;
char g_outPath[MAX_PATH] = {}; char g_outPath[MAX_PATH] = {};
// Which four addresses the single arming point computes. `modcount` is lane W2's set and is the
// default so its run stays reproducible byte for byte; `tshn` is lane W3's.
enum class Mode { ModCount, Tshn };
Mode g_mode = Mode::ModCount;
// ---- what is being watched ------------------------------------------------------------------ // ---- what is being watched ------------------------------------------------------------------
// //
// Filled in by the arming function, because in `tshn` mode two of the four slots are only nameable // Slot 0 and 1 are the two words the naming disagreement is about; slots 2 and 3 are the first two
// once the target object has been found. Printed with the address, so a reader of the log never // players' Status. Four slots is the hardware limit and it is exactly enough.
// has to trust that slot N is what the brief said it would be. const char* const kSlotName[4] = {
char g_slotName[4][96] = {}; "S+0x8 (A2:ModCount / T:PhaseCounter)",
"S+0xc (A2:Frame / addresses.json:ModCount)",
void SetSlotName(int i, const char* s) { std::snprintf(g_slotName[i], sizeof g_slotName[i], "%s", s); } "player[0]+0x164 Status",
"player[1]+0x164 Status",
};
// ---- hit records ----------------------------------------------------------------------------- // ---- hit records -----------------------------------------------------------------------------
// //
@ -183,206 +180,6 @@ void LogF(const char* fmt, ...) {
bool g_armed = false; bool g_armed = false;
// ---- lane W3: reaching the NVO map, the trade vector and the spy vector -----------------------
//
// All three hang off the same `S` the arming detour already has, so this adds no second hook.
//
// The offsets below were re-derived from the instruction stream of `ServerSystem::Write`
// 0x00749630 rather than taken from a table, because two published accounts disagreed by 8. The
// reason for the disagreement: `Write` is entered on the **IStreamable subobject at
// ServerSystem+0x8** (the RTTI COL offset for its vftable is +0x8), so every displacement in its
// body is 8 less than the ServerSystem-relative offset. `[edi+0x26c]`/`[edi+0x270]` at
// 0x0074a195/0x0074a07d are therefore `NVO._Myhead`/`_Mysize` at ServerSystem **+0x274/+0x278**.
//
// This code does not *rely* on that reconciliation. It probes both candidate bases on every
// system, counts how many validate under each, and logs both counts -- rule 1: an arm that landed
// on the wrong object is indistinguishable from "nothing writes this".
constexpr std::uint32_t kSysNvoHead = 0x274; // ServerSystem-relative
constexpr std::uint32_t kSysAFlags = 0xd4;
constexpr std::uint32_t kSysName = 0xa8; // std::string, 0x1c bytes
constexpr std::uint32_t kSysNveSize = 0x288;
constexpr std::uint32_t kNodeIsNil = 0x8d; // an NVO node is ~0x90 bytes; NVE's is 0x20
constexpr std::uint32_t kNodeValue = 0x10; // {int16 touched, int16 TShn, int32 OID, ...}
inline bool Readable(std::uintptr_t p, std::size_t n) {
return p != 0 && !IsBadReadPtr(reinterpret_cast<void*>(p), n);
}
inline std::uint32_t U32(std::uintptr_t p) { return *reinterpret_cast<volatile std::uint32_t*>(p); }
inline std::uint8_t U8(std::uintptr_t p) { return *reinterpret_cast<volatile std::uint8_t*>(p); }
struct NvoProbe {
bool ok = false;
std::uintptr_t head = 0;
std::uint32_t size = 0;
std::uintptr_t root = 0; // == _Myhead->_Parent
std::uint32_t key = 0; // the root node's key: a player INDEX (Write reads players[key])
};
// Validate `sys + headOff` as an MSVC `_Tree` header {_Myhead, _Mysize}. The head node is the nil
// sentinel, so its _Isnil byte must be 1 and the root it parents must have _Isnil 0.
NvoProbe ProbeNvo(std::uintptr_t sys, std::uint32_t headOff) {
NvoProbe r;
const std::uintptr_t hp = sys + headOff;
if (!Readable(hp, 8)) return r;
r.head = U32(hp);
r.size = U32(hp + 4);
if (!Readable(r.head, 0x90)) return r;
if (U8(r.head + kNodeIsNil) != 1) return r;
if (r.size == 0 || r.size > 64) return r;
const std::uintptr_t root = U32(r.head + 4);
if (!Readable(root, 0x90)) return r;
if (U8(root + kNodeIsNil) != 0) return r;
const std::uint32_t key = U32(root + 0xc);
if (key > 63) return r; // player index, not a handle
r.root = root;
r.key = key;
r.ok = true;
return r;
}
// MSVC std::string (0x1c): union _Bx at +0, _Mysize +0x10, _Myres +0x14; short strings live in the
// union. Copies at most `cap-1` bytes and always NUL-terminates.
void ReadStdString(std::uintptr_t s, char* out, std::size_t cap) {
out[0] = '\0';
if (!Readable(s, 0x18)) return;
const std::uint32_t len = U32(s + 0x10);
const std::uint32_t res = U32(s + 0x14);
const std::uintptr_t p = (res < 16) ? s : static_cast<std::uintptr_t>(U32(s));
if (len == 0 || len > 0x100 || !Readable(p, len)) return;
std::size_t n = len < cap - 1 ? len : cap - 1;
for (std::size_t i = 0; i < n; ++i) {
const char c = static_cast<char>(U8(p + i));
out[i] = (c >= 32 && static_cast<unsigned char>(c) < 127) ? c : '?';
}
out[n] = '\0';
}
// A std::vector here is {_Myfirst,_Mylast,_Myend,_Alval} = 0x10, allocator LAST (method rule 5).
// Returns the element count and reports the raw triple, because "the offset names something else"
// and "the container is empty" have to be separable from the log alone.
int VectorCount(std::uintptr_t vec, std::uint32_t* first, std::uint32_t* last, std::uint32_t* end,
std::uint32_t stride) {
*first = *last = *end = 0;
if (!Readable(vec, 12)) return -1;
*first = U32(vec);
*last = U32(vec + 4);
*end = U32(vec + 8);
if (*last < *first || (*last - *first) % stride) return -1;
return static_cast<int>((*last - *first) / stride);
}
// The systems vector in the S+4 frame. Lane E3 decoded tail phase 17 as `mov edx,[esi+0x44]` =
// systems.begin with esi = S, i.e. (S+4)+0x40 -- the same frame `StrategyServer_off_Players`
// (0x50) is expressed in. Recorded in ghidra/addresses.d/lane-w3.json; kept local rather than
// pulled from the generated header because a concurrent lane owns that header this session.
constexpr std::uint32_t kServerOffSystems = 0x40;
// The two containers lane W2 §8.3 located but did not arm. Both are one add from `S`.
constexpr std::uint32_t kServerOffTradeMgr = 0x154; // -> ServerTradeManagerImpl*
constexpr std::uint32_t kTradeMgrOffVec = 0x3c; // _Myfirst; _Mylast at +0x40
constexpr std::uint32_t kServerOffSpyMgr = 0x158; // -> ServerSpyManager*
constexpr std::uint32_t kSpyMgrOffVec = 0x10; // _Myfirst; _Mylast at +0x14
// Lane W3's four slots. Slot 0 is the whole point: the `TShn` word of an NVO record on a system
// whose AFlags is zero. Slot 1 is its map's _Mysize, which is what makes slot 0's silence mean
// something -- if the node were freed and reallocated, slot 0 would be watching dead memory and
// would report a confident nothing (rule 20's distinction, one level down).
void ArmTshnSlots(std::uintptr_t S) {
const std::uintptr_t sysVec = S + 4 + kServerOffSystems;
std::uint32_t f = 0, l = 0, e = 0;
const int nSys = VectorCount(sysVec, &f, &l, &e, 4);
LogF("watch: systems vector @0x%08x first=0x%08x last=0x%08x end=0x%08x count=%d "
"(expect 28 on the reference game)",
static_cast<unsigned>(sysVec), f, l, e, nSys);
// Does the vector hold the ServerSystem base, or its IStreamable subobject 8 bytes in? Probe
// both and let the systems themselves decide, rather than trusting either published table.
const std::uint32_t headOff[2] = {kSysNvoHead, kSysNvoHead - 8};
int okAt[2] = {0, 0};
for (int a = 0; a < 2; ++a) {
for (int i = 0; i < nSys && i < 256; ++i) {
const std::uintptr_t sys = U32(f + i * 4);
if (!Readable(sys, 0x300)) continue;
if (ProbeNvo(sys, headOff[a]).ok) ++okAt[a];
}
}
const int adj = (okAt[0] >= okAt[1]) ? 0 : 1;
const std::uint32_t d = adj ? 8 : 0;
LogF("watch: NVO header probe -- ServerSystem+0x%x validated on %d systems, +0x%x on %d; "
"using +0x%x (pointer delta %u)",
kSysNvoHead, okAt[0], kSysNvoHead - 8, okAt[1], headOff[adj], d);
std::uintptr_t target = 0;
NvoProbe tp;
char tname[40] = {};
for (int i = 0; i < nSys && i < 256; ++i) {
const std::uintptr_t sys = U32(f + i * 4);
if (!Readable(sys, 0x300)) continue;
const NvoProbe p = ProbeNvo(sys, headOff[adj]);
const std::uint32_t af =
Readable(sys + kSysAFlags - d, 4) ? U32(sys + kSysAFlags - d) : 0xffffffffu;
const std::uint32_t nve =
Readable(sys + kSysNveSize - d, 4) ? U32(sys + kSysNveSize - d) : 0xffffffffu;
char nm[40];
ReadStdString(sys + kSysName - d, nm, sizeof nm);
LogF("watch: sys[%d] @0x%08x '%s' AFlags=0x%x NVO=%u NVE=%u root=0x%08x key=%u ok=%d", i,
static_cast<unsigned>(sys), nm, af, p.size, nve, static_cast<unsigned>(p.root), p.key,
p.ok ? 1 : 0);
if (!target && af == 0 && p.ok) {
target = sys;
tp = p;
std::snprintf(tname, sizeof tname, "%s", nm);
}
}
if (target) {
g_watchAddr[0] = tp.root + kNodeValue;
g_watchAddr[1] = target + kSysNvoHead + 4 - d;
std::snprintf(g_slotName[0], sizeof g_slotName[0],
"'%s' NVO root+0x10 {touched:i16,TShn:i16} key=player %u", tname, tp.key);
std::snprintf(g_slotName[1], sizeof g_slotName[1], "'%s' NVO._Mysize (=%u at arm)", tname,
tp.size);
} else {
LogF("watch: NO system has AFlags==0 with a non-empty NVO -- slots 0/1 UNSET. That is a "
"FAILED TARGET SELECTION, not a measurement; every zero below is unmeasured.");
SetSlotName(0, "(no target found)");
SetSlotName(1, "(no target found)");
}
// The workload confirmation. Printing these two counts is the cheap half of the answer: two
// lanes have failed to build a trade/spy workload, and nobody has yet printed the containers
// from a live game to say whether a workload took.
const struct {
std::uint32_t mgrOff, vecOff;
const char* what;
} cont[2] = {
{kServerOffTradeMgr, kTradeMgrOffVec, "trade routes"},
{kServerOffSpyMgr, kSpyMgrOffVec, "spy programs"},
};
for (int i = 0; i < 2; ++i) {
const std::uintptr_t slot = S + 4 + cont[i].mgrOff;
const std::uintptr_t mgr = Readable(slot, 4) ? U32(slot) : 0;
std::uint32_t vf = 0, vl = 0, ve = 0;
int n = -1;
if (Readable(mgr, cont[i].vecOff + 12))
n = VectorCount(mgr + cont[i].vecOff, &vf, &vl, &ve, 4);
LogF("watch: %s -- manager=0x%08x (S+4+0x%x) vector@mgr+0x%x first=0x%08x last=0x%08x "
"end=0x%08x count=%d",
cont[i].what, static_cast<unsigned>(mgr), cont[i].mgrOff, cont[i].vecOff, vf, vl, ve,
n);
if (mgr) {
g_watchAddr[2 + i] = mgr + cont[i].vecOff + 4;
std::snprintf(g_slotName[2 + i], sizeof g_slotName[2 + i],
"%s vector _Mylast (mgr+0x%x, count %d at arm)", cont[i].what,
cont[i].vecOff + 4, n);
} else {
std::snprintf(g_slotName[2 + i], sizeof g_slotName[2 + i], "%s (manager null)",
cont[i].what);
}
}
}
DWORD WINAPI FlusherThread(LPVOID) { DWORD WINAPI FlusherThread(LPVOID) {
for (;;) { for (;;) {
Sleep(2000); Sleep(2000);
@ -406,21 +203,10 @@ extern "C" void WatchOnApplyAll(void* self) {
return; return;
} }
const std::uintptr_t S = reinterpret_cast<std::uintptr_t>(self); const std::uintptr_t S = reinterpret_cast<std::uintptr_t>(self);
g_watchAddr[0] = 0;
g_watchAddr[1] = 0;
g_watchAddr[2] = 0;
g_watchAddr[3] = 0;
if (g_mode == Mode::Tshn) {
ArmTshnSlots(S);
} else {
g_watchAddr[0] = S + 0x8; g_watchAddr[0] = S + 0x8;
g_watchAddr[1] = S + 0xc; g_watchAddr[1] = S + 0xc;
SetSlotName(0, "S+0x8 (A2:ModCount / T:PhaseCounter)"); g_watchAddr[2] = 0;
SetSlotName(1, "S+0xc (A2:Frame / addresses.json:ModCount)"); g_watchAddr[3] = 0;
SetSlotName(2, "player[0]+0x164 Status");
SetSlotName(3, "player[1]+0x164 Status");
// players vector lives at S+0x54 (raw +0x50 in the S+4 frame -- see the ctor enumeration in // players vector lives at S+0x54 (raw +0x50 in the S+4 frame -- see the ctor enumeration in
// StrategyServer_base_delta). Read defensively: a wrong pointer here must not fault. // StrategyServer_base_delta). Read defensively: a wrong pointer here must not fault.
@ -442,8 +228,6 @@ extern "C" void WatchOnApplyAll(void* self) {
} }
} }
} // end Mode::ModCount
// Instrument self-test: put slot 3 on a word we own, write it, and require exactly one trap // Instrument self-test: put slot 3 on a word we own, write it, and require exactly one trap
// before any game number is trusted. // before any game number is trusted.
const std::uintptr_t saved3 = g_watchAddr[3]; const std::uintptr_t saved3 = g_watchAddr[3];
@ -471,7 +255,7 @@ extern "C" void WatchOnApplyAll(void* self) {
g_armedTid = GetCurrentThreadId(); g_armedTid = GetCurrentThreadId();
g_armed = true; g_armed = true;
for (int i = 0; i < 4; ++i) for (int i = 0; i < 4; ++i)
LogF("watch: slot %d -> %s = 0x%08x%s", i, g_slotName[i], LogF("watch: slot %d -> %s = 0x%08x%s", i, kSlotName[i],
static_cast<unsigned>(g_watchAddr[i]), g_watchAddr[i] ? "" : " (unset)"); static_cast<unsigned>(g_watchAddr[i]), g_watchAddr[i] ? "" : " (unset)");
LogF("watch: ARMED on tid %lu dr7=0x%08x, S=%p (ApplyAllTurnCommands this)", g_armedTid, dr7, LogF("watch: ARMED on tid %lu dr7=0x%08x, S=%p (ApplyAllTurnCommands this)", g_armedTid, dr7,
self); self);
@ -498,12 +282,6 @@ bool watch_apply_config(const char* key, const char* value, std::string* err) {
else if (err) *err = "expected on|off"; else if (err) *err = "expected on|off";
return true; return true;
} }
if (std::strcmp(key, "watch.mode") == 0) {
if (std::strcmp(value, "modcount") == 0) g_mode = Mode::ModCount;
else if (std::strcmp(value, "tshn") == 0) g_mode = Mode::Tshn;
else if (err) *err = "expected modcount|tshn";
return true;
}
if (std::strcmp(key, "watch.out") == 0) { if (std::strcmp(key, "watch.out") == 0) {
std::snprintf(g_outPath, sizeof g_outPath, "%s", value); std::snprintf(g_outPath, sizeof g_outPath, "%s", value);
return true; return true;

View file

@ -34,16 +34,7 @@ namespace shim::hooks {
// shim.cfg keys owned here. Returns true if `key` was ours (whether or not the value parsed). // shim.cfg keys owned here. Returns true if `key` was ours (whether or not the value parsed).
// watch=off|on install the ApplyAllTurnCommands arming detour (default off) // watch=off|on install the ApplyAllTurnCommands arming detour (default off)
// watch.out=<path> hit log (default <gamedir>\shim.watch.txt) // watch.out=<path> hit log (default <gamedir>\shim.watch.txt)
// watch.players=<n> how many player Status words to watch (0..2, default 2; modcount mode) // watch.players=<n> how many player Status words to watch (0..2, default 2)
// watch.mode=modcount|tshn which four addresses the arming point computes (default modcount)
//
// `modcount` is lane W2's set: S+0x8, S+0xc and two players' Status.
// `tshn` is lane W3's: the `TShn` word of an NVO map record on a system with AFlags == 0, that
// map's _Mysize, and the `_Mylast` of the trade-route and spy-program vectors. All four are
// reachable from the same `S` the arming detour already holds, so the mode adds no second hook --
// only different arithmetic. The target system is chosen by predicate at arm time and its name,
// AFlags and map sizes are logged, because "the arm landed on the wrong object" and "nothing
// writes this" produce the same empty hit list (method rule 1).
bool watch_apply_config(const char* key, const char* value, std::string* err); bool watch_apply_config(const char* key, const char* value, std::string* err);
bool watch_enabled(); bool watch_enabled();

View file

@ -1,21 +0,0 @@
# Lane W3: the same watchpoint module, pointed at the NVO/TShn record and the trade + spy
# containers. Identical to shim.cfg.w3control except for the single key `watch=`, so the pair is a
# real rule-19 control: the armed run and the control run differ in nothing else.
hooks=trace
hook.Shim::SelfTest::Fill=off
hook.Mars::GlobalConsts::LoadFile=off
hook.Game::WeaponDictionary::Init=off
hook.Game::SectionDictionary::SectionDictionary=off
hook.Game::StrategyServer::ProcessFleetMovement=off
hook.Game::TechTree::ProcessResearch=trace
hook.Game::ServerPlayer::ComputeBudget=trace
hook.Game::ServerPlayer::OnTechResearched=trace
hook.Game::ServerSystem::ProcessTurn=trace
hook.Game::StrategyServer::MoveFleet=trace
trace.path=C:\SOTS\shim.trace.jsonl
trace.inline_max=256
trace.flush=always
watch=off
watch.players=2
watch.mode=tshn
watch.out=C:\SOTS\shim.watch.txt

View file

@ -1,21 +0,0 @@
# Lane W3 run 2 (lane AI4 probe): the SAME binary as shim.cfg.w3tshn with ONE key changed --
# watch.mode -- so the module arms lane W2's ModCount set instead. Paired with shim.cfg.w3control
# for rule 19; byte-neutrality of this build was established on ref-turn2 with mode=tshn.
hooks=trace
hook.Shim::SelfTest::Fill=off
hook.Mars::GlobalConsts::LoadFile=off
hook.Game::WeaponDictionary::Init=off
hook.Game::SectionDictionary::SectionDictionary=off
hook.Game::StrategyServer::ProcessFleetMovement=off
hook.Game::TechTree::ProcessResearch=trace
hook.Game::ServerPlayer::ComputeBudget=trace
hook.Game::ServerPlayer::OnTechResearched=trace
hook.Game::ServerSystem::ProcessTurn=trace
hook.Game::StrategyServer::MoveFleet=trace
trace.path=C:\SOTS\shim.trace.jsonl
trace.inline_max=256
trace.flush=always
watch=on
watch.players=2
watch.mode=modcount
watch.out=C:\SOTS\shim.watch.txt

View file

@ -1,21 +0,0 @@
# Lane W3: the same watchpoint module, pointed at the NVO/TShn record and the trade + spy
# containers. Identical to shim.cfg.w3control except for the single key `watch=`, so the pair is a
# real rule-19 control: the armed run and the control run differ in nothing else.
hooks=trace
hook.Shim::SelfTest::Fill=off
hook.Mars::GlobalConsts::LoadFile=off
hook.Game::WeaponDictionary::Init=off
hook.Game::SectionDictionary::SectionDictionary=off
hook.Game::StrategyServer::ProcessFleetMovement=off
hook.Game::TechTree::ProcessResearch=trace
hook.Game::ServerPlayer::ComputeBudget=trace
hook.Game::ServerPlayer::OnTechResearched=trace
hook.Game::ServerSystem::ProcessTurn=trace
hook.Game::StrategyServer::MoveFleet=trace
trace.path=C:\SOTS\shim.trace.jsonl
trace.inline_max=256
trace.flush=always
watch=on
watch.players=2
watch.mode=tshn
watch.out=C:\SOTS\shim.watch.txt

View file

@ -1,5 +1,5 @@
# game/sim tests: four hand-computed suites + a real-save smoke test (skips unless SOTS_SAVES_JSON). # game/sim tests: four hand-computed suites + a real-save smoke test (skips unless SOTS_SAVES_JSON).
foreach(_t economy research colony movement techgraph visibility construction player_turn) foreach(_t economy research colony movement techgraph visibility construction)
add_executable(game_sim_test_${_t} test_${_t}.cpp) add_executable(game_sim_test_${_t} test_${_t}.cpp)
target_link_libraries(game_sim_test_${_t} PRIVATE sots_game_sim) target_link_libraries(game_sim_test_${_t} PRIVATE sots_game_sim)
target_include_directories(game_sim_test_${_t} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(game_sim_test_${_t} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})

View file

@ -303,33 +303,19 @@ static void test_bankruptcy() {
TuningTable t; TuningTable t;
t.BANKRUPTCY_PROTECTION_LIMIT_FACTOR = 3.3; t.BANKRUPTCY_PROTECTION_LIMIT_FACTOR = 3.3;
t.BANKRUPTCY_ELIMINATION_TURNS = 5; t.BANKRUPTCY_ELIMINATION_TURNS = 5;
// LANE PL, rule 23: the protection factor is read `fmul dword ptr`, so it is a FLOAT32 // max income 1000: elimination at 1000 / -0.15 = -6666.67 -> -6666; protection -3300
// in the image whatever the data file's decimal is. 3.3f is 3.299999952316284, and the
// conversion TRUNCATES, so every expectation below whose product lands on an exact
// integer under the decimal moves down by one. The two constants disagree at every
// multiple of ten -- exactly 10% of integer max incomes, starting at maxIncome = 10,
// where the decimal gives 33.0 and the image's float gives 32.99999952.
//
// max income 1000: elimination at 1000 / -0.15 = -6666.67 -> -6666; protection -3299
BankruptcyLimits l = ComputeBankruptcyLimits(1000, t); BankruptcyLimits l = ComputeBankruptcyLimits(1000, t);
CHECK_EQ(l.eliminationFloor, -6666); CHECK_EQ(l.eliminationFloor, -6666);
CHECK_EQ(l.protectionLimit, -3299); CHECK_EQ(l.protectionLimit, -3300);
CHECK_EQ(BankruptcyLevel(-6667, l), 2); CHECK_EQ(BankruptcyLevel(-6667, l), 2);
CHECK_EQ(BankruptcyLevel(-6666, l), 1); CHECK_EQ(BankruptcyLevel(-6666, l), 1);
CHECK_EQ(BankruptcyLevel(-3300, l), 1); CHECK_EQ(BankruptcyLevel(-3301, l), 1);
CHECK_EQ(BankruptcyLevel(-3299, l), 0); CHECK_EQ(BankruptcyLevel(-3300, l), 0);
CHECK_EQ(BankruptcyLevel(0, l), 0); CHECK_EQ(BankruptcyLevel(0, l), 0);
l = ComputeBankruptcyLimits(100, t); l = ComputeBankruptcyLimits(100, t);
CHECK_EQ(l.eliminationFloor, -666); CHECK_EQ(l.eliminationFloor, -666);
CHECK_EQ(l.protectionLimit, -329); CHECK_EQ(l.protectionLimit, -330);
// the boundary the corpus never presents (rule 23): a max income NOT a multiple of ten,
// where the two constants agree, next to one that is, where they do not.
l = ComputeBankruptcyLimits(1001, t);
CHECK_EQ(l.protectionLimit, -3303); // 3.3 x 1001 = 3303.3 either way
l = ComputeBankruptcyLimits(10, t);
CHECK_EQ(l.protectionLimit, -32); // decimal would say -33
// a factor beyond the interest break-even is pinned to the elimination floor // a factor beyond the interest break-even is pinned to the elimination floor
TuningTable big = t; TuningTable big = t;
@ -341,7 +327,7 @@ static void test_bankruptcy() {
// huge income: the floor saturates at the treasury limit // huge income: the floor saturates at the treasury limit
l = ComputeBankruptcyLimits(400000000, t); l = ComputeBankruptcyLimits(400000000, t);
CHECK_EQ(l.eliminationFloor, -2000000000); CHECK_EQ(l.eliminationFloor, -2000000000);
CHECK_EQ(l.protectionLimit, -1319999980); CHECK_EQ(l.protectionLimit, -1320000000);
// LANE N: the divisor is the widened float literal, not the decimal -0.15. The two // LANE N: the divisor is the widened float literal, not the decimal -0.15. The two
// disagree for every maxIncome divisible by 3, and maxIncome = 3 is where it first // disagree for every maxIncome divisible by 3, and maxIncome = 3 is where it first

View file

@ -1,88 +0,0 @@
#include "game/sim/player_turn.h"
#include "check.h"
using namespace sots::sim;
static TuningTable tuning() {
TuningTable t;
t.BANKRUPTCY_PROTECTION_LIMIT_FACTOR = 3.3;
return t;
}
static void test_snapshot() {
// S00 is a plain copy, and the point of the test is that it is a copy of the value
// BEFORE the turn, so the identity has to hold for the negative and the saturated cases
// too rather than only for the corpus' positive treasuries.
CHECK_EQ(SnapshotPreviousTurn(50000), 50000);
CHECK_EQ(SnapshotPreviousTurn(0), 0);
CHECK_EQ(SnapshotPreviousTurn(-1234), -1234);
CHECK_EQ(SnapshotPreviousTurn(2000000000), 2000000000);
}
// The corpus' two real empires, with the max incomes recovered by inverting the `BnkEl` each
// save carries. Player 0 is not AI; player 1 is, and its income therefore carries the x1.1.
static void test_column_identified_from_the_stored_limit() {
const TuningTable t = tuning();
// turn1-state, player 0 ("re"): stored BnkEl -1,590,613, max income 238,592.
DifficultyColumnEvidence e =
IdentifyDifficultyColumn(-1590613, 238592, 262451, t);
CHECK(e.column == DifficultyColumn::NonAI);
CHECK(!e.IsAI());
CHECK(e.Reproduced());
// turn1-state, player 1 ("Fane Lao"): stored -1,811,273, max income 271,691 -- which is
// what the AI column produces; the non-AI column would produce 271691 / 1.1.
e = IdentifyDifficultyColumn(-1811273, 246992, 271691, t);
CHECK(e.column == DifficultyColumn::AI);
CHECK(e.IsAI());
CHECK(e.Reproduced());
}
static void test_zero_income_is_ambiguous_not_identified() {
const TuningTable t = tuning();
// Five of the corpus' eight players own nothing. Both columns reproduce the stored zero,
// and the honest answer is "the column is unknown and cannot matter" -- not "non-AI".
const DifficultyColumnEvidence e = IdentifyDifficultyColumn(0, 0, 0, t);
CHECK(e.column == DifficultyColumn::Ambiguous);
CHECK(!e.IsAI());
CHECK(e.Reproduced());
CHECK_EQ(e.nonAiLimit, 0);
CHECK_EQ(e.aiLimit, 0);
}
static void test_neither_column_fits_is_unidentified() {
const TuningTable t = tuning();
// A stored limit no column reproduces means the max-income chain is wrong for this
// player, and the caller must abstain rather than pick the nearer of two wrong answers.
const DifficultyColumnEvidence e = IdentifyDifficultyColumn(-1000000, 238592, 262451, t);
CHECK(e.column == DifficultyColumn::Unidentified);
CHECK(!e.IsAI());
CHECK(!e.Reproduced());
}
// The identification only works because the two columns are far apart. A tenth of the income
// is thousands of units of BnkEl at any empire size that matters, so a truncation can never
// make the wrong column fit -- but at a max income of a handful of units they collapse, and
// the answer there is Ambiguous, which is correct and is worth pinning.
static void test_the_two_columns_collapse_only_at_a_trivial_income() {
const TuningTable t = tuning();
DifficultyColumnEvidence e = IdentifyDifficultyColumn(-6, 1, 1, t);
CHECK(e.column == DifficultyColumn::Ambiguous);
// one unit apart, and already separable
e = IdentifyDifficultyColumn(-133, 20, 22, t);
CHECK(e.column == DifficultyColumn::NonAI);
e = IdentifyDifficultyColumn(-146, 20, 22, t);
CHECK(e.column == DifficultyColumn::AI);
}
int main() {
test_snapshot();
test_column_identified_from_the_stored_limit();
test_zero_income_is_ambiguous_not_identified();
test_neither_column_fits_is_unidentified();
test_the_two_columns_collapse_only_at_a_trivial_income();
return simtest::finish("player_turn");
}