Compare commits
7 commits
e0f80a1bf6
...
5d245e4c8b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d245e4c8b | ||
|
|
005e8f500f | ||
|
|
ca1b8c2cba | ||
|
|
9e914ef358 | ||
|
|
26b041106f | ||
|
|
c852965e0e | ||
|
|
bb81d3db2d |
18 changed files with 1226 additions and 86 deletions
299
docs/PL-players-residual.md
Normal file
299
docs/PL-players-residual.md
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
# 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.
|
||||
143
docs/W3-predictions.md
Normal file
143
docs/W3-predictions.md
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
# 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.
|
||||
70
docs/W3-watchpoints.md
Normal file
70
docs/W3-watchpoints.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# 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.
|
||||
|
|
@ -57,8 +57,14 @@ constexpr PhaseDesc kHost[] = {
|
|||
|
||||
constexpr PhaseDesc kStrategic[] = {
|
||||
{Driver::Strategic, 0, "S00", "SnapshotPreviousTurn", PhaseStatus::Partial,
|
||||
"bumps the modification counter; the previous-turn shadow-word snapshot is not modelled "
|
||||
"(the shadow words are not all identified on the wire)"},
|
||||
"bumps the modification counter and stamps PvSav from Sav on every live player -- the "
|
||||
"treasury's previous-turn shadow, taken before any phase of the turn can move it, which "
|
||||
"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,
|
||||
"per-system morale event + the abandon/chaos check below the minimum chaos population"},
|
||||
{Driver::Strategic, 2, "S02", "TradeManagerTurn", PhaseStatus::Stub,
|
||||
|
|
@ -268,18 +274,24 @@ constexpr PhaseDesc kTail[] = {
|
|||
{Driver::Tail, 28, "T28", "UpdateNodeLineSightingMasks", PhaseStatus::Stub, ""},
|
||||
{Driver::Tail, 29, "T29", "AbortInvisibleInterceptOrders", PhaseStatus::Stub, ""},
|
||||
{Driver::Tail, 30, "T30", "RebuildCommunicationMasks", PhaseStatus::Stub, ""},
|
||||
{Driver::Tail, 31, "T31", "UpdateBankruptcyLimits", PhaseStatus::Blocked,
|
||||
"the whole chain is now modelled: sum over owned, non-abandoned systems of "
|
||||
"max(ComputeMaxIncome, 0), and the phase self-checks it every run against the BnkEl the "
|
||||
"input save already carries -- 8 of 8 players on turn1-state with the AI flag supplied. "
|
||||
"Two things keep it blocked and neither is the formula. First, `ServerPlayer+0xf9` (is "
|
||||
"this player AI?) is a game-setup input the save does not carry, and it selects a "
|
||||
"difficulty column worth x1.1 on an AI empire; --ai-player N supplies it. Second, BnkPr "
|
||||
"needs BANKRUPTCY_PROTECTION_LIMIT_FACTOR from the data files, so it is offered only "
|
||||
"with a tuning table loaded. It used to close NOTHING because the limits move with the "
|
||||
"CIVILIAN population and that growth was not committed; now that S11 commits it, measured "
|
||||
"with --commit-blocked=T31 --ai-player 1: 2 closed, 0 regressed on EACH reference pair "
|
||||
"-- BnkEl for the human and for the AI. BnkPr still needs the tuning factor"},
|
||||
{Driver::Tail, 31, "T31", "UpdateBankruptcyLimits", PhaseStatus::Partial,
|
||||
"BnkEl is modelled and committed; BnkPr is not. The chain is the sum over owned, "
|
||||
"non-abandoned systems of max(ComputeMaxIncome, 0). CORRECTION (lane PL): this phase used "
|
||||
"to claim it self-checked against the BnkEl the input save carries, and it did not -- it "
|
||||
"compared 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, and its "
|
||||
"6-of-8 was thin coverage of exactly the shape rule 15 describes. The check is now taken "
|
||||
"at LOAD, from the colony state the save was written from, and it passes 8 of 8 on the "
|
||||
"reference saves and for every live player of all 11 corpus saves. That same check "
|
||||
"replaces the operator flag: `ServerPlayer+0xf9` (is this player AI?) is not on the wire, "
|
||||
"but it is worth 10% of the max income and BnkEl has a 6.67x slope, so recomputing the "
|
||||
"stored limit under both difficulty columns identifies which one was used -- 1 AI and 1 "
|
||||
"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, 33, "T33", "ShipManagerEndOfTurnHooks", PhaseStatus::Stub, ""},
|
||||
{Driver::Tail, 34, "T34", "RecordObservedDesigns", PhaseStatus::Stub, ""},
|
||||
|
|
|
|||
195
src/app/turn.cpp
195
src/app/turn.cpp
|
|
@ -18,6 +18,7 @@
|
|||
#include "game/sim/colony.h"
|
||||
#include "game/sim/economy.h"
|
||||
#include "game/sim/numeric.h"
|
||||
#include "game/sim/player_turn.h"
|
||||
#include "game/sim/rng.h"
|
||||
#include "game/sim/tuning.h"
|
||||
|
||||
|
|
@ -584,81 +585,151 @@ std::vector<PlayerBudgetFeed> BuildBudgetFeeds(const SaveGame& game, const TurnO
|
|||
return feeds;
|
||||
}
|
||||
|
||||
void RunUpdateBankruptcyLimits(SaveGame& game, const TurnOptions& opt, PhaseRecord& rec) {
|
||||
// A system table keyed by the handle id a player's `OwnId` list carries. The list is short
|
||||
// enough that a linear probe is cheaper than a map, and keeping it a value type means both
|
||||
// callers below build it the same way.
|
||||
class SystemIndex {
|
||||
public:
|
||||
explicit SystemIndex(const SaveGame& game) {
|
||||
for (const auto& e : game.sim.systems) {
|
||||
ids_.push_back(e.sysID);
|
||||
sys_.push_back(&e.sys);
|
||||
}
|
||||
}
|
||||
const Sys* Find(std::int32_t id) const {
|
||||
for (std::size_t i = 0; i < ids_.size(); ++i)
|
||||
if (ids_[i] == id) return sys_[i];
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
private:
|
||||
std::vector<std::int32_t> ids_;
|
||||
std::vector<const Sys*> sys_;
|
||||
};
|
||||
|
||||
// `sum over owned, non-abandoned systems of max(ComputeMaxIncome(s), 0)` for one player.
|
||||
int PlayerMaxIncome(const Player& p, bool isAI, const SystemIndex& index,
|
||||
const MaxIncomeInputs& ctx, int* dangling = nullptr) {
|
||||
int maxIncome = 0;
|
||||
for (std::int32_t id : p.owners) {
|
||||
const Sys* s = index.Find(id);
|
||||
if (!s) {
|
||||
if (dangling) ++*dangling;
|
||||
continue;
|
||||
}
|
||||
if (s->abdn) continue; // an abandoned colony is skipped, not counted as zero
|
||||
maxIncome += SystemMaxIncomeFromWire(*s, p, isAI, ctx);
|
||||
}
|
||||
return maxIncome;
|
||||
}
|
||||
|
||||
// Which difficulty column each player's income was computed under, recovered from the input
|
||||
// save before any phase mutates it. See `game/sim/player_turn.h` for why this is possible at
|
||||
// all; the short version is that `BnkEl` is a 6.67x-slope function of the max income, so the
|
||||
// 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);
|
||||
|
||||
// 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) {
|
||||
ids.push_back(e.sysID);
|
||||
byId.push_back(&e.sys);
|
||||
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));
|
||||
}
|
||||
const auto find = [&](std::int32_t id) -> const Sys* {
|
||||
for (std::size_t i = 0; i < ids.size(); ++i)
|
||||
if (ids[i] == id) return byId[i];
|
||||
return nullptr;
|
||||
};
|
||||
return out;
|
||||
}
|
||||
|
||||
int players = 0, dangling = 0, matches = 0, compared = 0, aiOwned = 0;
|
||||
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 (auto& pe : game.sim.players) {
|
||||
Player& p = pe.player;
|
||||
for (std::size_t i = 0; i < game.sim.players.size(); ++i) {
|
||||
Player& p = game.sim.players[i].player;
|
||||
if (p.elim) continue;
|
||||
++players;
|
||||
const bool isAI = opt.IsAIPlayer(p.plyrIdx);
|
||||
if (isAI) ++aiOwned;
|
||||
int maxIncome = 0;
|
||||
for (std::int32_t id : p.owners) {
|
||||
const Sys* s = find(id);
|
||||
if (!s) {
|
||||
++dangling;
|
||||
continue;
|
||||
}
|
||||
if (s->abdn) continue; // an abandoned colony is skipped, not counted as zero
|
||||
maxIncome += SystemMaxIncomeFromWire(*s, p, isAI, ctx);
|
||||
}
|
||||
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
|
||||
// previous turn from the same colony state, so comparing against them is a check of
|
||||
// the whole income chain that needs no running game -- the same "testable on load"
|
||||
// property the turn-record phase has.
|
||||
++compared;
|
||||
if (lim.eliminationFloor == p.bnkEl) {
|
||||
++matches;
|
||||
} else if (firstMiss.empty()) {
|
||||
firstMiss = fmt("player %d: BnkEl ours %d, save %d (maxIncome %d)", p.plyrIdx,
|
||||
lim.eliminationFloor, p.bnkEl, maxIncome);
|
||||
|
||||
// The column, from this player's own record where the save could settle it, and from
|
||||
// the operator's roster only where it could not.
|
||||
const sim::DifficultyColumnEvidence ev =
|
||||
i < columns.size() ? columns[i] : sim::DifficultyColumnEvidence{};
|
||||
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;
|
||||
|
||||
// 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.
|
||||
// It is therefore only offered when the table is loaded.
|
||||
int would = lim.eliminationFloor != p.bnkEl ? 1 : 0;
|
||||
const bool prModelled = opt.haveTuning;
|
||||
int would = lim.eliminationFloor != p.bnkEl ? 1 : 0;
|
||||
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;
|
||||
p.bnkEl = lim.eliminationFloor;
|
||||
if (prModelled) p.bnkPr = lim.protectionLimit;
|
||||
rec.committed = true;
|
||||
if (would) rec.committed = true;
|
||||
} else {
|
||||
rec.wouldWrite += would;
|
||||
}
|
||||
}
|
||||
rec.notes.push_back(fmt("%d player(s); BnkEl reproduced for %d of %d from the input save's "
|
||||
"own colony state",
|
||||
players, matches, compared));
|
||||
rec.notes.push_back(fmt("%d player(s); the input save's own BnkEl was reproduced for %d "
|
||||
"of %d from the colony state it carries",
|
||||
players, reproduced, 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 (dangling) rec.notes.push_back(fmt("%d owned-system id(s) absent from the system table",
|
||||
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
|
||||
// tuning table loaded the protection limit is not modelled even though BnkEl is.
|
||||
if (!opt.haveTuning)
|
||||
|
|
@ -890,6 +961,12 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
|
|||
const TurnRecordAudit recordAudit =
|
||||
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
|
||||
// string table, which arrives with the data root or not at all.
|
||||
const EventTextTable eventText(opt.catalog && opt.catalog->strings_loaded
|
||||
|
|
@ -965,6 +1042,26 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
|
|||
rec.notes.push_back("the real turn advances this counter 12-44 times, from "
|
||||
"writers spread across both drivers; only this one is "
|
||||
"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;
|
||||
}
|
||||
case 4: { // S04 RebuildAllianceMasks
|
||||
|
|
@ -1231,7 +1328,7 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
|
|||
rec.committed = v.leafWrites > 0;
|
||||
rec.notes = v.notes;
|
||||
} else if (tp[i].index == 31) {
|
||||
RunUpdateBankruptcyLimits(game, opt, rec);
|
||||
RunUpdateBankruptcyLimits(game, opt, rec, difficultyColumns);
|
||||
} else if (tp[i].index == 36) {
|
||||
RunFinalizeTurnRecords(game, opt, rec, recordAudit, allianceMasks);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ add_library(sots_game_sim STATIC
|
|||
colony.cpp
|
||||
movement.cpp
|
||||
visibility.cpp
|
||||
techgraph.cpp)
|
||||
techgraph.cpp
|
||||
player_turn.cpp)
|
||||
target_include_directories(sots_game_sim PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../..)
|
||||
target_compile_features(sots_game_sim PUBLIC cxx_std_17)
|
||||
if(NOT MSVC)
|
||||
|
|
@ -19,7 +20,7 @@ endif()
|
|||
option(SOTS_GAME_SIM_TESTS "Build the game/sim unit tests" OFF)
|
||||
if(SOTS_GAME_SIM_TESTS)
|
||||
enable_testing()
|
||||
set(_sim_tests economy research colony movement techgraph visibility construction)
|
||||
set(_sim_tests economy research colony movement techgraph visibility construction player_turn)
|
||||
foreach(_t IN LISTS _sim_tests)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -192,7 +192,21 @@ BankruptcyLimits ComputeBankruptcyLimits(int maxIncome, const TuningTable& t) {
|
|||
std::max(Ftol(static_cast<double>(maxIncome) / kBankruptcyInterestDivisor),
|
||||
-kTreasuryLimit);
|
||||
// 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);
|
||||
return l;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -211,7 +211,11 @@ struct BankruptcyLimits {
|
|||
// Limits from the sum of every owned system's maximum money output:
|
||||
// eliminationFloor = max(ftol(maxIncome / kBankruptcyInterestDivisor), -2e9)
|
||||
// (the debt at which 15 %/turn interest eats the whole maximum income)
|
||||
// protectionLimit = max(-ftol(BANKRUPTCY_PROTECTION_LIMIT_FACTOR x maxIncome), eliminationFloor)
|
||||
// protectionLimit = max(-ftol(float32(BANKRUPTCY_PROTECTION_LIMIT_FACTOR) x maxIncome),
|
||||
// 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
|
||||
// (and on load); the caller keeps them on the player.
|
||||
// CONFIDENCE: high -- the factor is on the protection limit, the elimination limit is
|
||||
|
|
|
|||
27
src/game/sim/player_turn.cpp
Normal file
27
src/game/sim/player_turn.cpp
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
#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
|
||||
77
src/game/sim/player_turn.h
Normal file
77
src/game/sim/player_turn.h
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
// 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
|
||||
|
|
@ -19,16 +19,19 @@ bool g_enabled = false;
|
|||
int g_players = 2;
|
||||
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 ------------------------------------------------------------------
|
||||
//
|
||||
// Slot 0 and 1 are the two words the naming disagreement is about; slots 2 and 3 are the first two
|
||||
// players' Status. Four slots is the hardware limit and it is exactly enough.
|
||||
const char* const kSlotName[4] = {
|
||||
"S+0x8 (A2:ModCount / T:PhaseCounter)",
|
||||
"S+0xc (A2:Frame / addresses.json:ModCount)",
|
||||
"player[0]+0x164 Status",
|
||||
"player[1]+0x164 Status",
|
||||
};
|
||||
// Filled in by the arming function, because in `tshn` mode two of the four slots are only nameable
|
||||
// once the target object has been found. Printed with the address, so a reader of the log never
|
||||
// has to trust that slot N is what the brief said it would be.
|
||||
char g_slotName[4][96] = {};
|
||||
|
||||
void SetSlotName(int i, const char* s) { std::snprintf(g_slotName[i], sizeof g_slotName[i], "%s", s); }
|
||||
|
||||
// ---- hit records -----------------------------------------------------------------------------
|
||||
//
|
||||
|
|
@ -180,6 +183,206 @@ void LogF(const char* fmt, ...) {
|
|||
|
||||
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) {
|
||||
for (;;) {
|
||||
Sleep(2000);
|
||||
|
|
@ -203,11 +406,22 @@ extern "C" void WatchOnApplyAll(void* self) {
|
|||
return;
|
||||
}
|
||||
const std::uintptr_t S = reinterpret_cast<std::uintptr_t>(self);
|
||||
g_watchAddr[0] = S + 0x8;
|
||||
g_watchAddr[1] = S + 0xc;
|
||||
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[1] = S + 0xc;
|
||||
SetSlotName(0, "S+0x8 (A2:ModCount / T:PhaseCounter)");
|
||||
SetSlotName(1, "S+0xc (A2:Frame / addresses.json:ModCount)");
|
||||
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
|
||||
// StrategyServer_base_delta). Read defensively: a wrong pointer here must not fault.
|
||||
if (g_players > 0) {
|
||||
|
|
@ -228,6 +442,8 @@ 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
|
||||
// before any game number is trusted.
|
||||
const std::uintptr_t saved3 = g_watchAddr[3];
|
||||
|
|
@ -255,7 +471,7 @@ extern "C" void WatchOnApplyAll(void* self) {
|
|||
g_armedTid = GetCurrentThreadId();
|
||||
g_armed = true;
|
||||
for (int i = 0; i < 4; ++i)
|
||||
LogF("watch: slot %d -> %s = 0x%08x%s", i, kSlotName[i],
|
||||
LogF("watch: slot %d -> %s = 0x%08x%s", i, g_slotName[i],
|
||||
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,
|
||||
self);
|
||||
|
|
@ -282,6 +498,12 @@ bool watch_apply_config(const char* key, const char* value, std::string* err) {
|
|||
else if (err) *err = "expected on|off";
|
||||
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) {
|
||||
std::snprintf(g_outPath, sizeof g_outPath, "%s", value);
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -34,7 +34,16 @@ namespace shim::hooks {
|
|||
// 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.out=<path> hit log (default <gamedir>\shim.watch.txt)
|
||||
// watch.players=<n> how many player Status words to watch (0..2, default 2)
|
||||
// watch.players=<n> how many player Status words to watch (0..2, default 2; modcount mode)
|
||||
// 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_enabled();
|
||||
|
|
|
|||
21
src/shim/shim.cfg.w3control
Normal file
21
src/shim/shim.cfg.w3control
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# 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
|
||||
21
src/shim/shim.cfg.w3mod
Normal file
21
src/shim/shim.cfg.w3mod
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# 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
|
||||
21
src/shim/shim.cfg.w3tshn
Normal file
21
src/shim/shim.cfg.w3tshn
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# 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
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
# 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)
|
||||
foreach(_t economy research colony movement techgraph visibility construction player_turn)
|
||||
add_executable(game_sim_test_${_t} test_${_t}.cpp)
|
||||
target_link_libraries(game_sim_test_${_t} PRIVATE sots_game_sim)
|
||||
target_include_directories(game_sim_test_${_t} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
|
|
|
|||
|
|
@ -303,19 +303,33 @@ static void test_bankruptcy() {
|
|||
TuningTable t;
|
||||
t.BANKRUPTCY_PROTECTION_LIMIT_FACTOR = 3.3;
|
||||
t.BANKRUPTCY_ELIMINATION_TURNS = 5;
|
||||
// max income 1000: elimination at 1000 / -0.15 = -6666.67 -> -6666; protection -3300
|
||||
// LANE PL, rule 23: the protection factor is read `fmul dword ptr`, so it is a FLOAT32
|
||||
// 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);
|
||||
CHECK_EQ(l.eliminationFloor, -6666);
|
||||
CHECK_EQ(l.protectionLimit, -3300);
|
||||
CHECK_EQ(l.protectionLimit, -3299);
|
||||
CHECK_EQ(BankruptcyLevel(-6667, l), 2);
|
||||
CHECK_EQ(BankruptcyLevel(-6666, l), 1);
|
||||
CHECK_EQ(BankruptcyLevel(-3301, l), 1);
|
||||
CHECK_EQ(BankruptcyLevel(-3300, l), 0);
|
||||
CHECK_EQ(BankruptcyLevel(-3300, l), 1);
|
||||
CHECK_EQ(BankruptcyLevel(-3299, l), 0);
|
||||
CHECK_EQ(BankruptcyLevel(0, l), 0);
|
||||
|
||||
l = ComputeBankruptcyLimits(100, t);
|
||||
CHECK_EQ(l.eliminationFloor, -666);
|
||||
CHECK_EQ(l.protectionLimit, -330);
|
||||
CHECK_EQ(l.protectionLimit, -329);
|
||||
|
||||
// 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
|
||||
TuningTable big = t;
|
||||
|
|
@ -327,7 +341,7 @@ static void test_bankruptcy() {
|
|||
// huge income: the floor saturates at the treasury limit
|
||||
l = ComputeBankruptcyLimits(400000000, t);
|
||||
CHECK_EQ(l.eliminationFloor, -2000000000);
|
||||
CHECK_EQ(l.protectionLimit, -1320000000);
|
||||
CHECK_EQ(l.protectionLimit, -1319999980);
|
||||
|
||||
// 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
|
||||
|
|
|
|||
88
tests/game_sim/test_player_turn.cpp
Normal file
88
tests/game_sim/test_player_turn.cpp
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
#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");
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue