diff --git a/findings/control-flow/raid-intercept-species-word.md b/findings/control-flow/raid-intercept-species-word.md new file mode 100644 index 0000000..4e04404 --- /dev/null +++ b/findings/control-flow/raid-intercept-species-word.md @@ -0,0 +1,207 @@ +# `z` — the raid interception word, and the species that pays it + +- **Type:** control-flow (static decode of the gate + live measurement) +- **Owner / date:** lane AZ · 2026-09-09 · guest **VM141** (`sots-re-win10-b`, 192.168.10.143) +- **Status:** predictions committed; results to follow +- **Decides:** the `z` term of lane AR's `k + s + z` tail model + (`findings/control-flow/raid-target-pick-verdict.md` §4.2) — **never observed through + `Slot13RngCalleeB` by any lane**, because every raid this campaign has run was Human +- **Instrument:** lane Z's return-address ledger (`draw_sites`) + lane H's entry probes at + **`probes=8`**, the byte-neutral set. **Not `probes=11`** (lane H bisected index 8 as not + byte-neutral, and lane AC's turn totals are not calibration data because of it) + +--- + +## 1. Predictions, committed before the build (rule 2) + +*Everything in §1 was written and committed before the shim was built, before the workload was +played and before any counter was read. It is not edited after the fact — only annotated with +verdicts below the line.* + +### 1.0 What the brief said, and the two ways it is incomplete + +My brief — inherited from lane AR's §4.2 and lane AG's §5.2 — states the gate as: + +> A **Human or Zuul** raider intercepts and draws nothing. **Every other species draws a word.** + +That is **wrong in two places**, and both were found by reading `FUN_00820af0` from the +instruction stream on the host before any guest was touched (rule 18's corollary: static reading +is for explaining, but here it is also for *not wasting a workload*). The function has **three** +short-circuits and they are a disjunction; the campaign has only ever named the third. + +``` +FUN_00820af0(TradeManager* this, Vector3* out, TradeRoute* rt, StarFleet* fleet) ; 0x00820af0 + + ebx = registry_lookup(S+0x84, rt->tro) ; the ROUTE OWNER (ServerPlayer) + ... + 00820bc2 cmp DWORD PTR [ebx+0x5c],0x1 ; ServerPlayer+0x5c is the serialised `Species` tag + 00820bc6 je 0x820bf6 ; SC1 -- route owner is species 1 + 00820bcf call 0x81a2d0 ; StarFleet_GetCrewSpeciesForIntercept(fleet) + 00820bd7 cmp eax,0x1 + 00820bda je 0x820bf6 ; SC2 -- RAIDER's crew species is 1 + 00820bdd call 0x81a2d0 ; again, same value + 00820be3 call 0x545cc0 ; SpeciesDef_Get(k) -> 0x00b10a00 + k*0x184 + 00820bed call 0x53baf0 ; SpeciesDef_HasInterceptFlag: [ecx+0x144] != 0 + 00820bf4 je 0x820bf8 ; SC3 -- flag clear: fall through to the draw + 00820bf6 mov al,0x1 + 00820bf8 fldz ; fstp [ebp+0xc] ; frac = 0.0 + 00820bfd test al,al + 00820bff jne 0x820c33 ; short-circuited: NO DRAW + 00820c1b call 0x47d830 ; <<< NextFloat, ONE WORD -- this is `z` +``` + +**Correction 1 — species 1 short-circuits too, and species 1 is Hiver.** `SC2` is a plain +`== 1` test on the raider's own crew species and has nothing to do with `SpeciesDef+0x144`. +It is not mentioned in AR's §4.2 disjunct list as a *species* test at all (AR annotates it +"raider is Species 0 -- no", which is true of AR's state and hides the rule). + +**Correction 2 — the route owner's species is a gate too.** `SC1` tests the *route owner*, not +the raider. A route owned by a species-1 empire is intercepted with **no draw whoever raids it**. +My brief does not mention this conjunct at all, and it is a way for this lane's run to come out +zero for a reason that has nothing to do with the raider. + +### 1.1 The species enum, named from the image + +`SpeciesDef_InitTable 0x005453a0` selects the `Species/%s/...` data-path prefix through +`jmp DWORD PTR [eax*4+0x545b60]`. Reading the seven-entry jump table **as bytes** (not by the +order the case bodies happen to be laid out in, which is a different order and would give the +wrong answer): + +| index | jump-table target | string | +|---|---|---| +| **0** | `0x005453f2` | **`Human`** | +| **1** | `0x005453f9` | **`Hiver`** | +| **2** | `0x00545400` | **`Tarkas`** | +| **3** | `0x00545407` | **`Liir`** | +| **4** | `0x0054541c` | **`_NPC`** | +| **5** | `0x0054540e` | **`Zuul`** | +| **6** | `0x00545415` | **`Morrigi`** | + +`SpeciesDef_Get 0x00545cc0` bounds-checks `k <= 6`, so **there are exactly seven**, and +`0x00b10a00 + k*0x184` is the element. This independently confirms lane AR's "species 5 = Zuul" +and "species 0 = Human", and it names 1, 2, 3, 4 and 6 for the first time in this campaign. + +The `+0x144` flag word is written only at `0x0054562f` (`or [ebx+0x144],2` when the element's +index is **5**) and `0x00545638` (`or [ebx+0x144],1` when it is **0**) — AR's read, reproduced. + +### 1.2 The gate as a predicate, and the corpus counted against it (rule 28 practices 1 and 5) + +> **`0x00820c1b` draws one word through `Slot13RngCalleeB` iff** +> **(a)** the route's owner is **not species 1 (Hiver)**, **and** +> **(b)** the raider fleet's crew species is **not 1 (Hiver)**, **and** +> **(c)** the raider fleet's crew species is **not 0 (Human) and not 5 (Zuul)**. +> +> Equivalently: the raider's crew species is one of **2 Tarkas, 3 Liir, 4 _NPC, 6 Morrigi**, +> and the route owner is not Hiver. + +`StarFleet_GetCrewSpeciesForIntercept 0x0081a2d0` returns the fleet **owner's** `Species` +(`FUN_0071e280(fleet)->+0x5c`) on every path except one: when that species is **4 (`_NPC`)** it +scans the fleet's ships for a `design->+0x14->+0xac..+0xb4` record and returns that record's +`+0x4`. So for a player-owned fleet, crew species == owner species, full stop. + +**Corpus count, all 30 saves in `verify/results/saves/`, read with `save_reader.py --json`:** + +| species present | saves | +|---|---| +| 0 Human (player 0) + 2 Tarkas (the AI) + 4 `_NPC` | **24** | +| 5 Zuul (both players) + 4 `_NPC` | 6 | +| 1 Hiver, 3 Liir, 6 Morrigi | **0** | + +**Corpus count against the predicate: 0 of 30.** And the near miss is instructive: **24 saves +already contain a Tarka empire**, which is a *drawing* species — but it is the **AI**, and +`tscr` is `253` in every one of them (bit 0 set = player 0 permitted, **bit 1 clear** = the AI has +no Commerce Raiding). So the AI never rolls, `A` never fires for it, and no amount of patience on +an existing save reaches `z`. **The "an AI of another species already raids" option my brief asked +me to consider is dead, and the corpus count is what killed it** — the state has to be built. + +**And the tech data says which species can even be a raider.** From +`verify/results/data-catalogs/tech_tree.json` (`MasterTechList.tech`, per-race +availability percentages as written): + +| edge | rp | Human | Zuul | Hiver | Tarkas | Liir | Morrigi | +|---|---|---|---|---|---|---|---| +| `CCC_FTLBrdB -> CCC_FTLEcon` | 18,000 | 100 | **0** | 100 | 100 | 100 | 100 | +| `CCC_FTLBrdB -> CCC_ComRaid` | 5,000 | 0 | **100** | 0 | 0 | 0 | 0 | +| `CCC_FTLEcon -> CCC_ComRaid` | 12,000 | 100 | 0 | **0** | **100** | **100** | **100** | + +So **Hiver can never research Commerce Raiding at all** (0% on both edges) — which makes `SC2` +unreachable for a Hiver *player* through `vslot13`, and leaves it as either a defensive test or +the path for an `_NPC`-owned fleet with a Hiver-crewed design. And the species that can both raid +and draw are exactly **Tarkas, Liir and Morrigi**. + +**This lane picks Tarkas (species 2).** Liir is rejected on movement (their early game is a +sublight crawl, and this workload lives or dies on parking a fleet on a distant node), Morrigi on +their fleet-size-dependent drive. Tarkas is a plain node-drive empire with a 100% path to +Commerce Raiding, and it is the species the AI already is in the lineage every other trade lane +has used, so the route-owner side is a known quantity. + +### 1.3 The workload + +A **new single-player game on VM141** with the species pool cut to **Tarkas only** — lane V's +trick from the Zuul save, which makes *both* players Tarkas and so pins the route owner's species +to 2 as well, satisfying `SC1` by construction rather than by luck. Create-game parameters as +close as I can get to lane AC's `MyGamel3trade` (`NSys 28`, `MaxP 2`, `PCol 10`, +`PSav 9,850,000`, `PTech 15`, `IncM 1.48`, `ResM 1.46`), because that is the setup that put a +human raider on a trade sector by turn 20 and gave the AI four trade routes by turn 22. + +Then: research `CCC_FTLBrdB -> CCC_FTLEcon -> CCC_ComRaid` (34,000 RP on this branch), build +**several separate single-ship long-range fleets**, and park them on a trade sector that **sources +a route**. Separate fleets, not one fleet — the roll is per fleet (lane AD) at +`ODDS + 0.01·nShips`, so `m` one-ship fleets roll `m` times and one `m`-ship fleet rolls once. + +**Cruisers if their range reaches, destroyers if not.** Cruisers roll at +`(double)0.2f + (double)0.01f = 0.21` per fleet-turn against a destroyer's `0.06` — three and a +half times the signal per fleet-turn — and a raider is **displaced off the node by its own +success** (AR §4.3), so each fleet contributes at most one success per parking and the arm +exhausts itself. That is the structural constraint on this workload and it is why hull choice +matters more than turn count. + +**The route set moves between turns and this map is not AD's map.** Lane AR's geometry table is +for the `MyGamel3trade` lineage only. I will re-read `trdmgr` on the state I actually build and +pick the target sector from *that* `trfr` set, preferring a sector that sources **one or two** +routes: `bound = n - 1`, so `n = 1` gives mask 0 and `n = 2` gives mask 1, and in both cases +`RNG_NextInt` **cannot reject** and `0x0088b613` costs exactly one word. At `n >= 3` the rejection +loop is live and `s` stops being one word per success. + +### 1.4 The numbered predictions + +| # | prediction | +|---|---| +| **P0** | The species enum is `0 Human, 1 Hiver, 2 Tarkas, 3 Liir, 4 _NPC, 5 Zuul, 6 Morrigi`, read from the jump table at `0x00545b60` as bytes. Falsifiable live: the save I build will carry `Species 2` on both `ServerPlayer` records and the game will have been created with **Tarkas** selected in the UI | +| **P1** | `FUN_00820af0` short-circuits on **three** conjuncts, not one. The campaign's published sentence ("Human or Zuul draws nothing, every other species draws") is incomplete: **Hiver raiders draw nothing** (`SC2`), and **a Hiver-owned route is intercepted without a draw whoever raids it** (`SC1`) | +| **P2** | The short-circuit sets `frac = **0.0**`, **not `1.0`** as both lane AG §5.2 and lane AR §4.2 write it. Traced through the x87 stack at `0x00820bf8`–`0x00820c33`: `fldz; fstp [ebp+0xc]` on the short-circuit arm, and `0.0 + (1.0 - 0.0)*NextFloat()` on the draw arm. The interception point is therefore at the route's **source** end for a Human or Zuul raider, not its destination. Word count unaffected; the published sentence is wrong (rule 28 practice 6 — read the polarity before the count) | +| **P3** | Corpus count against the predicate is **0 of 30**, and the "an AI of another species already raids" option is **dead**: 24 saves hold a Tarka AI, but `tscr = 253` in every one, so the AI lacks Commerce Raiding and never rolls | +| **P4** | **`OnAllCombatDone_Tail` costs `k + 2s` words** on this state, where `k` = permitted Tarka raiders parked with `Pos` bit-equal and `s` = successes on a route-sourcing sector. This is `k + s + z` with **`z = s`**, and it is the term no lane has ever observed | +| **P5** | `draw_sites` shows, on a turn with `s` successes: `NextFloat 0x00820e18` `calls == words == k`; `NextInt 0x0088b613` `calls == words == s`; and **`NextFloat 0x00820c1b` `calls == words == s`**. Bracket residual **0** on every turn | +| **P6** | **The `0x00820c1b` row is attributable to `B` and not to `CreateRaidEncounter` exactly when `NextInt 0x008939ee` reads `calls = 0` on the same turn.** Proof, and it is proof-carrying rather than a convention: `FUN_00820af0` has **exactly two `E8` callers in the whole image** — `0x0082cf65` and `0x0088b62c` — verified by an image-wide relative-call scan; `0x0082cf65` sits in `FUN_0082ce00`, whose only caller is `0x00892700` inside `FUN_00892640`, which `CreateRaidEncounter` calls at `0x00893a25`; and `0x008939ee` is on the **straight-line path** from the empty-list gate to `0x00893a25` with no branch around it (`0x008939c9 -> 0x008939ee -> 0x00893a25`, decoded). So `0x008939ee` at zero means the other caller never ran. If it is non-zero on a measured turn I will call that turn's attribution ambiguous and use another turn, not argue | +| **P7** | `B`'s cost on a success is **2** words for a Tarka raider. Lane AG §5.2's "1 (NextInt) + 1 (NextFloat)" is right for a drawing species; lane AR's measured **1** is right for Human and Zuul; AG's committed §3.3 figure of "3 words on a succeeding turn" is right for `k = 1` **and a drawing species**, and was wrong only because the lineage was Human | +| **P8** | The `hooks=off` control **will agree with itself** (two fresh processes, byte-identical autosaves) — I am predicting lane AR's outcome, not lane AD's, **and I am not confident**. AD's variation was confined to one AI player's decision sub-tree on a state ten turns earlier in a *different* game; a resolver is looking at the discrepancy now and neither outcome is safe to assume. If my control varies I will localise it by sub-tree with `state_checksum.py --tree` and state exactly which leaves my measurement depends on and whether *those* reproduce | +| **P9** | With `k` one-cruiser fleets at `p = 0.21` per fleet-turn (`(double)0.2f + (double)0.01f`) I expect the first success within the first two or three instrumented turns. Any zero is reported as a **binomial probability**, never as "unremarkable" (rule 20), and an entry probe on `B` separates "did not fire" from "fired and found nothing" | +| **P10** | **Not settled by this lane, predicted in advance** (rule 15): the freighter doubling of the raid odds; the `design+0x12c > 1` short-circuit; `bestScale` (`0x009f8d48 = 0.33f` when `ship+0x18 & 0x100000`); the rejection loop at `0x0088b613` for a candidate list of three or more; and whether `SC2` is reachable at all for a *player* (the tech data says no) | + +### 1.5 The falsifier, stated as the sentence I will have to write + +> **If a Tarka raider parked on a route-sourcing sector succeeds, `Slot13RngCalleeB` is entered, +> `NextInt 0x0088b613` fires — and there is still no `NextFloat 0x00820c1b` row — then the species +> flag is not the only thing gating that draw**, and the two conjuncts ahead of it become the live +> suspects. In that case I will report, per rule 28, exactly which conjunct I satisfied and which I +> did not: `SC1` is checkable in the save I built (the route owner's `Species`), `SC2` is checkable +> the same way (the raider's owner's `Species`), and if both check out then `SpeciesDef+0x144` is +> not what `0x0053baf0` is reading and the static decode is retired. + +That is a real result and it is the more interesting one, because it would mean two lanes' static +reads of the same twelve-byte function produced a confident wrong answer. + +### 1.6 What could go wrong with the *workload*, and what each failure looks like + +| way it fails | symptom | +|---|---| +| the Tarka AI never builds a trade route in the turns I can afford | `trdmgr` holds zero `rt` records; no sector sources a route; `A` fires and `B` returns at `G_B4` with 0 words — the exact zero lanes AC and AD measured, for AC's reason not mine | +| no Tarka hull reaches a route-sourcing sector | `A` never fires (`G4` fails, `Pos` not bit-equal); probe 5 reads 0 | +| I cannot get Commerce Raiding in the session | `tscr` stays `252`; `vslot13`'s `G3b` rejects; probe 2 fires and probe 5 reads 0 | +| the target sector sources three or more routes | `0x0088b613` `words > calls` and `s` stops being one word per success — reported, not hidden | +| the encounter query eats a scripted End Turn | detected from the `Done` pixel at (233,673) before every End Turn, per AR §10 | + +--- + +*Results, and the verdicts, follow below. Nothing above this line is edited after the run.* diff --git a/ghidra/addresses.d/az.json b/ghidra/addresses.d/az.json new file mode 100644 index 0000000..9dee899 --- /dev/null +++ b/ghidra/addresses.d/az.json @@ -0,0 +1,12 @@ +{ + "entries": [ + { + "name": "SpeciesDef_NameJumpTable", + "addr": "0x00545b60", + "convention": "data", + "prototype": "void*[7], the switch jump table SpeciesDef_InitTable 0x005453a0 dispatches through at 0x005453eb (`jmp [eax*4+0x545b60]`) to choose the `Species/%s/...` data-path prefix. READ AS BYTES, the seven entries are 0x005453f2 \"Human\", 0x005453f9 \"Hiver\", 0x00545400 \"Tarkas\", 0x00545407 \"Liir\", 0x0054541c \"_NPC\", 0x0054540e \"Zuul\", 0x00545415 \"Morrigi\" -- so the species enum is 0 Human, 1 HIVER, 2 TARKAS, 3 LIIR, 4 _NPC, 5 Zuul, 6 MORRIGI. NOTE the case bodies are NOT laid out in case order (index 4 jumps PAST index 5's body), so reading the disassembly top to bottom gives the wrong enum; only the table bytes settle it. SpeciesDef_Get 0x00545cc0 bounds-checks k <= 6, so there are exactly seven. This confirms lane AR's species 0 = Human / species 5 = Zuul from an independent direction and names 1, 2, 3, 4 and 6 for the first time", + "status": "verified", + "source": "findings/control-flow/raid-intercept-species-word.md (lane AZ 2026-09-09); disassembled from dumps/sots.exe" + } + ] +}