Compare commits
5 commits
3fe74bc6a5
...
2b7758123f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b7758123f | ||
|
|
e9dec36d77 | ||
|
|
b77a6116e2 | ||
|
|
cb1ea723e1 | ||
|
|
92ad44efb0 |
303
findings/control-flow/watchpoints-modcount-status.md
Normal file
|
|
@ -0,0 +1,303 @@
|
||||||
|
# Three questions, one armed run: `ModCount`, `Frame`, and the `Player.Status` regression
|
||||||
|
|
||||||
|
- **Type:** control-flow (live measurement)
|
||||||
|
- **Status:** **verified** — hardware data-write watchpoints on the running game, with a
|
||||||
|
byte-identical oracle control
|
||||||
|
- **Confidence:** high. Twenty-four consecutive `ModCount` values with no gap; every predicted
|
||||||
|
address hit to the byte.
|
||||||
|
- **Owner / date:** lane W2 · 2026-09-08
|
||||||
|
- **Instrument:** `src/shim/hooks/watchpoints.{h,cpp}`, build `w2watch-3512c9a-20260908T1900Z`,
|
||||||
|
config `shim.cfg.w2watch`
|
||||||
|
- **Tests:** `alliance-mask-and-modcount.md` §3 (lane A2's probe 1 and its four falsifiers),
|
||||||
|
`treaty-turn-stamp.md` §3 (lane T2), `turn-driver.md` §0.1 (lane T)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. The four results
|
||||||
|
|
||||||
|
1. **`ModCount` gets exactly 12 writes per End Turn on this save, and lane A2's prediction is
|
||||||
|
confirmed to the address.** Two turns measured, 12 and 12, values running 13→24 and 25→36 with
|
||||||
|
**no gap**. Ten of the twelve come from the command flush, one from `ProcessTurn`'s first
|
||||||
|
instruction, one from `OnAllCombatDone_Tail`. Zero from the abandon/chaos check. §2.
|
||||||
|
2. **The `ModCount` / `Frame` naming dispute is settled, and lane A2 was right.** `S+0x8` took 12
|
||||||
|
writes in the window; `S+0xc` took **exactly one**, from `BeginProcessTurn + 0x2a`, and its
|
||||||
|
value became the turn number. `addresses.json`'s `StrategyServer_off_ModCount` names the wrong
|
||||||
|
word and lane T's `StrategyServer_off_PhaseCounter` **is** `ModCount`. §3.
|
||||||
|
3. **The `Player.Status` regression has a writer, and it is not where lane T2 looked.**
|
||||||
|
`StrategyNetworkClient::OnMessage + 0xa15` writes `Status = 4` *after* `ProcessTurn` returns and
|
||||||
|
*before* the autosave, over the `1` the ProcessTurn tail had just written. T2's "there is NO
|
||||||
|
writer between tail phase 31 and the autosave" is falsified by watching it happen, twice. §4.
|
||||||
|
4. **The instrument is byte-neutral, and this was checked rather than assumed (rule 19).** With all
|
||||||
|
four watchpoints armed, one End Turn from `ref-turn2.sav` reproduced the determinism oracle
|
||||||
|
exactly: `(Autosave EndTurn).sav` `bb4fd9ac89f41e3b…`, `(Autosave).sav` `978041acd168b56e…`.
|
||||||
|
§1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The instrument, and its control
|
||||||
|
|
||||||
|
**One MinHook detour in the whole module.** Debug registers are per-thread, so the watchpoints have
|
||||||
|
to be armed from a point that runs on the turn thread and precedes the writes.
|
||||||
|
`StrategyServer::ApplyAllTurnCommands` `0x0078f6a0` is both — lane A2 read it as the End-Turn
|
||||||
|
command flush, called from `StrategyNetworkClient::OnMessage` immediately before that handler calls
|
||||||
|
`ProcessTurn`, and its `this` is the `S` frame — so `S+0x8` and `S+0xc` are one add away. The
|
||||||
|
detour is the register-transparent asm stub the M0 hook already uses; the watchpoints themselves
|
||||||
|
modify **no code at all**.
|
||||||
|
|
||||||
|
```
|
||||||
|
watch: arm hook StrategyServer::ApplyAllTurnCommands rva=0x0038f6a0 va=0120f6a0 create=MH_OK enable=MH_OK
|
||||||
|
watch: players vector @0e0b267c begin=0e163528 end=0e163548 count=8
|
||||||
|
watch: SELFTEST canary writes=1 traps=1 dr7=0xdddd0055 PASS
|
||||||
|
watch: slot 0 -> S+0x8 = 0x0e0b2630 slot 2 -> player[0]+0x164 Status = 0x0e0cc2d4
|
||||||
|
watch: slot 1 -> S+0xc = 0x0e0b2634 slot 3 -> player[1]+0x164 Status = 0x0e0c5d44
|
||||||
|
watch: ARMED on tid 5928 dr7=0xdddd0055, S=0e0b2628
|
||||||
|
```
|
||||||
|
|
||||||
|
**The self-test is not decoration.** Method rule 1 says a green verdict is not evidence, and "the
|
||||||
|
arm silently did nothing" looks exactly like "nothing writes this". So before any game address is
|
||||||
|
believed, DR3 is pointed at a word the shim owns, that word is written once, and the handler is
|
||||||
|
required to report exactly one trap. It did. `dr7 = 0xdddd0055` is all four slots enabled, R/W = 01
|
||||||
|
(data write), LEN = 11 (4 bytes).
|
||||||
|
|
||||||
|
**The control (rule 19).** Lane H's finding is that a MinHook detour can change the game's output,
|
||||||
|
so a run taken with a new detour installed proves nothing until the same run is taken without it.
|
||||||
|
This lane's control is stronger than a same-day A/B: the armed run **is** the oracle run. One End
|
||||||
|
Turn from `ref-turn2.sav`, watchpoints live, produced
|
||||||
|
|
||||||
|
| file | size | sha256 prefix | historical |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `(Autosave EndTurn).sav` | 66,732 | `bb4fd9ac89f41e3b` | **identical** |
|
||||||
|
| `(Autosave).sav` | 67,219 | `978041acd168b56e` | **identical** |
|
||||||
|
|
||||||
|
So the arming detour, four hardware watchpoints and ~36 exception deliveries changed **nothing** in
|
||||||
|
the turn. That is worth stating as its own small result: a data breakpoint is a trap taken after the
|
||||||
|
store retires, and unlike a code patch it has no relocation hazard — which is a point of contrast
|
||||||
|
with §2 of `tail-probes.md`, where a detour on a clean prologue boundary *did* perturb.
|
||||||
|
|
||||||
|
**What the instrument cannot see, stated up front.** Debug registers are per-thread and these were
|
||||||
|
armed on the turn thread (tid 5928) only. A write from another thread would be invisible. The
|
||||||
|
evidence that there was none is indirect but strong: the 24 recorded `ModCount` values are
|
||||||
|
**contiguous** — 13, 14, … 36, no gaps — so nothing incremented that word without being trapped.
|
||||||
|
|
||||||
|
## 2. `ModCount` — every writer, in order
|
||||||
|
|
||||||
|
Workload: `ref-turn2.sav` (turn 2, 8 players, `ModCount = 12`, `Frame = 2`), two End Turns. The
|
||||||
|
window below is the **second** one, which is fully covered — arming happens inside the first End
|
||||||
|
Turn, so the first window misses the three writes that precede the flush. Both windows agree on
|
||||||
|
everything they share.
|
||||||
|
|
||||||
|
Addresses are Ghidra VAs; the trap reports the instruction **after** the store, so a 3-byte `inc`
|
||||||
|
shows as site + 3.
|
||||||
|
|
||||||
|
| # | trap EIP | site | writer | value | return addresses |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | `0x0086c3e9` | `0x0086c3e6` | unresolved handler | 25 | `0x0088fce2` → `ApplyTurnCommandBatch+0x332` |
|
||||||
|
| 2-5 | `0x00821a87` | `0x00821a84` | unresolved handler, **×4** | 26-29 | `0x0088ffcb` → `ApplyTurnCommandBatch+0x61b` |
|
||||||
|
| 6 | `0x0084946e` | `0x0084946b` | unresolved handler | 30 | `0x008900a4` → `ApplyTurnCommandBatch+0x6f4` |
|
||||||
|
| 7 | `0x0088bf01` | `0x0088befe` | unresolved (near `DestroyFleet`) | 31 | `0x008902b4` → `ApplyTurnCommandBatch+0x904` |
|
||||||
|
| 8-9 | `0x0089046f` | `0x0089046c` | **inlined in `ApplyTurnCommandBatch`**, ×2 | 32-33 | `0x0078f6e6` → `ApplyAllTurnCommands+0x46` |
|
||||||
|
| 10 | `0x008657ad` | `0x008657aa` | **`StrategySim::MoveFleetCommand`** | 34 | `0x00890652` → `ApplyTurnCommandBatch+0xca2` |
|
||||||
|
| 11 | `0x007dc6f3` | `0x007dc6f0` | **`StrategyServer::ProcessTurn`, first instruction** | 35 | — |
|
||||||
|
| 12 | `0x007d92cd` | `0x007d92ca` | **`OnAllCombatDone_Tail + 0x2a`** | 36 | — |
|
||||||
|
|
||||||
|
Against lane A2's prediction, item by item:
|
||||||
|
|
||||||
|
- **"exactly 12 hits" — confirmed**, twice.
|
||||||
|
- **"two of them at `0x007dc6f0` and `0x007d92ca`" — confirmed, both, to the byte.** These are the
|
||||||
|
only two predicted *addresses* in A2's probe and both landed exactly.
|
||||||
|
- **"zero of them at `0x007b9e20`" — confirmed.** `Abdn` is false on every system of this save and
|
||||||
|
the abandon/chaos check's bump never fired. Note what kind of negative this is: rule 20's
|
||||||
|
distinction. This is "the site never trapped", which on a *watchpoint* is stronger than a word
|
||||||
|
count — but it still does not separate "the function was not called" from "it was called and the
|
||||||
|
gate held". A2's static reading says the gate is `sys->+0xc4`; that remains a static claim.
|
||||||
|
- **"the other 10 … with a return address in `ApplyTurnCommandBatch 0x0088f9b0` or its callers" —
|
||||||
|
confirmed.** Every one of the ten carries either an `ApplyTurnCommandBatch` return address or
|
||||||
|
`ApplyAllTurnCommands+0x46`, and the outer frame is `OnMessage+0x2c9` (`0x00784909`), which is
|
||||||
|
the return of the `ApplyAllTurnCommands` call A2 located at `0x00784904`. **A2's whole call chain
|
||||||
|
is confirmed live, address by address.**
|
||||||
|
- **A2's inlined site `0x0089046c` is confirmed** — the trap at `0x0089046f` is three bytes past it
|
||||||
|
— and it fires **twice** in this window, so the six inlined sites are not one-shot.
|
||||||
|
- **`0x008657aa` (`MoveFleetCommand`) confirmed** to the byte.
|
||||||
|
- **A2's falsifier (c) fires, and it is a refinement rather than a refutation.** A2 predicted all 12
|
||||||
|
would be "before `ProcessTurn` is entered". Ten are. The eleventh **is** `ProcessTurn`'s own first
|
||||||
|
instruction and the twelfth is *after* it. So the correct statement is: **all ten
|
||||||
|
command-application bumps precede `ProcessTurn`; the two driver bumps bracket the turn.** The
|
||||||
|
"queue is flushed first" reading survives intact.
|
||||||
|
- **Falsifier (b) fires and is the useful part.** Four of the ten handler EIPs are not attributable
|
||||||
|
to a named function — the nearest known symbol is between `0x581` and `0x10e9` away, which is not
|
||||||
|
containment. They are recorded as addresses in `ghidra/addresses.d/lane-w2.json` with status
|
||||||
|
`mapped` rather than dropped or guessed. **The one to disassemble first is `0x00821a84`: it fires
|
||||||
|
four times per turn, the most of any handler on this save**, and it sits immediately after
|
||||||
|
`StrategyServer::MarkPlayerTurnEnded` (`0x00821a40`, 60 bytes, ending `0x00821a7c`) — so it is
|
||||||
|
that function's neighbour, not that function.
|
||||||
|
- **Falsifier (d) does not fire**: no trap at `0x007850d5` / `0x0078514b` / `0x00785224`, so A2's
|
||||||
|
exclusion of `OnMessage`'s three `inc [reg+4]` sites was right. (`OnMessage` *does* appear in §4,
|
||||||
|
but for `Status`, not for `ModCount`.)
|
||||||
|
|
||||||
|
**12 is not a constant** (rule 20). It is this save's command count. The corpus disagrees with itself
|
||||||
|
usefully: `turn1-state → turn2-state → turn3-state` moves `ModCount` 0 → 12 → 24 (this game),
|
||||||
|
`human-turn2-orders.sav` sits at 25 on frame 2 (a different game), and the Zuul line runs
|
||||||
|
`63 @ f5 → 210 @ f15 → 412 @ f23`, i.e. ~15-25 per turn. The **structure** is what generalises: two
|
||||||
|
driver bumps plus one per applied command.
|
||||||
|
|
||||||
|
## 3. `ModCount` vs `Frame` — settled
|
||||||
|
|
||||||
|
Lane T (`StrategyServer_off_PhaseCounter`, `turn-driver.md` §0.1) and lane A2
|
||||||
|
(`StrategySim_off_ModCount`, `alliance-mask-and-modcount.md` §2.1) disagreed about which of `S+0x8`
|
||||||
|
and `S+0xc` is which, and `addresses.json`'s `StrategyServer_off_ModCount` sits on the third
|
||||||
|
opinion. One run separates them, because the two words behave completely differently:
|
||||||
|
|
||||||
|
| word | writes per End Turn | writer | value |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `S+0x8` | **12** | ten command handlers + `ProcessTurn` + `OnAllCombatDone_Tail` | monotonic, no relation to the turn |
|
||||||
|
| `S+0xc` | **1** | `BeginProcessTurn + 0x2a` (trap `0x007d990d`) | 3 → **4** on the turn that produced frame 4 |
|
||||||
|
|
||||||
|
A word written once per turn whose value is the turn number is `Frame`. A word written once per
|
||||||
|
applied command is a modification counter. **Lane A2 is right on both, and its correction of
|
||||||
|
`addresses.json` stands.** Lane T's `StrategyServer_off_PhaseCounter` — "nobody has named this one"
|
||||||
|
— is `ModCount`, and the note that "both advance once per turn in different functions" is wrong
|
||||||
|
about `S+0x8`: it advanced twelve times.
|
||||||
|
|
||||||
|
Lane A2 also predicted `BeginProcessTurn`'s bump at `0x007d990a` from the instruction stream. Trap
|
||||||
|
`0x007d990d`. Confirmed.
|
||||||
|
|
||||||
|
## 4. `Player.Status` — the complete ordered story
|
||||||
|
|
||||||
|
DR2 and DR3 watched `players[0]+0x164` and `players[1]+0x164` for the whole window. Every write of
|
||||||
|
`Status` on those two players, in order, for one End Turn:
|
||||||
|
|
||||||
|
| order | site | writer | player | value |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | `0x00821a75` (store ends here) | **`MarkPlayerTurnEnded + 0x35`**, called from `OnPlayerEndTurn + 0x35` (return `0x007d9b2a`) | **both**, one call each | **4** |
|
||||||
|
| 2 | — | `BeginProcessTurn` (Frame), `ApplyAllTurnCommands` (10 × ModCount), `ProcessTurn` entry | — | — |
|
||||||
|
| 3 | `0x007dcc8a` (trap `0x007dcc94`) | **`ProcessTurn + 0x5ca`** — the phase-31 site | **player 0 only** | **1** |
|
||||||
|
| 4 | `0x00785055` (store ends here) | **`StrategyNetworkClient::OnMessage + 0xa15`** | **player 0 only** | **4** |
|
||||||
|
| 5 | — | `OnAllCombatDone_Tail` (ModCount) | — | — |
|
||||||
|
| — | *(the post-turn autosave is written somewhere here)* | | | |
|
||||||
|
| 6 | `0x007ddd41` (trap `0x007ddd49`) | **`ResumePlaying + 0xb1`** | **both** | **0** |
|
||||||
|
|
||||||
|
**Step 4 is the answer to the open item.** The regression was stated as "the phase writes 1, the
|
||||||
|
file carries 4, a load resets to 0, and the writer between tail phase 31 and the autosave is
|
||||||
|
unaccounted". The writer is `OnMessage + 0xa15` and it is a **second, direct store site** — not a
|
||||||
|
call into `MarkPlayerTurnEnded`, because the trap EIP is inside `OnMessage`'s body, not inside
|
||||||
|
`0x00821a40`. This **corrects `treaty-turn-stamp.md` §3** on two points: `MarkPlayerTurnEnded` is
|
||||||
|
not "THE ONLY WRITER OF `Player.Status` = 4 IN THE IMAGE", and "there is NO writer between tail
|
||||||
|
phase 31 and the autosave; backlog item 6 looks in the wrong place" is wrong — the backlog item was
|
||||||
|
looking in exactly the right place, it just could not see the site by reading.
|
||||||
|
|
||||||
|
Two further refinements the watchpoint gives for free:
|
||||||
|
|
||||||
|
- **The phase-31 write of 1 hits the local player only**, not every player. T2 read the site
|
||||||
|
correctly (`ProcessTurn + 0x5ca`, value 1, inside a `0x44`-stride loop); the loop selects one
|
||||||
|
player on this save.
|
||||||
|
- **`ResumePlaying`'s zeroing runs inside the End Turn**, after the autosave, not only on load. The
|
||||||
|
saved files confirm the ordering independently: `(Autosave EndTurn).sav` (pre-turn) carries
|
||||||
|
`Status = 0` for all eight players, `(Autosave).sav` (post-turn) carries `4, 4, 4, 4, 0, 0, 0, 0`.
|
||||||
|
Player 0's value in that file is the `4` from step 4, having been `4 → 1 → 4` inside one turn;
|
||||||
|
players 1-3's is the untouched `4` from step 1; players 4-7 never receive one.
|
||||||
|
|
||||||
|
## 5. Coverage — what this run did NOT establish (rule 15)
|
||||||
|
|
||||||
|
- **One save, one game, two turns.** `ref-turn2.sav` only. Nothing here is a claim about a turn with
|
||||||
|
combat, with an abandoned system, or with more than two active players.
|
||||||
|
- **Two players watched, not eight.** DR2/DR3 covered `players[0]` and `players[1]`. Players 2-7's
|
||||||
|
`Status` was inferred from the save files, not watched. In particular, *who* calls
|
||||||
|
`MarkPlayerTurnEnded` for players 2 and 3 was not observed.
|
||||||
|
- **Four of the twelve `ModCount` writers are unnamed** (§2). The lane located them; it did not
|
||||||
|
identify them.
|
||||||
|
- **`0x007b9e20` never fired**, which is a real negative for this save and says nothing about a save
|
||||||
|
where `Abdn` is true. Rule 6: that path is still a hypothesis.
|
||||||
|
- **Per-thread blind spot** (§1). A `ModCount` write from a thread other than the turn thread would
|
||||||
|
be invisible; the contiguity of the values argues there was none, but only for these two turns.
|
||||||
|
- **The autosave's exact position** between steps 5 and 6 of §4 is inferred from the file contents,
|
||||||
|
not watched. A fifth watchpoint is not available (four DRs), and the `StrategyHost::Autosave` hook
|
||||||
|
already exists in the harness if anyone wants to bracket it precisely.
|
||||||
|
|
||||||
|
## 6. Files
|
||||||
|
|
||||||
|
- Hit log, both turns: `verify/results/shim/watchpoints/w2-modcount-status-2turns.txt`
|
||||||
|
- Instrument log incl. the self-test: `verify/results/shim/watchpoints/w2-shim-log-excerpt.txt`
|
||||||
|
- Addresses: `ghidra/addresses.d/lane-w2.json` (10 entries)
|
||||||
|
- Instrument: `sots-engine` `src/shim/hooks/watchpoints.{h,cpp}`, configs `shim.cfg.w2watch` /
|
||||||
|
`shim.cfg.w2control`
|
||||||
|
- Predictions, committed before the run: `sots-engine/docs/W2-predictions.md`
|
||||||
|
|
||||||
|
## 7. The instrument is reusable, and here is what it costs
|
||||||
|
|
||||||
|
`watch=on` in `shim.cfg` arms four 4-byte write watchpoints from
|
||||||
|
`StrategyServer::ApplyAllTurnCommands`. To watch something else, change the four addresses computed
|
||||||
|
in `WatchOnApplyAll` — anything reachable from `S` at that moment is one line. The run above cost
|
||||||
|
one build and about six minutes of VM time for two turns, and it answered three questions that had
|
||||||
|
consumed parts of four lanes of static reading. **Method rule 18, again: `what writes this?` is a
|
||||||
|
watchpoint.**
|
||||||
|
|
||||||
|
Two things a future user should keep — and then §8 for the queue items this lane did **not** reach,
|
||||||
|
each with the reason and the cheapest next step.
|
||||||
|
|
||||||
|
- **keep the canary self-test.** It is four lines and it is the difference between "nothing writes
|
||||||
|
this" and "the arm silently failed".
|
||||||
|
- **keep the oracle control.** Loading `ref-turn2.sav` and ending one turn costs nothing extra and
|
||||||
|
proves the run is measuring the un-instrumented game.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Not reached, and why — with the cheapest next step for each
|
||||||
|
|
||||||
|
Lane H's account of the trade workload was more useful than the attempt would have been. Same
|
||||||
|
discipline here.
|
||||||
|
|
||||||
|
### 8.1 `TShn` — the brief's address is wrong, and that is the finding
|
||||||
|
|
||||||
|
The lane brief said "watchpoint on the `player+0x274` map". **That is the wrong object.**
|
||||||
|
`player+0x274` is `observed_techs` (lane V, `eventlive-verification.md` §4.3). The `TShn` map is
|
||||||
|
`ServerSystem+0x274` — the `NVO` container — per `system-visibility-record.md` §8, whose own words
|
||||||
|
are "a watchpoint on `ServerSystem+0x274`'s map during one turn". Anyone who armed the briefed
|
||||||
|
address would have watched the tech-observation array and reported a confident nothing.
|
||||||
|
|
||||||
|
The real obstacle is structural, and it is why this was not just a fifth line in `WatchOnApplyAll`:
|
||||||
|
**a `std::map`'s nodes are heap-allocated, so `TShn` has no fixed address to arm before the node
|
||||||
|
exists.** Two ways round it, both cheap now that the module exists:
|
||||||
|
|
||||||
|
1. **Arm the map header** (`ServerSystem+0x274 … +0x27c`) rather than a leaf. That traps the
|
||||||
|
*insertion*, which is arguably the better question — E3's puzzle is which systems get an `NVO`
|
||||||
|
entry, and Spica-vs-Bismol is a question about the gate, not about the value.
|
||||||
|
2. **Arm the leaf directly**, by loading `turn1-state.sav`, walking the map for Spica's node once at
|
||||||
|
`ProcessTurn` entry, and pointing DR0 at that node's `TShn` word. This needs the node layout,
|
||||||
|
which `objects/` has.
|
||||||
|
|
||||||
|
Either is one arming function away. What is *not* solved is the arming point: `ApplyAllTurnCommands`
|
||||||
|
gives `S`, and `ServerSystem`s hang off `S+4+0x40`, so reaching a named system needs a name compare
|
||||||
|
in the arming code. About twenty lines.
|
||||||
|
|
||||||
|
### 8.2 `rcex` — closed without the VM
|
||||||
|
|
||||||
|
See `findings/subsystems/rcex-explained.md`. It is sixteen 4-bit per-player counters; nibble *p* is
|
||||||
|
set to 1 on the turn the system enters player *p*'s `AFlags` and ticked to 0 the next turn, 7/7
|
||||||
|
across two different games. The corpus had the answer; nobody had put the nibble index next to the
|
||||||
|
`AFlags` bit index. **The one part that still wants a watchpoint** is the site of the decrement — the
|
||||||
|
"`rcex` tick" named in `ServerSystem::ProcessTurn`'s body-order note — and `rcex` is at a fixed
|
||||||
|
offset in a fixed object, so it is the *easiest* target this module has.
|
||||||
|
|
||||||
|
### 8.3 The trade-route / spy workload — not attempted, deliberately
|
||||||
|
|
||||||
|
Lane H's reasons (`tail-probes.md` §3.2) all still hold: trade routes need trade-station
|
||||||
|
construction plus its tech, which from `ref-turn2` is tens of End Turns at 30-60 s each with a
|
||||||
|
Build/Research click path per turn, and the turn-23 Zuul save still has none; and **no lane has
|
||||||
|
identified which UI produces a spy-program entry**, so for spies there is still no click path to
|
||||||
|
write down. This lane spent its VM time on the multiplayer demonstration and the watchpoint run
|
||||||
|
because both had falsifiable predictions attached and this does not.
|
||||||
|
|
||||||
|
What is different now, and it is the thing that makes the next attempt materially cheaper:
|
||||||
|
**"confirm the workload took before spending a turn measuring it" is exactly what this module does**,
|
||||||
|
and both containers are one add from the arming point already in the code.
|
||||||
|
|
||||||
|
| container | address at `ApplyAllTurnCommands` entry (`this` = `S`) |
|
||||||
|
|---|---|
|
||||||
|
| trade-route vector (slot 15's body) | `*(void**)(S + 4 + 0x154)` → `tradeManager`, then `tradeManager + 0x3c` (`_Mylast` at `+0x40`) |
|
||||||
|
| spy-program vector (slot 14's body) | `*(void**)(S + 4 + 0x158)` → `spyManager`, then `spyManager + 0x10` (`_Mylast` at `+0x14`) |
|
||||||
|
|
||||||
|
Arm DR0/DR1 on the two `_Mylast` words and the game announces the moment either vector grows, with
|
||||||
|
the EIP and two return addresses of whoever grew it. For the spy vector that is not just workload
|
||||||
|
confirmation — **it is the answer to "which UI produces a spy-program entry", because the return
|
||||||
|
addresses name the caller.** That is a better use of the next VM slot than playing twenty turns
|
||||||
|
blind.
|
||||||
536
findings/subsystems/ai-stepping-and-passes.md
Normal file
|
|
@ -0,0 +1,536 @@
|
||||||
|
# The AI's stepping order and what the two passes are
|
||||||
|
|
||||||
|
Lane AI3, 2026-09-08. Program `sots` / "Sword of the Stars.exe", ImageBase 0x00400000, all addresses VAs.
|
||||||
|
Static only; VM140 is held by lane W2. **Nothing in this document has ever run under an instrument** —
|
||||||
|
every claim is read from the instruction stream, and §8 names the probe for each thing that needs one.
|
||||||
|
|
||||||
|
Continues `ai-task-system.md` (lane AI2) and `ai-turn-logic.md` (lane AI1). **This lane closes AI2's open
|
||||||
|
items 2, 3, 4, 5 and 6, and corrects two published claims.**
|
||||||
|
|
||||||
|
**Method.** Same as AI1's and AI2's: `objdump -b binary -m i386 -M intel` over the raw image, every body
|
||||||
|
swept to the **next function start** (rule 17), call targets from `dumps/functions.json`, jump tables and
|
||||||
|
data read out of the image at real instruction boundaries, indirect-site census from
|
||||||
|
`tools/vtable_map.py`. The decompiler was not used. The one new instrument is an unlimited-depth direct
|
||||||
|
call-graph built from an E8/E9 scan of all 41,089 function bodies — AI2's reachability table was cut at
|
||||||
|
depth 4 and that cut is the source of its wrong inference (§2).
|
||||||
|
|
||||||
|
Addresses: `ghidra/addresses.d/lane-ai3.json` (15 entries; `tools/gen_addresses.py` to a scratch path,
|
||||||
|
1,105 → **1,120**, no duplicate names).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. The two answers, up front
|
||||||
|
|
||||||
|
**1. The AI players are stepped in ascending server-player-index order — i.e. save player order — and each
|
||||||
|
player appears at most once per drain.** The enqueue site AI2 could not find is
|
||||||
|
`StrategyApp::OnClientEvent` **0x00838e10**, and the thing that fills it is
|
||||||
|
`StrategyServer::ResumePlaying` **0x007ddc90**, which walks the player vector at `StrategyServer+0x54`
|
||||||
|
**in index order**. The order is therefore predictable from a save alone, with no live measurement.
|
||||||
|
|
||||||
|
AI2's search missed it for a specific and generalisable reason: **`0x00b29f98` is not a pointer to the
|
||||||
|
`StrategyApp`, it *is* the `StrategyApp`.** MSVC folds the object base into the absolute address of the
|
||||||
|
member, so the enqueue writes the absolute `0x00b29fb4` (= app+0x1c) and never materialises
|
||||||
|
`0x00b29f98` at all. A scan for "functions that load the singleton" cannot see it. The address
|
||||||
|
`0x00b29fb4` has **exactly two references in the whole image**, both in the enqueue.
|
||||||
|
|
||||||
|
Compounding it: `0x00838e10` has **zero direct callers and no vtable slot**. Its address is stored once,
|
||||||
|
into `StrategyServer+0x170`, by `CreateGame` at `0x00889177`. That is lane B6's third blind spot, and it
|
||||||
|
is the second time this campaign has hit it.
|
||||||
|
|
||||||
|
**2. Pass 0 and pass 1 are not "plan then execute". `pass` is a numeric tier, and the pass-1 sweep is a
|
||||||
|
strict superset of the pass-0 sweep that additionally emits every order.**
|
||||||
|
|
||||||
|
The single instruction that settles it is at **0x006abb2a**, in
|
||||||
|
`FillCandidateToTierQuota 0x006abb00`:
|
||||||
|
|
||||||
|
```
|
||||||
|
have = f(slot->+0x10) + g(slot) + slot->+0x20 ; force already assigned to this candidate
|
||||||
|
want = (tier == 0) ? cand->+0x10 ; 0x006abb41
|
||||||
|
: (tier == 1) ? cand->+0x14 ; 0x006abb39
|
||||||
|
: 0
|
||||||
|
if (have >= want) return ; quota already met
|
||||||
|
```
|
||||||
|
|
||||||
|
Each candidate carries **two quota fields**, and `pass` picks which one is in force. The hub above it
|
||||||
|
runs `for (i = 0; i <= pass; ++i) gather(..., i, ...)`, so pass 1 re-runs tier 0 and then tops up to the
|
||||||
|
larger tier-1 quota.
|
||||||
|
|
||||||
|
And every order-emitting exit from that hub is gated `pass == 1`:
|
||||||
|
|
||||||
|
| exit | gate | reaches |
|
||||||
|
|---|---|---|
|
||||||
|
| `IssueRouteForFleets 0x006bbd50` | `if (pass != 1) return` @0x006bbd78 | `0x006b76a0` → `AI_IssueFleetTask` → **list 14** |
|
||||||
|
| `AssignFleetsAndIssueOrders 0x006c16c0` | `if (pass != 1) goto ret` @0x006c177e | **lists 14, 8, 10** |
|
||||||
|
| `RequestBuildForTask 0x006cea50` | `if (pass != 1) return` @0x006ceaac | **lists 3, 1** |
|
||||||
|
|
||||||
|
So the model is the classic two-phase allocation: **pass 0 claims fleets against every task's *minimum*
|
||||||
|
requirement, in priority order; pass 1 tops each task up to its *desired* requirement, again in priority
|
||||||
|
order, and issues the orders.** For `ModCount` that means **only the pass-1 sweep produces
|
||||||
|
`TurnCommands` elements** (§2.4 states the one case I could not close).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The stepping order, instruction by instruction
|
||||||
|
|
||||||
|
### 1.1 `StrategyApp` is a static object at `0x00b29f98`
|
||||||
|
|
||||||
|
Proved by `mov ecx,0xb29f98; call <method>` at `0x007842f6` and `0x007843a2` — an immediate, not a load —
|
||||||
|
and cross-checked at `0x007843ac`, where `Update` reads the absolutes `ds:0xb29fa8 - ds:0xb29fa4` exactly
|
||||||
|
where `SyncLocalClients 0x00815fd0` reads `this->+0x10 - this->+0xc`.
|
||||||
|
|
||||||
|
| offset | absolute | what | evidence |
|
||||||
|
|---|---|---|---|
|
||||||
|
| +0x0 | 0x00b29f98 | flag byte; bit 2 = "a `StrategyServer` exists" | `test byte [esi],4` @0x00815fd6, `or al,4` @0x008891a6 |
|
||||||
|
| +0x4 | 0x00b29f9c | `StrategyServer*` | `mov [esi+4],eax` @0x0088916c after `0x007d78d0` |
|
||||||
|
| +0xc/+0x10/+0x14 | 0x00b29fa4/a8/ac | `vector<StrategyClient*>` | walked by `RunPendingAITurns` and `SyncLocalClients` |
|
||||||
|
| **+0x1c/+0x20/+0x24** | **0x00b29fb4/b8/bc** | **`vector<int> pendingAITurns`** | §1.3 |
|
||||||
|
| +0x2c | 0x00b29fc4 | `AIProcessMinTime` (seconds, float) | AI2 §7 |
|
||||||
|
|
||||||
|
### 1.2 `StrategyServer::ResumePlaying 0x007ddc90` — the order
|
||||||
|
|
||||||
|
```
|
||||||
|
if (0x0080f4d0(&this->+0x4)) return
|
||||||
|
if (++this->+0x168 == 1 && this->+0x1b4) { obs->vt[4](2,0); obs->vt[7](); }
|
||||||
|
0x007dd230(&this->+0x174, 0) ; clear
|
||||||
|
0x007dd230(&this->+0x174, playerCount) ; resize
|
||||||
|
for (i = 0; i < playerCount; ++i) ; players[] at this->+0x54..+0x58, INDEX ORDER
|
||||||
|
if (players[i]->Elim == 0) players[i]->Status = 0 ; +0xf8, +0x164
|
||||||
|
for (i = 0; i < playerCount; ++i) ; SECOND walk, INDEX ORDER
|
||||||
|
p = players[i]
|
||||||
|
if (p->Status != 0) continue
|
||||||
|
ev = { vptr = 0x00a23bb8 } ; on the stack
|
||||||
|
cb = this->+0x170
|
||||||
|
if (!cb) Log("...") else cb(p->+0x4 /*netId*/, 0x26, &ev)
|
||||||
|
```
|
||||||
|
|
||||||
|
`+0xf8` is `Elim` and `+0x164` is `Status` — both **save-visible** `ServerPlayer` fields
|
||||||
|
(`findings/objects/struct-recovery.md`, rows `0xf8 | -0x2a8 | bool | Elim` and
|
||||||
|
`0x164 | -0x23c | int | Status`). So:
|
||||||
|
|
||||||
|
- **every non-eliminated player has `Status` reset to 0 and then receives `SEResumePlaying`, in player-array
|
||||||
|
order**;
|
||||||
|
- an eliminated player receives it only if its `Status` already happened to be 0.
|
||||||
|
|
||||||
|
**Cross-lane note (rule 18's open list).** `method-rules.md` names *"the `Player.Status` writer between
|
||||||
|
tail phase 31 and the autosave"* as an open watchpoint question. `0x007ddd3f` **is** a `Player.Status`
|
||||||
|
writer — `players[i]->Status = 0` for every live player. It fires at the *resume* boundary, which per AI1
|
||||||
|
is **after** the End-Turn autosave, so it is probably not the writer that item is chasing; recording it so
|
||||||
|
the next lane to take that watchpoint knows which hit is this one.
|
||||||
|
|
||||||
|
### 1.3 `StrategyApp::OnClientEvent 0x00838e10` — the enqueue
|
||||||
|
|
||||||
|
```
|
||||||
|
if (*(void**)0x00b29f9c == 0) return ; no StrategyServer
|
||||||
|
if (netId == 0) return
|
||||||
|
find client in [0x00b29fa4 .. 0x00b29fa8) with client->+0x148 == netId ; else return
|
||||||
|
p = client->+0x150
|
||||||
|
if (eventId == 0x26 && p->+0xf9 != 0 && p->+0xfa == 0) {
|
||||||
|
if (!0x00438fe0(&pending /*0x00b29fb4*/, &netId)) ; already queued?
|
||||||
|
0x0059f1a0(&pending, &netId) ; vector<int>::push_back
|
||||||
|
return
|
||||||
|
}
|
||||||
|
StrategyClient::RaiseEvent(client, eventId, ev) ; 0x00783ee0, synchronous
|
||||||
|
```
|
||||||
|
|
||||||
|
Three consequences that matter:
|
||||||
|
|
||||||
|
1. **`SEResumePlaying` for an AI player is the only deferred event in the game.** Every other event, and
|
||||||
|
the same event for a human player, is delivered inline from the server's own call.
|
||||||
|
2. **The queue is deduplicated.** `0x00438fe0` returns true when the id is already present and the push is
|
||||||
|
skipped, so a player can appear at most once per drain.
|
||||||
|
3. The AI test is **two bytes on the player**, `+0xf9 != 0 && +0xfa == 0`.
|
||||||
|
|
||||||
|
### 1.4 The AI-player predicate is on two bytes the save does not carry
|
||||||
|
|
||||||
|
`struct-recovery.md`'s `ServerPlayer` table runs `0xf8 Elim`, then jumps to `0xfb NPC`, `0xfc RebAI`,
|
||||||
|
`0xfd ReqCL`, `0xfe AIBn`, `0xff CnTrd`, `0x100 CnRad` … — a packed run of bools. **`+0xf9` and `+0xfa`
|
||||||
|
are the two holes in that run: in-memory bytes with no serialised counterpart.**
|
||||||
|
|
||||||
|
That is a real gap for Rung B. Whether a given player's turn is run by the AI is decided on state that is
|
||||||
|
**not in the save**, so it must be set at load or game-setup time from something that is (`AIBn`, `NPC`,
|
||||||
|
`RebAI`, or a `GameOptions` player-type list). **I did not find the writer.** An image-wide scan for the
|
||||||
|
`+0xf9`/`+0xfa` displacements returns 83 and 82 sites across many unrelated classes, and I did not filter
|
||||||
|
it to `ServerPlayer` receivers. This is the one thing in §1 I would hand to a watchpoint first (§8).
|
||||||
|
|
||||||
|
### 1.5 `RunPendingAITurns` re-reads its bounds every iteration
|
||||||
|
|
||||||
|
AI2 read the drain correctly. One detail worth adding, because a reimplementation will get it wrong:
|
||||||
|
`0x00838d16`–`0x00838d2a` **re-loads both `+0x1c` and `+0x20` on every iteration**, so the vector may
|
||||||
|
legally grow (and reallocate) while it is being walked, and any player enqueued *by* an AI turn is picked
|
||||||
|
up in the same drain. Whether that ever happens is unmeasured.
|
||||||
|
|
||||||
|
### 1.6 What this means for `ModCount`
|
||||||
|
|
||||||
|
The stepping order is **save player order**, filtered to live players, filtered to AI players, each once.
|
||||||
|
Combined with §2, the per-turn sequence of `TurnCommands` writes is:
|
||||||
|
|
||||||
|
> for each live AI player in save order: pass 0 (claims only, no writes) then pass 1 (writes), with tasks
|
||||||
|
> visited in AI2's stable descending priority order within each pass.
|
||||||
|
|
||||||
|
That is a complete, offline-computable ordering claim. It is the piece that was missing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The two passes
|
||||||
|
|
||||||
|
### 2.1 The dispatch, re-verified
|
||||||
|
|
||||||
|
`RunTaskList 0x006b3320(agent, &list, pass, &claims)` — `__cdecl`, four stack args. At `0x006b3478`:
|
||||||
|
|
||||||
|
```
|
||||||
|
eax = [ebp+0x10] ; pass
|
||||||
|
[edi+0x130] += 4 ; push the task onto the call stack at agent->+0x12c
|
||||||
|
edx = task->vt[5] ; [*task + 0x14]
|
||||||
|
push eax ; push agent ; ecx = task
|
||||||
|
call edx ; Execute(agent, pass), __thiscall, ret 8
|
||||||
|
if (back(agent->+0x12c) == task) pop
|
||||||
|
```
|
||||||
|
|
||||||
|
So `pass` is the second stack argument of `Execute`, at `[ebp+0xc]` in a standard frame. **All 30 distinct
|
||||||
|
`Execute` bodies read it.** (31 classes, 30 bodies: `AITColonize`/`AITColonizeGoal`,
|
||||||
|
`AITInvade`/`AITInvadeGoal`, `AITEscortGateInvade`/`AITEscortGateInvadeGoal` and
|
||||||
|
`AITRespondAttackSystem`/`AITRespondDefendSystem` share bodies.)
|
||||||
|
|
||||||
|
### 2.2 `pass` is a tier index, and the tier selects a quota
|
||||||
|
|
||||||
|
`AcquireFleetsForTask 0x006ceef0` is the hub every fleet-shaped task reaches. Its real span is **992
|
||||||
|
bytes** to the next function start; Ghidra's size is short again (rule 17, now **seven** functions across
|
||||||
|
seven lanes). It has three blocks, and each is the same shape:
|
||||||
|
|
||||||
|
```
|
||||||
|
for (i = 0; i <= pass; ++i) ; 0x006cef6a / 0x006cf039 / 0x006cf0fe -- `jl` guard then `jle` back-edge
|
||||||
|
gather(agent, threshold, target, candidates, i, &gathered)
|
||||||
|
if (0x00698960(candidates, ..., &gathered)) ; is the requirement met?
|
||||||
|
commit(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
Block A gathers via `GatherFleetsForTier 0x006abf80`, which walks the 0x20-stride candidate vector and
|
||||||
|
calls `FillCandidateToTierQuota 0x006abb00` per candidate with the tier. That function's prologue is the
|
||||||
|
definition quoted in §0: **tier 0 ⇒ the quota at `cand->+0x10`, tier 1 ⇒ the quota at `cand->+0x14`,
|
||||||
|
anything else ⇒ 0.** It is a compiler-generated switch (`sub ecx,0 / je / dec / jne`), not an `if`.
|
||||||
|
|
||||||
|
So the two passes are **two force requirements per candidate**, a minimum and a desired, filled in two
|
||||||
|
priority-ordered sweeps of the whole task list.
|
||||||
|
|
||||||
|
### 2.3 Pass 0 writes nothing
|
||||||
|
|
||||||
|
Three independent readings agree:
|
||||||
|
|
||||||
|
1. **The two emitters are pass-1 gated.** `IssueRouteForFleets 0x006bbd50` and
|
||||||
|
`AssignFleetsAndIssueOrders 0x006c16c0` both open with the MSVC `sub eax,0 / je L / dec eax / jne L`
|
||||||
|
shape on `pass`, taking the working arm only when `pass == 1`. `RequestBuildForTask 0x006cea50` does
|
||||||
|
the same.
|
||||||
|
2. **The hub returns an empty fleet list on pass 0.** The result vector lives at `[ebp-0x3c]`; I
|
||||||
|
enumerated *every* `lea` of that slot in the 992-byte body — five sites — and only two of them pass it
|
||||||
|
to a function that can write it, namely `0x006bbd50` (twice) and `0x006c16c0` (once). Both are pass-1
|
||||||
|
only. So on pass 0 the copy-out at `0x006cf24d` copies an empty vector, and every caller's
|
||||||
|
"for each fleet returned, issue the order" loop runs zero times.
|
||||||
|
3. **No gather or commit helper reaches an order method.** Unlimited-depth direct closures:
|
||||||
|
`0x006c3e50` (commit) reaches 16 functions and no order method; `0x006cb310` 268 and none; `0x006b7c90`
|
||||||
|
139 and none; `0x006abf80` 40 and none; `0x00698960` 1 and none. Only `0x006c16c0`, `0x006cea50` and
|
||||||
|
`0x006bbd50` — the three pass-1 gates — carry an order edge.
|
||||||
|
|
||||||
|
The two task-level bodies that gate on `pass` themselves corroborate:
|
||||||
|
|
||||||
|
- **`AITAdvanceIdleShips::Execute 0x0068f230`** is `if (pass != 1) return;` at `0x0068f25a` — its entire
|
||||||
|
body is pass-1 only. It has **priority 0**, so it is always the very last task in the list. The AI
|
||||||
|
sweeps up whatever is still idle only after every other task has taken both its minimum and its desired
|
||||||
|
force. That is exactly what the two-tier model predicts.
|
||||||
|
- **`AITNodeBore::Execute 0x0068e590`** does its setup (`0x00685810`) and its finaliser (`0x0068e090`)
|
||||||
|
only when `pass == 0`, and forwards `pass` to `0x0068a520` in both.
|
||||||
|
|
||||||
|
### 2.4 The one exception I could not close
|
||||||
|
|
||||||
|
`AITRaid::Execute 0x0068e670` reaches **list 16** at depth 1: at `0x0068e89e` it calls `0x006b76a0` and
|
||||||
|
then, at `0x0068e8b8`, loops `ClientOrder 0x007635f0(client, fleetId, 1)` over the returned fleets.
|
||||||
|
**Neither the call nor the loop has a `pass` guard of its own.** It fires iff the fleet vector at
|
||||||
|
`[ebp-0x28]` is non-empty, and that vector is downstream of the same hub — so §2.3's argument says it is
|
||||||
|
empty on pass 0, but I did not trace `[ebp-0x28]` to closure. **Treat "AITRaid emits nothing on pass 0"
|
||||||
|
as inferred, not verified.** It is one entry probe on `0x007635f0` (§8).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The `.data` invade tunables have no loader — they are constants (AI2 §10.6, closed)
|
||||||
|
|
||||||
|
`0x00a1795c` and `0x00a17960` each have **exactly one 4-byte reference in the entire image**, and both are
|
||||||
|
the `mov eax, ds:[imm32]` inside their own `GetPriority` override. There is no writer anywhere. Their
|
||||||
|
values are in the PE image:
|
||||||
|
|
||||||
|
| address | value | override | table priority | effect |
|
||||||
|
|---|---:|---|---:|---|
|
||||||
|
| `0x00a1795c` | **650** | `AITInvade::GetPriority` 0x00683670 | 500 | flag raises it to 650 |
|
||||||
|
| `0x00a17960` | **750** | `AITEscortGateInvade::GetPriority` 0x006835e0 | 400 | flag raises it to 750 |
|
||||||
|
|
||||||
|
They sit inside a run of unrelated statics (floats 0.5/0.75/0.9 and pointer tables into the weapon-name
|
||||||
|
block at `0x00a17618`), i.e. one translation unit's `.data`, packed by the linker. There is no CSV path
|
||||||
|
and nothing to find. Lane N's pop-type table and lane E1's difficulty table were *built in code from
|
||||||
|
`.rdata` literals*; this is the simpler shape one step further down — **no construction at all**.
|
||||||
|
|
||||||
|
### 3.1 Correction to `ai-task-system.md` §3: the flag polarity is inverted
|
||||||
|
|
||||||
|
AI2 published `if (!(this->+0x4 & 1)) return *(int*)0x00a1795c`. The instruction stream is:
|
||||||
|
|
||||||
|
```
|
||||||
|
00683670 movzx eax, byte [ecx+4]
|
||||||
|
00683674 not al
|
||||||
|
00683676 test al, 1
|
||||||
|
00683678 je 0x0068367f ; ZF set <=> bit0 of ~b == 0 <=> bit0 of b == 1
|
||||||
|
0068367a jmp 0x00694220 ; bit0 CLEAR -> the default table lookup
|
||||||
|
0068367f mov eax, ds:0xa1795c ; bit0 SET -> the tunable
|
||||||
|
```
|
||||||
|
|
||||||
|
So the tunable applies when **bit 0 of `this->+0x4` is SET**, the opposite of the published claim. This
|
||||||
|
matters because it flips the meaning of the flag — AI2 called it "not yet committed", and on the
|
||||||
|
instruction stream the tuned, *higher* priority is the flag-set state, not the flag-clear state. I have
|
||||||
|
**not** identified what bit 0 of `+0x4` is; note that `AITInvade::Execute` maintains two different bytes
|
||||||
|
at `+0x38` and `+0x39` that look far more like "committed" than `+0x4` does, so "committed" should be
|
||||||
|
treated as an unsupported name, not just an inverted one.
|
||||||
|
|
||||||
|
Also: the default arm is not the table directly. `0x00694220` is a shared 17-byte thunk,
|
||||||
|
`return AITask_PriorityForType(this->vt[1]())`, which then calls AI2's 33-arm table at `0x00691f00`. The
|
||||||
|
table itself stands unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Slots 11, 12 and 13 (AI2 §10.2)
|
||||||
|
|
||||||
|
### 4.1 Slot 12 — **named**: preemption permission
|
||||||
|
|
||||||
|
`IsClaimedByAnotherTask 0x006a8d20(agent; void* obj)` is the only consumer, and it is called from the
|
||||||
|
per-fleet filter inside `FillCandidateToTierQuota` at `0x006abc71` with `test al,al; jne <skip candidate>`
|
||||||
|
— so **true means "reject this candidate"**.
|
||||||
|
|
||||||
|
```
|
||||||
|
key = obj ? obj->+4 : 0
|
||||||
|
owner = lookup(key) in agent->+0x2e8..+0x2ec ; 8-byte pairs {IAITask* owner, int objectId}
|
||||||
|
if (not found here and not in the 4-byte set agent->+0x2d8..+0x2dc) return false ; free
|
||||||
|
cur = back(agent->+0x12c) ; the task call stack RunTaskList maintains
|
||||||
|
if (stack empty || !cur) return true
|
||||||
|
if (!cur->vt[12]()) return true ; 0x006a8db3
|
||||||
|
if (!owner) return false
|
||||||
|
if (cur->GetTypeId() == owner->GetTypeId()) return true
|
||||||
|
return !(cur->GetPriority() > owner->GetPriority())
|
||||||
|
```
|
||||||
|
|
||||||
|
**Slot 12 is "this task may take an object already claimed by a strictly lower-priority task of a
|
||||||
|
different type".** Default `false` (26 classes ⇒ any claimed object is off limits); five classes set it.
|
||||||
|
Because the list is processed in descending priority, the owner is normally the *higher*-priority task, so
|
||||||
|
the steal branch should almost never fire — which is a falsifiable prediction (§7, P3).
|
||||||
|
|
||||||
|
This also names `agent->+0x2e8` positively: it is the **claim registry**, a vector of
|
||||||
|
`{IAITask*, objectId}` pairs. `RunTaskList` erases the task's entries from it before calling `Execute`,
|
||||||
|
which is consistent.
|
||||||
|
|
||||||
|
### 4.2 Slot 13 — **consumer found**, semantics partially read
|
||||||
|
|
||||||
|
`RangePenaltyForTask 0x00696620` is the only slot-13 dispatch I found (`call [eax+0x34]` at `0x00696630`,
|
||||||
|
on the `this` receiver):
|
||||||
|
|
||||||
|
```
|
||||||
|
budget = this ? this->vt[13]() : 15
|
||||||
|
n = max(1, agent->+0x10->+0x8 - 0x0080da80(player) + 1)
|
||||||
|
if (n < budget) return 0
|
||||||
|
switch (player->Species) ; byte index @0x006966a8 = [0,0,0,0,2,1,0]
|
||||||
|
; jump table @0x0069669c = [0x69668a, 0x696693, 0x69668a]
|
||||||
|
species 5 (Zuul) -> return 0
|
||||||
|
species 0,1,2,3,4,6 and >6 -> return 1000000
|
||||||
|
```
|
||||||
|
|
||||||
|
So slot 13 is a **range/hop budget**, compared against a count, with a prohibitive 1,000,000 penalty past
|
||||||
|
it. Its default is 15; `AITDefendColonyIncoming` and `AITDefendGateIncoming` return `INT_MAX`, so they
|
||||||
|
never take the penalty — an incoming attack is answered at any distance.
|
||||||
|
|
||||||
|
**And the Zuul are exempt.** That is a **fourth independent cross-check on AI2's species reading**, after
|
||||||
|
Hiver-only gates in arm 1, Zuul-only node-bore in arm 5, and NPC building nothing in arm 4. Four
|
||||||
|
coincidences is not a coincidence.
|
||||||
|
|
||||||
|
I have not identified `agent->+0x10->+0x8` or `0x0080da80`, so I am not naming the units. `n` is a small
|
||||||
|
count that grows with something; "hops" and "turns" both fit and I cannot separate them statically.
|
||||||
|
|
||||||
|
### 4.3 Slot 11 — consumer found, semantics not read
|
||||||
|
|
||||||
|
Dispatched at **`0x006cf10e`**, inside `AcquireFleetsForTask`'s third block: `eax = task ? task->vt[11]()
|
||||||
|
: 1`, and `eax` is then pushed as an argument to `0x006cb310`, the block-C gatherer. Its default is `true`
|
||||||
|
and only `AITInvade`/`AITInvadeGoal` override it, returning `this->+0x39`.
|
||||||
|
|
||||||
|
`AITInvade::Execute 0x0068d7a0` computes that byte at `0x0068d80f`–`0x0068d82a`:
|
||||||
|
|
||||||
|
```
|
||||||
|
this->+0x39 = (0x006a6380(agent, this->+0xc) < 2 * 0x006a6260(agent, this->+0xc))
|
||||||
|
```
|
||||||
|
|
||||||
|
i.e. a comparison of two per-target quantities with a factor of two — a "do I have less than twice X?"
|
||||||
|
ratio test. So slot 11 gates how block C gathers, and for an invade it is a strength ratio against the
|
||||||
|
target. **Not named.** `0x006a6260` and `0x006a6380` were not read.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. The nine "no order method" tasks are **not** planners — AI2 §4.2 corrected
|
||||||
|
|
||||||
|
AI2 wrote that nine task classes have no order method within depth 4 and inferred, explicitly flagged as
|
||||||
|
unverified, that they are "goal tasks whose job is to spawn sub-tasks onto the list, not to emit orders".
|
||||||
|
|
||||||
|
**That is wrong, and the cause is the depth-4 cut.** All nine reach an order method by *direct* calls
|
||||||
|
only, at depths 4 to 9. Unlimited-depth direct closure, one concrete path each:
|
||||||
|
|
||||||
|
| task | order | path |
|
||||||
|
|---|---|---|
|
||||||
|
| `AITColonize` / `AITColonizeGoal` | **list 7 (colonize)** @4 | `0068b400 → 0068b280 → 006930f0 → 00578ff0 → 00769640` |
|
||||||
|
| `AITEscortGateInvade` / `…Goal` | **list 7** @4 | `0068c7c0 → 0068c5d0 → 00693080 → 005790b0 → 00769640` |
|
||||||
|
| `AITInvade` / `AITInvadeGoal` | **list 14** @5 | `0068d7a0 → 0068d460 → 006ceb80 → 006c16c0 → 006987e0 → 007634d0` |
|
||||||
|
| `AITNodeBore` | **list 14** @5 | `0068e590 → 0068a520 → 006ceef0 → 006c16c0 → 006987e0 → 007634d0` |
|
||||||
|
| `AITBuildPoliceShips` | **list 3 (build)** @6 | `00690380 → 006ce460 → 006ce360 → 006ce190 → 006bd790 → 006b3bc0 → 00762fd0` |
|
||||||
|
| `AITBuildDeepScanShips` | **list 3** @6 | `006901a0 → …` (identical) |
|
||||||
|
|
||||||
|
`AITColonize` emitting the **colonize** order and `AITBuildPoliceShips` emitting a **build** order is
|
||||||
|
exactly what those names promise. There is no planner tier.
|
||||||
|
|
||||||
|
What the six shared/thin bodies actually are is **forwarders into shared parameterised workers**:
|
||||||
|
|
||||||
|
| body | forwards to | shape |
|
||||||
|
|---|---|---|
|
||||||
|
| `0x0068b400` (Colonize, ColonizeGoal) | `0x0068b280` | 48 B; `worker(ecx = agent, this, pass, this->+0x8, &this->+0x20, &this->+0x10)`, plus `edi = this->+0xc` as an **implicit register argument** |
|
||||||
|
| `0x0068c7c0` (EscortGateInvade, …Goal) | `0x0068c5d0` | 80 B; `__fastcall(ecx = this->+0xc, edx = agent)` + 8 stack args |
|
||||||
|
| `0x0068d7a0` (Invade, InvadeGoal) | `0x0068d460` | 160 B; 11 args, then maintains `this->+0x38`/`+0x39` |
|
||||||
|
|
||||||
|
The `+0xc`-in-`edi` convention in the first of those is worth flagging on its own: it is a
|
||||||
|
whole-program-optimised custom calling convention, invisible to a decompiler prototype, and a
|
||||||
|
reimplementation that only ports the stack arguments will silently pass garbage.
|
||||||
|
|
||||||
|
**What the "Goal" suffix actually distinguishes is not behaviour — the paired classes share an identical
|
||||||
|
`Execute` — it is the two `GetTarget` slots.** AI2's slot 2/3 body census already showed the variants
|
||||||
|
return different members (`+0x8`/`+0xc` vs `+0xc`/`+0x10`). So a Goal task is the same task pointed at a
|
||||||
|
different pair of fields, not a different kind of thing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. The reachability bound, honestly (AI2 §10.1)
|
||||||
|
|
||||||
|
AI2's table was a depth-4 direct closure. Mine is an unlimited-depth direct closure over an E8/E9 scan of
|
||||||
|
all 41,089 bodies. **It is still a lower bound**, and here is the size of the gap.
|
||||||
|
|
||||||
|
The union of the unlimited direct closures of all 31 `Execute` bodies is **1,534 functions**, 358 of them
|
||||||
|
in the AI band. Inside that set, `tools/vtable_map.py` finds **940 indirect call sites**:
|
||||||
|
|
||||||
|
| kind | count | reachability risk |
|
||||||
|
|---|---:|---|
|
||||||
|
| `call-abs` (import / CRT thunks) | 598 | none |
|
||||||
|
| `virtual`, slot resolved | 156 | low — 133 of them are slots 0–13, i.e. `IAITask` itself |
|
||||||
|
| **`call-reg-unresolved`** | **88** | **unknown** |
|
||||||
|
| **`vptr-unresolved`** | **72** | **unknown** |
|
||||||
|
| `call-reg-nonmem` / `not-vptr` | 26 | low |
|
||||||
|
|
||||||
|
So the honest statement is: **160 indirect call sites inside the AI closure cannot be resolved by the
|
||||||
|
current tool, and any of them could reach an order method.** Rule 16 stands.
|
||||||
|
|
||||||
|
Where it matters most, though, the closure is **tight**. The two functions that carry every order emission
|
||||||
|
have almost no indirect surface at all:
|
||||||
|
|
||||||
|
- `AcquireFleetsForTask 0x006ceef0`: 7 indirect sites — 4 import thunks, `IAITask` slot 1 (`GetTypeId`,
|
||||||
|
returns a constant) twice, and slot 11 (returns a bool). None can reach code.
|
||||||
|
- `AssignFleetsAndIssueOrders 0x006c16c0`: 4 indirect sites, all import thunks.
|
||||||
|
|
||||||
|
One thing the census turned up that deserves a follow-up: **slot 5 (`Execute`) is dispatched at 14 sites
|
||||||
|
inside the AI closure**, not just from `RunTaskList 0x006b348d`. `0x006c8fd0` alone has twelve. Tasks
|
||||||
|
running other tasks is real; I did not read those sites and cannot say whether they are sub-task execution
|
||||||
|
or an unrelated class that shares slot 5.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Predictions (rule 2) — written before any run
|
||||||
|
|
||||||
|
### P1 — the AI turn sequence is fully determined by the save
|
||||||
|
|
||||||
|
Stepping order = live players in save-array order, filtered to AI; within each player, tasks in AI2's
|
||||||
|
stable descending priority order; two passes, the first writing nothing.
|
||||||
|
|
||||||
|
**Prediction:** hook `0x006b348d` and log `(playerNetId, task->GetTypeName(), task->GetPriority(), pass)`
|
||||||
|
for one turn. The log is a concatenation of per-player blocks in ascending save-player order; within each
|
||||||
|
block the priorities are non-increasing within pass 0, then non-increasing again within pass 1, over the
|
||||||
|
**same** multiset.
|
||||||
|
*Falsified if:* the player blocks are in any other order (then `ResumePlaying` is not the only filler of
|
||||||
|
the pending vector, or the callback is not the only enqueue), or the two passes visit different sets.
|
||||||
|
|
||||||
|
### P2 — pass 0 writes no `TurnCommands` at all
|
||||||
|
|
||||||
|
**Prediction:** hook the 26 order methods and record `pass` from a hook on `0x006b348d`. **Zero** order
|
||||||
|
calls occur between the pass-0 entry and the pass-0 exit of `RunTaskList` for every player.
|
||||||
|
*Falsified if:* any order method fires during pass 0. The **most likely** falsifier is
|
||||||
|
`AITRaid`'s list-16 emit at `0x0068e8b8` (§2.4), which has no pass guard of its own — so instrument
|
||||||
|
`0x007635f0`'s **entry**, not its cost (rule 20).
|
||||||
|
*Why it matters:* this halves the `ModCount` arithmetic. AI2's P1 says an AI fleet order costs two list-14
|
||||||
|
elements; if pass 0 also emitted, it would cost four.
|
||||||
|
|
||||||
|
### P3 — task-to-task preemption never fires on a normal turn
|
||||||
|
|
||||||
|
Slot 12 permits stealing only from a **strictly lower-priority** owner, and the list is processed in
|
||||||
|
descending priority, so by the time a task runs, every existing owner outranks it.
|
||||||
|
|
||||||
|
**Prediction:** an entry probe on `0x006a8d20` shows it is called often and **the steal branch
|
||||||
|
(`0x006a8ded` via `0x006a8de9`) is never taken**. Note this is a rule-20 shape: a count of zero at the
|
||||||
|
call site cannot distinguish "never called" from "called and always rejected" — probe the branch.
|
||||||
|
*Falsified if:* the branch fires. That would mean the claim registry outlives the sort, i.e. claims from a
|
||||||
|
*previous* task list survive into this turn, which would make the AI's state path-dependent across turns
|
||||||
|
and is a much bigger deal for Rung B than the preemption itself.
|
||||||
|
|
||||||
|
### P4 — the two AI-player bytes are derived, not loaded
|
||||||
|
|
||||||
|
`ServerPlayer+0xf9`/`+0xfa` have no save field (§1.4).
|
||||||
|
|
||||||
|
**Prediction:** a write watchpoint on `player+0xf9` fires exactly once per player during game setup /
|
||||||
|
load, from a function that reads `AIBn`, `NPC` or `RebAI`, and never again during a turn.
|
||||||
|
*Falsified if:* it is written mid-turn — then whether a player is AI-controlled can change during a game
|
||||||
|
and the stepping-order model needs a per-turn input the save does not supply.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. What this lane did **not** do
|
||||||
|
|
||||||
|
1. **Nothing ran under an instrument.** Every claim above is static. §7 is the measurement plan; §2.4,
|
||||||
|
§4.2, §4.3 and §1.4 are the four places where I would spend the probes first.
|
||||||
|
2. **`ServerPlayer+0xf9`/`+0xfa` have no located writer** (§1.4). This is the biggest remaining hole,
|
||||||
|
because it is an *input* to the stepping order that the save does not contain. Probe: a hardware write
|
||||||
|
watchpoint on `player+0xf9` across load and one turn.
|
||||||
|
3. **`AITRaid`'s pass-0 behaviour is inferred, not verified** (§2.4). Probe: entry hook on `0x007635f0`.
|
||||||
|
4. **Slot 11 is not named** (§4.3), and `0x006a6260`/`0x006a6380` were not read.
|
||||||
|
5. **Slot 13's units are not named** (§4.2); `agent->+0x10->+0x8` and `0x0080da80` were not read.
|
||||||
|
6. **Bit 0 of `IAITask+0x4` is not identified** (§3.1). I corrected the polarity and withdrew the name.
|
||||||
|
7. **160 indirect call sites inside the AI closure are unresolved** (§6), so the reachability result is
|
||||||
|
still a lower bound — just a much larger one than AI2's.
|
||||||
|
8. **The 14 non-`RunTaskList` slot-5 dispatch sites were not read** (§6).
|
||||||
|
9. **`0x006cb310`, `0x006b7c90` and `0x006c3e50` were read only for their order-method reachability**, not
|
||||||
|
for what they do. The claim "pass 0 claims fleets" is the *shape* the quota code implies; I did not
|
||||||
|
verify that `0x006c3e50` actually records a claim.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Corrections to earlier findings
|
||||||
|
|
||||||
|
- **`ai-task-system.md` §4.2 — "the nine with no order method are goal/planner tasks".** They are not.
|
||||||
|
All nine emit orders; the depth-4 cut hid it (§5). AI2 flagged the inference as unverified and it was
|
||||||
|
the right thing to flag.
|
||||||
|
- **`ai-task-system.md` §3 — the two priority overrides' flag polarity.** The tunable applies when bit 0
|
||||||
|
of `+0x4` is **set**, not clear, and "committed" is not a supported name for that bit (§3.1).
|
||||||
|
- **`ai-task-system.md` §10.6 — "the `.data` priority tunables were not traced to a loader".** There is no
|
||||||
|
loader; they are image constants, 650 and 750, with one reader each and no writer (§3).
|
||||||
|
- **`ai-task-system.md` §10.4 — "the two-pass meaning is inference".** Settled (§2): `pass` is a tier
|
||||||
|
index selecting between two per-candidate quota fields, and pass 0 emits nothing.
|
||||||
|
- **`ai-task-system.md` §6.1 / §10.5 — "the `StrategyApp+0x1c` fill site was not found".** Found (§1).
|
||||||
|
AI2's method was sound; it was defeated by the object being static rather than heap-allocated.
|
||||||
|
- **`ai-turn-logic.md` / `ai-task-system.md` — `IAITask` slot 12.** Named: a preemption permission (§4.1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Ranked plan for `sots-engine/src/game/ai`
|
||||||
|
|
||||||
|
AI2's items 5 and 7 are done. The revision:
|
||||||
|
|
||||||
|
| # | deliverable | why | testable how | blocked on |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **1** | ~~`game/ai/tasks` tuned priorities~~ **DONE this lane** — the two tunables are now constants (650/750) with the corrected flag polarity, and the misleading `committed` name is withdrawn | §3 makes them facts, not inputs | host: golden values + polarity test | — |
|
||||||
|
| **2** | ~~`game/ai/turn_order`~~ **DONE this lane** — the stepping order and the two-pass model as pure functions | §1 and §2. Zero game state; it is the `ModCount` sequence | host: order + dedup + pass-emission cases | — |
|
||||||
|
| **3** | **`game/ai/tables`** — the nine AI CSV tables (AI1's item 1, AI2's item 2) | unchanged: zero AI understanding needed, `mars/text` already exists | oracle diff vs the shipped CSVs | nothing |
|
||||||
|
| **4** | **measure P1 and P2 on VM140** | P2 halves the `ModCount` arithmetic; P1 is the whole ordering claim | hook `0x006b348d` + entry probes on the order methods | VM140 (lane W2) |
|
||||||
|
| **5** | **find the `ServerPlayer+0xf9` writer** (§8.2) | an input to the stepping order that the save does not carry | write watchpoint (rule 18: this is a watchpoint, not a week of reading) | VM140 |
|
||||||
|
| **6** | **read `0x006c3e50` and the claim registry** | closes "pass 0 claims fleets" from shape to fact | — | a lane |
|
||||||
|
| **7** | **`game/ai/agent`** — the spine as named stubs | now has real semantics for phase 20 and the two passes | host: phase-order test | #6 |
|
||||||
264
findings/subsystems/multiplayer-tier0-verified.md
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
# Multiplayer Tier 0, run on the real game: two players, one guest, no server
|
||||||
|
|
||||||
|
- **Type:** subsystem (live verification)
|
||||||
|
- **Status:** **verified** — a two-player multiplayer game was started, joined and played on VM140
|
||||||
|
with no GameSpy service of any kind, and the whole run was captured outside the guest
|
||||||
|
- **Confidence:** high. Every claim below is a screenshot or a packet.
|
||||||
|
- **Owner / date:** lane W2 · 2026-09-08
|
||||||
|
- **Tests:** `findings/subsystems/multiplayer-gamespy.md` §7 (lane G2's five predictions, written
|
||||||
|
before any VM run) and `sots-engine/docs/W2-predictions.md` (this lane's restatement, committed
|
||||||
|
before the run)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. The headline
|
||||||
|
|
||||||
|
**SOTS1 multiplayer is not dead, and restoring it needs no server.** Two clients on one Windows
|
||||||
|
guest, joined by typed IP, played two full turns in lockstep. Over the entire session the capture
|
||||||
|
on the host side of the guest's NIC shows **44,319 packets and exactly zero** to UDP 27900, UDP
|
||||||
|
27901, TCP 28910, TCP 28900, TCP 6667 or TCP 6500 — every GameSpy port the SDK in this binary
|
||||||
|
knows about.
|
||||||
|
|
||||||
|
Lane G2's Tier 0 was the cheapest item on the board and it landed exactly as G2 read it out of the
|
||||||
|
instruction stream. Four of its five predictions are confirmed; the fifth (Tier 3, the self-hosted
|
||||||
|
master server) was deliberately not attempted and is reported as not-run.
|
||||||
|
|
||||||
|
**And the game says so itself.** The Message of the Day panel on the main menu is fetched live, on
|
||||||
|
every launch, from `www.kerberos-productions.com/motd/motd_EN.txt` — the server still answers today,
|
||||||
|
`Last-Modified: Fri, 16 Jun 2017`, served by LiteSpeed at 106.0.62.78. Its text, in full, is
|
||||||
|
Kerberos's own 2012 notice:
|
||||||
|
|
||||||
|
> Attention SolForce personnel;
|
||||||
|
>
|
||||||
|
> It is with wide, astonished eyes that we have to report that Gamespy was recently purchased by a
|
||||||
|
> third-party. This party then began shutting off game servers and informing developers and
|
||||||
|
> publishers that they would have to pay tens of thousdands of dollars a month in upkeep, in order
|
||||||
|
> to keep the servers on. This despite the fact that SotS1 used Gamespy thanks to a contracted and
|
||||||
|
> not-cheap agreement between Lighthouse Interactive and Gamespy.
|
||||||
|
>
|
||||||
|
> We are looking into our options. In the meantime, you will not be able to play via Internet.
|
||||||
|
> However, you can still play the game via direct connect with your friends. **Game hosts please
|
||||||
|
> note they have to host the game in LAN mode, not Internet mode, or direct connect will not
|
||||||
|
> work.**
|
||||||
|
|
||||||
|
That is the `this+0x2c == 1 && this+0x30 != 0` predicate G2 recovered from `0x007c2dd0`, written by
|
||||||
|
the developer, and it is shipped inside the client we are testing. The fetch uses
|
||||||
|
`User-Agent: GameSpyHTTP/1.0` — the ghttp SDK component — which is the one piece of GameSpy code on
|
||||||
|
any live path, and it talks to Kerberos, not to GameSpy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What was run
|
||||||
|
|
||||||
|
VM140, GOG 1.8.1, single Win10 guest, `hooks=off` — **the instrument was removed** (method rule 19),
|
||||||
|
because the question is what the shipped game does, and because two processes sharing one trace file
|
||||||
|
is not a measurement. Capture: `tcpdump -i tap140i0` on **spicy**, i.e. on the host side of the
|
||||||
|
guest's tap device. That is outside the guest, so it cannot perturb it, and it sees everything the
|
||||||
|
VM emits.
|
||||||
|
|
||||||
|
**The blind spot, stated up front:** loopback traffic between the two instances never reaches the
|
||||||
|
tap, so this capture cannot show the game's own UDP 3369 exchange. That is the right trade — the
|
||||||
|
predictions that matter are all about traffic that *leaves*, and the join working is evidence
|
||||||
|
enough that the loopback path carried it. The socket table is the corroboration (§3).
|
||||||
|
|
||||||
|
Established before the run, and it is what makes §4 a real test: from the guest,
|
||||||
|
`swordots.available.gamespy.com` returns **NXDOMAIN** while `www.google.com` resolves. There is no
|
||||||
|
stale wildcard record at `gamespy.com`; the SOA in the negative answers is
|
||||||
|
`ns-889.awsdns-47.net` / `awsdns-hostmaster.amazon.…`.
|
||||||
|
|
||||||
|
## 2. P5 — `/concurrent` — **confirmed**
|
||||||
|
|
||||||
|
Instance A launched normally. Instance B launched as
|
||||||
|
|
||||||
|
```
|
||||||
|
"C:\SOTS\Sword of the Stars.exe" /concurrent /join 127.0.0.1:3369
|
||||||
|
```
|
||||||
|
|
||||||
|
Both reached their own window and their own main loop: `Get-Process` showed **two** PIDs (4728 and
|
||||||
|
8780), and B's own stdout log records the full startup — VFS mount, D3D adapter, `ver.1.8.1 Wed Dec
|
||||||
|
13 03:38:31 2017`, profile switch — rather than the foreground-and-exit path. The
|
||||||
|
`CreateMutexA` / `ERROR_ALREADY_EXISTS` / argv-compare chain G2 read at `0x0089ddaf` behaves exactly
|
||||||
|
as read. **A second instance on one guest is a supported, shipped configuration**, and the
|
||||||
|
two-client test needs one VM, not two.
|
||||||
|
|
||||||
|
## 3. P1 — direct join with zero GameSpy traffic — **confirmed, both halves**
|
||||||
|
|
||||||
|
Instance A: *Host Multi-Player* → **Custom** + **LAN** → OK → Custom Game Setup (2 players, 28
|
||||||
|
stars, defaults) → *Create Game* → game name `MyGame` → lobby.
|
||||||
|
|
||||||
|
At that moment Windows Defender Firewall raised its "has blocked some features of this app" prompt
|
||||||
|
for `C:\sots\sword of the stars.exe` — the host had just opened its listening socket. Allowed for
|
||||||
|
private networks. **This is worth knowing for anyone reproducing the setup: the host, not the
|
||||||
|
joiner, triggers the firewall prompt, and it appears at *Create Game*, not at launch.**
|
||||||
|
|
||||||
|
Socket table with the host in the lobby (`Get-NetUDPEndpoint`, filtered by PID):
|
||||||
|
|
||||||
|
| PID | role | UDP bindings |
|
||||||
|
|---|---|---|
|
||||||
|
| 4728 | host | **0.0.0.0:3369**, 0.0.0.0:60924 |
|
||||||
|
| 8780 | joiner | 0.0.0.0:60925 |
|
||||||
|
|
||||||
|
Three facts fall out. The host binds `HostPort` **3369** exactly as `Game_LoadNetworkConfig`
|
||||||
|
defaults it (there is no `[Network]` section in `sots.ini` at all, so every value in G2 §3 is a
|
||||||
|
compiled-in default in this install). **No TCP anywhere**, confirming G2's import-table reading (no
|
||||||
|
`listen`, no `accept`). And `CombatHostPort` **3370 is not bound at lobby time** — it must be opened
|
||||||
|
later, when a combat starts, which no lane has yet observed.
|
||||||
|
|
||||||
|
The joiner appeared in slot 2 of the host's lobby within ~50 s of launch. Both instances then showed
|
||||||
|
two player cards, the client's view carrying *Ready* / *Leave Game* and the host's *Launch* /
|
||||||
|
*Cancel Game*. Client pressed Ready; host pressed Launch.
|
||||||
|
|
||||||
|
**The game started.** Turn 1, two distinct human players — the host is `Venkman`, the client is
|
||||||
|
`Hitomi`, different portraits, different homeworlds, different treasuries. Each pressed *End Turn*;
|
||||||
|
the turn resolved and **both** advanced to Turn 2 (host Imperial Savings 11,200,596; client
|
||||||
|
11,299,146 — different players, same turn). A second round took both to **Turn 3**.
|
||||||
|
|
||||||
|
Capture over the whole session: **zero** packets to any GameSpy port; **zero** DNS queries for any
|
||||||
|
`*.gamespy.com` name during the hosting and joining sequence. The only names resolved in that window
|
||||||
|
were Windows telemetry.
|
||||||
|
|
||||||
|
**This is the multiplayer revival, demonstrated end to end.** Not "the code path exists" — a game
|
||||||
|
was created, joined, launched and played.
|
||||||
|
|
||||||
|
## 4. P2 — the availability check fails open — **confirmed**
|
||||||
|
|
||||||
|
Fresh single instance, DNS cache flushed, *Join Multi-Player* → **Internet** → OK.
|
||||||
|
|
||||||
|
The Game Browser opened on the Internet tab with an empty *Available Games* list, the MOTD panel
|
||||||
|
below it, and **no `MATCHINGSERVICE_UNSUPPORTED` dialog** — no "Online support … is no longer
|
||||||
|
available", nothing. G2's fail-open reading of `GSIStartAvailableCheck` / `GSIAvailableCheckThink`
|
||||||
|
is correct on the running game.
|
||||||
|
|
||||||
|
Exactly **one** A query each, all answered NXDOMAIN, and **no retry**:
|
||||||
|
|
||||||
|
```
|
||||||
|
14:39:35.670 A? swordots.available.gamespy.com. -> NXDOMAIN
|
||||||
|
14:39:35.940 A? swordots.ms5.gamespy.com. -> NXDOMAIN
|
||||||
|
14:39:36.108 A? peerchat.gamespy.com. -> NXDOMAIN
|
||||||
|
```
|
||||||
|
|
||||||
|
Three refinements to G2 §7's P2. **(a)** One query, not one-or-two: the two-attempt/2000 ms retry at
|
||||||
|
`0x0040a2bb` is a *socket* retry, and it never runs because the socket was never created. **(b)**
|
||||||
|
The browser resolves the **server-list** and **peerchat** hostnames on the same screen entry, not
|
||||||
|
just the availability host — so a `hosts`-file redirect for Tier 1 must cover `ms5` and `peerchat`
|
||||||
|
as well or those two will still fail. **(c)** `swordots.master.gamespy.com` and the two `natneg`
|
||||||
|
names were **not** resolved: they belong to the host/reporting path, not the join path.
|
||||||
|
|
||||||
|
**Consequence for the revival plan: Tier 1 is confirmed unnecessary.** G2 hoped finding it a no-op
|
||||||
|
would be a good outcome; it is a no-op. Nothing has to answer the availability check for a client to
|
||||||
|
reach the browser, the manual-join dialog, or a game.
|
||||||
|
|
||||||
|
## 5. G2 §7 caveat (a) — falsified
|
||||||
|
|
||||||
|
G2 flagged, honestly, that it had not read the *Join Manually* button's enable predicate and that
|
||||||
|
the button might be disabled without a browser object. It is not. On the Internet page with an empty
|
||||||
|
list and every GameSpy name dead, **Join Manually is enabled**, and it opens a dialog with *Enter
|
||||||
|
Host's IP Address*, *Enter Password*, a *Favorites* list, and Join/Cancel. The GameSpy-free arm G2
|
||||||
|
traced from `Game_ManualJoin_OnAccept` is reachable in the shipped UI in the state the world is
|
||||||
|
actually in.
|
||||||
|
|
||||||
|
## 6. P3 — LAN discovery with no server — **confirmed, and my own prediction was wrong**
|
||||||
|
|
||||||
|
I rated this *lower* than G2 did, and wrote down why: both instances are on one host, so a broadcast
|
||||||
|
to `255.255.255.255:3369` and the host's QR2 socket might contend for one UDP port. Written before
|
||||||
|
the run, and wrong. The socket table in §3 shows why: **the client never binds 3369** — it
|
||||||
|
broadcasts from an ephemeral port (60925) — so there is no contention to have.
|
||||||
|
|
||||||
|
With A hosting in LAN mode, B's *Join Multi-Player* → **LAN** page listed the game **on first open,
|
||||||
|
with no manual Refresh**:
|
||||||
|
|
||||||
|
| Game Name | Players | Ping | Password | Status | Version |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `re : MyGame` | 1/2 | 32 | No | Staging | 1.8.1 |
|
||||||
|
|
||||||
|
That row is the QR2 custom-key set of G2 §4.2 rendered by the client, answered by the host's own
|
||||||
|
socket, with no master server in existence. Selecting the row and pressing **Join** also worked: B
|
||||||
|
went straight into the lobby. So both join paths — manual address and browser-mediated — work
|
||||||
|
GameSpy-free.
|
||||||
|
|
||||||
|
One thing G2 could not settle and this run does: **the browser-mediated LAN join makes no NAT
|
||||||
|
negotiation attempt.** No `natneg1`/`natneg2` name was ever resolved and nothing was sent to UDP
|
||||||
|
27901. G2's reading that NatNeg hangs off the *browser* join is right about the code, but on the LAN
|
||||||
|
path (`queryVersion = 1`, `lanBrowse = 1`) it is not reached.
|
||||||
|
|
||||||
|
## 7. P4 — the self-hosted master server — **not run**
|
||||||
|
|
||||||
|
Deliberately. Tier 3 is a container deployment plus a DNS redirect; it is a lane's work on its own,
|
||||||
|
it is not on the path to *playing*, and running it before Tier 0 was proved would have been the
|
||||||
|
wrong order. Nothing here confirms or disconfirms the OpenSpy / UniSpyServer plan, and §4's finding
|
||||||
|
that Tier 1 is unnecessary does **not** generalise to Tier 3 — the Internet list stays empty because
|
||||||
|
nothing answers, and only a real SB v2 + QR2 backend can change that.
|
||||||
|
|
||||||
|
Reported as not-run rather than unknown: the experiment is fully specified in
|
||||||
|
`multiplayer-gamespy.md` §5 and §7, and the two extra hostnames in §4(b) above are a correction to
|
||||||
|
its `hosts`-file list.
|
||||||
|
|
||||||
|
## 8. What this run did NOT cover (rule 15)
|
||||||
|
|
||||||
|
Loudly, because the headline is a strong one and it was established on a narrow workload.
|
||||||
|
|
||||||
|
- **One guest, loopback only.** No two-machine LAN test, no VPN, no NAT, no port forwarding. The
|
||||||
|
claim "two boxes on a LAN work" remains G2's inference plus Kerberos's MOTD; what is *measured* is
|
||||||
|
two processes on one box over 127.0.0.1.
|
||||||
|
- **No combat.** Both turns resolved without a battle, so `CombatHostPort` 3370 was never bound and
|
||||||
|
the combat lockstep — the part with a 1000 ms `CombatLatency` and its own sync checking — is
|
||||||
|
entirely untested. This is the most likely place for the direct path to still be broken.
|
||||||
|
- **Two turns.** Long-run desync, the `SyncCheckStrategy` machinery, and reconnection are untested.
|
||||||
|
- **Two players, both Human, both on defaults.** No password, no teams, no alliances, no scenario,
|
||||||
|
no more than two slots.
|
||||||
|
- **`sots_server.exe` was not run.** It is present in `C:\SOTS` alongside
|
||||||
|
`Dedicated Server Launchpad.exe`. The community bug G2 quotes ("the dedicated server does not seem
|
||||||
|
to work") is still unreproduced and unexplained, and it is now the cheapest remaining multiplayer
|
||||||
|
item.
|
||||||
|
- **The shim was `hooks=off`**, so nothing internal was observed — no message types, no wire
|
||||||
|
formats, no sync-check payloads. Everything above is UI, sockets and packets.
|
||||||
|
|
||||||
|
## 9. Files
|
||||||
|
|
||||||
|
- Capture (filtered to DNS, every GameSpy port, the game's own ports and the MOTD host):
|
||||||
|
`verify/results/multiplayer/w2-tier0-filtered.pcap`
|
||||||
|
- Screenshots: `verify/results/multiplayer/w2-lobby-client.png`, `w2-mp-turn1.png`,
|
||||||
|
`w2-mp-turn2-host.png`, `w2-mp-turn2-client.png`, `w2-internet-browser-no-error.png`,
|
||||||
|
`w2-join-manually-dialog.png`, `w2-lan-browse-listing.png`
|
||||||
|
- Predictions, committed before the run: `sots-engine/docs/W2-predictions.md`
|
||||||
|
|
||||||
|
## 10. Recipe, so nobody has to re-derive it
|
||||||
|
|
||||||
|
At 1024x768, with the game at the main menu:
|
||||||
|
|
||||||
|
- *Host Multi-Player* (512,478) → **Custom** (446,287) → **LAN** (446,428) → OK (551,522)
|
||||||
|
- Custom Game Setup: *Create Game* (835,707) → name dialog OK (600,392)
|
||||||
|
- Firewall prompt: *Allow access* (623,550) — appears once, on the first host
|
||||||
|
- Host lobby: *Launch* (931,663). Client lobby: *Ready* (931,663), *Leave Game* (931,692)
|
||||||
|
- *Join Multi-Player* (512,506) → **LAN** (446,357) / **Internet** (577,357) → OK (551,451)
|
||||||
|
- Game Browser: *Join Manually* (781,245), *Refresh* (874,245), *Join* (965,443), first row
|
||||||
|
(200,159), *Exit* (989,716)
|
||||||
|
- Second instance: `schtasks` task running
|
||||||
|
`"C:\SOTS\Sword of the Stars.exe" /concurrent /join 127.0.0.1:3369`. It must be a scheduled task
|
||||||
|
with `LogonType=InteractiveToken`; a process started straight from the SSH session lands in a
|
||||||
|
different window station and cannot be seen or clicked.
|
||||||
|
- Clicking a *specific* instance needs a helper that resolves the window by **PID**
|
||||||
|
(`Get-Process -Id`), not `Select -First 1` — the existing `C:\SOTS\ui\click_helper.ps1` picks
|
||||||
|
whichever instance it finds first. Lane W2 left `C:\SOTS\w2\ui.ps1` (takes a PID, or `-1` for "do
|
||||||
|
not foreground anything", which is how the firewall dialog gets clicked); it reads the PID from
|
||||||
|
`C:\SOTS\w2\pid.txt` and the commands from `C:\SOTS\w2\cmd.txt`, and is run by the **SOTSUI2**
|
||||||
|
scheduled task, which is registered and left in place.
|
||||||
|
- **The second-instance launcher is staged but NOT registered.** `C:\SOTS\w2\sotsb.xml` and
|
||||||
|
`C:\SOTS\w2\argsB.txt` are on disk; re-register with
|
||||||
|
`schtasks /Create /TN SOTSB /XML C:\SOTS\w2\sotsb.xml /F` and put the arguments in `argsB.txt`.
|
||||||
|
It was deliberately left unregistered so a stray run cannot start a second game under a later
|
||||||
|
lane.
|
||||||
|
- **PowerShell over SSH needs `-ExecutionPolicy Bypass`** even for `-EncodedCommand`, or any `& …
|
||||||
|
.ps1` inside it dies with `running scripts is disabled on this system` — and, because the failure
|
||||||
|
is inside the encoded block, it looks like the script ran and did nothing. This cost one wasted
|
||||||
|
launch here.
|
||||||
|
|
||||||
|
## 11. Corrections filed
|
||||||
|
|
||||||
|
- Against `multiplayer-gamespy.md` §7: caveat (a) is falsified (§5); P2's "exactly one DNS query"
|
||||||
|
is right but for a different reason, and the browser resolves **three** names not one (§4); P3's
|
||||||
|
mechanism is confirmed but the single-guest port-contention worry (this lane's, not G2's) was
|
||||||
|
unfounded (§6).
|
||||||
|
- The MOTD is **live**, not canned: this corrects any assumption that a 2026 launch is offline. One
|
||||||
|
outbound HTTP GET to `www.kerberos-productions.com` happens on every launch and on every entry to
|
||||||
|
the Game Browser.
|
||||||
90
findings/subsystems/rcex-explained.md
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
# `rcex` — a per-player nibble array, and the rule is one line
|
||||||
|
|
||||||
|
- **Type:** subsystem (field semantics)
|
||||||
|
- **Address / RVA:** `ServerSystem` field, streamed as `int64` (`struct-recovery.md` row `0xf8`);
|
||||||
|
ticked inside `ServerSystem::ProcessTurn` (the "`rcex` tick", already in `addresses.json`'s
|
||||||
|
body-order note for `0x0074…`)
|
||||||
|
- **Status:** **mapped** — rule holds 7/7 across the corpus, from the saves alone, no VM time
|
||||||
|
- **Confidence:** high on the rule, medium on the *name* of the event that sets it
|
||||||
|
- **Owner / date:** lane W2 · 2026-09-08
|
||||||
|
- **Closes:** `system-visibility-prediction.md` §"What the model deliberately does not write" —
|
||||||
|
"`rcex` (6 leaves per pair). **Unexplained.**"; `system-visibility-record.md` §8 row
|
||||||
|
"`rcex` | unassigned"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The rule
|
||||||
|
|
||||||
|
`rcex` is a **64-bit array of sixteen 4-bit counters, one per player index**. Player *p*'s counter
|
||||||
|
is nibble *p*, i.e. bits `4p … 4p+3`.
|
||||||
|
|
||||||
|
On the turn where a system enters player *p*'s `AFlags` — the per-player acquisition/observation
|
||||||
|
bit — nibble *p* is set to **1**. On the next turn the tick in `ServerSystem::ProcessTurn`
|
||||||
|
decrements it to **0**. That is the whole of the observed `0 → 1<<16 → 0` behaviour, and it is
|
||||||
|
exactly the shape `strategic-turn-internals.md` guessed at ("`Bats2` / `rcex` 64-bit nibble arrays:
|
||||||
|
per-player 4-bit countdowns (battle / recon cooldown)") without being able to pin the index.
|
||||||
|
|
||||||
|
## The evidence
|
||||||
|
|
||||||
|
Every system in the 11-save corpus with a non-zero `rcex`, with its `AFlags` beside it:
|
||||||
|
|
||||||
|
| save | frame | system | `rcex` | set bit | ⇒ nibble | `AFlags` | ⇒ set bit | nibble value |
|
||||||
|
|---|---:|---|---:|---:|---:|---:|---:|---:|
|
||||||
|
| `turn2-state` | 2 | Hyperion | 65536 | 16 | **4** | 16 | **4** | 1 |
|
||||||
|
| `turn2-state` | 2 | Koa'Vo | 268435456 | 28 | **7** | 128 | **7** | 1 |
|
||||||
|
| `turn2-state` | 2 | Kaa'Vaalu | 65536 | 16 | **4** | 16 | **4** | 1 |
|
||||||
|
| `turn2-state` | 2 | Markab | 65536 | 16 | **4** | 16 | **4** | 1 |
|
||||||
|
| `turn2-state` | 2 | Kea'Pono | 65536 | 16 | **4** | 16 | **4** | 1 |
|
||||||
|
| `turn2-state` | 2 | Ko'Rorkor | 65536 | 16 | **4** | 16 | **4** | 1 |
|
||||||
|
| `human-turn2-orders` | 2 | Terra | 4096 | 12 | **3** | 8 | **3** | 1 |
|
||||||
|
|
||||||
|
**7 for 7.** `nibble index == the index of the single set `AFlags` bit`, and the nibble's value is
|
||||||
|
always 1. The seventh row is the strong one: `human-turn2-orders.sav` is a **different game** with a
|
||||||
|
different map, a different system and a different player index, and it obeys the same rule.
|
||||||
|
|
||||||
|
Every other save in the corpus — `turn1-state`, `turn3-state`, `human-turn3-noderoute`, and all six
|
||||||
|
Zuul saves from frame 5 to frame 23 — has `rcex == 0` on all 28 systems, which is what a counter
|
||||||
|
that decays to zero in one turn looks like when nothing was acquired that turn.
|
||||||
|
|
||||||
|
That also explains the `system-visibility-record.md` §7 observation directly: `rcex` moves
|
||||||
|
`0 → 65536` on `turn1 → turn2` and back `65536 → 0` on `turn2 → turn3` **on the same six systems**,
|
||||||
|
and Koa'Vo takes `1 << 28` rather than `1 << 16` — because Koa'Vo was acquired by player 7 and the
|
||||||
|
other five by player 4.
|
||||||
|
|
||||||
|
## Why this had to be six leaves per pair and not more
|
||||||
|
|
||||||
|
The visibility model's residual was "6 leaves per pair" for `rcex`. It is six because six systems
|
||||||
|
changed hands in the turn-1→turn-2 pair and each contributes one `int64` leaf. The count was never
|
||||||
|
about `rcex` being complicated; it was about nobody having connected the nibble index to the player
|
||||||
|
index.
|
||||||
|
|
||||||
|
## How this could be wrong, and the symptom of each way (rule 2)
|
||||||
|
|
||||||
|
- **The index is not the player index but something correlated with it on this corpus.** Every save
|
||||||
|
here has **single-bit** `AFlags` on every system — the corpus limitation
|
||||||
|
`system-visibility-prediction.md` already flags — so "nibble index == player index" and "nibble
|
||||||
|
index == index of the lowest set `AFlags` bit" are indistinguishable. *Falsifying workload:* a
|
||||||
|
save where **two** players acquire the same system on the same turn. The rule predicts **two**
|
||||||
|
nibbles set to 1; a lowest-bit rule predicts one.
|
||||||
|
- **The initial value is not always 1.** Every observed nibble is 1. A longer cooldown (2, 3) would
|
||||||
|
appear on some other event. *Symptom:* a nibble > 1, and a system whose `rcex` takes two turns to
|
||||||
|
reach zero. Nothing in the corpus shows one.
|
||||||
|
- **It counts something other than acquisition.** `AFlags`, `VFlags` and `EFlags` are all equal on
|
||||||
|
all seven rows, so this corpus cannot separate them. *Falsifying workload:* any save where a
|
||||||
|
system's `VFlags` and `AFlags` disagree.
|
||||||
|
- **The decrement is not in `ServerSystem::ProcessTurn`.** The "rcex tick" is named in that
|
||||||
|
function's body-order note in `addresses.json`, between the `Bats2` tick and the `haltv` clear;
|
||||||
|
the corpus is consistent with it but does not prove the site. *This is the one item that wants a
|
||||||
|
watchpoint* — and it is now cheap: `src/shim/hooks/watchpoints.cpp` arms four 4-byte write
|
||||||
|
watchpoints from a known `this`, and `rcex` is at a fixed offset in `ServerSystem`. See
|
||||||
|
`findings/control-flow/watchpoints-modcount-status.md` §7.
|
||||||
|
|
||||||
|
## Cross-refs
|
||||||
|
|
||||||
|
- `findings/objects/struct-recovery.md` (the `int64` typing, and the warning that "R2 int is wrong")
|
||||||
|
- `findings/subsystems/strategic-turn-internals.md` (the nibble-array reading this confirms)
|
||||||
|
- `findings/subsystems/system-visibility-record.md` §7-8 and
|
||||||
|
`findings/subsystems/system-visibility-prediction.md` (the open item this closes)
|
||||||
|
- **`Bats2` is the same shape and is still unassigned.** It is `0` on every system of every corpus
|
||||||
|
save, so it is a rule-6 hypothesis: the nibble-per-player reading is inherited from `rcex` and has
|
||||||
|
never been exercised. The workload is a save taken on the turn after a battle.
|
||||||
124
ghidra/addresses.d/lane-ai3.json
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
{
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"name": "StrategyApp_OnClientEvent",
|
||||||
|
"addr": "0x00838e10",
|
||||||
|
"convention": "cdecl",
|
||||||
|
"prototype": "void __cdecl Game::StrategyApp::OnClientEvent(int netId, int eventId, void* ev) -- THE AI ENQUEUE SITE (lane AI2 §6.1 open item, closed). Operates on the STATIC StrategyApp at 0x00b29f98 (not a pointer -- `mov ecx,0xb29f98` at 0x007843a2 proves the object itself lives there). Body: (1) `if (*(void**)0x00b29f9c == 0) return` -- app+0x4, the StrategyServer; (2) `if (netId == 0) return`; (3) linear search of the client vector at app+0xc/+0x10 (absolutes 0x00b29fa4/0x00b29fa8) for `client->+0x148 == netId`; not found -> return; (4) `p = client->+0x150`; (5) `if (eventId == 0x26 (SEResumePlaying) && p->+0xf9 != 0 && p->+0xfa == 0)` then `if (!0x00438fe0(&pending, &netId)) 0x0059f1a0(&pending, &netId)` -- a DEDUPLICATED push_back onto the pending-AI vector at app+0x1c (absolute 0x00b29fb4), and RETURN; (6) otherwise `StrategyClient::RaiseEvent 0x00783ee0(client, eventId, ev)` inline. So event 0x26 for an AI player is the ONLY deferred event; everything else is delivered synchronously. Registered as StrategyServer+0x170 by CreateGame at 0x00889177; it has ZERO direct callers and no vtable slot (lane B6's third blind spot)",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/subsystems/ai-stepping-and-passes.md#1 -- lane AI3 2026-09-08, instruction-stream read of dumps/sots.exe swept to the next function start (rule 17); enqueue located by absolute-reference scan for 0x00b29fb4, which has exactly 2 references in the image, both here"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "StrategyServer_ResumePlaying",
|
||||||
|
"addr": "0x007ddc90",
|
||||||
|
"convention": "thiscall",
|
||||||
|
"prototype": "void __thiscall Game::StrategyServer::ResumePlaying() -- THE STEPPING ORDER. (1) `if (0x0080f4d0(&this->+0x4)) return`; (2) `if (++this->+0x168 == 1 && this->+0x1b4) { obs->vt[4](2,0); obs->vt[7](); }`; (3) 0x007dd230(&this->+0x174, 0) then 0x007dd230(&this->+0x174, playerCount) -- clear+resize; (4) FIRST walk of the player vector at this->+0x54..+0x58, IN INDEX ORDER: `if (!p->Elim /*+0xf8*/) p->Status /*+0x164*/ = 0` -- this is a Player.Status WRITER; (5) SECOND walk, again in index order: `if (p->Status == 0) { ev = {vptr 0x00a23bb8}; cb = this->+0x170; cb ? cb(p->+0x4 /*netId*/, 0x26, &ev) : Log(0x00a23c08); }`. The callback is StrategyApp::OnClientEvent 0x00838e10, so the pending-AI vector is filled in SERVER PLAYER INDEX ORDER -- i.e. save player order -- and RunPendingAITurns then walks it in index order. Both Elim and Status are save-visible ServerPlayer fields",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/subsystems/ai-stepping-and-passes.md#1 -- lane AI3 2026-09-08, instruction-stream read; field names from findings/objects/struct-recovery.md (0xf8 Elim, 0x164 Status)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "StrategyServer_SetClientEventCallback",
|
||||||
|
"addr": "0x007861f0",
|
||||||
|
"convention": "thiscall",
|
||||||
|
"prototype": "void __thiscall Game::StrategyServer::SetClientEventCallback(void (__cdecl* cb)(int netId, int eventId, void* ev)) -- RET 4. Two instructions: `this->+0x170 = arg`. The only registration in the image is CreateGame 0x00889177 installing StrategyApp::OnClientEvent 0x00838e10. Every server->client event in the game funnels through this one pointer; SynchronizePlayer 0x007c6220 dispatches through it at 0x007c6384, 0x007c65aa, 0x007c6889, 0x007c6a00, 0x007c6ae0 and more",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/subsystems/ai-stepping-and-passes.md#1 -- lane AI3 2026-09-08, instruction-stream read"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "g_StrategyApp",
|
||||||
|
"addr": "0x00b29f98",
|
||||||
|
"convention": "data",
|
||||||
|
"prototype": "Game::StrategyApp -- the STATIC APP OBJECT ITSELF, not a pointer to one. Proved by `mov ecx,0xb29f98; call <method>` at 0x007842f6/0x007843a2 and by the absolute pair 0x00b29fa4/0x00b29fa8 being read where a method reads this->+0xc/this->+0x10. Layout confirmed this lane: +0x0 flag byte (bit 2 = 'a StrategyServer exists'), +0x4 StrategyServer*, +0xc/+0x10/+0x14 vector<StrategyClient*>, +0x1c/+0x20/+0x24 vector<int> pendingAITurns (absolutes 0x00b29fb4/b8/bc), +0x2c AIProcessMinTime (seconds, float). Lane AI2's scan for 'functions that load 0x00b29f98' missed the enqueue because MSVC folds the object base into the absolute address of the member: the enqueue writes 0x00b29fb4 directly and never materialises 0x00b29f98",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/subsystems/ai-stepping-and-passes.md#1 -- lane AI3 2026-09-08"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "StrategyAIAgent_AcquireFleetsForTask",
|
||||||
|
"addr": "0x006ceef0",
|
||||||
|
"convention": "cdecl",
|
||||||
|
"prototype": "void __cdecl Game::StrategyAIAgent::AcquireFleetsForTask(StrategyAIAgent* agent, IAITask* task, double dA, double dB, void* targetA, void* targetB, vector<Candidate32>* candidates, int pass, int flag, vector<StarFleet*>* out) -- THE HUB EVERY FLEET-SHAPED TASK GOES THROUGH, and where `pass` acquires its meaning. Real span 992 bytes to the next function start (Ghidra's size is short). Three blocks, each a `for (i = 0; i <= pass; ++i) gather(..., i, ...)` loop followed by an 0x00698960 sufficiency test: block A (0x006cef5a, entered when targetB->+0x14 == 0, or == 2 with task->GetTypeId() == 0x1e) gathers via 0x006abf80; block B (0x006cf029) gathers via 0x006b7c90; block C (0x006cf0ee, only if A and B both failed and targetA != 0) gathers via 0x006cb310, taking task->vt[11]() at 0x006cf10e as an argument. ALL THREE order-emitting exits are pass==1 only: 0x006bbd50 (0x006cefcf, 0x006cf180) returns immediately unless pass==1, and 0x006c16c0 (0x006cf198) takes the arm at 0x006c1791 only when pass==1. The result vector at [ebp-0x3c] has exactly two possible writers -- 0x006bbd50 and 0x006c16c0 -- enumerated from every `lea` of that slot in the body, so on pass 0 this function RETURNS AN EMPTY FLEET LIST AND WRITES NO TurnCommands. At 0x006cf1aa it special-cases task->GetTypeId() 0x17 (StockFreighters) and 0x1a (NodeBore), substituting 0.0 for dA",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/subsystems/ai-stepping-and-passes.md#2 -- lane AI3 2026-09-08, instruction-stream read"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "StrategyAIAgent_GatherFleetsForTier",
|
||||||
|
"addr": "0x006abf80",
|
||||||
|
"convention": "cdecl",
|
||||||
|
"prototype": "void __cdecl Game::StrategyAIAgent::GatherFleetsForTier(StrategyAIAgent* agent, float threshold, void* target, vector<Candidate32>* candidates, int tier, vector<Slot36>* out) -- 112 bytes. Walks the 0x20-stride candidate vector in index order, calling 0x006abb00(agent, threshold, target, &cand[k], tier, out, &out[k]) for each. `tier` is the loop index i of AcquireFleetsForTask's `for (i = 0; i <= pass; ++i)`, so it takes the values 0..pass",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/subsystems/ai-stepping-and-passes.md#2 -- lane AI3 2026-09-08, instruction-stream read"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "StrategyAIAgent_FillCandidateToTierQuota",
|
||||||
|
"addr": "0x006abb00",
|
||||||
|
"convention": "cdecl",
|
||||||
|
"prototype": "void __cdecl Game::StrategyAIAgent::FillCandidateToTierQuota(StrategyAIAgent* agent, float threshold, void* target, Candidate32* cand, int tier, vector<Slot36>* out, Slot36* slot) -- 464 bytes. THE INSTRUCTION THAT DEFINES THE TWO PASSES, at 0x006abb1c..0x006abb4d: `have = 0x00695b90(&slot->+0x10) + 0x00698860(slot) + slot->+0x20; want = (tier == 0) ? cand->+0x10 : (tier == 1) ? cand->+0x14 : 0; if (have >= want) return;` -- a compiler-generated switch on tier with case 0 -> 0x006abb41 (cand->+0x10) and case 1 -> 0x006abb39 (cand->+0x14). So each candidate carries TWO quota fields and `pass` selects which one is in force: pass 0 fills the +0x10 quota, pass 1 re-runs tier 0 and then fills the larger +0x14 quota. Then a per-fleet filter loop rejecting on 0x0069c8c0, IsClaimedByAnotherTask 0x006a8d20 and 0x006ab900",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/subsystems/ai-stepping-and-passes.md#2 -- lane AI3 2026-09-08, instruction-stream read"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "StrategyAIAgent_IssueRouteForFleets",
|
||||||
|
"addr": "0x006bbd50",
|
||||||
|
"convention": "cdecl",
|
||||||
|
"prototype": "bool __cdecl Game::StrategyAIAgent::IssueRouteForFleets(StrategyAIAgent* agent, int pass, vector<Slot36>* fleets, void* target, vector<StarFleet*>* out) -- 192 bytes. `if (pass != 1) return;` at 0x006bbd78 (the MSVC `sub eax,0 / je / dec / jne` switch shape). Otherwise walks the 0x24-stride fleet vector, calling 0x0057aac0 per element to build a route, then 0x006b76a0(agent, &route, target, out), which is one of the three callers of AI_IssueFleetTask 0x006987e0. This is one of the two pass-1 gates that make pass 0 emit nothing",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/subsystems/ai-stepping-and-passes.md#2 -- lane AI3 2026-09-08, instruction-stream read"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "StrategyAIAgent_RequestBuildForTask",
|
||||||
|
"addr": "0x006cea50",
|
||||||
|
"convention": "cdecl",
|
||||||
|
"prototype": "void __cdecl Game::StrategyAIAgent::RequestBuildForTask(StrategyAIAgent* agent, IAITask* task, int pass, double, double, void* targetA, void* targetB, void* targetA2, vector<Slot36>* gathered) -- 304 bytes, AcquireFleetsForTask's LAST-RESORT arm: no fleet could be found, so build ships. Two gates in the prologue: (1) 0x006cea7b..0x006ceaa6 `if (agent->+0x10->+0x150->+0x2d8 /*plcy*/ == 0 && task->GetTypeId() != 0x1a /*NodeBore*/) return` -- a SECOND, independent consumer of the save-visible `plcy` field, beyond the two defence creators lane AI2 found; (2) 0x006ceaac `if (pass != 1) return`. Reaches list 3 (build orders) via 0x006ce460 -> 0x006ce360 -> 0x006ce190 -> 0x006bd790 -> 0x006b3bc0 -> 0x00762fd0",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/subsystems/ai-stepping-and-passes.md#2 -- lane AI3 2026-09-08, instruction-stream read"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "StrategyAIAgent_AssignFleetsAndIssueOrders",
|
||||||
|
"addr": "0x006c16c0",
|
||||||
|
"convention": "cdecl",
|
||||||
|
"prototype": "void __cdecl Game::StrategyAIAgent::AssignFleetsAndIssueOrders(StrategyAIAgent* agent, IAITask* task, int pass, vector<StarFleet*>* fleets, void* targetA, void* targetB, int flag) -- 3536 bytes, the busiest AI->TurnCommands function. `if (pass != 1) goto 0x006c241f` at 0x006c177e (the same `sub eax,0 / je / dec / jne` shape), so its ENTIRE working body -- including both calls to AI_IssueFleetTask 0x006987e0 at 0x006c1c86 and 0x006c1f78, and the two 0x00699fa0 -> list 8 paths -- runs on pass 1 only. Its only indirect call sites are four import thunks (0x009dd12c/0x009dd150), so its direct-call closure is complete: no vtable edge can escape it",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/subsystems/ai-stepping-and-passes.md#2 -- lane AI3 2026-09-08, instruction-stream read; indirect-site census from tools/vtable_map.py"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "StrategyAIAgent_IsClaimedByAnotherTask",
|
||||||
|
"addr": "0x006a8d20",
|
||||||
|
"convention": "thiscall",
|
||||||
|
"prototype": "bool __thiscall Game::StrategyAIAgent::IsClaimedByAnotherTask(void* obj) -- RET 4. NAMES IAITask VTABLE SLOT 12 (lane AI2 §10.2). Looks `obj->+4` up in the 8-byte-stride claim registry at agent->+0x2e8..+0x2ec (pairs of {IAITask* owner, int objectId}) and in the 4-byte set at agent->+0x2d8..+0x2dc; if the object is in neither, returns false (free). Otherwise `cur = back(agent->+0x12c /*the task call stack*/)`; with an empty stack or a null top it returns TRUE (claimed). Then at 0x006a8db3: `if (!cur->vt[12]()) return true;` -- and when slot 12 IS set, it returns false (i.e. lets the task take the object) only when the owner exists, `cur->GetTypeId() != owner->GetTypeId()`, and `cur->GetPriority() > owner->GetPriority()`. So SLOT 12 IS A PREEMPTION PERMISSION: 'this task may take an object already claimed by a strictly lower-priority task of a different type'. Default false; five classes set it. Caller 0x006abb00 skips the candidate when this returns true",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/subsystems/ai-stepping-and-passes.md#4 -- lane AI3 2026-09-08, instruction-stream read"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "StrategyAIAgent_RangePenaltyForTask",
|
||||||
|
"addr": "0x00696620",
|
||||||
|
"convention": "thiscall",
|
||||||
|
"prototype": "int __thiscall Game::StrategyAIAgent::RangePenaltyForTask() -- the ONLY consumer of IAITask vtable slot 13 found in the image, dispatched at 0x00696630 on the `this` receiver. `budget = this ? this->vt[13]() : 15; n = max(1, agent->+0x10->+0x8 - 0x0080da80(player) + 1); if (n < budget) return 0;` else a 7-arm species switch on player->+0x5c through the byte index at 0x006966a8 = [0,0,0,0,2,1,0] and the table at 0x0069669c: species 0,1,2,3,4,6 and out-of-range -> 1000000 (0x000f4240), species 5 (Zuul) -> 0. So slot 13 is a RANGE/HOP BUDGET compared against a count, with a prohibitive penalty past it -- and the Zuul are exempt, a FOURTH independent cross-check on lane AI2's species reading (after Hiver gates, Zuul node-bore and NPC building nothing). The two 'Incoming' defence tasks return INT_MAX from slot 13, so they never take the penalty",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/subsystems/ai-stepping-and-passes.md#4 -- lane AI3 2026-09-08, instruction-stream read; jump table and byte index read from the image at instruction boundaries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AITask_GetPriorityDefaultThunk",
|
||||||
|
"addr": "0x00694220",
|
||||||
|
"convention": "thiscall",
|
||||||
|
"prototype": "int __thiscall Game::IAITask::GetPriority_Default() -- 17 bytes: `return AITask_PriorityForType(this->vt[1]() /*GetTypeId*/);`, i.e. the vt[1] dispatch followed by a direct call to the 33-arm table at 0x00691f00. This is what the two tuned GetPriority overrides tail-jump to when their flag bit is CLEAR, so lane AI2's priority table stands with an extra hop in front of it",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/subsystems/ai-stepping-and-passes.md#3 -- lane AI3 2026-09-08, instruction-stream read"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "g_AITInvadeUncommittedPriority",
|
||||||
|
"addr": "0x00a1795c",
|
||||||
|
"convention": "data",
|
||||||
|
"prototype": "int -- image-initialised value 650 (0x0000028a). AITInvade::GetPriority 0x00683670 is `movzx eax,byte [ecx+4]; not al; test al,1; je +5; jmp 0x00694220; mov eax,ds:0xa1795c; ret` -- so the tunable is returned when BIT 0 OF this->+0x4 IS SET, which is the OPPOSITE of lane AI2's stated `if (!(this->+0x4 & 1))`. It has EXACTLY ONE reference in the whole image (this load) and no writer anywhere: no loader, no CSV path. It is a code constant that happens to live in the writable data section. AITInvade's table priority is 500, so the flag raises it to 650",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/subsystems/ai-stepping-and-passes.md#3 -- lane AI3 2026-09-08; value read from the PE image, reference count from an exhaustive 4-byte absolute-reference scan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "g_AITEscortGateInvadeUncommittedPriority",
|
||||||
|
"addr": "0x00a17960",
|
||||||
|
"convention": "data",
|
||||||
|
"prototype": "int -- image-initialised value 750 (0x000002ee), the twin of 0x00a1795c. AITEscortGateInvade::GetPriority 0x006835e0 has the identical shape and the identical inverted polarity: the tunable applies when bit 0 of this->+0x4 IS SET. Exactly one reference in the image, no writer. AITEscortGateInvade's table priority is 400, so the flag raises it to 750",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/subsystems/ai-stepping-and-passes.md#3 -- lane AI3 2026-09-08; value read from the PE image, reference count from an exhaustive 4-byte absolute-reference scan"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
84
ghidra/addresses.d/lane-w2.json
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
{
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"name": "StrategyServer_ProcessTurn_ModCountBump",
|
||||||
|
"addr": "0x007dc6f0",
|
||||||
|
"convention": "site",
|
||||||
|
"prototype": "`inc [esi+0x8]` with esi = S -- the FIRST instruction of StrategyServer::ProcessTurn's body bumps ModCount. OBSERVED LIVE by a DR0 4-byte write watchpoint on S+0x8 (lane W2): the trap reports EIP 0x007dc6f3, i.e. the instruction after a 3-byte `inc`, on both measured End Turns. This is the ordering marker for the whole ModCount question -- every command-application bump precedes it and the OnAllCombatDone_Tail bump follows it",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/control-flow/watchpoints-modcount-status.md §2 (lane W2 2026-09-08, hardware watchpoint, 2 End Turns from ref-turn2.sav); address predicted by lane A2 alliance-mask-and-modcount.md §3 and by lane T turn-driver.md §0.1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "StrategyServer_OnAllCombatDone_Tail_ModCountBump",
|
||||||
|
"addr": "0x007d92ca",
|
||||||
|
"convention": "site",
|
||||||
|
"prototype": "ModCount bump at OnAllCombatDone_Tail + 0x2a (trap EIP 0x007d92cd). CORRECTS lane A2's prose, which called it 'OnAllCombatDone_Tail's first instruction': the address A2 predicted is exactly right, the offset is +0x2a and not +0. It is the LAST ModCount write of the turn and it lands AFTER StrategyServer::ProcessTurn has been entered, which refines A2's falsifier (c) -- 'hits after ProcessTurn is entered' is expected for this one site and only this one",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/control-flow/watchpoints-modcount-status.md §2 (lane W2 2026-09-08, hardware watchpoint)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "StrategyServer_BeginProcessTurn_FrameBump",
|
||||||
|
"addr": "0x007d990a",
|
||||||
|
"convention": "site",
|
||||||
|
"prototype": "The turn-number increment: `inc [reg+0xc]` with reg = S, at BeginProcessTurn + 0x2a (trap EIP 0x007d990d). OBSERVED LIVE on DR1 watching S+0xc: EXACTLY ONE write per End Turn, value 3 -> 4 on the second measured turn, against TWELVE writes to S+0x8 in the same window. THIS SETTLES THE NAMING DISPUTE: S+0x8 is a modification counter (lane A2's `StrategySim_off_ModCount`) and S+0xc is the frame/turn number (lane A2's `StrategySim_off_Frame`). addresses.json's `StrategyServer_off_ModCount` (0x8 in the raw frame == S+0xc) carries the name on the wrong word, and lane T's `StrategyServer_off_PhaseCounter` (S+0x8) is ModCount",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/control-flow/watchpoints-modcount-status.md §3 (lane W2 2026-09-08, hardware watchpoint)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "StrategyServer_ProcessTurnTail_PlayerStatusOne",
|
||||||
|
"addr": "0x007dcc8a",
|
||||||
|
"convention": "site",
|
||||||
|
"prototype": "Writes Player.Status(+0x164) = 1 for the LOCAL player only (not for every player), at StrategyServer::ProcessTurn + 0x5ca. Trap EIP 0x007dcc94, so the store is a 10-byte `mov dword ptr [reg+0x164], 1`. Confirms lane T2's static reading of the site AND narrows it: on a two-human-player save only player[0]'s Status moved; player[1]'s did not",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/control-flow/watchpoints-modcount-status.md §4 (lane W2 2026-09-08, DR2/DR3 on players[0]/[1] +0x164)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "StrategyNetworkClient_OnMessage_PlayerStatusFour",
|
||||||
|
"addr": "0x00785055",
|
||||||
|
"convention": "site",
|
||||||
|
"prototype": "THE MISSING WRITER OF Player.Status = 4 between tail phase 31 and the post-turn autosave. This address is the instruction AFTER the store (the trap address); the store itself ends here. It lives in StrategyNetworkClient::OnMessage at +0xa15 -- the End-Turn dispatcher -- and it fires AFTER StrategyServer::ProcessTurn has returned and BEFORE the autosave, writing 4 to the local player's Status over the 1 the ProcessTurn tail had just written. CORRECTS lane T2's treaty-turn-stamp.md §3, which read StrategyServer::MarkPlayerTurnEnded 0x00821a40 as 'THE ONLY WRITER OF Player.Status = 4 IN THE IMAGE' and concluded 'there is NO writer between tail phase 31 and the autosave'. There is, and it was watched happening twice on two consecutive End Turns",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/control-flow/watchpoints-modcount-status.md §4 (lane W2 2026-09-08, hardware watchpoint on ServerPlayer+0x164)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "StrategyServer_MarkPlayerTurnEnded_StatusStore",
|
||||||
|
"addr": "0x00821a75",
|
||||||
|
"convention": "site",
|
||||||
|
"prototype": "The `p->Status(+0x164) = 4` store inside StrategyServer::MarkPlayerTurnEnded, at +0x35 (trap EIP 0x00821a75, so the store ends here). Called ONCE PER PLAYER at the START of an End Turn, from OnPlayerEndTurn 0x007d9af0 (return address 0x007d9b2a, i.e. the call is at +0x35) -- confirming lane T2's caller list live. It runs BEFORE the pre-turn autosave's successor and before ApplyAllTurnCommands, which is why the `(Autosave EndTurn)` file still carries Status 0",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/control-flow/watchpoints-modcount-status.md §4 (lane W2 2026-09-08, hardware watchpoint)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "StrategySim_ModCountBump_unresolved_0086c3e6",
|
||||||
|
"addr": "0x0086c3e6",
|
||||||
|
"convention": "site",
|
||||||
|
"prototype": "A ModCount bump observed live but NOT attributable to a named function: the trap EIP is 0x0086c3e9 and the nearest preceding known symbol is ServerTradeManager_ProcessTurn 0x0086b300, +0x10e9 away -- far too far to claim containment. Its return address is 0x0088fce2 (inside StrategySim::ApplyTurnCommandBatch 0x0088f9b0), so it IS one of lane A2's twenty command handlers; only the handler's identity is open. Recorded as an address to disassemble rather than dropped",
|
||||||
|
"status": "mapped",
|
||||||
|
"source": "findings/control-flow/watchpoints-modcount-status.md §2 (lane W2 2026-09-08)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "StrategySim_ModCountBump_unresolved_00821a84",
|
||||||
|
"addr": "0x00821a84",
|
||||||
|
"convention": "site",
|
||||||
|
"prototype": "A ModCount bump observed live FOUR times per turn -- the single most frequent command handler on this save. Trap EIP 0x00821a87; nearest known symbol is StrategyServer_MarkPlayerTurnEnded 0x00821a40, but lane T2 measured that function at 60 bytes (ending 0x00821a7c), so this is the NEXT function and MarkPlayerTurnEnded's neighbour, not MarkPlayerTurnEnded. Return address 0x0088ffcb (StrategySim::ApplyTurnCommandBatch)",
|
||||||
|
"status": "mapped",
|
||||||
|
"source": "findings/control-flow/watchpoints-modcount-status.md §2 (lane W2 2026-09-08)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "StrategySim_ModCountBump_unresolved_0084946b",
|
||||||
|
"addr": "0x0084946b",
|
||||||
|
"convention": "site",
|
||||||
|
"prototype": "A ModCount bump observed live once per turn; trap EIP 0x0084946e, nearest known symbol ServerTradeManagerImpl_vslot11 0x00848570 at +0xefe (not containment). Return address 0x008900a4 (StrategySim::ApplyTurnCommandBatch)",
|
||||||
|
"status": "mapped",
|
||||||
|
"source": "findings/control-flow/watchpoints-modcount-status.md §2 (lane W2 2026-09-08)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "StrategySim_ModCountBump_unresolved_0088befe",
|
||||||
|
"addr": "0x0088befe",
|
||||||
|
"convention": "site",
|
||||||
|
"prototype": "A ModCount bump observed live once per turn; trap EIP 0x0088bf01, nearest known symbol StrategyServer_DestroyFleet 0x0088b980 at +0x581 -- plausibly inside it, but unproven. Return address 0x008902b4 (StrategySim::ApplyTurnCommandBatch), so it is a command handler called from the batch applier rather than an inlined site",
|
||||||
|
"status": "mapped",
|
||||||
|
"source": "findings/control-flow/watchpoints-modcount-status.md §2 (lane W2 2026-09-08)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -245,3 +245,28 @@ gate**, where exactly one player passes on both corpus saves. A constant fitted
|
||||||
is not a constant.
|
is not a constant.
|
||||||
|
|
||||||
Instrument the **entry**, not just the cost.
|
Instrument the **entry**, not just the cost.
|
||||||
|
|
||||||
|
## 21. A lane never touches the shared working directory — worktree or clone, always
|
||||||
|
|
||||||
|
Two incidents, one session. A VM lane ran `git checkout --` in the shared `sots-engine` worktree and
|
||||||
|
discarded another lane's in-flight generated header. An AI lane ran `git checkout -b` there, which
|
||||||
|
**moved the repo's HEAD**, so the integrator's concurrent commit landed on the lane's branch instead
|
||||||
|
of `main` and was orphaned when that branch was deleted. Nothing was lost either time, but only
|
||||||
|
because both lanes reported it.
|
||||||
|
|
||||||
|
`git worktree add` or a separate clone. This is the same failure the `addresses.d/README` documents
|
||||||
|
for `git add`, one level up: **shared mutable state plus concurrency, with no lock.**
|
||||||
|
|
||||||
|
The integrator's counterpart: after any concurrent round, check `git log --oneline` on `main` before
|
||||||
|
pushing, and confirm the commits you believe you made are the ones that are there.
|
||||||
|
|
||||||
|
## 22. Union-resolving a merge is not textual concatenation
|
||||||
|
|
||||||
|
Two independent modules both added a branch to the same `if`/`else if` chain and a file to the same
|
||||||
|
CMake source list. Concatenating the conflict halves produced a `)` in the middle of the list and an
|
||||||
|
`if` body with no closing brace — the host build passed (those files are Windows-only) and **the
|
||||||
|
shim cross-build failed**, which is exactly the gap rule 13 exists for.
|
||||||
|
|
||||||
|
When both sides add a *member of a construct*, the resolution is to merge them **into that
|
||||||
|
construct** — one list, one chain — not to paste one after the other. Read the resolved region
|
||||||
|
before committing, and let the cross-build be the judge.
|
||||||
|
|
|
||||||
BIN
verify/results/multiplayer/w2-internet-browser-no-error.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
verify/results/multiplayer/w2-join-manually-dialog.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
verify/results/multiplayer/w2-lan-browse-listing.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
verify/results/multiplayer/w2-lobby-client.png
Normal file
|
After Width: | Height: | Size: 179 KiB |
BIN
verify/results/multiplayer/w2-mp-turn1.png
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
verify/results/multiplayer/w2-mp-turn2-client.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
verify/results/multiplayer/w2-mp-turn2-host.png
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
verify/results/multiplayer/w2-tier0-filtered.pcap
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
watchhit seq=0 mark=1 slot=0 dr6=0xffff0ff1 eip=0x012ec3e9 value=13(0x0000000d) tid=5928 ebpret=0x0130fce2 ebpret2=0x0120f6e6 scan=0x0130fce2,0x013dad98,0x0120f6e6,0x01204909
|
||||||
|
watchhit seq=1 mark=1 slot=0 dr6=0xffff0ff1 eip=0x012a1a87 value=14(0x0000000e) tid=5928 ebpret=0x0130ffcb ebpret2=0x0120f6e6 scan=0x0130ffcb,0x013dad98,0x0120f6e6,0x01204909
|
||||||
|
watchhit seq=2 mark=1 slot=0 dr6=0xffff0ff1 eip=0x012a1a87 value=15(0x0000000f) tid=5928 ebpret=0x0130ffcb ebpret2=0x0120f6e6 scan=0x0130ffcb,0x013dad98,0x0120f6e6,0x01204909
|
||||||
|
watchhit seq=3 mark=1 slot=0 dr6=0xffff0ff1 eip=0x012a1a87 value=16(0x00000010) tid=5928 ebpret=0x0130ffcb ebpret2=0x0120f6e6 scan=0x0130ffcb,0x013dad98,0x0120f6e6,0x01204909
|
||||||
|
watchhit seq=4 mark=1 slot=0 dr6=0xffff0ff1 eip=0x012a1a87 value=17(0x00000011) tid=5928 ebpret=0x0130ffcb ebpret2=0x0120f6e6 scan=0x0130ffcb,0x013dad98,0x0120f6e6,0x01204909
|
||||||
|
watchhit seq=5 mark=1 slot=0 dr6=0xffff0ff1 eip=0x012c946e value=18(0x00000012) tid=5928 ebpret=0x013100a4 ebpret2=0x0120f6e6 scan=0x013100a4,0x013dad98,0x0120f6e6,0x01204909
|
||||||
|
watchhit seq=6 mark=1 slot=0 dr6=0xffff0ff1 eip=0x0130bf01 value=19(0x00000013) tid=5928 ebpret=0x013102b4 ebpret2=0x0120f6e6 scan=0x01402148,0x012c9514,0x0141bb08,0x013102b4
|
||||||
|
watchhit seq=7 mark=1 slot=0 dr6=0xffff0ff1 eip=0x0131046f value=20(0x00000014) tid=5928 ebpret=0x0120f6e6 ebpret2=0x0139dfa1 scan=0x013dad98,0x0120f6e6,0x01204909,0x00000000
|
||||||
|
watchhit seq=8 mark=1 slot=0 dr6=0xffff0ff1 eip=0x0131046f value=21(0x00000015) tid=5928 ebpret=0x0120f6e6 ebpret2=0x0139dfa1 scan=0x013dad98,0x0120f6e6,0x01204909,0x00000000
|
||||||
|
watchhit seq=9 mark=1 slot=0 dr6=0xffff0ff1 eip=0x012e57ad value=22(0x00000016) tid=5928 ebpret=0x01310652 ebpret2=0x0120f6e6 scan=0x01339285,0x013fb198,0x01310652,0x013dad98
|
||||||
|
watchhit seq=10 mark=1 slot=0 dr6=0xffff0ff1 eip=0x0125c6f3 value=23(0x00000017) tid=5928 ebpret=0x72433b04 ebpret2=0x724337d4 scan=0x0141005c,0x012e57ad,0x00000000,0x00000000
|
||||||
|
watchhit seq=11 mark=1 slot=2 dr6=0xffff0ff4 eip=0x0125cc94 value=1(0x00000001) tid=5928 ebpret=0x72433b04 ebpret2=0x724337d4 scan=0x0141005c,0x012e57ad,0x00000000,0x00000000
|
||||||
|
watchhit seq=12 mark=1 slot=2 dr6=0xffff0ff4 eip=0x01205055 value=4(0x00000004) tid=5928 ebpret=0x0139dfa1 ebpret2=0x013a5651 scan=0x012317aa,0x0140c080,0x0124cac1,0x0124cad9
|
||||||
|
watchhit seq=13 mark=1 slot=0 dr6=0xffff0ff1 eip=0x012592cd value=24(0x00000018) tid=5928 ebpret=0x72406104 ebpret2=0x72405dd4 scan=0x0140fca0,0x00e81000,0x01205055,0x00000000
|
||||||
|
watchhit seq=14 mark=1 slot=2 dr6=0xffff0ff4 eip=0x0125dd49 value=0(0x00000000) tid=5928 ebpret=0x01204fe4 ebpret2=0x0139dfa1 scan=0x01410138,0x01204fe4,0x010345f4,0x012290be
|
||||||
|
watchhit seq=15 mark=1 slot=3 dr6=0xffff0ff8 eip=0x0125dd49 value=0(0x00000000) tid=5928 ebpret=0x01204fe4 ebpret2=0x0139dfa1 scan=0x01410138,0x01204fe4,0x010345f4,0x012290be
|
||||||
|
watchhit seq=16 mark=1 slot=3 dr6=0xffff0ff8 eip=0x012a1a75 value=4(0x00000004) tid=5928 ebpret=0x01259b2a ebpret2=0x0120481b scan=0x01259b2a,0x0140fd08,0x0120481b,0x0135e74a
|
||||||
|
watchhit seq=17 mark=1 slot=2 dr6=0xffff0ff4 eip=0x012a1a75 value=4(0x00000004) tid=5928 ebpret=0x01259b2a ebpret2=0x0120481b scan=0x01259b2a,0x0140fd08,0x0120481b,0x0135e74a
|
||||||
|
watchhit seq=18 mark=1 slot=1 dr6=0xffff0ff2 eip=0x0125990d value=4(0x00000004) tid=5928 ebpret=0x012048dd ebpret2=0x0139dfa1 scan=0x0140fce0,0x012048dd,0x00000000,0x00000000
|
||||||
|
watchhit seq=19 mark=2 slot=0 dr6=0xffff0ff1 eip=0x012ec3e9 value=25(0x00000019) tid=5928 ebpret=0x0130fce2 ebpret2=0x0120f6e6 scan=0x0130fce2,0x013dad98,0x0120f6e6,0x01204909
|
||||||
|
watchhit seq=20 mark=2 slot=0 dr6=0xffff0ff1 eip=0x012a1a87 value=26(0x0000001a) tid=5928 ebpret=0x0130ffcb ebpret2=0x0120f6e6 scan=0x0130ffcb,0x013dad98,0x0120f6e6,0x01204909
|
||||||
|
watchhit seq=21 mark=2 slot=0 dr6=0xffff0ff1 eip=0x012a1a87 value=27(0x0000001b) tid=5928 ebpret=0x0130ffcb ebpret2=0x0120f6e6 scan=0x0130ffcb,0x013dad98,0x0120f6e6,0x01204909
|
||||||
|
watchhit seq=22 mark=2 slot=0 dr6=0xffff0ff1 eip=0x012a1a87 value=28(0x0000001c) tid=5928 ebpret=0x0130ffcb ebpret2=0x0120f6e6 scan=0x0130ffcb,0x013dad98,0x0120f6e6,0x01204909
|
||||||
|
watchhit seq=23 mark=2 slot=0 dr6=0xffff0ff1 eip=0x012a1a87 value=29(0x0000001d) tid=5928 ebpret=0x0130ffcb ebpret2=0x0120f6e6 scan=0x0130ffcb,0x013dad98,0x0120f6e6,0x01204909
|
||||||
|
watchhit seq=24 mark=2 slot=0 dr6=0xffff0ff1 eip=0x012c946e value=30(0x0000001e) tid=5928 ebpret=0x013100a4 ebpret2=0x0120f6e6 scan=0x013100a4,0x013dad98,0x0120f6e6,0x01204909
|
||||||
|
watchhit seq=25 mark=2 slot=0 dr6=0xffff0ff1 eip=0x0130bf01 value=31(0x0000001f) tid=5928 ebpret=0x013102b4 ebpret2=0x0120f6e6 scan=0x01402148,0x012c9514,0x0141bb08,0x013102b4
|
||||||
|
watchhit seq=26 mark=2 slot=0 dr6=0xffff0ff1 eip=0x0131046f value=32(0x00000020) tid=5928 ebpret=0x0120f6e6 ebpret2=0x0139dfa1 scan=0x013dad98,0x0120f6e6,0x01204909,0x00000000
|
||||||
|
watchhit seq=27 mark=2 slot=0 dr6=0xffff0ff1 eip=0x0131046f value=33(0x00000021) tid=5928 ebpret=0x0120f6e6 ebpret2=0x0139dfa1 scan=0x013dad98,0x0120f6e6,0x01204909,0x00000000
|
||||||
|
watchhit seq=28 mark=2 slot=0 dr6=0xffff0ff1 eip=0x012e57ad value=34(0x00000022) tid=5928 ebpret=0x01310652 ebpret2=0x0120f6e6 scan=0x01339285,0x013fb198,0x01310652,0x013dad98
|
||||||
|
watchhit seq=29 mark=2 slot=0 dr6=0xffff0ff1 eip=0x0125c6f3 value=35(0x00000023) tid=5928 ebpret=0x72433b04 ebpret2=0x724337d4 scan=0x0141005c,0x012e57ad,0x00000000,0x00000000
|
||||||
|
watchhit seq=30 mark=2 slot=2 dr6=0xffff0ff4 eip=0x0125cc94 value=1(0x00000001) tid=5928 ebpret=0x72433b04 ebpret2=0x724337d4 scan=0x0141005c,0x012e57ad,0x00000000,0x00000000
|
||||||
|
watchhit seq=31 mark=2 slot=2 dr6=0xffff0ff4 eip=0x01205055 value=4(0x00000004) tid=5928 ebpret=0x0139dfa1 ebpret2=0x013a5651 scan=0x012317aa,0x0140c080,0x0124cac1,0x0124cad9
|
||||||
|
watchhit seq=32 mark=2 slot=0 dr6=0xffff0ff1 eip=0x012592cd value=36(0x00000024) tid=5928 ebpret=0x72406104 ebpret2=0x72405dd4 scan=0x0140fca0,0x00e81000,0x01205055,0x00000000
|
||||||
|
watchhit seq=33 mark=2 slot=2 dr6=0xffff0ff4 eip=0x0125dd49 value=0(0x00000000) tid=5928 ebpret=0x01204fe4 ebpret2=0x0139dfa1 scan=0x01410138,0x01204fe4,0x010345f4,0x012290be
|
||||||
|
watchhit seq=34 mark=2 slot=3 dr6=0xffff0ff8 eip=0x0125dd49 value=0(0x00000000) tid=5928 ebpret=0x01204fe4 ebpret2=0x0139dfa1 scan=0x01410138,0x01204fe4,0x010345f4,0x012290be
|
||||||
87
verify/results/shim/watchpoints/w2-shim-log-excerpt.txt
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
|
||||||
|
15:06:12.285 [tid 5928] ==== sots-engine shim (binkw32 proxy) build w2watch-3512c9a-dirty-20260908T1900Z ====
|
||||||
|
15:06:12.285 [tid 5928] config: watch=on
|
||||||
|
15:06:12.285 [tid 5928] config: watch.players=2
|
||||||
|
15:06:12.285 [tid 5928] config: watch.out=C:\SOTS\shim.watch.txt
|
||||||
|
15:06:12.769 [tid 5928] watch: VEH=006a8308 text=[0x00e81000,0x0145ca00) out=C:\SOTS\shim.watch.txt
|
||||||
|
15:06:12.785 [tid 5928] watch: arm hook StrategyServer::ApplyAllTurnCommands rva=0x0038f6a0 va=0120f6a0 create=MH_OK
|
||||||
|
enable=MH_OK
|
||||||
|
15:12:27.093 [tid 5928] watch: players vector @0e0b267c begin=0e163528 end=0e163548 count=8
|
||||||
|
15:12:27.093 [tid 5928] watch: SELFTEST canary writes=1 traps=1 dr7=0xdddd0055 PASS
|
||||||
|
15:12:27.093 [tid 5928] watch: slot 0 -> S+0x8 (A2:ModCount / T:PhaseCounter) = 0x0e0b2630
|
||||||
|
15:12:27.093 [tid 5928] watch: slot 1 -> S+0xc (A2:Frame / addresses.json:ModCount) = 0x0e0b2634
|
||||||
|
15:12:27.093 [tid 5928] watch: slot 2 -> player[0]+0x164 Status = 0x0e0cc2d4
|
||||||
|
15:12:27.093 [tid 5928] watch: slot 3 -> player[1]+0x164 Status = 0x0e0c5d44
|
||||||
|
15:12:27.093 [tid 5928] watch: ARMED on tid 5928 dr7=0xdddd0055, S=0e0b2628 (ApplyAllTurnCommands this)
|
||||||
|
15:12:27.578 [tid 5300] watchhit seq=0 mark=1 slot=0 dr6=0xffff0ff1 eip=0x012ec3e9 value=13(0x0000000d) tid=5928
|
||||||
|
ebpret=0x0130fce2 ebpret2=0x0120f6e6 scan=0x0130fce2,0x013dad98,0x0120f6e6,0x01204909
|
||||||
|
15:12:27.578 [tid 5300] watchhit seq=1 mark=1 slot=0 dr6=0xffff0ff1 eip=0x012a1a87 value=14(0x0000000e) tid=5928
|
||||||
|
ebpret=0x0130ffcb ebpret2=0x0120f6e6 scan=0x0130ffcb,0x013dad98,0x0120f6e6,0x01204909
|
||||||
|
15:12:27.578 [tid 5300] watchhit seq=2 mark=1 slot=0 dr6=0xffff0ff1 eip=0x012a1a87 value=15(0x0000000f) tid=5928
|
||||||
|
ebpret=0x0130ffcb ebpret2=0x0120f6e6 scan=0x0130ffcb,0x013dad98,0x0120f6e6,0x01204909
|
||||||
|
15:12:27.578 [tid 5300] watchhit seq=3 mark=1 slot=0 dr6=0xffff0ff1 eip=0x012a1a87 value=16(0x00000010) tid=5928
|
||||||
|
ebpret=0x0130ffcb ebpret2=0x0120f6e6 scan=0x0130ffcb,0x013dad98,0x0120f6e6,0x01204909
|
||||||
|
15:12:27.578 [tid 5300] watchhit seq=4 mark=1 slot=0 dr6=0xffff0ff1 eip=0x012a1a87 value=17(0x00000011) tid=5928
|
||||||
|
ebpret=0x0130ffcb ebpret2=0x0120f6e6 scan=0x0130ffcb,0x013dad98,0x0120f6e6,0x01204909
|
||||||
|
15:12:27.578 [tid 5300] watchhit seq=5 mark=1 slot=0 dr6=0xffff0ff1 eip=0x012c946e value=18(0x00000012) tid=5928
|
||||||
|
ebpret=0x013100a4 ebpret2=0x0120f6e6 scan=0x013100a4,0x013dad98,0x0120f6e6,0x01204909
|
||||||
|
15:12:27.578 [tid 5300] watchhit seq=6 mark=1 slot=0 dr6=0xffff0ff1 eip=0x0130bf01 value=19(0x00000013) tid=5928
|
||||||
|
ebpret=0x013102b4 ebpret2=0x0120f6e6 scan=0x01402148,0x012c9514,0x0141bb08,0x013102b4
|
||||||
|
15:12:27.578 [tid 5300] watchhit seq=7 mark=1 slot=0 dr6=0xffff0ff1 eip=0x0131046f value=20(0x00000014) tid=5928
|
||||||
|
ebpret=0x0120f6e6 ebpret2=0x0139dfa1 scan=0x013dad98,0x0120f6e6,0x01204909,0x00000000
|
||||||
|
15:12:27.578 [tid 5300] watchhit seq=8 mark=1 slot=0 dr6=0xffff0ff1 eip=0x0131046f value=21(0x00000015) tid=5928
|
||||||
|
ebpret=0x0120f6e6 ebpret2=0x0139dfa1 scan=0x013dad98,0x0120f6e6,0x01204909,0x00000000
|
||||||
|
15:12:27.578 [tid 5300] watchhit seq=9 mark=1 slot=0 dr6=0xffff0ff1 eip=0x012e57ad value=22(0x00000016) tid=5928
|
||||||
|
ebpret=0x01310652 ebpret2=0x0120f6e6 scan=0x01339285,0x013fb198,0x01310652,0x013dad98
|
||||||
|
15:12:27.578 [tid 5300] watchhit seq=10 mark=1 slot=0 dr6=0xffff0ff1 eip=0x0125c6f3 value=23(0x00000017) tid=5928
|
||||||
|
ebpret=0x72433b04 ebpret2=0x724337d4 scan=0x0141005c,0x012e57ad,0x00000000,0x00000000
|
||||||
|
15:12:27.578 [tid 5300] watchhit seq=11 mark=1 slot=2 dr6=0xffff0ff4 eip=0x0125cc94 value=1(0x00000001) tid=5928
|
||||||
|
ebpret=0x72433b04 ebpret2=0x724337d4 scan=0x0141005c,0x012e57ad,0x00000000,0x00000000
|
||||||
|
15:12:27.578 [tid 5300] watchhit seq=12 mark=1 slot=2 dr6=0xffff0ff4 eip=0x01205055 value=4(0x00000004) tid=5928
|
||||||
|
ebpret=0x0139dfa1 ebpret2=0x013a5651 scan=0x012317aa,0x0140c080,0x0124cac1,0x0124cad9
|
||||||
|
15:12:27.578 [tid 5300] watchhit seq=13 mark=1 slot=0 dr6=0xffff0ff1 eip=0x012592cd value=24(0x00000018) tid=5928
|
||||||
|
ebpret=0x72406104 ebpret2=0x72405dd4 scan=0x0140fca0,0x00e81000,0x01205055,0x00000000
|
||||||
|
15:12:27.578 [tid 5300] watchhit seq=14 mark=1 slot=2 dr6=0xffff0ff4 eip=0x0125dd49 value=0(0x00000000) tid=5928
|
||||||
|
ebpret=0x01204fe4 ebpret2=0x0139dfa1 scan=0x01410138,0x01204fe4,0x010345f4,0x012290be
|
||||||
|
15:12:27.578 [tid 5300] watchhit seq=15 mark=1 slot=3 dr6=0xffff0ff8 eip=0x0125dd49 value=0(0x00000000) tid=5928
|
||||||
|
ebpret=0x01204fe4 ebpret2=0x0139dfa1 scan=0x01410138,0x01204fe4,0x010345f4,0x012290be
|
||||||
|
15:15:26.531 [tid 5928] watchhit seq=16 mark=1 slot=3 dr6=0xffff0ff8 eip=0x012a1a75 value=4(0x00000004) tid=5928
|
||||||
|
ebpret=0x01259b2a ebpret2=0x0120481b scan=0x01259b2a,0x0140fd08,0x0120481b,0x0135e74a
|
||||||
|
15:15:26.531 [tid 5928] watchhit seq=17 mark=1 slot=2 dr6=0xffff0ff4 eip=0x012a1a75 value=4(0x00000004) tid=5928
|
||||||
|
ebpret=0x01259b2a ebpret2=0x0120481b scan=0x01259b2a,0x0140fd08,0x0120481b,0x0135e74a
|
||||||
|
15:15:26.531 [tid 5928] watchhit seq=18 mark=1 slot=1 dr6=0xffff0ff2 eip=0x0125990d value=4(0x00000004) tid=5928
|
||||||
|
ebpret=0x012048dd ebpret2=0x0139dfa1 scan=0x0140fce0,0x012048dd,0x00000000,0x00000000
|
||||||
|
15:15:26.968 [tid 5300] watchhit seq=19 mark=2 slot=0 dr6=0xffff0ff1 eip=0x012ec3e9 value=25(0x00000019) tid=5928
|
||||||
|
ebpret=0x0130fce2 ebpret2=0x0120f6e6 scan=0x0130fce2,0x013dad98,0x0120f6e6,0x01204909
|
||||||
|
15:15:26.968 [tid 5300] watchhit seq=20 mark=2 slot=0 dr6=0xffff0ff1 eip=0x012a1a87 value=26(0x0000001a) tid=5928
|
||||||
|
ebpret=0x0130ffcb ebpret2=0x0120f6e6 scan=0x0130ffcb,0x013dad98,0x0120f6e6,0x01204909
|
||||||
|
15:15:26.968 [tid 5300] watchhit seq=21 mark=2 slot=0 dr6=0xffff0ff1 eip=0x012a1a87 value=27(0x0000001b) tid=5928
|
||||||
|
ebpret=0x0130ffcb ebpret2=0x0120f6e6 scan=0x0130ffcb,0x013dad98,0x0120f6e6,0x01204909
|
||||||
|
15:15:26.968 [tid 5300] watchhit seq=22 mark=2 slot=0 dr6=0xffff0ff1 eip=0x012a1a87 value=28(0x0000001c) tid=5928
|
||||||
|
ebpret=0x0130ffcb ebpret2=0x0120f6e6 scan=0x0130ffcb,0x013dad98,0x0120f6e6,0x01204909
|
||||||
|
15:15:26.968 [tid 5300] watchhit seq=23 mark=2 slot=0 dr6=0xffff0ff1 eip=0x012a1a87 value=29(0x0000001d) tid=5928
|
||||||
|
ebpret=0x0130ffcb ebpret2=0x0120f6e6 scan=0x0130ffcb,0x013dad98,0x0120f6e6,0x01204909
|
||||||
|
15:15:26.968 [tid 5300] watchhit seq=24 mark=2 slot=0 dr6=0xffff0ff1 eip=0x012c946e value=30(0x0000001e) tid=5928
|
||||||
|
ebpret=0x013100a4 ebpret2=0x0120f6e6 scan=0x013100a4,0x013dad98,0x0120f6e6,0x01204909
|
||||||
|
15:15:26.968 [tid 5300] watchhit seq=25 mark=2 slot=0 dr6=0xffff0ff1 eip=0x0130bf01 value=31(0x0000001f) tid=5928
|
||||||
|
ebpret=0x013102b4 ebpret2=0x0120f6e6 scan=0x01402148,0x012c9514,0x0141bb08,0x013102b4
|
||||||
|
15:15:26.968 [tid 5300] watchhit seq=26 mark=2 slot=0 dr6=0xffff0ff1 eip=0x0131046f value=32(0x00000020) tid=5928
|
||||||
|
ebpret=0x0120f6e6 ebpret2=0x0139dfa1 scan=0x013dad98,0x0120f6e6,0x01204909,0x00000000
|
||||||
|
15:15:26.968 [tid 5300] watchhit seq=27 mark=2 slot=0 dr6=0xffff0ff1 eip=0x0131046f value=33(0x00000021) tid=5928
|
||||||
|
ebpret=0x0120f6e6 ebpret2=0x0139dfa1 scan=0x013dad98,0x0120f6e6,0x01204909,0x00000000
|
||||||
|
15:15:26.968 [tid 5300] watchhit seq=28 mark=2 slot=0 dr6=0xffff0ff1 eip=0x012e57ad value=34(0x00000022) tid=5928
|
||||||
|
ebpret=0x01310652 ebpret2=0x0120f6e6 scan=0x01339285,0x013fb198,0x01310652,0x013dad98
|
||||||
|
15:15:26.968 [tid 5300] watchhit seq=29 mark=2 slot=0 dr6=0xffff0ff1 eip=0x0125c6f3 value=35(0x00000023) tid=5928
|
||||||
|
ebpret=0x72433b04 ebpret2=0x724337d4 scan=0x0141005c,0x012e57ad,0x00000000,0x00000000
|
||||||
|
15:15:26.968 [tid 5300] watchhit seq=30 mark=2 slot=2 dr6=0xffff0ff4 eip=0x0125cc94 value=1(0x00000001) tid=5928
|
||||||
|
ebpret=0x72433b04 ebpret2=0x724337d4 scan=0x0141005c,0x012e57ad,0x00000000,0x00000000
|
||||||
|
15:15:26.968 [tid 5300] watchhit seq=31 mark=2 slot=2 dr6=0xffff0ff4 eip=0x01205055 value=4(0x00000004) tid=5928
|
||||||
|
ebpret=0x0139dfa1 ebpret2=0x013a5651 scan=0x012317aa,0x0140c080,0x0124cac1,0x0124cad9
|
||||||
|
15:15:26.968 [tid 5300] watchhit seq=32 mark=2 slot=0 dr6=0xffff0ff1 eip=0x012592cd value=36(0x00000024) tid=5928
|
||||||
|
ebpret=0x72406104 ebpret2=0x72405dd4 scan=0x0140fca0,0x00e81000,0x01205055,0x00000000
|
||||||
|
15:15:26.968 [tid 5300] watchhit seq=33 mark=2 slot=2 dr6=0xffff0ff4 eip=0x0125dd49 value=0(0x00000000) tid=5928
|
||||||
|
ebpret=0x01204fe4 ebpret2=0x0139dfa1 scan=0x01410138,0x01204fe4,0x010345f4,0x012290be
|
||||||
|
15:15:26.968 [tid 5300] watchhit seq=34 mark=2 slot=3 dr6=0xffff0ff8 eip=0x0125dd49 value=0(0x00000000) tid=5928
|
||||||
|
ebpret=0x01204fe4 ebpret2=0x0139dfa1 scan=0x01410138,0x01204fe4,0x010345f4,0x012290be
|
||||||
|
|
||||||
|
|
||||||