sots-re/findings/control-flow/trade-raid-rng-gate.md

24 KiB
Raw Blame History

The tail does draw under active contents — the trade-raid gate, decoded and fired

  • Type: control-flow (static decode + live measurement)
  • Status: verified for the trade half; the spy half is decoded but not reached and is labelled a hypothesis (rule 6)
  • Confidence: high on the trade half — the callees were entered live, the RNG word delta moved, and the state that moved it was reached by playing a stock game with no file edited
  • Owner / date: lane AC · 2026-09-08 · guest VM141 (sots-re-win10-b, 192.168.10.143)
  • Predicts / corrects: lane W3's open risk (not retired — realised), lane L3's trade-and-spy-workload.md §3.5, lane V2's "closure 185 functions, draw-free" reading of the trade slots

0. Headline

Yes. The strategic-turn tail draws RNG once its containers are active. StrategyServer::OnAllCombatDone_Tail consumes 1 word per turn in the state this lane built, against 0 on the same guest, same build and same config on the state lane L3 left. TradeManager::Slot13RngCalleeA — entered 0 times in every measurement any lane had ever taken — was entered 1×/turn on both instrumented turns, and Slot13RngCalleeB on one of them.

Lane W3's open risk is realised, not retired. The RNG ledger and the standalone's generator model need extending by one draw site.

And the condition that does it is not the one lane L3 named. Freighters in service do not do it and a deployed spy would not do it either. The gate is:

a player-owned fleet parked exactly on a trade-sector node, whose owner's bit is set in that sector's tscr mask — which is set by researching CCC_ComRaid ("Commerce Raiding").

That is the trade-raid roll. It had never fired in this campaign because no lane had ever researched Commerce Raiding, and without it the game refuses the order in as many words: Alpha Fleet cannot raid trade sector.


1. What was measured

Instrument: shim.cfg.l3probe verbatim (lane L3's, = lane H's hp11 + watch.mode=cont), shim build ac-7e726b9-20260909T0017Z, cross-built on the dev box. All eleven probes reported create=MH_OK enable=MH_OK; no COVERAGE: line was emitted; the watchpoint canary printed SELFTEST canary writes=1 traps=1 dr7=0xdddd0055 PASS on every End Turn. These are measured numbers, not MinHook failures.

1.1 Entry counts, active state (turns 21→22 and 22→23 of the AC lineage)

The baseline column is this lane's own control, not a quotation: the same shim build, the same shim.cfg.l3probe, the same guest, one End Turn on lane L3's human-turn15-spyprogram.sav, run immediately after the active measurement. It reproduces lane H's and lane L3's published counts line for line.

probe active, turn 1 active, turn 2 AC control (L3's turn 15)
ServerSpyManager::vslot13 1 1 1
ServerSpyManager::vslot14 1 1 1
ServerTradeManagerImpl::vslot13 1 1 1
ServerTradeManagerImpl::vslot15 1 1 1
SpyManager::Slot13RngCallee 0 0 0
TradeManager::Slot13RngCalleeA 1 1 0
TradeManager::Slot13RngCalleeB 1 0 0
ServerTradeManager::CreateRaidEncounter 0 1 0
ServerTradeManager::GenerateTradeRaidEncounters 1 1 1
ServerSpyManager::vslot15 [control] 1 1 1
ServerTradeManagerImpl::vslot14 [control] 1 1 1
EncounterDetect::Run [control] NOT INSTALLED NOT INSTALLED —

Slot13RngCalleeB firing on turn 1 and not on turn 2 is exactly what the decoded control flow predicts: B is called only when A returns true, i.e. only when the raid roll succeeds. Two turns, one success — the roll is doing what a roll does.

1.2 The RNG ledger

side.rng word deltas. Control first (L3's turn 15, no raider, same instrument):

ProcessTurn                     353 -> 355   delta  2
  OnAllCombatDone_Tail          355 -> 355   delta  0

Active (raider parked on sector 6's node), both turns:

ProcessTurn                     448 -> 468   delta 20
  OnAllCombatDone_Tail          468 -> 469   delta  1
ProcessTurn                     469 -> 487   delta 18
  OnAllCombatDone_Tail          487 -> 488   delta  1

One word per turn inside the tail, on a turn where the tail had never drawn a word before. The draw is FUN_0047d830(StrategyServer+0x16c + 4) inside Slot13RngCalleeA — one uniform compared against the raid odds. Slot13RngCalleeB and CreateRaidEncounter each cost 0 words on the turns they ran, so the whole of the new consumption is A's single roll.


2. The gates, decoded from the instruction stream

Read off sots.exe (image base 0x00400000), disassembling to the next function start rather than to Ghidra's reported size (rule 17).

2.1 Slot13RngCalleeA / B — called from ServerTradeManagerImpl::vslot13

Both calls are direct E8 rel32, not vtable slots:

callee called from at
TradeManager::Slot13RngCalleeA 0x00820ca0 ServerTradeManagerImpl::vslot13 0x0088ef80 0x0088f036
TradeManager::Slot13RngCalleeB 0x0088b440 same function 0x0088f042
SpyManager::Slot13RngCallee 0x008408e0 ServerSpyManager::vslot13 0x008877b0 0x00887af2

ServerTradeManagerImpl::vslot15 (0x0082cca0) and ServerSpyManager::vslot14 (0x0088db80) do not call any of the three. The only data references to the four outer callees are their vtable slots (0xa30770, 0xa30774, 0xa31ba8, 0xa31bb0).

vslot13 does not iterate trade routes or trade sectors. It iterates StrategyServer+0x64..+0x68 — the fleet vector — and per fleet requires, in order:

0088ef95  test  ecx,0xfffffffc              ; G0  no fleets -> return
0088efb1  je    0x88f047                    ; G1  null slot
0088efb9  call  0x6fe320                    ; StarFleet::GetLocationAsTradeSector()
0088efc0  je    0x88f047                    ; G2  location is not a trade-sector node
0088efc7  call  0x80ed50                    ; the raid predicate
0088efd1  je    0x88f047                    ; G3
0088efeb..0088f016                          ; G4  fleet.Pos == sector.Pos, all three floats
0088f036  call  0x820ca0                    ;     <-- A
0088f03d  je    0x88f047                    ; G6  raid roll failed
0088f042  call  0x88b440                    ;     <-- B

G2 is the one nobody had satisfied. 0x006fe320 reads StarFleet+0xa0 (LocID, resolved to a pointer) and returns it only if node+0x14 == 2. That kind word is written by StarMapNode::StarMapNode(int) 0x00730900, and enumerating every call to it gives exactly three: StarSystem ctor 0x00752ea0 passes 0, StarFleet ctor 0x00706140 passes 1, TradeSector ctor 0x00858020 passes 2. So vslot13 only ever acts on a fleet whose location is a trade-sector node, never a star. Every fleet in every corpus save before this lane was parked at a star, so G2 rejected all of them — which is why the "23-fleet Zuul save" experiment in tail-probes.md §3.1 could not have worked no matter how many fleets it had.

G3 is FUN_0080ed50(fleet):

if (!fleet->GetLocationAsTradeSector())            return false;
if (FUN_00813ab0(fleet, 0x80000, 0))               return false;   // runtime flag pair +0xb8/+0xbc
ServerPlayer *p = fleet->[0x58];  if (!p)          return false;
if (!(sector->[0x90] & (1u << p->[0x28])))         return false;   // ** tscr **
if (FUN_0080e9c0(sector, p))                                        // ** tsct ** bit, or p->[0x5c]==4
    if (!FUN_006fe1d0(fleet, 0x800))               return false;   // FtFlg & 0x800
return true;

FUN_0080eac0 masks six consecutive words with a low-N-players mask, which is what identifies them as per-player bitmasks and pins the tags:

0080ead7  and [ecx+0x88],edx   tssec      0080eae3  and [ecx+0x90],edx   tscr
0080eadd  and [ecx+0x8c],edx   tsct       0080eae9  and [ecx+0x94],edx   ptssec …

G4 requires the fleet's Pos (+0x18) to be bit-equal to the sector's — arrived, not in transit. The test ah,0x44 / jp after fucompp is the MSVC float-not-equal idiom.

2.2 SpyManager::Slot13RngCallee — decoded, and much further away than "deployed"

0088781d  je  0x887e92     ; SpyCraft.deat (+0x10) == 0        -> next spy
00887856  je  0x887c5b     ; SpyCraft.sdet (+0x40) == -1       -> DETECTION-ROLL branch, not P
00887867  jl  0x887e92     ; (turn - sdet) < 3                 -> next spy
0088787a  je  0x887c4f     ; system unowned                    -> sdet = -1
00887891  je  0x887c4f     ; system owner == spy's owner       -> sdet = -1
00887af2  call 0x8408e0    ; <-- P

P itself is the false-flag draw: 0.25 (0x009e5ac0) for an ordinary owner, or 0.75 (0x009e5ac4) plus a uniform pick over the other players when owner->[0x5c] == 6; its result is the name that goes into EVENT_SPY_DESTROYED, posted to the system owner.

So deploying a spy is necessary but nowhere near sufficient for P: the spy must be deployed, then detected on an earlier turn, then survive three more turns at a system still owned by someone else. Lane L3's "the next condition is spyon = 0" understates the distance by three turns and a detection roll.

But the sdet == -1 arm at 0x00887c5b is itself an RNG draw — FUN_008e6dd0(StrategyServer+0x16c, spy->sdo), gated on FUN_00743f80(system) & 1 — and it sits inline in vslot13, not inside P. That is a second, independent way for the tail to draw that no entry probe on P can see. It is unreachable today only because deat == 0 rejects the docked spy two gates earlier. Rule 20, pointing the other way: instrumenting the entry was necessary and was not sufficient — a probe on P reading 0 does not mean the spy half is draw-free.

This is a hypothesis, not a result (rule 6): no save in the corpus has a deployed spy, and this lane did not manage to build one (§5).


3. tscr is the raid mask and CCC_ComRaid sets it — measured, before and after

This is the part that turns the decode into a recipe, and it was not predicted: this lane's committed prediction P6 said a player fleet on a sector node would not fire the callees, on the reasoning that tscr = 252 = 0b11111100 is exactly the six NPC player slots (2–7) and excludes both real players (0 = re, 1 = The Eternal Empire). That reading of the value was right and the inference from it was wrong, because the mask is not fixed.

save tscr, all six sectors player re CnRad
human-turn15-spyprogram.sav (L3) 252 = 0b11111100 absent/false
AC turn 19, after CCC_ComRaid completes 253 = 0b11111101 true

Researching Commerce Raiding sets bit 0 — the researching player's index — in every trade sector's tscr, and sets the ServerPlayer flag serialised as CnRad. CnRad is to CCC_ComRaid exactly what CnTrd (ServerPlayer+0xff) is to CCC_FTLEcon. The AI, which never researched it, stays out of the mask: CnRad false, bit 1 clear, in the same save.

So tscr reads: the set of players permitted to raid this sector — the six NPC slots by default (which is why TRADE_RAID_ODDS_NPC and TRADE_RAID_ODDS_REFUGEE exist), plus any empire that has bought its way in with the tech.

Two further tags fall out of the same pair of saves: tsct is the set of players trading through the sector (bit 1 on sectors 1, 2 and 5 — the AI's; 0 on sector 6, which is mine), and it is tsct that switches on the extra FtFlg & 0x800 requirement. Sector 6 has tsct = 0, so my raider needed no flag.


4. The recipe, end to end

Stock game, stock data, no file edited, starting from lane L3's human-turn15-spyprogram.sav. About 45 minutes of clicking.

  1. Research CCC_ComRaid. MasterTechList.tech: CCC_FTLEcon -> CCC_ComRaid RP:12000 Human:100. At this game's settings it is one turn. In the tech tree it is the node directly above FTL Economics in the C3 branch, and at the zoom the screen opens at it is drawn off the top of the viewport: zoom out two notches with the wheel, then zoom back in one notch with the cursor low on the screen (wheel 365 560 120 1), which shifts the camera down far enough to expose it. Vertical right-drag does not pan the tech tree at all — only horizontal, and only in ~600 px steps, and even then the response is not monotonic in distance.
  2. Toggle Trade View. Bottom bar circle (300,714) opens a six-icon vertical strip; the fifth icon (301,626) is Toggle Trade View. Trade sectors then draw as large translucent wireframe cubes with a padlock/$ marker at the sector node itself. Nothing else in the UI shows a trade sector, and there is no bar button for trade.
  3. Order a fleet to the sector node. Select the system, select the fleet row, click Move (155,464), hover the sector marker — the readout reads Sector 6 / Range 5.0 / ETA 2 Turns — then plain click it. This must be one cmd.txt; and note the jiggle-click variant (jclick) silently failed to register the destination where a plain click worked. Confirm from the fleet panel: it reads Dest: Sector 6, 2 Turns. Confirm again from the save — do not trust the panel: on the first attempt the panel showed nothing and the save showed LocID 48 (unmoved).
  4. Two End Turns. The fleet arrives: LocID 832 = TradeID 832 = sector 6, Pos [0, 0, 8.0] bit-identical to the sector's Pos.
  5. End Turn with the instrument on. Slot13RngCalleeA fires.

The refusal is the oracle. Before the tech, clicking the sector marker with a fleet selected pops Alpha Fleet cannot raid trade sector. (MOVEWARNING_NO_RAID; the server-side twin is OnCommand: Fleet not able to raid.). After the tech, the same click is accepted silently. That single dialog is the cheapest possible test of the whole gate chain.


5. What this lane could not reach: a deployed spy

Reported as an absence, not inferred around.

ARes2 on ServerSystem is the asteroid-belt field — its non-zero values in this save (1703, 1948, 2061, 2172, 2531) sit inside SYSTEM_MIN/MAX_ASTEROID_RESOURCES 1500–3000, and it is 0 elsewhere. Deploying a spy needs a system that is not mine, has no enemy fleet present, has no spy already, and has a belt. In human-turn15-spyprogram.sav only five of 28 systems have one:

idx id name ARes2 owner
1 64 Ka'Palum 2061 unowned
4 112 Regulus 1703 unowned
10 208 Kam'Tara 1948 unowned, AI fleet parked on it
12 240 Downbelow 2172 mine
27 480 Hitomi 2531 player index 7, Independent Colony

No system owned by the AI has an asteroid belt. The textbook target — an enemy colony with a belt — does not exist in this game. The only non-own, player-owned belt system is Hitomi, held by the NPC Independent Colony; it is 10.1 ly from the tender's berth against a hull range of 9.0, so it needs a staging hop, and this lane ran out of session before establishing whether it is even discovered. The Special menu at the tender's own system offers only Repair / Scuttle / Auto-repair / Flag As Guard — no Deploy Spy, which is consistent with the gate but does not locate it.

So: the spy half of "active contents" remains unreached, and §2.2's prediction that a deployed spy makes the tail draw through the inline detection roll is a hypothesis for the next lane. The save to start from is verify/results/saves/ac-turn22-raider.sav or L3's turn-15.

spies2 is still 0 in all 28 systems in every save this lane produced, including the turn-22 one. That is unchanged from L3 and is expected: no spy was ever deployed, so the experiment that distinguishes "spies2 is not the spy list" from "spies2 fills only for a deployed spy" was not run. The rule-6 flag on spies2 / SysMem / mts stays up. What this lane can add is that the question is now cheap for whoever reaches a deploy, because deat (SpyCraft+0x10) is the field vslot13 uses as the system handle, and spies2 living on ServerSystem makes the deployed-spy reading the natural one.


6. Corrections to earlier findings (rule 11)

Lane L3, trade-and-spy-workload.md §3.5 — "every trade sector has tsnumflt = 0, no freighters in service". Not true of the save L3 shipped. human-turn15-spyprogram.sav, sector TradeID 816 (tsgridID 5): tsnumflt 1, tsflt 2736 — and fleet 2736 is Freighters, PID 32, the AI's, with all six member systems AI-owned. The single serialised route record is the AI's too. So across L3's seven instrumented turns the game already had a freighter in service on an active route, and the trade callees were entered 0 times anyway. "Freighters in service" was already falsified as the gate by L3's own data, before this lane started, and §2.1 now says why: freighters are not in the predicate at all.

By turn 20 of this lane's lineage the AI had built two more (sector 5 tsnumflt 2, sector 2 tsnumflt 1) with no effect on any probe.

Lane V2 — ServerTradeManagerImpl vftable slot 13 recorded as "closure 185 functions, draw-free". It is not draw-free. It reaches a NextFloat at 0x00820ca0 on any turn a permitted raider is parked on a sector node. The closure was right; "draw-free" was a statement about the states that occurred (rule 23).

Lane W3's standing risk — "if either container ever fills, the ledger needs extending". The risk was correctly identified and the trigger was wrong: filling the containers changes nothing, and the ledger needs extending anyway, for a reason that has nothing to do with either container's contents.

tsflt is not a freighter list. At turn 20, sector 6 reads tsnumflt 1, tsflt 3744 — and 3744 is my raiding fleet's id, not a freighter. Whatever the tag is named, it counts fleets present in the sector.


7. Controls and coverage (rules 15, 19, 26)

Guest adoption / instrument-removed control. hooks=off watch=off on ref-turn2.sav, one End Turn, on VM141 with this lane's own shim build:

(Autosave EndTurn).sav  BB4FD9AC89F41E3BC0DB2AF08B18CE83417521AC4BCEE695FC9FA6CE16E30948
(Autosave).sav          978041ACD168B56ED8EB3F5E42E78D5E70EAE6E6517D75E659A5EB7CA3D60921

Both identical to the published three-run oracle in verify/results/saves/determinism-hashes.txt. That is agreement with a control already reproduced in three fresh processes by other lanes on another guest, which is the rule-26 bar; this lane ran it once here rather than twice, and says so.

Instrument armed. All eleven probes create=MH_OK enable=MH_OK, no COVERAGE: line; watchpoint canary PASS on every End Turn; container watch read trade routes count=46 -> 47, spy programs count=1 across the two measured turns.

Thin coverage, stated plainly.

  • Two instrumented turns, one raiding fleet, one sector, one ship in the fleet. The word cost is 1 on both, but 1 fleet × 1 sector is the smallest possible workload and the per-fleet loop means the cost should scale with permitted raiders parked on sector nodes, which this lane did not vary. Do not fit a constant to two observations (rule 20).
  • Slot13RngCalleeA's own body has a short-circuit that skips the draw entirely: any ship whose design+0x12c > 1 returns true without rolling. My fleet is one CR Repair and Salvage, so the short-circuit was not exercised, and a raid by a different hull could cost 0 words while still firing B. Untested.
  • The odds arithmetic (0.20 vs 0.05 base, +0.01·nShips, doubled when a freighter shares the sector, scaled by a per-ship float) was read statically and not validated against measured success rates: two turns, one success.
  • probes=11 is not determinism-neutral (lane H bisected the detour on probe index 8). These entry counts and word deltas are comparable line-for-line with lane H's and L3's tables, which is what they are for; they are not a byte-identity claim. probes=8 still installs indices 0–7, i.e. all four outer and all three inner callees, and is the configuration to use if a future lane needs both at once.
  • EncounterDetect::Run [control] is index 11 and probes=11 installs 0–10, so it reports NOT INSTALLED, never 0.

8. Artifacts

what where
turn-22 save, raider parked on a trade sector, both callees having fired verify/results/saves/ac-turn22-raider.sav
turn-20 save, the moment the fleet arrives on the node (pre-measurement) verify/results/saves/ac-turn20-raider.sav
probe + ledger trace, two instrumented End Turns verify/traces/ac-probe-turn21-turn23.jsonl
shim log and watch hits verify/results/shim/ac/ac-probe-run-{shim.log,watch.txt}
predictions, committed before the workload and again after the decode sots-engine docs/AC-predictions.md
instrument sots-engine src/shim/shim.cfg.l3probe, unchanged

New content these saves carry (rule 27 — expect the coverage ratchet to break, and type the content rather than moving the bar): a ServerPlayer with CnRad true; trade sectors with tscr = 253 rather than the corpus-universal 252; a trdmgr with three rt route records rather than one; sectors with tsnumflt up to 2 and a tsflt entry that is a warship fleet id rather than a freighter; and a StarFleet whose LocID is a TradeSector id (832) rather than a star system id — the first fleet in the corpus located anywhere but a star, which is precisely the state that makes the tail draw.


9. VM141 as left, and two things that cost this lane time

Guest state. Game stopped. C:\SOTS\shim.cfg is a copy of shim.cfg.l3cont (hooks=off, watch=on, watch.mode=cont — the cheap config); the lane's dist is C:\SOTS\shimdist-ac (BUILD_ID ac-7e726b9-20260909T0017Z) and C:\SOTS\binkw32.dll is that build's proxy, with the original still at binkw32_real.dll. SavedGames has been restored from C:\SOTS\SavedGames-acstash and additionally holds ac-turn20-raider.sav and L3's three saves, so every Load-dialog row position recorded by an earlier lane has moved on this guest — screenshot the dialog, do not reuse a remembered row. Also left, all harmless: C:\SOTS\acdata\ (config text extracted from sots.gob) and C:\SOTS\ui\ac-*.ps1.

click_helper.ps1 gained six verbs, purely additive, every existing verb byte-for-byte unchanged; pre-lane backup at click_helper.ps1.preAC:

drag  x1 y1 x2 y2     left-button drag                (ported from VM144, lane L3)
rdrag x1 y1 x2 y2     RIGHT-button drag, 30 steps     (the only thing that pans the tech tree)
sdrag x1 y1 x2 y2     slow left drag, 40 steps
wheel x y delta [n]   n wheel notches
jmove x y             move with a real relative mouse_event jiggle   (new; ported from lane L2's ui.ps1)
jclick x y            click with the same jiggle                     (new)

The jiggle is needed to read the star map and harmful when committing to it. jmove is what makes the map's hover readout update — without it the Sector 6 / Range 5.0 / ETA 2 Turns line never appears and you are clicking blind. But jclick on the sector marker did not register the destination, twice, silently; a plain click at the same coordinate did, first time. So: jmove to read, plain click to commit. Lane L2's note that the map "needs real mouse motion" is right about motion and wrong if read as "use the jiggle for everything".

Verify a fleet order from the save, never from the panel. The first move order left the fleet panel showing no destination and the save showing LocID 48 — unmoved — through two End Turns, with no error anywhere. The second attempt showed Dest: Sector 6, 2 Turns in the panel and moved. There is no failure signal; the only reliable check is Flt.LocID in the autosave.

Dev-box build note (not a repo change). cmake/toolchain-mingw-i686.cmake links libgcc and libstdc++ statically but not libwinpthread. On the WSL dev box the i686 mingw is the posix threads model, so binkw32.dll comes out importing libwinpthread-1.dll and the game dies at load with 0xC0000135 STATUS_DLL_NOT_FOUND — no shim log, no banner, just an exit code in dxvklog\run.txt. Adding -DCMAKE_SHARED_LINKER_FLAGS=-static fixes it. CT111 does not have this problem (its mingw uses the win32 model), which is why no lane has hit it before; the toolchain file was deliberately not changed, because that is a shared-infrastructure decision for the integrator rather than a lane's.