From b67362b999f5f52ffa8465423bc6030bd805fef3 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 20:08:40 -0400 Subject: [PATCH 1/3] SD: predictions for the ship-design composer, written before the build The cost model for 0x006ad700 read from the instruction stream: nine live draw sites, an exit taxonomy saying what each of the eight bail-outs has already drawn, and a closed form for the loop-carried draw's trip count f = 1.00 / 0.75 / 0.50 by request flags, hull size and one 0.3 coin M = (int)(N * f) D' = max(1, (N + 1) / M) L = #{ qualifying mounts j : PointDefence section, or j mod D' == 0 } with the falsifiable corollary that the 0.3 coin can only move the word count at N in {1,2,3,5}, and that N=1 with f<1 enters the loop and draws nothing. Also predicts client 32's seven turn-1 words as two composer calls -- a costOnly=1 price query (A+B+C) and a costOnly=0 build (A+B+C+F) -- which is read statically off 0x006cda40's two push sites, not fitted to the number. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ --- docs/SD-predictions.md | 197 +++++++++++++++++++++++++++++++++++++++ src/shim/shim.cfg.sdbr | 57 +++++++++++ src/shim/shim.cfg.sdoff | 57 +++++++++++ src/shim/shim.cfg.sdpin | 57 +++++++++++ src/shim/shim.cfg.sdpin2 | 57 +++++++++++ 5 files changed, 425 insertions(+) create mode 100644 docs/SD-predictions.md create mode 100644 src/shim/shim.cfg.sdbr create mode 100644 src/shim/shim.cfg.sdoff create mode 100644 src/shim/shim.cfg.sdpin create mode 100644 src/shim/shim.cfg.sdpin2 diff --git a/docs/SD-predictions.md b/docs/SD-predictions.md new file mode 100644 index 0000000..17599c6 --- /dev/null +++ b/docs/SD-predictions.md @@ -0,0 +1,197 @@ +# Lane SD — predictions, written before the shim build + +Target: `AIComposeShipBlueprint 0x006ad700`, the AI ship-design composer. Lane PAR localised the +AI's whole RNG variance to it: on `turn1-state`, six of client 32's seven words are drawn inside it +and the seventh is drawn by a helper it calls. It holds nine live draw sites, including the only +loop-carried one. + +Everything below is derived from the **instruction stream only** (`0x006ad700`–`0x006ae61a`, swept +to the next function start per rule 17; the body ends with a `ret` at `0x006ae61a` and `int3` +padding to `0x006ae620`, so Ghidra's 3864-byte size is right on this one). Nothing here has been +run yet. The instrument is `sots-engine` `wip/sd`, `aidesign=on` in `src/shim/shim.cfg.sd*`. + +--- + +## The model + +One call is `int AIComposeShipBlueprint(ECX agent, EDX parts[3], STACK req*, STACK costOnly)`. +`req = {int hullSize; float budget; int role; uint flags}`. It returns 0 on success and 1..8 for +eight bail-outs. Its **only** caller `0x006ae620` runs it once per candidate hull size — 1, 2 or 3 +sizes depending on `flags & 1` and `flags & 2` — and **breaks on the first success**, so one design +request costs one to three composer calls and every failed attempt has already spent whatever it +drew before it failed. + +Draw sites, in execution order, named by the return address the airng tables know them by: + +| tag | ret | primitive | words | fires when | +|---|---|---|---|---| +| A | `0x006ad878` | `cl_RandFloat` → `NextFloat` | **1** | tech latch still ok **and** `(flags & 0x40) == 0` | +| B | `0x006ad94c` | `cl_RandRange(0, nH-1)` → `NextInt` | `E=(mask+1)/nH` | `parts[0].section == 0` and `nH > 0` (`nH` capped at 0x32) | +| C | `0x00691ea0` | `cl_RandRange(0, nC-1)` → `NextInt` | `E=(mask+1)/nC` | command slot empty, section not fixed, `nC > 0` | +| D | `0x006adf35` | `cl_Chance(0.5f)` | **1** | `K > 0`, where `K` = mounts with kind ∈ {0, 0xc, 0x17} and size 3 | +| E | `0x006adf44` | `cl_RandRange(0, K-1)` | `E=(mask+1)/K` | D accepted | +| — | `0x006adf62`, `0x006adf71` | | **dead** | `xor esi,esi / cmp esi,ebx / jle` with `ebx = 0` — confirmed by Ghidra removing both blocks | +| F | `0x006adfca` | `cl_Chance(0.8f)` | **1** | unconditional once the call gets this far | +| G | `0x006ae418` | `cl_Chance(0.3f)` | **1** | `(flags & 0x20000) == 0` **and** `req.hullSize > 0` | +| H | `0x006ae57a` | `cl_Chance(0.2f)` | **L** | **once per qualifying mount, see below** | + +`RNG_Chance` costs exactly one word at every one of these `p` values (0.2/0.3/0.5/0.8 are all +strictly inside `(0,1)`, so neither of its zero-word early-outs is reachable here). Only B, C and E +can cost more than one word per call, and only through `NextInt`'s rejection loop. + +### The loop-carried draw's trip count + +The tail is `for part in 0..2 { for mount in 0..part.mountCount }`. The **outer count is a literal +3** and never varies. The inner draw fires for a mount iff: + +* `mount.kind == 0 && mount.size == 1` (call these the **qualifying** mounts, `N` of them across all + three sections), **and** +* the section's name is `DEPointDefence` or `CRPointDefence` (case-insensitive) — those take every + qualifying mount — **or** the mount's **global** qualifying index `j` satisfies `j mod D' == 0`, + **and** +* the restricted weapon lookup at `0x006ae3c1` returned non-null (else the site short-circuits with + no call), and the default weapon lookup at `0x006ae3dc` returned non-null (else the whole tail + block is skipped), and `M > 0`. + +with + +``` +f = 1.00 if (flags & 0x20000) == 0 and req.hullSize > 0 and site G accepted + = 0.75 if (flags & 0x20000) != 0, or G was drawn and refused + = 0.50 if (flags & 0x20000) == 0 and req.hullSize <= 0 +M = (int)(N * f) float32 multiply, floor, truncate — non-negative, so one cast +D' = max(1, (N + 1) / M) integer division +L = #{ j in [0,N) : section(j) is *PointDefence, or j mod D' == 0 } +``` + +Consequences that make this falsifiable rather than decorative: + +* `f = 1.00` ⇒ `D' = 1` ⇒ **`L = N`**. +* `f = 0.75` ⇒ `L = 0, 1, 2, 4, 3, 6, 7, 8` for `N = 1..8` — note it is **not** monotone: `N = 5` + costs 3 and `N = 4` costs 4, because `D'` jumps. +* `f = 0.50` ⇒ `L = 0, 1, 1, 2, 2, 3, 4, 4` for `N = 1..8`. +* `N = 1` with `f < 1` gives `M = 0` and **zero** draws — a whole loop that is entered and costs + nothing (method rule 20's shape, and the reason the probe logs `N` next to `H_obs`). +* The 0.3 coin at G therefore changes the word count **only when `N = 1`, `N = 2`, `N = 3` or + `N = 5`**. For `N = 4` and `N ≥ 6` both `f = 0.75` and `f = 1.00` give `D' = 1` and the same `L`. + +### Exit taxonomy — what a failed attempt has already paid + +| rc | where | drawn before it returns | +|---|---|---| +| 5 / 6 / 7 | a forced-tech lookup for flag bit 4 / 8 / 0x10 | **nothing** | +| 2 | no hull-section candidate | A | +| 3 | no command section | A + B | +| 4 | no engine section | A + B + C | +| 1 | over budget | A + B + C | +| — | `costOnly != 0` returns here | A + B + C | +| 8 | no weapon matched a mount | A + B + C + D + E + F | +| 0 | success | everything | + +--- + +## Predictions + +### P1 — the model holds, per call + +On **every** composer row the probe emits, `H_pred == H_obs`, where `H_pred` is computed by the +probe from `N`, the three per-section point-defence flags and `f` (with `f` taken from the request +flags, the hull size, and G's **observed accept/refuse**, which the extended `cl_Chance` detour now +records). + +*Falsified by:* any row printing `model=WRONG`. A row printing `model=GATED-OR-WRONG` (`H_pred > 0` +but `H_obs == 0`) is **not** a falsification on its own — it is the weapon-lookup gate — but it is +also not a confirmation, and if every row is `GATED-OR-WRONG` the model is untested and I will say +so rather than claim it held. + +### P2 — one design request, two composer calls, and the first is a price query + +`0x006cda40` calls the driver twice: once with `costOnly = 1` (statically read at `0x006cda9a`, +`push 0x1`) and, if that succeeded, once with `costOnly = 0` (`0x006cdb17`, `push 0x0`). So a turn +in which the AI designs one ship should show **two** composer rows: one with `dry=1` that returns +`rc=0` having drawn **A, B, C and nothing else**, and one with `dry=0` that goes further. + +This is the arithmetic of client 32's seven turn-1 words, and it is the prediction I most want +checked because it was derived to explain a number rather than measured: + +``` +call 1 (dry=1): A 1 + B 1 + C 1 = 3 words +call 2 (dry=0): A 1 + B 1 + C 1 + F 1 = 4 words + total 7 <- PAR's measured 7 +``` +with `D`, `E`, `G`, `H` all zero: `K = 0` (no size-3 mounts on a turn-1 hull), and G silent because +`req.hullSize <= 0` on the smallest hull. + +*Falsified by:* one composer row, or three; or `dry = 0` on both; or `F` firing on the dry call. + +### P3 — pinned seeds reproduce exactly + +Two runs of `turn1-state → turn2` with `airng.pin_seed=5A17C0DE` and `aidesign=on`, in two fresh +processes, produce **byte-identical `aidesign*` rows** — every field, every row, in order. + +*Falsified by:* any differing field. That would mean the composer's path depends on something the +bracket-entry re-seed does not pin, which would be a bigger finding than the model. + +### P4 — a different pinned seed moves the count, and moves it through the path + +A run with `airng.pin_seed=B16B00B5` differs from P3's runs in at least one of: + +* site A's coin (`RandFloat() >= 0.5`) landing the other way, which sets request-flag bit `0x40`, + forces a tech section and therefore **changes the sections, `N`, and possibly `H`**; +* the number of words `B` or `C` spends, `NextInt`'s rejection loop resolving differently. + +The total for client 32 is **not** required to differ — PAR's §3.4 already showed a pinned run +holding the count at 3 while replacing the whole stream. What is required is that if the *path* +changes, `H_pred` tracks it: `model=HOLDS` must survive the seed change. + +*Falsified by:* `model=WRONG` appearing only under the second seed, which would mean the model is +fitted to one design rather than derived. + +### P5 — the instrument is armed, and says so + +`shim.airng.txt` must contain +`aidesign: composer 0x006ad700 rva=0x002ad700 va=... create=MH_OK enable=MH_OK`. +Lane L3 found a configuration that printed `watch=on` and armed nothing; a missing or failed hook +here would produce **no `aidesign` rows at all**, which is indistinguishable from "the composer was +never called" unless the arming line is read. If that line is absent or not `MH_OK`, every zero in +this lane's output is void and the run is discarded, not interpreted. + +Second arming check, independent of the log line: the `airngcall`/`airngsite` totals for the +bracket must equal the sum of the `aidesign` rows' `sub_words`. If the composer hook silently failed +the sub-bracket would be zero while the turn total stayed 7. + +### P6 — rule 19: the composer detour does not change the game + +With `aidesign=off` (and `airng=on`, unpinned), two fresh processes on `turn1-state` must both +produce the published post-turn autosave `d59bb9f2fd0eb535`. With `aidesign=on`, unpinned, two more +fresh processes must produce the same file. + +The composer detour patches five bytes at a clean prologue boundary (`push ebp; mov ebp,esp; +push -1` is exactly 5 bytes, and the function has one caller and no internal branch target below +`0x006ad705`), but lane H's finding is that a *correctly placed* patch changed the autosave anyway +and the mechanism is still unknown. So this is measured, not argued. + +*Falsified by:* any of the four runs producing a different file. If the `aidesign=off` pair +disagrees **with each other**, the workload is the problem and no control exists (rule 26); I will +say so and fall back to pinned comparisons only. + +--- + +## How this could be wrong + +1. **`N` is read after the call, from the design the call produced.** The composer counts `N` during + its weapon-assignment pass over slots that *received* a weapon; if a slot was skipped, my + post-hoc count is high and `H_pred` is too big. Symptom: `H_pred > H_obs` by a small amount on + rows with `rc = 8`. This is why the rc is in the row. +2. **`part.mountCount` is `min(hull mounts, 0x32)`.** I clamp to the hull's own vector length as + well; if the two disagree the probe prints both and I will notice. +3. **`f` for the `flags & 0x20000` case is an inference from one `fld` at `0x006ae3f8`.** If bit + `0x20000` is never set on this board the term is untested, and I will label it rule-6 rather than + claim it. +4. **The dead-code claim on `0x006adf62`/`0x006adf71`** is static. Ghidra independently removed both + blocks as unreachable, which is a second reading of the same instructions and not a second + instrument. If either ever appears in an `airngcall` row, the claim is wrong. +5. **The measurement could double-count.** PAR's instrument reported one word twice because `Chance` + calls `NextFloat` and both were detoured; it caught that only because it took two independent + measurements. This lane's sub-bracket takes two as well — `left` read off the generator object + (`words`) and the observer sum (`sub_words`) — and prints both on every row. They must agree. diff --git a/src/shim/shim.cfg.sdbr b/src/shim/shim.cfg.sdbr new file mode 100644 index 0000000..caa2662 --- /dev/null +++ b/src/shim/shim.cfg.sdbr @@ -0,0 +1,57 @@ +# Lane SD -- THE SHIP-DESIGN COMPOSER 0x006ad700. FOUR CONFIGS, DIFFERING BY TWO KEYS. +# +# sdoff airng=on aidesign=off the rule-19 control: the airng bracket +# alone, exactly lane PAR's parbr +# sdbr airng=on aidesign=on the composer probe, UNPINNED +# sdpin airng=on aidesign=on pin_seed=5A17C0DE pinned; two fresh processes must agree +# sdpin2 airng=on aidesign=on pin_seed=B16B00B5 pinned, a DIFFERENT seed +# +# sdoff and sdbr must produce the SAME autosave as each other and as the published unpinned result +# for the workload. The two sdpin configs deliberately change the AI's stream and their autosaves +# are never compared against the oracle (method rule 19). +# +# `aidesign=on` installs ONE extra detour, on 0x006ad700. If its arming line does not say +# create=MH_OK enable=MH_OK, every `aidesign` row is absent and an absent row is NOT a measured +# zero (lane L3's `hooks=off watch=on` trap). +hooks=trace +hook.Shim::SelfTest::Fill=off +hook.Mars::GlobalConsts::LoadFile=off +hook.Game::WeaponDictionary::Init=off +hook.Game::SectionDictionary::SectionDictionary=off +hook.Game::TechTree::ProcessResearch=off +hook.Game::ServerPlayer::ComputeBudget=off +hook.Game::ServerPlayer::OnTechResearched=off +hook.Game::ServerPlayer::ProcessTurn=off +hook.Game::ServerSystem::ProcessTurn=off +hook.Game::ServerSystem::GroupOutput=off +hook.Game::ServerSystem::ComputeTotalOutput=off +hook.Game::StrategyServer::MoveFleet=off +hook.Game::StrategyServer::ProcessFleetMovement=off +hook.Game::StrategyHost::Autosave=off +hook.Game::StrategyServer::ProcessTurn=off +hook.Game::StrategyServer::OnAllCombatDone_Tail=off +hook.Game::StrategyServer::ApplyEncounterResult=off +hook.Game::StrategyServer::NodeLineDecay=off +hook.Game::StrategyServer::ProcessNodeSpaceTravel=off +hook.Game::EncounterDetect::AssignContacts=off +hook.Game::EncounterDetect::ProcessTeamRecord=off +hook.Game::StrategyServer::BeginProcessTurn=off +hook.Game::SVSOSwarmQueen::OnTurnBegin=off +hook.Game::SVSOSwarmQueen::RegisterHives=off +hook.Game::SVSOSwarmQueen::TickHives=off +hook.Game::SVSOSlaversRefuel::UpdateDifficultyTier=off +hook.Mars::RNG::Seed=off +hook.Game::StrategyApp::RunAI=off +fpu.sample_turn=off +fpu.sample_ticks=off +probes=off +watch=off +aiorders=off +aiprobes=off +airesearch=off +trace.path=C:\SOTS\shim.trace.jsonl +trace.flush=always +airng.out=C:\SOTS\shim.airng.txt +airng=on +aidesign=on +airng.pin_seed=off diff --git a/src/shim/shim.cfg.sdoff b/src/shim/shim.cfg.sdoff new file mode 100644 index 0000000..4806197 --- /dev/null +++ b/src/shim/shim.cfg.sdoff @@ -0,0 +1,57 @@ +# Lane SD -- THE SHIP-DESIGN COMPOSER 0x006ad700. FOUR CONFIGS, DIFFERING BY TWO KEYS. +# +# sdoff airng=on aidesign=off the rule-19 control: the airng bracket +# alone, exactly lane PAR's parbr +# sdbr airng=on aidesign=on the composer probe, UNPINNED +# sdpin airng=on aidesign=on pin_seed=5A17C0DE pinned; two fresh processes must agree +# sdpin2 airng=on aidesign=on pin_seed=B16B00B5 pinned, a DIFFERENT seed +# +# sdoff and sdbr must produce the SAME autosave as each other and as the published unpinned result +# for the workload. The two sdpin configs deliberately change the AI's stream and their autosaves +# are never compared against the oracle (method rule 19). +# +# `aidesign=on` installs ONE extra detour, on 0x006ad700. If its arming line does not say +# create=MH_OK enable=MH_OK, every `aidesign` row is absent and an absent row is NOT a measured +# zero (lane L3's `hooks=off watch=on` trap). +hooks=trace +hook.Shim::SelfTest::Fill=off +hook.Mars::GlobalConsts::LoadFile=off +hook.Game::WeaponDictionary::Init=off +hook.Game::SectionDictionary::SectionDictionary=off +hook.Game::TechTree::ProcessResearch=off +hook.Game::ServerPlayer::ComputeBudget=off +hook.Game::ServerPlayer::OnTechResearched=off +hook.Game::ServerPlayer::ProcessTurn=off +hook.Game::ServerSystem::ProcessTurn=off +hook.Game::ServerSystem::GroupOutput=off +hook.Game::ServerSystem::ComputeTotalOutput=off +hook.Game::StrategyServer::MoveFleet=off +hook.Game::StrategyServer::ProcessFleetMovement=off +hook.Game::StrategyHost::Autosave=off +hook.Game::StrategyServer::ProcessTurn=off +hook.Game::StrategyServer::OnAllCombatDone_Tail=off +hook.Game::StrategyServer::ApplyEncounterResult=off +hook.Game::StrategyServer::NodeLineDecay=off +hook.Game::StrategyServer::ProcessNodeSpaceTravel=off +hook.Game::EncounterDetect::AssignContacts=off +hook.Game::EncounterDetect::ProcessTeamRecord=off +hook.Game::StrategyServer::BeginProcessTurn=off +hook.Game::SVSOSwarmQueen::OnTurnBegin=off +hook.Game::SVSOSwarmQueen::RegisterHives=off +hook.Game::SVSOSwarmQueen::TickHives=off +hook.Game::SVSOSlaversRefuel::UpdateDifficultyTier=off +hook.Mars::RNG::Seed=off +hook.Game::StrategyApp::RunAI=off +fpu.sample_turn=off +fpu.sample_ticks=off +probes=off +watch=off +aiorders=off +aiprobes=off +airesearch=off +trace.path=C:\SOTS\shim.trace.jsonl +trace.flush=always +airng.out=C:\SOTS\shim.airng.txt +airng=on +aidesign=off +airng.pin_seed=off diff --git a/src/shim/shim.cfg.sdpin b/src/shim/shim.cfg.sdpin new file mode 100644 index 0000000..24f697f --- /dev/null +++ b/src/shim/shim.cfg.sdpin @@ -0,0 +1,57 @@ +# Lane SD -- THE SHIP-DESIGN COMPOSER 0x006ad700. FOUR CONFIGS, DIFFERING BY TWO KEYS. +# +# sdoff airng=on aidesign=off the rule-19 control: the airng bracket +# alone, exactly lane PAR's parbr +# sdbr airng=on aidesign=on the composer probe, UNPINNED +# sdpin airng=on aidesign=on pin_seed=5A17C0DE pinned; two fresh processes must agree +# sdpin2 airng=on aidesign=on pin_seed=B16B00B5 pinned, a DIFFERENT seed +# +# sdoff and sdbr must produce the SAME autosave as each other and as the published unpinned result +# for the workload. The two sdpin configs deliberately change the AI's stream and their autosaves +# are never compared against the oracle (method rule 19). +# +# `aidesign=on` installs ONE extra detour, on 0x006ad700. If its arming line does not say +# create=MH_OK enable=MH_OK, every `aidesign` row is absent and an absent row is NOT a measured +# zero (lane L3's `hooks=off watch=on` trap). +hooks=trace +hook.Shim::SelfTest::Fill=off +hook.Mars::GlobalConsts::LoadFile=off +hook.Game::WeaponDictionary::Init=off +hook.Game::SectionDictionary::SectionDictionary=off +hook.Game::TechTree::ProcessResearch=off +hook.Game::ServerPlayer::ComputeBudget=off +hook.Game::ServerPlayer::OnTechResearched=off +hook.Game::ServerPlayer::ProcessTurn=off +hook.Game::ServerSystem::ProcessTurn=off +hook.Game::ServerSystem::GroupOutput=off +hook.Game::ServerSystem::ComputeTotalOutput=off +hook.Game::StrategyServer::MoveFleet=off +hook.Game::StrategyServer::ProcessFleetMovement=off +hook.Game::StrategyHost::Autosave=off +hook.Game::StrategyServer::ProcessTurn=off +hook.Game::StrategyServer::OnAllCombatDone_Tail=off +hook.Game::StrategyServer::ApplyEncounterResult=off +hook.Game::StrategyServer::NodeLineDecay=off +hook.Game::StrategyServer::ProcessNodeSpaceTravel=off +hook.Game::EncounterDetect::AssignContacts=off +hook.Game::EncounterDetect::ProcessTeamRecord=off +hook.Game::StrategyServer::BeginProcessTurn=off +hook.Game::SVSOSwarmQueen::OnTurnBegin=off +hook.Game::SVSOSwarmQueen::RegisterHives=off +hook.Game::SVSOSwarmQueen::TickHives=off +hook.Game::SVSOSlaversRefuel::UpdateDifficultyTier=off +hook.Mars::RNG::Seed=off +hook.Game::StrategyApp::RunAI=off +fpu.sample_turn=off +fpu.sample_ticks=off +probes=off +watch=off +aiorders=off +aiprobes=off +airesearch=off +trace.path=C:\SOTS\shim.trace.jsonl +trace.flush=always +airng.out=C:\SOTS\shim.airng.txt +airng=on +aidesign=on +airng.pin_seed=5A17C0DE diff --git a/src/shim/shim.cfg.sdpin2 b/src/shim/shim.cfg.sdpin2 new file mode 100644 index 0000000..a04dc39 --- /dev/null +++ b/src/shim/shim.cfg.sdpin2 @@ -0,0 +1,57 @@ +# Lane SD -- THE SHIP-DESIGN COMPOSER 0x006ad700. FOUR CONFIGS, DIFFERING BY TWO KEYS. +# +# sdoff airng=on aidesign=off the rule-19 control: the airng bracket +# alone, exactly lane PAR's parbr +# sdbr airng=on aidesign=on the composer probe, UNPINNED +# sdpin airng=on aidesign=on pin_seed=5A17C0DE pinned; two fresh processes must agree +# sdpin2 airng=on aidesign=on pin_seed=B16B00B5 pinned, a DIFFERENT seed +# +# sdoff and sdbr must produce the SAME autosave as each other and as the published unpinned result +# for the workload. The two sdpin configs deliberately change the AI's stream and their autosaves +# are never compared against the oracle (method rule 19). +# +# `aidesign=on` installs ONE extra detour, on 0x006ad700. If its arming line does not say +# create=MH_OK enable=MH_OK, every `aidesign` row is absent and an absent row is NOT a measured +# zero (lane L3's `hooks=off watch=on` trap). +hooks=trace +hook.Shim::SelfTest::Fill=off +hook.Mars::GlobalConsts::LoadFile=off +hook.Game::WeaponDictionary::Init=off +hook.Game::SectionDictionary::SectionDictionary=off +hook.Game::TechTree::ProcessResearch=off +hook.Game::ServerPlayer::ComputeBudget=off +hook.Game::ServerPlayer::OnTechResearched=off +hook.Game::ServerPlayer::ProcessTurn=off +hook.Game::ServerSystem::ProcessTurn=off +hook.Game::ServerSystem::GroupOutput=off +hook.Game::ServerSystem::ComputeTotalOutput=off +hook.Game::StrategyServer::MoveFleet=off +hook.Game::StrategyServer::ProcessFleetMovement=off +hook.Game::StrategyHost::Autosave=off +hook.Game::StrategyServer::ProcessTurn=off +hook.Game::StrategyServer::OnAllCombatDone_Tail=off +hook.Game::StrategyServer::ApplyEncounterResult=off +hook.Game::StrategyServer::NodeLineDecay=off +hook.Game::StrategyServer::ProcessNodeSpaceTravel=off +hook.Game::EncounterDetect::AssignContacts=off +hook.Game::EncounterDetect::ProcessTeamRecord=off +hook.Game::StrategyServer::BeginProcessTurn=off +hook.Game::SVSOSwarmQueen::OnTurnBegin=off +hook.Game::SVSOSwarmQueen::RegisterHives=off +hook.Game::SVSOSwarmQueen::TickHives=off +hook.Game::SVSOSlaversRefuel::UpdateDifficultyTier=off +hook.Mars::RNG::Seed=off +hook.Game::StrategyApp::RunAI=off +fpu.sample_turn=off +fpu.sample_ticks=off +probes=off +watch=off +aiorders=off +aiprobes=off +airesearch=off +trace.path=C:\SOTS\shim.trace.jsonl +trace.flush=always +airng.out=C:\SOTS\shim.airng.txt +airng=on +aidesign=on +airng.pin_seed=B16B00B5 From dd105da7591b8be8fc55a464f719a51ec37c38e5 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 20:30:22 -0400 Subject: [PATCH 2/3] SD: a sub-bracket on the ship-design composer 0x006ad700 Extends lane PAR's airng module with a detour on the composer itself, so a row says what ONE COMPOSER CALL spent rather than what the turn spent. Three pieces: * the detour. The composer's calling convention is neither cdecl nor MSVC __fastcall -- two register arguments, two stack arguments, and the CALLER cleans -- so it is entered through a hand-written thunk that re-pushes the four arguments as cdecl, and the trampoline is re-entered through a second thunk that restores ECX/EDX. * the sub-bracket. left (RNG+0x9c8) read off the client generator at composer entry and exit, and the draws seen in between re-tallied by return address. Two independent numbers per row, printed together, because PAR's own instrument double-counted a word and only its second measurement caught it. * cl_Chance's note is now taken AFTER the trampoline, so a row carries the DECISION as well as the call. Site 0x006ae413's 0.3 coin selects the fraction that governs the loop-carried draw's trip count, and a call count alone cannot say which way it went. The row also carries the model's prediction beside the measurement: N, the per-section bank counts and the PointDefence flags are read off the design the call produced, L is computed from them, and the row says HOLDS, WRONG, or GATED-OR-WRONG when the prediction is positive and the measurement is zero -- which is the weapon-lookup gate, and is not counted as a confirmation. Header regenerated from ghidra/addresses.json plus the fragments, never hand-resolved; the six new lane-sd entries do not change any existing constant and the module uses none of them by name. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ --- include/generated/sots_addresses.h | 36 ++- src/shim/hooks/ai_rng.cpp | 348 ++++++++++++++++++++++++++++- 2 files changed, 376 insertions(+), 8 deletions(-) diff --git a/include/generated/sots_addresses.h b/include/generated/sots_addresses.h index 7cbb9cd..8e3b0c2 100644 --- a/include/generated/sots_addresses.h +++ b/include/generated/sots_addresses.h @@ -1,5 +1,5 @@ // GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1). -// Source: sots-re ghidra/addresses.json @ 7d51767, generated 2026-09-08 by tools/gen_addresses.py +// Source: sots-re ghidra/addresses.json @ 1bae6f1, generated 2026-09-08 by tools/gen_addresses.py // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated). #pragma once #include @@ -1831,6 +1831,28 @@ constexpr uint32_t InlinedDrawScan_FalsePositive_008cca30 = 0x004cca8f; constexpr uint32_t MT_TemperMask1 = 0xff3a58ad; // constant 0xffffdf8c -- the second tempering mask as the image spells it, `y ^= (y & 0xffffdf8c) << 15`, equal to the textbook `(y << 15) & 0xefc60000` (0xefc60000 >> 15 == 0x0001df8c == 0xffffdf8c & 0x0001ffff). Image-wide there are 34 occurrences of these bytes inside decoded instructions; 33 are genuine and ONE (0x008cca90) is the rel32 displacement of a call. Always require BOTH masks plus a preceding `shr r32,0xb` before calling a hit a draw [verified] constexpr uint32_t MT_TemperMask2 = 0xffffdf8c; +// thiscall bool (IDMap* this, int numNodes, int localNode, int startId) // ret 0xc. THE SEEDING ENTRY POINT, and it names the save's three NM* tags. Calls FUN_008b8f80 (clear), then REFUSES numNodes > 0x10 ('IDMap: Cannot support %d nodes.') -- SIXTEEN is the hard cap, and it is the low nibble of IDMap_AllocateID's id. Refuses localNode outside [0, numNodes) ('IDMap: Node %d does not exist, valid nodes are 0-%d.'). On success: resize the 0x14-stride node vector at this->+0x08 via FUN_008b99d0, ZERO EVERY NODE'S COUNTER (the loop at 0x008b9b20 stores 0 to +eax+0x10, eax += 0x14), then write startId into nodes[localNode].counter ALONE (0x008b9b38), then this->+0x18 = localNode (0x008b9b3d). SO: exactly one node's counter is ever seeded; every other node starts at 0, and nothing else in the class ever restores one -- IDMap_Insert 0x008b9350 does NOT touch a counter. Four call sites: StrategyServer_Read +0x105 = Initialize(NMSz, NMLc, NMnx) on S+0x84 -- WHICH NAMES THE TAGS: NMSz = node count, NMLc = LOCAL NODE INDEX, NMnx = that node's counter; StrategyServer_LoadGame +0x107 = Initialize(16, 0, 0); StrategySim_OnCreateGame +0x45 = Initialize(msg->+4, msg->+8, msg->+0xc) on the CLIENT's map at +0x80; and FUN_008a9f80 +0x1ce = Initialize(1, 0, 0) on an unrelated Mars scene object's map at +0x68 [verified] +constexpr uint32_t IDMap_Initialize = 0x004b9ad0; +// thiscall int (IDMap* this) // 10 B: push [ecx+0x18] (the local node index); call IDMap_AllocateID; ret. The IDMap-relative form of the same thunk IDMap_AllocateLocalID 0x0080f710 wraps from the StrategySim side (that one is `ecx += 0x80` then jmp here). Five direct call sites, all StrategySim methods reached on BOTH the server and a client: StrategySim_CreateDesign +0xe0, FUN_008713a0 +0x3df, SystemBuildQueue_AttachBuiltShip 0x0088e7f0 +0x42, TradeManager_SpawnEncounterSquadron 0x0088f070 +0x216 and +0x28f [verified] +constexpr uint32_t IDMap_AllocateOnLocalNode = 0x004b8b70; +// thiscall int (IDMap* this, int nodeIndex) // 29 B, ret 4: node = IDMap_FindNode(nodeIndex); return node ? node->+0x10 : 0. A READ of a per-node counter with no side effect. Its only interesting caller is StrategyServer_InitGameForPlayer 0x007c8f05, which uses it to SEED a client: the CreateGame message carries the server's current counter for the node that client is about to own. Since a save restores only ONE node's counter (Initialize zeroes the rest), this returns 0 for every client node in a freshly loaded game [verified] +constexpr uint32_t IDMap_GetNodeCounter = 0x004b8b80; +// thiscall void* (IDMap* this, int nodeIndex) // 101 B, ret 4. Bounds-checks nodeIndex against (this->+0x0c - this->+0x08)/0x14 -- the 0x66666667 / sar 3 divide-by-20 that pins the NodeEntry stride at 0x14 -- logs 'IDMap: Node %d does not exist, valid nodes are 0-%d.' and returns 0 when out of range; else returns _Myfirst + nodeIndex*0x14. Shared by IDMap_AllocateID and IDMap_GetNodeCounter [verified] +constexpr uint32_t IDMap_FindNode = 0x004b8a70; +// thiscall void (StrategySim* this, CreateGameMsg* msg) // ret 4. CASE 0 of StrategyClient::RaiseEvent 0x00783ee0's 0x2c-entry jump table at 0x00784200 (the handler body is at 0x00783f05). FIRST ACT: IDMap_Initialize(this+0x80, msg->+0x04 numNodes, msg->+0x08 localNode, msg->+0x0c startId) -- THIS IS WHERE A CLIENT'S ID SPACE IS SET UP, and the only path by which an IDMap ever gets a local node index other than 0. Then copies the rest of the message into the sim: +0x10 -> this->+0x08, +0x48 -> this->+0x10, +0x14 -> this->+0xb8, the two floats at +0x18/+0x1c -> this->+0xbc/+0xc0, the bools at +0x20/+0x21 -> this->+0xc4/+0xc5, +0x6c -> this->+0x154, +0x4c -> this->+0xf8, then the vectors from this+0x40 on [verified] +constexpr uint32_t StrategySim_OnCreateGame = 0x00376f20; +// thiscall void (StrategyServer* this /*the S frame*/, int playerObjectId, int arg2) // THE NODE-INDEX ASSIGNMENT, in four instructions. Resolves the player through this->+0x84 (the id-to-object lookup FUN_008b9240) and takes esi = player->PlyrIdx(+0x28). Then at 0x007c8ecb: `cmp esi,-1 / je L / inc esi / jmp / L: xor esi,esi` -- localNode = (PlyrIdx == -1) ? 0 : PlyrIdx + 1. NODE 0 IS THE SERVER'S; PLAYER k GETS NODE k+1. It then fills the CreateGame message on the stack at [ebp-0x138]: +0x00 vtable 0x00a252c0, +0x04 numNodes = (server IDMap node vector length, the /20 divide at 0x007c8ee1 over ebx+0x8c/+0x90), +0x08 localNode = that esi, +0x0c startId = IDMap_GetNodeCounter(S+0x84, localNode). Sends it, and StrategySim_OnCreateGame is what receives it. See findings/subsystems/id-allocation.md section 3 for why startId is always 0 for a client after a save load [verified] +constexpr uint32_t StrategyServer_InitGameForPlayer = 0x003c8d90; +// thiscall void (StrategyServer* this, ...) // At +0x107 it calls IDMap_Initialize(this+0x84, 16, 0, 0) -- SIXTEEN NODES, LOCAL NODE 0, COUNTER 0. That is where the corpus's NMSz 16 / NMLc 0 come from, and it is why every id created by the host carries low nibble 0 [verified] +constexpr uint32_t StrategyServer_LoadGame = 0x003dd530; +// thiscall void (StrategySim* this, ServerPlayer* owner, void* params, int explicitId, char, char, char, char) // ret 0x18. THE CLIENT-ALLOCATES / SERVER-HONOURS SPLIT, in one branch. operator new(0x1a8) -> ctor FUN_00874c70 -> FUN_0057c6d0(params); design->+0x130 = owner, design->+0x134 = this->+0x08. Then at 0x008828b3: `if (explicitId != 0) use it; else id = IDMap_AllocateOnLocalNode(this+0x80)`, followed by IDMap_Insert(this+0x80, design+0xa0, id). BECAUSE StrategyClient AND StrategyServer BOTH DERIVE FROM StrategySim, this is the SAME code on both sides: the client runs it with explicitId 0 and mints an id on ITS node, puts that id in the turn command, and the server runs it again with explicitId set and mints nothing. A reimplementation that allocates on apply produces every AI-created id wrong [verified] +constexpr uint32_t StrategySim_CreateDesign = 0x004827e0; +// label THE techId -> NAME MAP, in eight instructions inside MasterTechTree_ctor 0x0058b870, immediately after the LoadTechFile loop. 0x0058b9df: vector::operator=(this+0x24, this+0x14) -- the sorted list at +0x24 is a COPY of the parse-order list at +0x14, which is why a parse-order dump does not match the ids. 0x0058b9ff: std::sort(first=[this+0x24], last=[this+0x28], ideal=(last-first)/4, pred) = FUN_00583b10, MSVC _Sort (the _ISORT_MAX 0x20 test and the _Ideal 3/4 halving are both there); the predicate is INLINED in FUN_00581130 at 0x00581190 and 0x005811b8 as `_stricmp(a->name, b->name) < 0` on TechDef+0x04 (a std::string, SSO-tested at +0x14 against 0x10) -- MSVCR100 _stricmp, so the order is CASE-INSENSITIVE, not byte-wise. 0x0058ba19..0x0058ba37: `for (i = 0; i < n; ++i) sortedList[i]->+0x00 = i;` -- THE WIRE techId IS LITERALLY THE 0-BASED INDEX INTO THE _stricmp-SORTED MASTER LIST. The same sorted vector is then the binary-search index: FUN_0057f120 (std::lower_bound) resolves every `requires`/`allows` name against it at 0x0058baed. NOT the 10000-based TechID enum, which is a separate 196-entry .rdata table [verified] +constexpr uint32_t MasterTechTree_SortAndNumber = 0x0018b9df; +// thiscall bool (TechTree* this /*per-player*/, int techId) // THE WIRE-SPACE BOUNDS CHECK, and it is what proves the space. Rejects techId < 0 and techId >= (this->+0x14 - this->+0x10)/4 -- the per-player node vector, 293 entries in stock data. Then esi = masterList[techId] from this->+0x04 -> +0x24/+0x28 (the SORTED vector, MasterTechTree_SortAndNumber) and edx = this->+0x10[techId] (the player's node). Reads the name as a std::string at masterEntry+0x04 with the SSO test at +0x18 -- so TechDef is {+0x00 int techId, +0x04 std::string name, ...}. Called by the research-target gate applier at 0x0088ff49 with the techId straight off the command payload ([block-0x28]) [verified] +constexpr uint32_t TechTree_IsResearchable = 0x0017e820; +// cdecl void (T** first, T** last, int ideal, Pred pred) // MSVC std::sort's _Sort: the `(last-first)/4 <= 0x20` insertion-sort cutoff at 0x00583b26, the `ideal -= ideal/2 + ideal/4` heap-sort fallback counter, and _Unguarded_partition FUN_00581130. Identified here because MasterTechTree_ctor uses it to build the tech id space; the same body will be reached from anywhere else that sorts a pointer vector [verified] +constexpr uint32_t MSVC_Sort = 0x00183b10; // thiscall void (CombatResolveContext* this) // PLAIN RET, no stack args. THE COMBAT RESOLVER under phase 6 of StrategyServer::OnAllCombatDone_Tail. Exactly one caller: StrategyServer::ApplyEncounterResult 0x007d8920 at 0x007d8d24, on the full-battle path only (res->+0x4 == 0). REAL BODY IS 0x007d5af0..0x007d78c8 = 7641 B; Ghidra's 7499 stops mid-instruction at 0x007d783b. SHAPE, read from the instruction stream: (1) prologue + 16 unconditional this-calls 0x007d5b1e..0x007d5c02; (2) ONE loop over enc->members, stride 0x44, 0x007d5c30..0x007d779d -- 7021 of the 7641 bytes, with four inner loops and no other outer control flow; (3) five more unconditional this-calls 0x007d77a3..0x007d77cd; (4) a victor block gated on ctx->+0xa34 != -1; (5) FUN_0079c740 and the epilogue. ONLY EIGHT NON-STACK STORES IN THE WHOLE BODY and exactly ONE indirect call (inside a _CxxThrowException path): it composes and posts per-player events and delegates every state mutation to callees. Strings it composes: EVENT__FIGHT, EVENTSUM_, EVENTMSG_<...>, EVENT_TRADERAIDERS, EVENT_COMBAT_OBSERVED, EVENT_DEFEAT, EVENT_VICTORY, EVENT_ENGAGED, EVENT_STATION_KILLED, and the ENTITYVICTORY/ENTITYDEFEAT/UNRESOLVED outcome tokens. DRAWS NO RNG ITSELF -- see CombatResolve_NodeCannon and CombatResolve_SalvageBackEng [verified] constexpr uint32_t CombatResolver_Run = 0x003d5af0; // thiscall CombatResolveContext* (CombatResolveContext* this, StrategyServer* S /*the S frame*/, Encounter* enc, Game::EncounterResults* res) // built as a ~0xea0-byte STACK local at [ebp-0xea0] in StrategyServer::ApplyEncounterResult, immediately before the resolver call. Field assignment read from the instruction stream: this->+0x00 = S; this->+0x04 = operator new(0x5c) then FUN_005a13f0 (a per-player lookup object); this->+0x08 = enc; this->+0x0c = res; this->+0x10 = 0; this->+0x14 = 0 (byte); this->+0x18 = an empty std::string; this->+0x38 = FUN_00536890. Further fields the resolver uses: +0x290/+0x330/+0x430 per-player int arrays indexed by PlyrIdx*4; +0x7b0 + PlyrIdx*0x10 a per-player vector; +0x9b0 + PlyrIdx*4 the posted-event pointer; +0xa34 the winner PlyrIdx (-1 = none); +0xe7c = FUN_00787690(enc), set by the resolver's first act [verified] @@ -2113,6 +2135,18 @@ constexpr uint32_t TurnCommands_WriteColonizeList = 0x00422960; constexpr uint32_t StrategySim_ApplyTurnCommandBatch_GateLoopB = 0x004907b1; // label the THIRD and last per-player gate loop inside StrategySim::ApplyTurnCommandBatch. `esi = block+0x24`; it tests the group-4 gate at +0x2c and applies its {bool, int} payload through 0x00821b90. It is the final step of the whole batch. Together with the loop-A head at 0x0088fdb0 and the loop-B head at 0x008907b1, and the six inlined ModCount bump sites, this gives nine positions of the thirty-step apply schedule an address-monotonicity check -- the only part of the sequence that can be re-derived rather than inherited from the read of the `add edi, imm` chain [verified] constexpr uint32_t StrategySim_ApplyTurnCommandBatch_GateLoopC = 0x0049080a; +// custom int (ECX: StrategyAIAgent* agent, EDX: SectionContext parts[3], STACK: DesignRequest* req, STACK: char costOnly) /* ECX+EDX in registers, TWO stack arguments, and the CALLER cleans them (`add esp,0x8` at 0x006ae719) -- neither cdecl nor MSVC __fastcall, but the private convention MSVC gives a static function whose address never escapes. Returns 0 on success and 1..8 for eight distinct bail-outs: 1 over budget, 2 no hull candidate, 3 no command section, 4 no engine section, 5/6/7 a forced-tech lookup failed for request-flag bit 4/8/0x10, 8 no weapon matched a bank. `req` = {int hullSize; float budget; int role; uint flags}. `parts` is the same 0x124-stride three-section array lane D2 found on the other side of the pipeline at Game::ShipDesign::AggregateSectionStats: +0 ShipSectionDef*, +4.. one weapon id per weapon BANK, +0xcc the bank count (min(hull banks, 0x32)). `costOnly` non-zero returns right after pricing, having already drawn at sites A/B/C -- which is why one AI design request can cost two composer calls */ [verified] +constexpr uint32_t AIComposeShipBlueprint = 0x002ad700; +// custom void (ECX: ?, EDX: StrategyAIAgent* agent, STACK: ..., DesignRequest* req, char costOnly) /* the composer's ONLY caller. Builds a list of 1..3 hull sizes from req.hullSize and request-flag bits 0 and 1 (bit 0 adds size-1, bit 1 adds size+1 when size<2), clears both bits, then calls AIComposeShipBlueprint once per size and BREAKS ON THE FIRST SUCCESS. So one design request costs 1..3 composer calls, and every failed attempt has already spent the RNG words it reached before failing. Three call sites: 0x006cdaa6 and 0x006cdb1c in 0x006cda40 (the real design pass) and 0x006b7ecb in 0x006b7e40, which passes costOnly=1 */ [verified] +constexpr uint32_t AIComposeShipDesign = 0x002ae620; +// custom void* (EAX: int count, STACK: void** array) /* 0x1f bytes whole: `if (!count) return 0; return array[cl_RandRange(0, count-1)];`. The count arrives in EAX. This is a LIVE DRAW SITE that lane PAR's 23-row table does not list -- PAR measured it live at return address 0x00691ea0 (2 calls, 2 words on turn 1) without adding it to the static inventory, so the AI turn has 22 live sites, not 21. The composer calls it once, at 0x006ada61, to pick the command section */ [verified] +constexpr uint32_t AIPickRandomElement = 0x00291e90; +// offset size -- one entry of the three-section design context array the composer fills and Game::ShipDesign::AggregateSectionStats 0x00826af0 consumes. +0x000 ShipSectionDef* (null = empty slot), +0x004..+0x0cb up to 0x32 weapon ids, ONE PER BANK, which is exactly the DGbnk2 list the design record carries on the wire (SHIP_DESIGN_RULES B1: one weapon per bank{} block, in file order); +0x0cc the bank count = min((def+0x320 - def+0x31c)/0x30, 0x32); +0x0d0..+0x11f up to 20 candidate weapon ids gathered for that section; +0x120 that candidate count, capped at 20 -- the cap the string "AIComposeShipBlueprint: SectionBlueprint::MAX_OPTIONS" names. Slot order in memory is mission, command, engine (verify/design-rules/SHIP_DESIGN_RULES.md 1), NOT the on-disk order. Live: on turn 1 a Human destroyer design gives bank counts 3 / 1 / 2 for DEExtendedRange / DECommand / DEFission [verified] +constexpr uint32_t AIShipSectionContext_stride = 0x00000124; +// offset std::vector begin -- weapon-bank descriptors on a Game::ShipSectionDef, stride 0x30, end pointer at +0x320. Bank+0x00 = turretclass index (0 = `standard`, the class 1,533 of the 3,721 shipped banks carry; 0xc and 0x17 are two others the composer's large-bank pass also accepts), Bank+0x04 = turretsize index (1 = `small`, 3 = `large`). The composer's only loop-carried draw fires once per (class 0, size 1) bank -- a small standard bank -- and its job is to overwrite a fraction of them with a point-defence weapon [verified] +constexpr uint32_t ShipSectionDef_off_Banks = 0x0000031c; +// offset Mars::String -- the section's file name on a Game::ShipSectionDef; capacity word at +0x1c8, so the payload is the inline buffer when capacity < 0x10 and *(char**)(def+0x1b4) otherwise. The composer compares it case-insensitively against "DEPointDefence" and "CRPointDefence" at 0x006ae4e6/0x006ae4fd: a section whose name matches gets a point-defence weapon in EVERY qualifying bank instead of every D'-th one [verified] +constexpr uint32_t ShipSectionDef_off_Name = 0x000001b4; // thiscall int (Game::SVScriptObject* this, int evt, void* arg) // the script-object event bus. Calls this->vft[0x10](evt, arg) -- the GENERIC handler every object sees -- then `cmp evt,0x20; ja done; jmp dword [evt*4 + SVScriptObject_EventSlotJumpTable]`, which dispatches to ONE event-specific vtable slot with the argument shape that event carries. Every hand-written `vft[0x10](id,0); vft[slot]()` pair in the two turn drivers is this same two-step done on the root object [verified] constexpr uint32_t SVScriptObject_DispatchEvent = 0x003a60d0; // data void* [33] // evt (0..0x20) -> the vtable slot SVScriptObject_DispatchEvent calls. Slot byte offsets in evt order: 0x14 0x18 0x1c 0x20 0x24 0x28 0x2c 0x30 0x34 0x38 0x3c 0x40 0x44 0x48 0x4c 0x50 0x54 0x58 0x5c 0x60 0x64 0x6c 0x70 0x74 0x68 0x7c 0x80 0x84 0x78 0x88 0x8c 0x90 0x94. Note 0x15->+0x6c, 0x16->+0x70, 0x17->+0x74, 0x18->+0x68 and 0x1c->+0x78 are NOT in slot order [verified] diff --git a/src/shim/hooks/ai_rng.cpp b/src/shim/hooks/ai_rng.cpp index 3ab0edf..8d96eb0 100644 --- a/src/shim/hooks/ai_rng.cpp +++ b/src/shim/hooks/ai_rng.cpp @@ -183,6 +183,40 @@ std::uint32_t g_siteOverflow = 0; std::uint32_t g_foreignWords = 0; std::uint32_t g_foreignCalls = 0; +// ---- lane SD: the composer sub-bracket --------------------------------------------------------- +// +// One call to the ship-design composer 0x006ad700 is nested inside the turn bracket. These counters +// are zeroed at composer entry and read at composer exit, so a row can say what THAT CALL spent +// rather than what the turn spent. They are fed from the two places a word is already seen -- +// `ObserveDraw` (every draw, on the watched generator) and `NoteCaller` (the two hooked facades) -- +// so the sub-bracket adds no third measurement to disagree with the first two. +bool g_subActive = false; +std::uint32_t g_subWords = 0; +std::uint32_t g_subRandFloatCalls = 0; +struct SubRow { + std::uint32_t ret_rva = 0; + std::uint8_t which = 0; + std::uint32_t calls = 0; + std::uint32_t trues = 0; +}; +constexpr std::size_t kMaxSub = 16; +SubRow g_subRows[kMaxSub]; +std::size_t g_nsub = 0; +std::uint32_t g_subOverflow = 0; +// The turn-bracket sequence number, declared here because the composer rows are stamped with it. +std::uint32_t g_seq = 0; + +// Facade call counts for one composer call, by the return address the site is known by. +std::uint32_t SubCalls(std::uint32_t va, std::uint32_t* trues = nullptr) { + for (std::size_t i = 0; i < g_nsub; ++i) + if (A::IMAGE_BASE + g_subRows[i].ret_rva == va) { + if (trues) *trues = g_subRows[i].trues; + return g_subRows[i].calls; + } + if (trues) *trues = 0; + return 0; +} + // NESTED ENTRY POINTS -- the double count draw_sites.h warned about, now observed. // `RNG_Chance`'s own body calls `RNG_NextFloat`, and BOTH are detoured, so one drawn word is // reported twice: once against the game's call site and once against 0x008e6e09, the instruction @@ -239,6 +273,13 @@ void ObserveDraw(DrawEntry entry, std::uint32_t ret_rva, const void* generator, } g_observedWords += words; ++g_observedCalls; + // Lane SD's sub-bracket: the same words, re-tallied for the composer call that is open right + // now. `cl_RandFloat` is not a hooked facade (PAR left it alone on purpose), so its draw is + // only visible here, at its draw-site return address 0x006ad878. + if (g_subActive) { + g_subWords += words; + if (A::IMAGE_BASE + ret_rva == 0x006ad878u) ++g_subRandFloatCalls; + } for (std::size_t i = 0; i < g_nsites; ++i) { if (g_sites[i].ret_rva == ret_rva && g_sites[i].entry == entry) { ++g_sites[i].calls; @@ -292,6 +333,7 @@ struct CallerRow { std::uint32_t ret_rva = 0; std::uint8_t which = 0; // 0 cl_Chance, 1 cl_RandRange, 2 cl_RandFloat std::uint32_t calls = 0; + std::uint32_t trues = 0; // cl_Chance only: how many of those calls ACCEPTED }; CallerRow g_callers[kMaxCallers]; std::size_t g_ncallers = 0; @@ -300,19 +342,47 @@ const char* caller_kind(std::uint8_t w) { return w == 0 ? "cl_Chance" : w == 1 ? "cl_RandRange" : "cl_RandFloat"; } -void NoteCaller(const void* ret, std::uint8_t which) { +// `result` is the facade's return value where it is a decision (`cl_Chance`: 1 accepted, 0 not) and +// -1 where it is not (`cl_RandRange` returns an index, which the site table cannot summarise). The +// accepted count is what lane SD's cost model needs: the composer's 0.3 coin at 0x006ae413 chooses +// the fraction that governs the loop-carried draw's trip count, and a call count alone cannot say +// which way it went (method rule 20 applied to a boolean). +void NoteCaller(const void* ret, std::uint8_t which, int result) { if (!g_active) return; // only inside a bracket const std::uint32_t rva = static_cast(reinterpret_cast(ret) - g_exeBase); + if (g_subActive) { + bool found = false; + for (std::size_t i = 0; i < g_nsub; ++i) + if (g_subRows[i].ret_rva == rva && g_subRows[i].which == which) { + ++g_subRows[i].calls; + if (result > 0) ++g_subRows[i].trues; + found = true; + break; + } + if (!found) { + if (g_nsub >= kMaxSub) { + ++g_subOverflow; + } else { + g_subRows[g_nsub].ret_rva = rva; + g_subRows[g_nsub].which = which; + g_subRows[g_nsub].calls = 1; + g_subRows[g_nsub].trues = result > 0 ? 1u : 0u; + ++g_nsub; + } + } + } for (std::size_t i = 0; i < g_ncallers; ++i) if (g_callers[i].ret_rva == rva && g_callers[i].which == which) { ++g_callers[i].calls; + if (result > 0) ++g_callers[i].trues; return; } if (g_ncallers >= kMaxCallers) return; g_callers[g_ncallers].ret_rva = rva; g_callers[g_ncallers].which = which; g_callers[g_ncallers].calls = 1; + g_callers[g_ncallers].trues = result > 0 ? 1u : 0u; ++g_ncallers; } @@ -322,20 +392,268 @@ void* g_trClChance = nullptr; void* g_trClRandRange = nullptr; bool SHIM_CDECL DetourClChance(float p) { - NoteCaller(__builtin_return_address(0), 0); - return reinterpret_cast(g_trClChance)(p); + // The return address is captured BEFORE the trampoline runs and the note is taken AFTER, so the + // row can carry the decision as well as the call. The draw itself is still counted by the + // draw-site observer inside the trampoline, exactly as before. + const void* ra = __builtin_return_address(0); + const bool r = reinterpret_cast(g_trClChance)(p); + NoteCaller(ra, 0, r ? 1 : 0); + return r; } int SHIM_CDECL DetourClRandRange(int lo, int hi) { - NoteCaller(__builtin_return_address(0), 1); + NoteCaller(__builtin_return_address(0), 1, -1); return reinterpret_cast(g_trClRandRange)(lo, hi); } + +// ---- lane SD: the ship-design composer, 0x006ad700 --------------------------------------------- +// +// WHY THIS FUNCTION. Lane PAR localised the AI's whole random consumption to it: on `turn1-state` +// client 32 spends seven words and every one of them is drawn inside this call or inside the +// one-line helper 0x00691e90 that it calls. It holds nine of the AI turn's live draw sites, +// including the only LOOP-CARRIED one (`cl_Chance(0.2f)` at 0x006ae575, once per qualifying weapon +// mount), so it is where a word count stops being a constant and starts being a function of state. +// +// THE CALLING CONVENTION IS NOT ONE THE COMPILER CAN SPELL. The single call site 0x006ae714 passes +// two arguments in ECX and EDX and two on the stack, and then the CALLER cleans (`add esp,0x8`). +// That is neither `cdecl` (no register args) nor MSVC `__fastcall` (callee cleans) -- it is the +// private convention MSVC gives a static function whose address never escapes. GCC has no attribute +// for it, so the detour is entered through a hand-written thunk that re-pushes the four arguments +// as `cdecl`, and the trampoline is re-entered through a second thunk that restores ECX/EDX. Each +// thunk is straight-line and touches no callee-saved register. +// +// WHAT IS READ. The request record (`{int hullSize; float budget; int role; uint flags}` at +// [ebp+8]) on the way in; the three-section design array (EDX, stride 0x124, +0 = ShipSectionDef*, +// +4.. = one weapon id per mount, +0xcc = mount count -- the same 0x124-stride triple lane D2 found +// on the OTHER side of the design pipeline, at Game::ShipDesign::AggregateSectionStats) on the way +// out, plus the return code, which is 0 on success and 1..8 for the eight distinct bail-outs. + +struct DesignReq { + std::int32_t hullSize; + float budget; + std::int32_t role; + std::uint32_t flags; +}; + +constexpr std::uint32_t kPartStride = 0x124; +constexpr std::uint32_t kPartMountCount = 0xcc; // part[0x33], = min(hull mounts, 0x32) +constexpr std::uint32_t kSecMountsBegin = 0x31c; // ShipSectionDef mount vector, stride 0x30 +constexpr std::uint32_t kSecMountsEnd = 0x320; +constexpr std::uint32_t kSecName = 0x1b4; // Mars::String: union at +0, size +0x10, cap +0x14 +constexpr std::uint32_t kSecNameCap = 0x1c8; +constexpr std::uint32_t kMountStride = 0x30; + +// Draw sites inside one composer call, by the return address each is known by in the airng tables. +constexpr std::uint32_t kSiteA = 0x006ad878; // cl_RandFloat -- the 0.5 coin that forces a section +constexpr std::uint32_t kSiteB = 0x006ad94c; // cl_RandRange -- pick the hull section +constexpr std::uint32_t kSiteC = 0x00691ea0; // cl_RandRange -- pick the command section (helper) +constexpr std::uint32_t kSiteD = 0x006adf35; // cl_Chance 0.5 -- "use a special large mount" +constexpr std::uint32_t kSiteE = 0x006adf44; // cl_RandRange -- which one +constexpr std::uint32_t kSiteF = 0x006adfca; // cl_Chance 0.8 -- unconditional once reached +constexpr std::uint32_t kSiteG = 0x006ae418; // cl_Chance 0.3 -- picks the point-defence FRACTION +constexpr std::uint32_t kSiteH = 0x006ae57a; // cl_Chance 0.2 -- THE LOOP-CARRIED DRAW + +const char* SecName(std::uintptr_t sec, char* buf, std::size_t cap) { + buf[0] = '\0'; + if (!sec) return buf; + const std::uint32_t res = U32(sec + kSecNameCap); + std::uintptr_t p = sec + kSecName; + if (res >= 16) p = U32(p); + if (!Readable(p, 1)) return buf; + std::size_t i = 0; + for (; i + 1 < cap; ++i) { + if (!Readable(p + i, 1)) break; + const char c = *reinterpret_cast(p + i); + if (!c) break; + buf[i] = c; + } + buf[i] = '\0'; + return buf; +} + +bool IsPointDefenceSection(const char* name) { + return _stricmp(name, "DEPointDefence") == 0 || _stricmp(name, "CRPointDefence") == 0; +} + +// The three float32 fractions the composer chooses between at 0x006ae3f8 / 0x006ae41f / 0x006ae423. +// Read out of the image as four bytes, not assumed from a decimal (method rule 23). +constexpr float kFracDefault = 0.75f; // ds:0x009e5ac4 +constexpr float kFracHalf = 0.5f; // ds:0x009e2ea0 +constexpr float kFracFull = 1.0f; // fld1 + +bool g_design = false; +std::uint32_t g_designSeq = 0; +int g_composerDepth = 0; +void* g_trComposer = nullptr; + +extern "C" int sd_composer_c(void* ecx, void* edx, DesignReq* req, unsigned dry); +extern "C" int sd_call_composer(void* tramp, void* ecx, void* edx, void* req, unsigned dry); +extern "C" void sd_composer_thunk(void); + +int sd_composer_body(void* ecx, void* edx, DesignReq* req, unsigned dry) { + if (!g_design || g_composerDepth != 0 || !g_active) { + if (g_design && g_composerDepth != 0) + LogF("aidesign NESTED depth=%d -- not measured", g_composerDepth); + ++g_composerDepth; + const int rc0 = sd_call_composer(g_trComposer, ecx, edx, req, dry); + --g_composerDepth; + return rc0; + } + + DesignReq in = {0, 0.0f, 0, 0}; + if (Readable(reinterpret_cast(req), sizeof in)) in = *req; + + const std::uint32_t seq = ++g_designSeq; + const std::int32_t leftIn = LeftOf(g_active); + + g_subActive = true; + g_subWords = 0; + g_subRandFloatCalls = 0; + g_nsub = 0; + g_subOverflow = 0; + ++g_composerDepth; + const int rc = sd_call_composer(g_trComposer, ecx, edx, req, dry); + --g_composerDepth; + g_subActive = false; + + const std::int32_t leftOut = LeftOf(g_active); + const std::uint32_t words = WordsBetween(leftIn, leftOut); + + // ---- the state the cost model is a function of, read off the design the call produced ------- + const std::uintptr_t parts = reinterpret_cast(edx); + std::uint32_t qual[3] = {0, 0, 0}; + bool isPD[3] = {false, false, false}; + std::uint32_t sectionId[3] = {0, 0, 0}; + char names[3][64]; + std::uint32_t N = 0; + for (int p = 0; p < 3; ++p) { + names[p][0] = '\0'; + const std::uintptr_t part = parts + static_cast(p) * kPartStride; + const std::uintptr_t sec = U32(part); + sectionId[p] = static_cast(sec); + if (!sec) continue; + SecName(sec, names[p], sizeof names[p]); + isPD[p] = IsPointDefenceSection(names[p]); + const std::uint32_t mb = U32(sec + kSecMountsBegin); + const std::uint32_t me = U32(sec + kSecMountsEnd); + if (!mb || me < mb) continue; + std::uint32_t avail = (me - mb) / kMountStride; + std::uint32_t n = U32(part + kPartMountCount); + if (n > avail) n = avail; + for (std::uint32_t i = 0; i < n; ++i) { + const std::uintptr_t m = mb + i * kMountStride; + if (U32(m) == 0 && U32(m + 4) == 1) ++qual[p]; + } + N += qual[p]; + } + + // ---- the observed sites ---------------------------------------------------------------------- + std::uint32_t tD = 0, tF = 0, tG = 0, tH = 0; + const std::uint32_t cA = g_subRandFloatCalls; + const std::uint32_t cB = SubCalls(kSiteB); + const std::uint32_t cC = SubCalls(kSiteC); + const std::uint32_t cD = SubCalls(kSiteD, &tD); + const std::uint32_t cE = SubCalls(kSiteE); + const std::uint32_t cF = SubCalls(kSiteF, &tF); + const std::uint32_t cG = SubCalls(kSiteG, &tG); + const std::uint32_t cH = SubCalls(kSiteH, &tH); + + // ---- the prediction, computed here so the log carries model AND measurement side by side ---- + // + // f the fraction of size-1/kind-0 mounts that get a point-defence weapon + // M = (int)floorf(N * f) -- 0 skips the whole tail block, so no loop-carried draw + // D' = max(1, (N + 1) / M) -- integer division; every D'-th qualifying mount is offered + // H = #{ qualifying mount j : the section is a *PointDefence hull, or j mod D' == 0 } + // with j counted GLOBALLY across the three sections, not per section. + float f = kFracDefault; + if ((in.flags & 0x20000u) != 0) { + f = kFracDefault; + } else if (in.hullSize > 0) { + f = (cG > 0 && tG > 0) ? kFracFull : kFracDefault; + } else { + f = kFracHalf; + } + // The image does `fild N; fmul f; fstp float32; floor(); fstp float32; __ftol` -- a float32 + // round, a floor, another float32 round, then truncation toward zero. For a non-negative + // product floor and truncation agree, so one float32 multiply and one cast reproduce it. + const float scaled = static_cast(N) * f; + const std::int32_t M = static_cast(scaled); + std::int32_t Dp = 1; + std::uint32_t Hpred = 0; + if (M > 0) { + Dp = (static_cast(N) + 1) / M; + if (Dp < 1) Dp = 1; + std::int32_t j = 0; + for (int p = 0; p < 3; ++p) + for (std::uint32_t i = 0; i < qual[p]; ++i) { + if (isPD[p] || (j % Dp) == 0) ++Hpred; + ++j; + } + } + + LogF("aidesign seq=%u dseq=%u rc=%d dry=%u size=%d budget=%.3f role=%d flags=0x%08x " + "words=%u sub_words=%u left=%d->%d N=%u q=%u/%u/%u pd=%d%d%d " + "f=%.2f M=%d D=%d H_pred=%u H_obs=%u model=%s", + g_seq, seq, rc, dry, in.hullSize, static_cast(in.budget), in.role, in.flags, words, + g_subWords, leftIn, leftOut, N, qual[0], qual[1], qual[2], isPD[0] ? 1 : 0, + isPD[1] ? 1 : 0, isPD[2] ? 1 : 0, static_cast(f), M, Dp, Hpred, cH, + Hpred == cH ? "HOLDS" : (cH == 0 ? "GATED-OR-WRONG" : "WRONG")); + LogF("aidesignsite seq=%u dseq=%u A_randfloat=%u B_hull=%u C_cmd=%u D_chance=%u(t%u) " + "E_pick=%u F_chance=%u(t%u) G_chance=%u(t%u) H_loop=%u(t%u) sub_overflow=%u", + g_seq, seq, cA, cB, cC, cD, tD, cE, cF, tF, cG, tG, cH, tH, g_subOverflow); + for (int p = 0; p < 3; ++p) + LogF("aidesignpart seq=%u dseq=%u part=%d sec=0x%08x mounts=%u qual=%u pd=%d name=%s", + g_seq, seq, p, sectionId[p], U32(parts + static_cast(p) * kPartStride + + kPartMountCount), + qual[p], isPD[p] ? 1 : 0, names[p]); + return rc; +} + +extern "C" int sd_composer_c(void* ecx, void* edx, DesignReq* req, unsigned dry) { + return sd_composer_body(ecx, edx, req, dry); +} + +#if SHIM_X86_WIN +// Entry: [esp]=ret [esp+4]=&req [esp+8]=dry, with ECX and EDX live. Re-push all four as cdecl, +// call the C body, then drop everything and return WITHOUT touching the caller's 8 stack bytes -- +// the game's own call site cleans them (`add esp,0x8` at 0x006ae719). +__asm__(".text\n" + ".globl _sd_composer_thunk\n" + "_sd_composer_thunk:\n" + " pushl %edx\n" + " pushl %ecx\n" + " pushl 16(%esp)\n" // dry + " pushl 16(%esp)\n" // &req + " pushl 12(%esp)\n" // edx + " pushl 12(%esp)\n" // ecx + " call _sd_composer_c\n" + " addl $16, %esp\n" + " addl $8, %esp\n" + " ret\n"); +// cdecl(tramp, ecx, edx, req, dry) -> re-enter the trampoline in the game's own convention. +__asm__(".text\n" + ".globl _sd_call_composer\n" + "_sd_call_composer:\n" + " pushl %ebp\n" + " movl %esp, %ebp\n" + " pushl 24(%ebp)\n" // dry + " pushl 20(%ebp)\n" // req + " movl 12(%ebp), %ecx\n" + " movl 16(%ebp), %edx\n" + " call *8(%ebp)\n" + " addl $8, %esp\n" + " popl %ebp\n" + " ret\n"); +#else +extern "C" int sd_call_composer(void*, void*, void*, void*, unsigned) { return 0; } +extern "C" void sd_composer_thunk(void) {} +#endif + // ---- the bracket detour ------------------------------------------------------------------------ using ResumeFn = void(SHIM_THISCALL*)(void*, void*); using SeedFn = void*(SHIM_THISCALL*)(void*, std::uint32_t); void* g_trResume = nullptr; -std::uint32_t g_seq = 0; int g_depth = 0; void SHIM_THISCALL DetourOnResumePlaying(void* self, void* ev) { @@ -397,10 +715,10 @@ void SHIM_THISCALL DetourOnResumePlaying(void* self, void* ev) { g_sites[i].zero_calls); } for (std::size_t i = 0; i < g_ncallers; ++i) - LogF("airngcall seq=%u pid=%u ret_rva=0x%08x va=0x%08x facade=%s calls=%u", seq, + LogF("airngcall seq=%u pid=%u ret_rva=0x%08x va=0x%08x facade=%s calls=%u trues=%u", seq, static_cast(playerId), g_callers[i].ret_rva, static_cast(A::IMAGE_BASE + g_callers[i].ret_rva), - caller_kind(g_callers[i].which), g_callers[i].calls); + caller_kind(g_callers[i].which), g_callers[i].calls, g_callers[i].trues); if (watched != rng) LogF("airng seq=%u WARNING watched generator moved during the bracket", seq); // The census, every bracket: every generator the process has drawn from, and every site that @@ -430,6 +748,10 @@ bool ai_rng_config(const char* key, const char* value, std::string* err) { std::snprintf(g_outPath, sizeof g_outPath, "%s", value); return true; } + if (std::strcmp(key, "aidesign") == 0) { + g_design = std::strcmp(value, "on") == 0 || std::strcmp(value, "1") == 0; + return true; + } if (std::strcmp(key, "airng.pin_seed") == 0) { if (std::strcmp(value, "off") == 0 || std::strcmp(value, "0") == 0) { g_pin = false; @@ -480,6 +802,18 @@ void install_ai_rng(std::uintptr_t exeBase, const char* gameDir, void (*log)(con LogF("airng: facade %s rva=0x%08x va=%p create=%s enable=%s", f.name, f.rva, t, MH_StatusToString(a), MH_StatusToString(b)); } + if (g_design) { + void* ct = reinterpret_cast(exeBase + A::AIComposeShipBlueprint); + MH_STATUS a = MH_CreateHook(ct, reinterpret_cast(&sd_composer_thunk), &g_trComposer); + MH_STATUS b = a == MH_OK ? MH_EnableHook(ct) : a; + LogF("aidesign: composer 0x006ad700 rva=0x%08x va=%p create=%s enable=%s", + A::AIComposeShipBlueprint, ct, MH_StatusToString(a), MH_StatusToString(b)); + if (a != MH_OK || b != MH_OK) + LogF("aidesign: THE COMPOSER HOOK IS NOT ARMED -- every aidesign row below is missing, " + "and an absent row is NOT a measured zero"); + } else { + LogF("aidesign: off -- no composer rows will be emitted"); + } draw_sites_set_observer(&ObserveDraw); } From 414bcc743e032d06c9f5cd1936c9378e3e37921d Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 20:51:17 -0400 Subject: [PATCH 3/3] SD: record the two weapon lookups that gate the loop-carried draw The loop-carried cl_Chance(0.2f) at 0x006ae575 has measured zero on all six composer calls of all four runs, and a word count cannot say which of its two gates did it: a null from the UNRESTRICTED weapon lookup skips the whole point-defence block, a null from the RESTRICTED one short-circuits every iteration without drawing. Both print zero -- method rule 20 one level below the level the sub-bracket was built for. So the chooser 0x006ad2a0 is detoured and its two return values recorded per composer call, keyed by the return address that distinguishes the call sites (0x006ae3c6 restricted, 0x006ae3e1 default), and printed as w_alt / w_def. NOT YET RUN. Every measurement in findings/subsystems/ship-design-composer.md was taken with the previous build; this hook is the instrument for the next lane, not for the numbers already published. Header regenerated from ghidra/addresses.json plus the fragments, never hand-resolved. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ --- include/generated/sots_addresses.h | 4 +++- src/shim/hooks/ai_rng.cpp | 37 ++++++++++++++++++++++++++++-- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/include/generated/sots_addresses.h b/include/generated/sots_addresses.h index 8e3b0c2..8cbfb93 100644 --- a/include/generated/sots_addresses.h +++ b/include/generated/sots_addresses.h @@ -1,5 +1,5 @@ // GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1). -// Source: sots-re ghidra/addresses.json @ 1bae6f1, generated 2026-09-08 by tools/gen_addresses.py +// Source: sots-re ghidra/addresses.json @ 67cd78b, generated 2026-09-08 by tools/gen_addresses.py // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated). #pragma once #include @@ -2147,6 +2147,8 @@ constexpr uint32_t AIShipSectionContext_stride = 0x00000124; constexpr uint32_t ShipSectionDef_off_Banks = 0x0000031c; // offset Mars::String -- the section's file name on a Game::ShipSectionDef; capacity word at +0x1c8, so the payload is the inline buffer when capacity < 0x10 and *(char**)(def+0x1b4) otherwise. The composer compares it case-insensitively against "DEPointDefence" and "CRPointDefence" at 0x006ae4e6/0x006ae4fd: a section whose name matches gets a point-defence weapon in EVERY qualifying bank instead of every D'-th one [verified] constexpr uint32_t ShipSectionDef_off_Name = 0x000001b4; +// cdecl int (StrategyAIAgent* agent, int hullClass, std::vector* restrictTo, int bankClass, int bankSize, void* ctx, int flag) /* seven stack arguments, caller cleans 0x38 for the two calls at 0x006ae3c1/0x006ae3dc. Returns a weapon id, or 0 when nothing fits. The composer calls it in the weapon-assignment loop and then TWICE more just before the point-defence pass: once with a one-element list holding the literal 0x25 (read as the point-defence weapon family -- INFERRED, from the DEPointDefence/CRPointDefence section names the same pass matches and the 0.2 probability it rolls) and once unrestricted. A null from the unrestricted call skips the whole point-defence block; a null from the restricted one makes every iteration short-circuit without drawing. Both make the loop-carried draw cost zero, which is why lane SD's probe records the two return values separately */ [verified] +constexpr uint32_t AIChooseWeaponForBank = 0x002ad2a0; // thiscall int (Game::SVScriptObject* this, int evt, void* arg) // the script-object event bus. Calls this->vft[0x10](evt, arg) -- the GENERIC handler every object sees -- then `cmp evt,0x20; ja done; jmp dword [evt*4 + SVScriptObject_EventSlotJumpTable]`, which dispatches to ONE event-specific vtable slot with the argument shape that event carries. Every hand-written `vft[0x10](id,0); vft[slot]()` pair in the two turn drivers is this same two-step done on the root object [verified] constexpr uint32_t SVScriptObject_DispatchEvent = 0x003a60d0; // data void* [33] // evt (0..0x20) -> the vtable slot SVScriptObject_DispatchEvent calls. Slot byte offsets in evt order: 0x14 0x18 0x1c 0x20 0x24 0x28 0x2c 0x30 0x34 0x38 0x3c 0x40 0x44 0x48 0x4c 0x50 0x54 0x58 0x5c 0x60 0x64 0x6c 0x70 0x74 0x68 0x7c 0x80 0x84 0x78 0x88 0x8c 0x90 0x94. Note 0x15->+0x6c, 0x16->+0x70, 0x17->+0x74, 0x18->+0x68 and 0x1c->+0x78 are NOT in slot order [verified] diff --git a/src/shim/hooks/ai_rng.cpp b/src/shim/hooks/ai_rng.cpp index 8d96eb0..3e5442b 100644 --- a/src/shim/hooks/ai_rng.cpp +++ b/src/shim/hooks/ai_rng.cpp @@ -386,6 +386,30 @@ void NoteCaller(const void* ret, std::uint8_t which, int result) { ++g_ncallers; } +// ---- lane SD: the two weapon lookups that gate the loop-carried draw ----------------------------- +// +// `H_obs = 0` has two causes the sub-bracket cannot tell apart from outside: the DEFAULT weapon +// lookup returning null skips the whole point-defence block, and the RESTRICTED (point-defence) +// lookup returning null short-circuits every iteration without drawing. Both print zero. That is +// method rule 20 one level below the level the probe was built for, so the two return values are +// captured here, keyed by the return address that distinguishes the two call sites +// (0x006ae3c6 = restricted, 0x006ae3e1 = default). +std::uint32_t g_wAlt = 0xffffffffu; // 0xffffffff = "not called in this composer call" +std::uint32_t g_wDef = 0xffffffffu; +using ChooseWeaponFn = int(SHIM_CDECL*)(int, int, int, int, int, int, int); +void* g_trChooseWeapon = nullptr; + +int SHIM_CDECL DetourChooseWeapon(int a1, int a2, int a3, int a4, int a5, int a6, int a7) { + const std::uintptr_t ra = reinterpret_cast(__builtin_return_address(0)); + const int r = reinterpret_cast(g_trChooseWeapon)(a1, a2, a3, a4, a5, a6, a7); + if (g_subActive) { + const std::uint32_t va = static_cast(ra - g_exeBase) + A::IMAGE_BASE; + if (va == 0x006ae3c6u) g_wAlt = static_cast(r); + else if (va == 0x006ae3e1u) g_wDef = static_cast(r); + } + return r; +} + using ClChanceFn = bool(SHIM_CDECL*)(float); using ClRandRangeFn = int(SHIM_CDECL*)(int, int); void* g_trClChance = nullptr; @@ -506,6 +530,8 @@ int sd_composer_body(void* ecx, void* edx, DesignReq* req, unsigned dry) { const std::int32_t leftIn = LeftOf(g_active); g_subActive = true; + g_wAlt = 0xffffffffu; + g_wDef = 0xffffffffu; g_subWords = 0; g_subRandFloatCalls = 0; g_nsub = 0; @@ -598,8 +624,9 @@ int sd_composer_body(void* ecx, void* edx, DesignReq* req, unsigned dry) { isPD[1] ? 1 : 0, isPD[2] ? 1 : 0, static_cast(f), M, Dp, Hpred, cH, Hpred == cH ? "HOLDS" : (cH == 0 ? "GATED-OR-WRONG" : "WRONG")); LogF("aidesignsite seq=%u dseq=%u A_randfloat=%u B_hull=%u C_cmd=%u D_chance=%u(t%u) " - "E_pick=%u F_chance=%u(t%u) G_chance=%u(t%u) H_loop=%u(t%u) sub_overflow=%u", - g_seq, seq, cA, cB, cC, cD, tD, cE, cF, tF, cG, tG, cH, tH, g_subOverflow); + "E_pick=%u F_chance=%u(t%u) G_chance=%u(t%u) H_loop=%u(t%u) sub_overflow=%u " + "w_alt=0x%08x w_def=0x%08x", + g_seq, seq, cA, cB, cC, cD, tD, cE, cF, tF, cG, tG, cH, tH, g_subOverflow, g_wAlt, g_wDef); for (int p = 0; p < 3; ++p) LogF("aidesignpart seq=%u dseq=%u part=%d sec=0x%08x mounts=%u qual=%u pd=%d name=%s", g_seq, seq, p, sectionId[p], U32(parts + static_cast(p) * kPartStride + @@ -803,6 +830,12 @@ void install_ai_rng(std::uintptr_t exeBase, const char* gameDir, void (*log)(con MH_StatusToString(a), MH_StatusToString(b)); } if (g_design) { + void* wt = reinterpret_cast(exeBase + A::AIChooseWeaponForBank); + MH_STATUS wa = MH_CreateHook(wt, reinterpret_cast(&DetourChooseWeapon), + &g_trChooseWeapon); + MH_STATUS wb = wa == MH_OK ? MH_EnableHook(wt) : wa; + LogF("aidesign: weapon chooser 0x006ad2a0 rva=0x%08x va=%p create=%s enable=%s", + A::AIChooseWeaponForBank, wt, MH_StatusToString(wa), MH_StatusToString(wb)); void* ct = reinterpret_cast(exeBase + A::AIComposeShipBlueprint); MH_STATUS a = MH_CreateHook(ct, reinterpret_cast(&sd_composer_thunk), &g_trComposer); MH_STATUS b = a == MH_OK ? MH_EnableHook(ct) : a;