lane T2: the treaty-turn stamp, and Player.Status's real writer
findings/subsystems/treaty-turn-stamp.md. Briefed on the post-combat tail, ranked its
phases by leaves-closed-per-effort against the standalone's 158-leaf residual, and found
that every tail phase which moves a leaf in this corpus is blocked on another lane
(ship construction, the budget) or on the tail's vtable blind spot. Section 5 lists that
ranking with the numbers. The two items below are what a lane holding no VM could close.
1. StrategyServer_StampTreatyTurns 0x007898c0, read as instructions. The diplomacy
ledger's per-turn stamp over every ordered pair of players holding a treaty, creating
the entry on demand. Sole caller is ApplyTurnCommands, so it is a HOST step and not a
tail phase. Three corrections to strategic-turn-internals.md 5.2, which is flagged in
place: the relation codes are 3=ally / 2=NAP / 1=cease-fire and not the reverse (which
combat-done-tail.md 2A already implied independently); the stamped value is Frame, not
ModCount; and the bit is the PlyrIdx field, not the player's vector position -- the
opposite convention from the shared-vision mask.
Modelled in sots-engine as host phase H02. Predicted 26 leaves on the reference pair
and 14 on pair 2 before building; measured exactly 26 and 14, 0 regressed, and +12 on
each of three further pairs from a different game at turns 2, 15 and 16 that the model
was never fitted to. 76 closed / 0 regressed over five pairs, 0 RNG words. The rule
also reproduces the ledger of ten of the eleven corpus saves entry for entry.
2. Player.Status. backlog.md item 6 has it blocked on "the writer between tail 31 and the
autosave -- watchpoint". There is no such writer. A whole-image scan for immediate
stores to ServerPlayer+0x164 finds exactly three: ProcessTurn's encounter loop writes
1, ResumePlaying writes 0 on load (the 4 -> 0 the determinism note recorded), and
StrategyServer_MarkPlayerTurnEnded 0x00821a40 writes 4 from the three End Turn
SUBMISSION paths, which run before the turn is processed. Item 6 needs one predicate,
not a watchpoint -- and an entry probe on that function names the set directly.
NOT written. The only two saves in the corpus with a non-zero Status agree with
Species != 4 on all eight players, and that is eight observations on a 1-bit predicate
that nine of the eleven saves do not exercise at all. Evaluated and reported.
Seven addresses in ghidra/addresses.d/lane-t2.json, validated to a scratch path against
the merged set (1,095 entries, no duplicate). verify/results/standalone/{report,status}
deliberately untouched: lane C3 published into them minutes before this run.
This commit is contained in:
parent
3b99e255c7
commit
49fa5501ba
3 changed files with 423 additions and 0 deletions
|
|
@ -513,6 +513,19 @@ words are copied in. Nothing in `StrategyServer::ProcessTurn` itself is diplomac
|
|||
diplomacy upkeep" (0x0086b300 / 0x007adc80) is **trade** (§1.4).
|
||||
|
||||
### 5.2 `DiplomacyStats` (vector at `+0x230`, one entry per other player, 0x24 B)
|
||||
|
||||
> **CORRECTED by lane T2, 2026-09-08 — two of this paragraph's three facts.** The caller and the location are
|
||||
> right; the relation codes and the stamped value are not. (a) `GetRelation` 0x0080e050 forwards to the cdecl
|
||||
> 0x006d2050, which reads **`3 = ally` (and self), `2 = NAP`, `1 = cease-fire`, `0 = war`** — 1 and 3 the
|
||||
> other way round from the line below, which labelled itself MEDIUM confidence on exactly this mapping.
|
||||
> `combat-done-tail.md` §2A agrees independently (`ResupplyAlliedFleets` is gated on `GetRelation == 3`).
|
||||
> (b) The stamped value is **`S+0xc`, which lane Z named `Frame` — the turn — not `ModCount`**: the
|
||||
> instruction is `mov eax,[edi+0xc]` with `edi = S`, and across the corpus every stamp equals the save's own
|
||||
> `Frame` on ten of eleven saves while `ModCount` runs an order of magnitude higher (0/12/24 on the
|
||||
> reference family). Also: the entry is created on demand, a fresh one starts its three `last*` at **-1**,
|
||||
> and the relation's bit is the **`PlyrIdx` field**, not the position in the player vector.
|
||||
> See `findings/subsystems/treaty-turn-stamp.md`. Modelled in `sots-engine` as host phase H02.
|
||||
|
||||
Updated by 0x007898c0 right after the alliance diff (called from `ApplyTurnCommands`):
|
||||
* for every pair with a current relation (0x0080e050: 1 ally, 2 NAP, 3 cease-fire) the matching `last*` int16
|
||||
(`lastally` @+0x10, `lastnap` @+8, `lastcf` @+0x18) is set to the current `ModCount`;
|
||||
|
|
|
|||
350
findings/subsystems/treaty-turn-stamp.md
Normal file
350
findings/subsystems/treaty-turn-stamp.md
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
# The treaty-turn stamp, and where `Player.Status` really comes from
|
||||
|
||||
Lane T2, 2026-09-08. Program `sots` / "Sword of the Stars.exe", ImageBase 0x00400000, all addresses VAs.
|
||||
|
||||
**Method.** Every claim about control flow below is read from the instruction stream with
|
||||
`objdump -b binary -m i386 -M intel` over the raw image (PE section table mapping, call targets resolved
|
||||
against `dumps/functions.json`, callers found with a whole-image byte scan for `E8/E9 rel32`). Nothing here
|
||||
comes from the decompiler. Where a claim is an inference from save bytes rather than from instructions it
|
||||
says so in the sentence that makes it.
|
||||
|
||||
This lane was briefed on `OnAllCombatDone_Tail` and went looking for the tail phase with the best
|
||||
leaves-closed-per-effort. **The answer was not in the tail.** §5 says why, and lists what the tail's own
|
||||
phases are actually blocked on. §1–§3 are what the search found instead.
|
||||
|
||||
---
|
||||
|
||||
## 0. The prediction, written before the run
|
||||
|
||||
Two models, both derived statically, both with a falsification section. Recorded here **before** anything
|
||||
was built or measured, per rule 2.
|
||||
|
||||
### 0.1 H02 `StampTreatyTurns`
|
||||
|
||||
> Every turn, for every ordered pair of players `(A, B)` with a live treaty, `A`'s `DiplomacyStats` entry
|
||||
> for `B` has the matching `last*` field stamped with the **current turn** (`Frame`), the entry being
|
||||
> created on demand. Concretely, per player `A` in player-vector order, per player `B` in player-vector
|
||||
> order, `B != A`:
|
||||
>
|
||||
> ```
|
||||
> rel = 3 if A.plyrIdx == B.plyrIdx else
|
||||
> 3 if (A.AL & (1 << B.plyrIdx)) else
|
||||
> 2 if (A.NA & (1 << B.plyrIdx)) else
|
||||
> 1 if (A.CF & (1 << B.plyrIdx)) else 0
|
||||
> if rel == 0: continue
|
||||
> e = the first entry of A.dipstats with e.other == B.PlayerID,
|
||||
> else a NEW entry appended at the end with
|
||||
> {other = B.PlayerID, lastnap = lastally = lastcf = -1, every other field 0}
|
||||
> e.lastally = Frame if rel == 3
|
||||
> e.lastnap = Frame if rel == 2
|
||||
> e.lastcf = Frame if rel == 1
|
||||
> ```
|
||||
>
|
||||
> **Predicted leaves closed: 26 on the reference pair (turn1 -> turn2), 14 on pair 2 (turn2 -> turn3).
|
||||
> Predicted regressed: 0. Predicted RNG words: 0.**
|
||||
|
||||
The 26 are six players gaining a `dipstats` vector (2 entries for each of the two `Singularity` players,
|
||||
4 rows each — the container leaf plus its entries — and 4 entries each for the four species-4 players,
|
||||
5 rows each). The 14 on pair 2 are `lastnap`/`lastally` moving 2 -> 3 on the fourteen entries that already
|
||||
exist.
|
||||
|
||||
**How this could be wrong, and the symptom of each way.**
|
||||
|
||||
1. *The stamp is not `Frame`.* `strategic-turn-internals.md` §5.2 says it is `ModCount`. If §5.2 is right
|
||||
and I am wrong, the stamped value is 12 (pair 1) or 24 (pair 2), not 2 or 3 — **14 regressed leaves on
|
||||
pair 2 and 26 wrong values on pair 1**, and the two are trivially distinguishable because `ModCount` and
|
||||
`Frame` differ by an order of magnitude on both pairs.
|
||||
2. *The relation mapping is inverted.* If `1 = ally, 2 = NAP, 3 = ceasefire` (also §5.2) rather than
|
||||
`3 = ally, 2 = NAP, 1 = ceasefire`, the value lands in `lastcf` where the oracle has `lastally`:
|
||||
**pair 1 closes ~12 of 26 and regresses none** (the created entries are still created, with the right
|
||||
`other`, but two of their thirteen counters are wrong), and pair 2 **closes 0 and regresses 14**.
|
||||
3. *The shift is by vector position, not `plyrIdx`.* Undetectable in this corpus — every save has
|
||||
`plyrIdx == vector position`. The symptom would appear only on a save where a player was removed. Stated
|
||||
as a limitation, not tested.
|
||||
4. *Entries are ordered by something other than the stamping order.* Then the created vectors have the right
|
||||
contents in the wrong order and **pair 1 closes the container leaf and regresses the entry leaves**.
|
||||
5. *The phase is somewhere else in the turn and sees a different `Frame`.* If `Frame` were bumped after this
|
||||
ran, the stamp would be `Frame - 1` — 1 and 2 instead of 2 and 3 — i.e. **0 closed, 26 wrong on pair 1**.
|
||||
6. *Some other writer also touches these fields on a normal turn.* Then the closed count is short of 26/14
|
||||
by whatever that writer moves, and the residual is named rather than netted.
|
||||
|
||||
### 0.2 `Player.Status` — evaluated, not written
|
||||
|
||||
> `Status` is set to **4** by `FUN_00821a40` (§3), which runs on the **End Turn submission** path, before
|
||||
> the turn is processed — not between tail phase 31 and the autosave, which is where `backlog.md` item 6
|
||||
> looks for it. Which players get it is a property of who submits an End Turn, and the only two saves in
|
||||
> the corpus where the field is non-zero agree with `Species != 4`.
|
||||
>
|
||||
> **Predicted leaves: 4 on the reference pair, 0 on pair 2 — and NOT written**, because the gate is a
|
||||
> two-observation inference and the campaign's own rule 20 says a constant fitted to two observations is not
|
||||
> a constant.
|
||||
|
||||
---
|
||||
|
||||
## 1. `StampTreatyTurns` — 0x007898c0, and it is not in the tail
|
||||
|
||||
**One caller, whole-image byte scan for `E8/E9 rel32`:** `ApplyTurnCommands` 0x007b18b0 at **0x007b2461**
|
||||
(+0xbb1), which is `strategic-turn-internals.md` §5.2's "right after the alliance diff". So the phase runs
|
||||
**before** either turn driver, in the host's command-application step — the same bracket
|
||||
`phase_catalog.h` already calls `Driver::Host`.
|
||||
|
||||
`void __thiscall (StrategyServer* S, vector<uint>* allianceBroken, vector<uint>* napBroken,
|
||||
vector<uint>* cfBroken)`, `ret 0xc`, 700 B. `this` is the **`S` frame** — `[edi+0x54]`/`[edi+0x58]` is the
|
||||
`vector<ServerPlayer*> Players` and `[edi+0xc]` is `Frame`, both `S`-frame offsets lane T and lane Z
|
||||
established.
|
||||
|
||||
### 1.1 Pass A, the stamp — 0x00789920..0x007899c4, read as instructions
|
||||
|
||||
```
|
||||
007898fc mov eax,[edi+0x58] ; Players._Mylast
|
||||
007899 02 mov ecx,[edi+0x54] ; ... _Myfirst
|
||||
sar eax,2 ; nPlayers
|
||||
loop i:
|
||||
007d9920 mov ebx,[ecx+esi*4] ; A = Players[i]
|
||||
loop j:
|
||||
007d9930 mov esi,[ecx+edx*4] ; B = Players[j]
|
||||
007d9933 cmp ebx,esi
|
||||
007d9935 je 0x78999a ; A == B -> skip
|
||||
007d9937 push esi ; mov ecx,ebx
|
||||
007d993a call 0x80e050 ; rel = A->GetRelation(B)
|
||||
007d993f dec eax ; je 0x78995a ; rel == 1 -> slot 8
|
||||
007d9942 dec eax ; je 0x789951 ; rel == 2 -> slot 0
|
||||
007d9945 dec eax ; jne 0x78999a ; rel != 3 -> skip
|
||||
007d9948 mov [ebp-0x5c],4 ; rel == 3 -> slot 4
|
||||
007d9961 lea ecx,[ebp-0x58] ; call 0x80e7b0 ; DiplomacyStats ctor on the stack
|
||||
007d9969 push esi ; lea eax,[ebp-0x58] ; push eax
|
||||
mov ecx,ebx ; call 0x8180e0 ; A->GetDipStat(&local, B)
|
||||
007d997c mov eax,[edi+0xc] ; Frame
|
||||
007d9986 mov WORD PTR [ebp+ecx*2-0x50],ax ; local.<slot> = (short)Frame
|
||||
007d998e push esi ; lea edx,[ebp-0x58] ; push edx
|
||||
mov ecx,ebx ; call 0x863950 ; A->SetDipStat(&local, B)
|
||||
```
|
||||
|
||||
The stamped field is at `local + 8 + slot*2`, because the ctor's `this` is `[ebp-0x58]` and the store's base
|
||||
is `[ebp-0x50]`. Against `objects/layouts.md`'s `Game::DiplomacyStats` (`+8 lastnap`, `+0x10 lastally`,
|
||||
`+0x18 lastcf`), that gives:
|
||||
|
||||
| `GetRelation` | slot | field |
|
||||
|---|---|---|
|
||||
| 1 | 8 | `lastcf` |
|
||||
| 2 | 0 | `lastnap` |
|
||||
| 3 | 4 | `lastally` |
|
||||
|
||||
### 1.2 `GetRelation` 0x0080e050 — 33 bytes, fully resolved
|
||||
|
||||
```c
|
||||
// 0x0080e050: thiscall, ret 4 -> tail-calls the cdecl 0x006d2050 with
|
||||
// (this->PlyrIdx(+0x28), &this->Alliances(+0x168), other->PlyrIdx(+0x28))
|
||||
int Relation(int myIdx, PlayerAlliances* a, int otherIdx) { // 0x006d2050, 58 bytes
|
||||
if (myIdx == otherIdx) return 3;
|
||||
uint32_t bit = 1u << otherIdx; // shl edx,cl -- x86 masks the count to 5 bits
|
||||
if (a->AL(+0x04) & bit) return 3;
|
||||
if (a->NA(+0x08) & bit) return 2;
|
||||
return (a->CF(+0x0c) & bit) ? 1 : 0;
|
||||
}
|
||||
```
|
||||
|
||||
**So the relation codes are `3 = ally (and self)`, `2 = NAP`, `1 = ceasefire`, `0 = war`.**
|
||||
`strategic-turn-internals.md` §5.2 has 1 and 3 the wrong way round; it labelled itself MEDIUM confidence on
|
||||
exactly this mapping, and it was the half that was wrong. The corrected mapping is independently confirmed by
|
||||
`combat-done-tail.md` §2A, which reads `Node::ResupplyAlliedFleets` as gated on `GetRelation == 3 (allied/self)`
|
||||
and `UpdateDiplomacyStatsFromCombat` as gated on `GetRelation < 1` (war).
|
||||
|
||||
Two further notes on this function, both places a reimplementation goes quietly wrong:
|
||||
|
||||
* The shift count is the **`PlyrIdx` field** (`ServerPlayer+0x28`), *not* the player's position in the player
|
||||
vector. This is the exact opposite of the alliance/shared-vision mask (`app/alliance.h` §1), which uses the
|
||||
vector position and never loads the index field. Both are in the same subsystem and they disagree. No save
|
||||
in the corpus separates them — `plyrIdx == vector position` on all eleven — so this is an
|
||||
instruction-stream reading with no behavioural evidence behind it.
|
||||
* `AL` is consulted **unconditionally**. It is *not* gated on `ALid != -1` the way the shared-vision mask's
|
||||
alliance term is.
|
||||
|
||||
### 1.3 The three helpers
|
||||
|
||||
**`DiplomacyStats::DiplomacyStats()` 0x0080e7b0** (55 B): vptr `0x00a21430`, everything zeroed, then
|
||||
`lastcf(+0x18) = lastnap(+8) = lastally(+0x10) = -1`. So a fresh entry is
|
||||
`{other 0, lastnap -1, lastnapbty 0, bknnap 0, btynap 0, lastally -1, lastallybty 0, bknally 0, btyally 0,
|
||||
lastcf -1, lastcfbty 0, bkncf 0, btycf 0, deadhome 0}`.
|
||||
|
||||
**`ServerPlayer::GetDipStat(out, other)` 0x008180e0** (294 B, `ret 8`): if `out == 0` return; re-initialise
|
||||
`*out` to those defaults; `out->other = other->+0x4` (the handle id — the wire's `PlayerID`); if `other == 0`
|
||||
return; then a **linear, first-match** scan of the `0x24`-stride vector at `ServerPlayer+0x230` for
|
||||
`entry.other == GetId(other)` and, on a hit, copy the entry's thirteen `int16` fields into `out`.
|
||||
|
||||
**`ServerPlayer::SetDipStat(src, other)` 0x00863950** (328 B, `ret 8`): if `src == 0 || other == 0` return;
|
||||
the same linear first-match scan; **on a miss, default-construct and `push_back`**, then set
|
||||
`back().other = other->+0x4`; finally copy `src`'s thirteen `int16` fields into the entry and re-write
|
||||
`other`. So a new entry is appended **at the end**, and the append order is the order in which pairs are
|
||||
first stamped — i.e. the `(i, j)` double-loop order, which is player-vector order.
|
||||
|
||||
The `0x24` stride is read twice, once in each helper, from `0x38e38e39 / sar 3` over
|
||||
`[p+0x234] - [p+0x230]`. That is `objects/layouts.md`'s `dipstats` vector at `ServerPlayer+0x230` and
|
||||
`sizeof(Game::DiplomacyStats) == 0x24`, agreeing with the serializer's own enumeration.
|
||||
|
||||
### 1.4 The value is `Frame`, not `ModCount`
|
||||
|
||||
`mov eax,[edi+0xc]` with `edi = S`, and lane Z's `StrategyServer::Write` tags `S+0x8` as `ModCount` and
|
||||
`S+0xc` as `Frame`. The save bytes settle it independently and are worth stating because they are the
|
||||
cheaper check: across `turn1/2/3-state.sav` `ModCount` runs 0 -> 12 -> 24 while `Frame` runs 1 -> 2 -> 3, and
|
||||
every stamped `last*` in the corpus is 2 or 3. **`strategic-turn-internals.md` §5.2's "set to the current
|
||||
`ModCount`" is wrong**; it is the turn.
|
||||
|
||||
`Frame` is already the *new* turn when this runs: `StrategyServer::BeginProcessTurn` 0x007d990a does
|
||||
`inc [esi+0xc]` and the host order is `BeginProcessTurn -> ApplyTurnCommands -> ... -> ProcessTurn`.
|
||||
|
||||
### 1.5 Pass B is the betrayal counter, and it needs a command stream
|
||||
|
||||
0x007899ca onward is a second double loop, gated on `GetRelation(A,B) <= 0` (`jg` skips), that indexes the
|
||||
three `vector<uint32>` arguments by the inner loop index — with a size check against `nPlayers` that
|
||||
substitutes a zero local when the vector is the wrong length. Those three vectors are the alliance-broken /
|
||||
NAP-broken / CF-broken masks the alliance diff in `ApplyTurnCommands` builds from the turn's `SNMUpdate`
|
||||
commands. **With no commands they are empty, the size check fails, the zero local is used, and pass B is a
|
||||
no-op.** It is not modelled: the standalone has no command stream, and a betrayal cannot occur without one.
|
||||
|
||||
---
|
||||
|
||||
## 2. What the save bytes say, and they say the same thing
|
||||
|
||||
`dipstats` across the reference family, every non-zero field shown:
|
||||
|
||||
| save | `Frame` | `ModCount` | P496 | P512 | P528 / P544 / P560 / P576 |
|
||||
|---|---|---|---|---|---|
|
||||
| turn1-state | 1 | 0 | — | — | — (all six vectors empty) |
|
||||
| turn2-state | 2 | 12 | `{other 512, lastally 2}` | `{other 496, lastally 2}` | three entries each, `lastnap 2`, `other` = the other three in vector order |
|
||||
| turn3-state | 3 | 24 | `lastally 3` | `lastally 3` | `lastnap 3` |
|
||||
|
||||
Every created entry carries `lastnap = -1, lastcf = -1` beside the stamped `lastally`, or `lastally = -1,
|
||||
lastcf = -1` beside the stamped `lastnap` — which is the 0x0080e7b0 constructor, byte for byte. The two
|
||||
`Singularity` players are allied to each other (relation 3); the four species-4 players are mutually
|
||||
non-aggressive (relation 2); the two real empires have no treaty with anyone and their vectors stay empty on
|
||||
all three turns. Nothing else in the vector ever moves in this corpus, so `lastnapbty`, `bkn*`, `bty*` and
|
||||
`deadhome` are **unexercised** and are hypotheses, per rule 6.
|
||||
|
||||
---
|
||||
|
||||
## 3. `Player.Status` — the writer, and a correction to the roadmap
|
||||
|
||||
`Status` is `ServerPlayer+0x164` (`objects/layouts.json` grades it `off_abs 356`). A whole-image scan for
|
||||
`C7 8x 64 01 00 00 imm32` — a `mov dword [reg+0x164], imm` — finds every immediate store to that
|
||||
displacement in the image. Three matter:
|
||||
|
||||
| VA | value | containing function |
|
||||
|---|---|---|
|
||||
| 0x007dcc8a | **1** | `ProcessTurn` +0x5ca, inside a `0x44`-stride loop (the encounter-member stride), gated on two bytes at `member+0xf9`/`+0xfa` |
|
||||
| 0x00821a6b | **4** | `FUN_00821a40`, 60 B |
|
||||
| 0x007ddd41 | **0** | `ResumePlaying` 0x007ddc90 +0xb1 |
|
||||
|
||||
`FUN_00821a40(this, playerId)`, `ret 4`, in full:
|
||||
|
||||
```
|
||||
p = HandleMap::Resolve(this + 0x80, playerId) ; sub ecx,0xffffff80 is add ecx,0x80
|
||||
if (!p) { Log(2, <0x00a2fb30>, playerId); return false; }
|
||||
p->Status(+0x164) = 4
|
||||
return true
|
||||
```
|
||||
|
||||
Its three callers, from the same byte scan: `EndTurn` 0x00783be0 (+0x70), `EndTurnForced` 0x00783d30
|
||||
(+0x7b), `OnPlayerEndTurn` 0x007d9af0 (+0x35). In `EndTurn` the id passed is `this->+0x148`, the client's own
|
||||
player id, and the call is immediately followed by `[this+0x15c] = 1` and `[this+0x4c8] = 1` and a
|
||||
`RaiseEvent(0x21)` — this is the **End Turn submission** path, which runs before the turn is processed.
|
||||
|
||||
**This corrects `backlog.md` item 6.** The roadmap has `Player.Status` blocked on "the writer between tail
|
||||
31 and the autosave — watchpoint". There is no such writer. `Status = 4` is set when a player submits an End
|
||||
Turn; `ProcessTurn`'s encounter loop can set 1; `ResumePlaying` sets 0 on load, which is exactly the
|
||||
"`Status` resetting 4 -> 0 on load" that `determinism-oracle.md` recorded. Item 6 does not need a VM
|
||||
watchpoint; it needs one predicate — *which players submit an End Turn* — and that is a question about the
|
||||
client/host controller layer, not about the turn.
|
||||
|
||||
What the corpus can say about that predicate, and it is not much: of the eleven saves, **only
|
||||
`turn2-state.sav` and `turn3-state.sav` carry a non-zero `Status` at all** (every other save was written
|
||||
through a load, and `ResumePlaying` had zeroed it). On those two, the four players with `Status = 4` are
|
||||
exactly the four with `Species != 4`, and the four with `Status = 0` are exactly the four species-4
|
||||
monster/neutral players. That is 8 observations on 2 saves of a 1-bit predicate, on a field that no other
|
||||
save exercises. Rule 20 applies. **Evaluated, reported, not written.**
|
||||
|
||||
The cheap way to settle it is not a watchpoint either: an entry probe on 0x00821a40 recording its `playerId`
|
||||
argument over one End Turn names the set directly, and a probe on `OnPlayerEndTurn` separates "the host ends
|
||||
the AI's turn" from "the AI player never ends a turn".
|
||||
|
||||
---
|
||||
|
||||
## 3A. The measurement
|
||||
|
||||
`sots-engine` host phase **H02 `StampTreatyTurns`** (`src/app/treaty.{h,cpp}`), run through
|
||||
`tools/standalone_report.py`. `closed` and `regressed` are separate columns and are never netted. The
|
||||
"before" column is `main` at `0f1c007` **rebuilt on the same host** — the checked-in `build-host` was stale
|
||||
and reported `closed 5` on the reference pair, which would have made this table look four times better than
|
||||
it is.
|
||||
|
||||
| pair | baseline | before | after | closed | regressed |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| `turn1-state -> turn2-state` (the reference End Turn) | 209 | 158 | **132** | 51 -> **77** | **0** |
|
||||
| `turn2-state -> turn3-state` (real End Turn) | 108 | 87 | **73** | 21 -> **35** | **0** |
|
||||
| `human-turn2-orders -> human-turn3-noderoute` | 375 | 311 | **299** | 64 -> **76** | **0** |
|
||||
| `zuul-turn15-orders -> zuul-turn16-noderoute` | 282 | 264 | **252** | 18 -> **30** | **0** |
|
||||
| `zuul-turn16-noderoute -> zuul-turn17-orders2` | 345 | 328 | **316** | 17 -> **29** | **0** |
|
||||
|
||||
**+26 and +14 on the two reference pairs, exactly the predicted numbers, and +12 on each of three pairs the
|
||||
model was never fitted to** — a different game, species 5, turns 2, 15 and 16. Twelve is precisely the number
|
||||
of ordered treaty pairs each of those saves holds. Total **76 leaves closed, 0 regressed**, 0 RNG words.
|
||||
|
||||
`zuul-turn17-orders2 -> zuul-turn23-fleet23` is a six-turn gap rather than a pair; it reports `closed 0,
|
||||
regressed 1` **both before and after**, so that one regression is not this phase's and is untouched by it.
|
||||
|
||||
The measurement was taken on the WSL host with a WSL-built `sots_turn`, and the `before` column was taken
|
||||
with the same tool on the same host from a freshly rebuilt `main`. `verify/results/standalone/report.txt`
|
||||
and `status.json` are **deliberately not committed by this lane**: lane C3 published its own numbers into
|
||||
those two files minutes before this run, and regenerating them from a branch that does not carry C3's work
|
||||
would silently replace a better result with a worse one. They are the integrator's to regenerate after the
|
||||
merge.
|
||||
|
||||
All eleven corpus saves run clean end to end. On ten of them the phase creates **zero** entries and only
|
||||
re-stamps, which is the check that matters: the entry set the rule derives is already the entry set the
|
||||
game wrote. Only `turn1-state.sav` creates any (14), and that is the save on which no turn has ever run.
|
||||
|
||||
**What this did not cover, stated as loudly as the divergences.** The cease-fire arm has never executed —
|
||||
no save in the corpus contains a cease-fire — so `Relation::CeaseFire` and the `lastcf` store are held by a
|
||||
unit test and by the instruction stream, and by nothing else. The betrayal counters (`lastnapbty`, `bkn*`,
|
||||
`bty*`) and `deadhome` are likewise untouched by any save; pass B is not modelled at all. Nine of the eleven
|
||||
saves have exactly one relation kind (NAP) and eight players, so the corpus tests one shape hard rather than
|
||||
many shapes at all. And `plyrIdx == vector position` on every save, so the index-vs-position reading of §1.2
|
||||
has no behavioural evidence behind it whatsoever.
|
||||
|
||||
---
|
||||
|
||||
## 4. Two names that should be in Ghidra
|
||||
|
||||
| VA | proposed name | evidence |
|
||||
|---|---|---|
|
||||
| 0x007898c0 | `StrategyServer::StampTreatyTurns` | §1.1 |
|
||||
| 0x006d2050 | `PlayerAlliances::Relation` | §1.2 |
|
||||
| 0x0080e050 | `ServerPlayer::GetRelation` | §1.2 |
|
||||
| 0x0080e7b0 | `Game::DiplomacyStats::DiplomacyStats` | §1.3 |
|
||||
| 0x008180e0 | `ServerPlayer::GetDipStat` | §1.3 |
|
||||
| 0x00863950 | `ServerPlayer::SetDipStat` | §1.3 |
|
||||
| 0x00821a40 | `StrategyServer::MarkPlayerTurnEnded` | §3 |
|
||||
|
||||
---
|
||||
|
||||
## 5. Why the answer was not in the tail
|
||||
|
||||
The lane was briefed on the post-combat tail and ranked its phases by leaves-closed-per-effort against the
|
||||
standalone's current 158-leaf residual on the reference pair. Every tail phase that moves a leaf in this
|
||||
corpus is blocked on something another lane owns, or on a blind spot:
|
||||
|
||||
| tail phase | leaves it would move (pair 1 / pair 2) | why not now |
|
||||
|---|---|---|
|
||||
| T24 maintenance + research bonus + `ShipRecs` | 9 / 5 | `Maint` moves 0 -> 500 -> 1000 in lockstep with one ship completing per turn, and every `ShipRecs` leaf is a **new** ship's record. Blocked on ship construction, which is lane B6's |
|
||||
| T31 `UpdateBankruptcyLimits` | 4 / 4 | already `Blocked` in the phase table, on `ComputeOutput`'s turn path (lane C3) |
|
||||
| T36 `FinalizeTurnRecords` | 24 / 8 | already `Blocked`, on the budget and ship construction |
|
||||
| T02 first contact, T30 comms mask | 0 / 0 | `HasEnc`/`HasDiscCl` and `ServerPlayer+0x198` move no leaf in this corpus |
|
||||
| T05 `UpdateDiplomacyStatsFromCombat` | 0 / 0 | writes `deadhome` and `bty*`; **no battle occurs on either pair** — every encounter result seen has `res->+0x4 != 0` (lane Z). A path no save exercises is a hypothesis |
|
||||
| T11 node-line decay | ? | draws RNG; the corpus turn cost is measured at 0 words from this phase, so no line expires |
|
||||
| T08 / T20 / T26 script hooks, T23 / T33 trade vtables | 8 / 2 (`SvSctOb`) | the `SVScriptObject` encounter state (`EncObj[*]/Hives`, `did`, `didc`, `ini`, `CDiff`) is the tail's tier-4 blind spot: eleven virtual slots, none identified |
|
||||
|
||||
The remaining large clusters — `TShn` (10/10) and `rcex` (6/6) — are lane E3's named residual with a
|
||||
watchpoint already specified, and `Events` (6/6) needs the whole turn's event inventory, not one phase.
|
||||
|
||||
So the tail's own ranked list is, right now, correct and unactionable: it is waiting on C3, B6 and a
|
||||
vtable sweep. The two items above are what a lane holding no VM and no data-file dependency can close today.
|
||||
60
ghidra/addresses.d/lane-t2.json
Normal file
60
ghidra/addresses.d/lane-t2.json
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
{
|
||||
"entries": [
|
||||
{
|
||||
"name": "StrategyServer_StampTreatyTurns",
|
||||
"addr": "0x007898c0",
|
||||
"convention": "thiscall",
|
||||
"prototype": "void (StrategyServer* S, vector<uint32>* allianceBroken, vector<uint32>* napBroken, vector<uint32>* cfBroken) // 700 B, ret 0xc. THE DIPLOMACY LEDGER'S PER-TURN STAMP, and the only writer of DiplomacyStats on a turn with no combat and no diplomatic command. Pass A (0x00789920..0x007899c4): over every ORDERED pair (A,B) of the S-frame player vector at S+0x54/+0x58, skipping A==B by POINTER, rel = A->GetRelation(B) 0x0080e050; rel 1 -> slot 8, rel 2 -> slot 0, rel 3 -> slot 4, else skip; then ctor a DiplomacyStats on the stack, GetDipStat(&local,B) 0x008180e0, store (int16)S->Frame(+0xc) at local+8+slot*2, SetDipStat(&local,B) 0x00863950. Field mapping: rel 3 -> lastally(+0x10), rel 2 -> lastnap(+8), rel 1 -> lastcf(+0x18). Pass B (0x007899ca..) is the betrayal counter and indexes the three broken-mask arguments by the inner loop index, substituting a zero local when a vector's length != nPlayers -- with no command stream it is a no-op. SOLE CALLER: ApplyTurnCommands 0x007b18b0 at 0x007b2461, so this runs BEFORE both turn drivers and AFTER BeginProcessTurn's frame bump",
|
||||
"status": "mapped",
|
||||
"source": "findings/subsystems/treaty-turn-stamp.md (lane T2 2026-09-08); closes 26 leaves on the reference pair and 14 on pair 2 in sots-engine, 0 regressed, and reproduces the ledger of 10 of 11 corpus saves exactly"
|
||||
},
|
||||
{
|
||||
"name": "PlayerAlliances_Relation",
|
||||
"addr": "0x006d2050",
|
||||
"convention": "cdecl",
|
||||
"prototype": "int (int myPlyrIdx, PlayerAlliances* a, int otherPlyrIdx) // 58 B. if (myPlyrIdx == otherPlyrIdx) return 3; bit = 1 << otherPlyrIdx (shl by cl, so masked to 5 bits); if (a->AL(+4) & bit) return 3; if (a->NA(+8) & bit) return 2; return (a->CF(+0xc) & bit) ? 1 : 0. THE RELATION CODES ARE 3 = ALLIED (and self), 2 = NON-AGGRESSION, 1 = CEASE-FIRE, 0 = WAR -- strategic-turn-internals.md section 5.2 had 1 and 3 the other way round. The bit is the INDEX FIELD, not the position in the player vector (the opposite of the shared-vision mask), and AL is tested with NO alliance-id guard",
|
||||
"status": "mapped",
|
||||
"source": "findings/subsystems/treaty-turn-stamp.md (lane T2 2026-09-08); agrees with combat-done-tail.md section 2A, which reads Node::ResupplyAlliedFleets as gated on GetRelation == 3 and UpdateDiplomacyStatsFromCombat as gated on GetRelation < 1"
|
||||
},
|
||||
{
|
||||
"name": "ServerPlayer_GetRelation",
|
||||
"addr": "0x0080e050",
|
||||
"convention": "thiscall",
|
||||
"prototype": "int (ServerPlayer* this, ServerPlayer* other) // 33 B, ret 4. A thin forwarder: tail-calls the cdecl PlayerAlliances_Relation 0x006d2050 with (this->PlyrIdx(+0x28), &this->Alliances(+0x168), other->PlyrIdx(+0x28)). 60+ call sites across the image",
|
||||
"status": "mapped",
|
||||
"source": "findings/subsystems/treaty-turn-stamp.md (lane T2 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "DiplomacyStats_ctor",
|
||||
"addr": "0x0080e7b0",
|
||||
"convention": "thiscall",
|
||||
"prototype": "DiplomacyStats* (DiplomacyStats* this) // 55 B. vptr = 0x00a21430; every field zeroed; then lastcf(+0x18) = lastnap(+8) = lastally(+0x10) = -1. So a fresh entry's three 'last in force' fields are -1, NOT 0, and every counter (lastnapbty/bkn*/bty*/deadhome) is 0. This is what distinguishes 'never' from 'on turn 0' in the ledger",
|
||||
"status": "mapped",
|
||||
"source": "findings/subsystems/treaty-turn-stamp.md (lane T2 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "ServerPlayer_GetDipStat",
|
||||
"addr": "0x008180e0",
|
||||
"convention": "thiscall",
|
||||
"prototype": "void (ServerPlayer* this, DiplomacyStats* out, ServerPlayer* other) // 294 B, ret 8. if (!out) return; re-initialise *out to the ctor's defaults IN PLACE (the vptr is not touched); out->other(+4) = other->+0x4 (the handle id, i.e. the wire's PlayerID); if (!other) return; then a LINEAR FIRST-MATCH scan of the 0x24-stride vector at this->dipstats(+0x230/+0x234) for entry.other == GetId(other) 0x0042bfb0, copying the entry's thirteen int16 fields (out+8..out+0x21) on a hit. Stride read as 0x38e38e39 / sar 3",
|
||||
"status": "mapped",
|
||||
"source": "findings/subsystems/treaty-turn-stamp.md (lane T2 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "ServerPlayer_SetDipStat",
|
||||
"addr": "0x00863950",
|
||||
"convention": "thiscall",
|
||||
"prototype": "void (ServerPlayer* this, const DiplomacyStats* src, ServerPlayer* other) // 328 B, ret 8. if (!src || !other) return; the same linear first-match scan; ON A MISS default-construct a DiplomacyStats on the stack and push_back it (0x0085bc40) so a NEW ENTRY IS APPENDED AT THE END, then back().other = other->+0x4; finally copy src's thirteen int16 fields into the entry and re-write other. The append order is therefore the order in which pairs are first stamped, which is player-vector order",
|
||||
"status": "mapped",
|
||||
"source": "findings/subsystems/treaty-turn-stamp.md (lane T2 2026-09-08)"
|
||||
},
|
||||
{
|
||||
"name": "StrategyServer_MarkPlayerTurnEnded",
|
||||
"addr": "0x00821a40",
|
||||
"convention": "thiscall",
|
||||
"prototype": "bool (StrategyServer* this, int playerId) // 60 B, ret 4. p = HandleMap::Resolve(this + 0x80, playerId) 0x008b9240; if (!p) { Log(2, <0x00a2fb30>, playerId); return false; } p->Status(+0x164) = 4; return true. THE ONLY WRITER OF Player.Status = 4 IN THE IMAGE. Three callers, all End Turn SUBMISSION paths that run before the turn is processed: EndTurn 0x00783be0 (+0x70, passes the client's own id at client+0x148), EndTurnForced 0x00783d30 (+0x7b), OnPlayerEndTurn 0x007d9af0 (+0x35). The other two immediate stores to +0x164 in the image are ProcessTurn +0x5ca (value 1, inside the 0x44-stride encounter-member loop) and ResumePlaying +0xb1 (value 0, the load-path normalisation determinism-oracle.md recorded as 'Status resets 4 -> 0 on load'). There is NO writer between tail phase 31 and the autosave; backlog.md item 6 looks in the wrong place",
|
||||
"status": "mapped",
|
||||
"source": "findings/subsystems/treaty-turn-stamp.md (lane T2 2026-09-08) section 3"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue