sots-re/findings/control-flow/tail-probes.md
alex 8ad9971f6e lane H: five live probes, the oracle restored, and a harness perturbation bisected to one hook
- determinism oracle regenerated and byte-identical (bb4fd9ac / 978041ac)
- the four phase-23/33 draw-bearing tail callees run EVERY turn; the three inner
  functions holding the draws run zero times -- the gate is inside each outer body
- CreateRaidEncounter is called (2 on one turn) and draws nothing: candidate list empty
- Zuul: 7 calls / 7 words per trade-raid Chance site, 14 not 16, as predicted
- EncounterDetect_Run receives an EMPTY record vector, so ProcessTeamRecord and
  AssignContacts never run; the 2-word detection residual is in 0x007d5150's subtree
- a MinHook detour on 0x00893290 changes the game's output; bisected over six runs.
  The un-instrumented game and lane Z's instrument agree, so lane Z's numbers stand
- lane AI1 insert: P2 held across two fresh processes, Rung B stays as written
2026-09-08 13:53:36 -04:00

27 KiB
Raw Blame History

Five probes on the running game — and a harness defect the probes found in themselves

Lane H, 2026-09-08. Program sots / "Sword of the Stars.exe", ImageBase 0x00400000, all addresses VAs. Engine worktree wip/probes, predictions in sots-engine/docs/H-probes.md, committed before the build (method rule 2). VM140 held for the duration; released at the end.

The headline, in order of what depends on what.

  1. The determinism oracle reproduces exactly. bb4fd9ac… and 978041ac…, byte for byte, after everything this session changed. §1.
  2. A hook in this lane's own instrument changed the game's output, and it was caught by comparing against a save lane Z had already produced. Bisected to one address. Every measurement below was then re-taken on a configuration proved byte-neutral against that save. §2.
  3. The four tail callees that can draw are entered on every single turn and gated inside; the three inner functions that hold the draws are entered zero times. "Never observed firing" was about a gate, not about reachability. §3.
  4. CreateRaidEncounter is called — twice on one measured turn — and draws nothing. The two hypotheses a word count could not separate are separated: the candidate list is empty. §4.
  5. The Zuul prediction holds exactly: 7 calls and 7 words per site, 14 not 16. The raid loop iterates the player vector. §5.
  6. EncounterDetect::AssignContacts cannot fire on our corpus for a reason nobody had measured: its caller is never called, because EncounterDetect_Run receives an empty record vector. The 2-word detection residual is somewhere else entirely, and §6 names the function it is in.

1. The oracle, regenerated — and it reproduces

Lane N's End Turns overwrote the three autosave contents on VM140 and said so. The file set was never disturbed, ref-turn2.sav still hashed ab4ac2d7e2977260… (checked before the run), and all 11 curated saves were in the repo, so nothing was lost — but the reference pair had to be remade.

Load ref-turn2.sav, one End Turn, build recap-7584bad-20260908T0615Z, hooks=trace:

file size sha256 historical value
(Autosave EndTurn).sav 66,732 bb4fd9ac89f41e3bc0db2af08b18ce83417521ac4bcee695fc9fa6ce16e30948 identical
(Autosave).sav 67,219 978041acd168b56ed8eb3f5e42e78d5e70eae6e6517d75e659a5eb7ca3d60921 identical

Both reproduce. The turn-2 state on screen (Imperial Savings 289,688) and the turn-3 state (532,369) match determinism-oracle.md as well.

This is worth more than a repeat. The process the oracle now runs in is not the one it was measured in: nineteen template hooks and seven entry-point detours installed instead of one asm stub, an x87 control-word module, eight save files where there were four, and a Zuul game played to turn 64 in the same directory in between. The oracle is revalidated across all of that, which is what every Phase-2 comparison downstream of it needed.

Files: verify/results/shim/probes/oracle-restore/h-oracle-{endturn,autosave}.sav.

And the same two files were put back on the VM at the end, so the three autosaves once again carry the documented oracle bytes. That is a restore of bytes this lane produced by an actual run today, not a substitution of an older snapshot — the distinction lane N was right to insist on.


2. The instrument perturbed the game. Bisected to one address.

This section is first among the probes because it invalidates a run and because nothing in this campaign has ever tested it.

2.1 How it was caught

Three End Turns from ref-turn2 under the full lane-H build gave per-turn word counts of 19, 18, 19. Lane Z's per-site table for the same three turns of the same save reads 19, 18, 20. One word apart on the third turn — the kind of difference that is easy to explain away as workload.

It was not explainable, because the input was byte-identical. The run's (Autosave EndTurn).sav (the turn-4 pre-turn state) hashed 8648957fecf5041233f9add4c8173f78981f026f133ecdbecdb89ddd10917048, which is exactly lane Z's z2-endturn.sav. Same bytes in; different bytes out:

(Autosave).sav after the turn-4 → turn-5 End Turn size
lane Z (z2-autosave.sav, and z-t5-autosave.sav from a second lane-Z run) b2124798470d85ea… 68,267
lane H, full build 058656787877cbee… 68,263

Both sides are reproducible: lane Z's value appears in two of its runs, and lane H's appeared in two of its own (three consecutive End Turns, and a fresh load of z2-endturn.sav followed by one End Turn). So this is not noise in either direction.

2.2 The bisect

z2-endturn.sav was pushed to the VM and the same single End Turn run five more times, changing one thing each time. A probes= key was added to shim.cfg for exactly this purpose — an instrument that can change what it measures has to be switchable, or the question cannot be asked at all.

configuration (Autosave).sav verdict
hooks=off — the shim installs nothing b2124798… the un-instrumented game
all template hooks + 7 draw-site detours, probes=0 b2124798… neutral
+ probes 0–3 (the four phase-23/33 vtable callees) b2124798… neutral
+ probes 4–7 (three inner callees, CreateRaidEncounter) b2124798… neutral
+ probe 8 (ServerTradeManager_GenerateTradeRaidEncounters 0x00893290) 058656… perturbs

Two things fall out, and the first is the more important:

  • The un-instrumented game agrees with lane Z, not with lane H. So lane Z's ledger — 7 draw-site detours and 6 nested template hooks — is behaviour-neutral on this pair, and its published numbers stand. This is the first time any lane has checked that, and it came out the right way.
  • Hooking GenerateTradeRaidEncounters at 0x00893290 with a MinHook detour changes the turn. Specifically it suppresses the ServerPlayer::RollResearchEvent draw at 0x0088df4f: the clean run spends 20 words with that site firing once, the perturbed run spends 19 with it not firing at all.

2.3 What the mechanism is not

The obvious relocation hazard is absent. The bytes at 0x00893290 are

55              push ebp
8b ec           mov  ebp,esp
6a ff           push -1                 <- the 5-byte patch boundary falls exactly here
68 37 c3 99 00  push 0x99c337           ; SEH scope table
64 a1 00 00 00 00  mov eax,fs:[0]       ; ... standard MSVC __try + /GS prologue

so MinHook's five bytes land on a clean instruction boundary, the relocated prologue is three position-independent instructions, and there is no branch target inside the patched range. A mangled prologue is therefore not the explanation, which makes this more interesting rather than less.

The mechanism is undetermined and is recorded as an open item, not guessed at. What is odd, and is the thing to chase: RollResearchEvent runs earlier in the turn than trade-raid generation, so hooking 0x00893290 changed a decision taken before the hooked function was reached. The cheapest next probe is a compare-mode hook on RollResearchEvent in both configurations, reading the threshold and the progress value it tests, on the same input save.

2.4 What this costs, and what it does not

  • Every number in §§3–6 was re-taken at probes=8, the largest configuration proved byte-identical to the un-instrumented game on this save. Nothing below rests on the perturbed run.
  • The perturbed run's trace is kept — verify/traces/h-probes-human-perturbed-t3-5.jsonl.gz, report verify/results/shim/probes/human-t3-5-perturbed.txt — labelled as such, because it is the evidence for §2 and because its probe entry counts agree with the clean runs on every row.
  • The general lesson is a method rule waiting to be written. A behavioural harness has to be checked against the oracle, not just against itself. It cost one save comparison to find this; it would have cost a published wrong number to find it later. The probes= key exists so the check is cheap to repeat: any lane adding detours should run one End Turn from z2-endturn.sav and confirm b2124798470d85ea… before trusting its own measurements.

3. The four tail callees — reached on every turn, gated inside

Lane V2 closed lane K's tier-4 blind spot: four of the eleven phase-23/33 vtable targets on StrategyServer+0x158/+0x15c reach a draw on the strategic generator, at eight instructions. Lane Z measured 0 tail words on eight turns. Between those two facts sat a question a word count cannot answer — not reached, or reached and gated?

Register-transparent asm stubs (pushfl; pushal; push idx; call; popal; popfl; jmp trampoline) count entries without assuming a calling convention. Two saves, three measured turns:

address what Human t5 Zuul t24 Zuul t25
0x008877b0 ServerSpyManager slot 13 1 1 1
0x0088db80 ServerSpyManager slot 14 1 1 1
0x0088ef80 ServerTradeManagerImpl slot 13 1 1 1
0x0082cca0 ServerTradeManagerImpl slot 15 1 1 1
0x008408e0 SpyManager_Slot13RngCallee 0 0 0
0x00820ca0 TradeManager_Slot13RngCalleeA 0 0 0
0x0088b440 TradeManager_Slot13RngCalleeB 0 0 0

P2a and P2c both hold. The phase-23/33 dispatch block is unconditional, exactly as lane V2 read it; and the gate that keeps the draws from firing is inside each outer body, above the inner call. No draw-site row appeared at any of the eight return addresses on any turn, and the tail's bracket cost stayed 0 — reproducing lane Z's number under an instrument that can now say why.

So the correct statement is no longer "the tail draws nothing" and not even "the tail draws nothing on these turns". It is: the tail's four draw-bearing callees run on every turn and return before reaching their draws. That is a much stronger negative, and it converts the open question from "which turns reach these functions" to "what does the gate in each body test".

3.1 Three of the four gates are an empty container; the fourth is not, and it was wrong

Read from the bodies (each confirmed a real function start with exactly one caller):

callee the loop it drives empty when
ServerTradeManagerImpl slot 15 vector at tradeManager+0x3c .. +0x40 no trade routes
ServerSpyManager slot 14 vector at spyManager+0x10 .. +0x14 no spy programs
ServerSpyManager slot 13 1,869 bytes, same manager, not fully read —
ServerTradeManagerImpl slot 13 GetServer()+0x64 .. +0x68 — the FLEETS vector no fleets

That last row produced a competing prediction, P2b-alt, committed after the bodies were read and before any run: on a save with fleets, slot 13's loop body runs, TradeManager_Slot13RngCalleeA fires, and the tail cost is non-zero.

P2b-alt is falsified, and cleanly. zuul-turn23-fleet23.sav is named for its 23 fleets; slot 13 was entered on both its turns and 0x00820ca0 was entered zero times. So the fleet loop is not the gate — the per-fleet test chain that precedes the call at 0x0088f036 is, and it rejects every fleet on a 23-fleet save. That is a narrower and more useful place to look than "the loop never runs", and it is the kind of wrong prediction that pays for itself: had P2b been the only one written down, its confirmation would have been read as "no trade routes, no spies" and the fleet loop would have gone unexamined.

3.2 What is still a hypothesis (rule 6)

Whether any of the eight draw sites ever fires is still unexercised. The workload that would settle it — a game with live trade routes and a live spy program — was not built, and it is worth saying exactly why rather than vaguely:

  • SotS 1 trade income requires trade-station construction and the tech behind it; from ref-turn2 (turn 2, 1 colony, 0 techs) that is tens of End Turns at 30–60 s each plus a Build/Research click path per turn. The newest Zuul save in the corpus (turn 23) still has no trade routes: slot 15's loop body did not execute on it.
  • The corresponding spy state is worse — no lane has yet identified which UI produces an entry in the spyManager+0x10 vector, so there is no click path to write down, only a container to watch.
  • What this lane did instead was cheaper and, for the map, better: it established that the containers are the gate for two of the four and that the fourth is gated per-fleet. A future lane that builds the workload now knows which two vectors to watch fill, and can confirm the workload took before spending a turn measuring.

4. CreateRaidEncounter — the two hypotheses, separated

A successful trade-raid roll calls ServerTradeManagerImpl slot 17, CreateRaidEncounter 0x008938a0, which draws a NextInt at 0x008939ee to pick a target but returns at 0x0089391c without drawing when the candidate vector is empty. It cost 0 words on all three of lane Z's turns, which lane Z read as consistent with either "no roll succeeded" or "the candidate list is empty".

A correction to the prior first (rule 11). Lane Z wrote that no roll succeeding is "≈11% on the defaults, so three quiet turns in a row is unremarkable". 11% is the probability of a quiet turn: 0.8^8 × 0.95^8 = 0.1113. Three consecutive is 0.00138, about 1 in 720. The two hypotheses were never close to equally likely, and the expected number of successes per turn is 8 × 0.2 + 8 × 0.05 = 2.0.

Measured:

turn CreateRaidEncounter entries words drawn
Human t4 → t5 0 0
Zuul t23 → t24 2 0
Zuul t24 → t25 1 0
Human t3/t4/t5, perturbed run 0 / 1 / 0 0

Settled: rolls succeed at about the predicted rate, and the candidate list is empty every time. Four successful rolls were observed across five measured turns and not one of them reached the NextInt at 0x008939ee — no draw-site row at return address 0x008939f3 on any turn.

For the standalone this is the useful form: on a corpus like ours the cost of a successful raid roll is 0 further words, and the row lane Y flagged as "the one that will bite" does not bite yet. It will bite the first turn a raid candidate exists, and the candidate list is built by 0x0083b110 — a function no lane has read.

One caveat this lane found and must state. CreateRaidEncounter is slot 17 and is reached by virtual dispatch, and GenerateTradeRaidEncounters is not its only caller: 0x007d5150, the other subtree of DetectEncounters (see §6), also dispatches slot 17 — (**(code **)(**(int **)(S + 0x158) + 0x44))(out, player, 1, records) — on the branch where its own per-player roll returns 0x12. So an entry count of 2 does not prove two trade-raid successes; it proves two slot-17 calls from one of two known sites. Separating them needs the return address, which an entry counter deliberately does not capture. tail-rng-ledger.md §11.1's "called from GenerateTradeRaidEncounters at 0x0089345e / 0x00893548 / 0x00893603" is incomplete as a list of callers.


5. The Zuul word count — 14, exactly as predicted

Lane Z's free prediction, restated by lane Y as a consequence of its trade_raid.cpp model: the two Chance sites in GenerateTradeRaidEncounters cost one word per entry of StrategyServer::Players, which holds 8 on the Human saves and 7 on the Zuul ones. Lane N ran a Zuul End Turn with the RNG hooks off, so it could not answer.

zuul-turn23-fleet23.sav, two End Turns, probes=8:

site Human t5 Zuul t24 Zuul t25
0x00893426 Chance (TRADE_RAID_ODDS_PLAYER) 8 calls / 8 words 7 / 7 7 / 7
0x00893513 Chance (TRADE_RAID_ODDS_NPC) 8 calls / 8 words 7 / 7 7 / 7
trade-raid block total 16 14 14
turn total (attributed = measured) 20 18 18

P5 holds in full, and it is a prediction that survived a changed workload rather than a repeat of a recording: 7 is a number no earlier run produced. The loop iterates the server's player vector and nothing else — not the TeamRecord vector it is handed, not a species list, not a fixed NPC count. Lane Y's trade_raid.cpp, which counts NumPlrs, is right.

The Zuul turn's remaining 4 words are 2 detection (§6) and 2 research — both ProcessResearch sites (0x00587888 and 0x005878bb) fired on both turns, which is the second research site's first appearance in a site table.


6. DetectEncounters — the residual is not the AssignContacts family, and here is where it is

Lane Y's standalone leaves a 2-word residual per turn, attributed by lane Z to 0x0050329d (NextFloat, depth 4) and 0x007929a4 (NextInt, depth 3), and refused to model it: "fitting 2/turn to three observations is not a model." Lane I bounds the containing family at |contacts| × |detectors| and showed EncounterDetect_AssignContacts draws one inlined NextFloat per (contact, detector) trial. Lane Z found AssignContacts never ran.

EncounterDetect_ProcessTeamRecord 0x007ca640 — its only caller — was hooked, with lane I's gate predicate, contact count and detector count recomputed from the record at hook entry.

It never ran either. On any turn, on either save. The hook installed (MH_OK for create and enable, logged), was registered in the trace's meta line, and emitted zero records across five End Turns, while the entry probe on EncounterDetect_Run recorded one call per turn, every turn.

EncounterDetect_Run calls ProcessTeamRecord once per 0x74-byte record. One call, zero records means the vector it receives is empty. So:

  • AssignContacts cannot fire on our corpus, and the reason is one level higher than anyone thought. It is not that the +0xfc gate fails on the records — there are no records. Lane I's |contacts| × |detectors| bound is correct and simply has nothing to bound yet.
  • P3b is falsified, exactly as its falsification clause anticipated, and the depth reasoning that produced it was wrong about the path, not the depth.

6.1 Where the two words actually are

A call-graph walk from both residual sites upward lands in the same place, and it is not EncounterDetect_Run:

StrategyServer::DetectEncounters 0x007d7f70          (the last phase of ProcessTurn)
 ├─ 0x007d8469  GenerateTradeRaidEncounters   [virtual]   16 words (14 on Zuul)   §5
 ├─ 0x007d8470  EncounterDetect_Run 0x007cb080           0 words -- empty record vector
 └─ 0x007d860b  0x007d5150                               ** the 2 words **
      ├─ 0x007d52b3  0x0078cc70 -> 0x0078ccb1  0x00503200 -> NextFloat 0x0050329d   1 word
      └─ 0x007d5311 / 0x007d5338  0x00792750  -> NextInt  0x007929a4                1 word

Every one of those edges is a direct call with a single incoming reference, so the attribution is not a guess: 0x00503200 has exactly one caller (0x0078cc70), which has exactly one caller (0x007d5150); 0x00792750 has two call sites, both inside 0x007d5150; and 0x007d5150 has exactly one caller, DetectEncounters at 0x007d860b.

The whole detection residual is inside one 744-byte function with one call site. That is a far better handle than a family bound over two counts nobody can observe, and it makes the residual a tractable modelling target rather than a fitted constant. From its body:

  • it first builds a bitmask over the same records vector (entry[0]->+0xf9 == 0 sets bit entry[0]->+0x28) and returns immediately if the mask is 0;
  • it then loops the player vector at +0x54/+0x58, skipping players whose mask bit is set or whose +0xf9 is non-zero, builds a per-player candidate vector (0x00795870), and for each non-empty one calls 0x0078cc70 — that is the NextFloat — whose non-zero return leads on, and whose return value of 0x12 dispatches slot 17, CreateRaidEncounter (§4's caveat);
  • the measured cost is 1 call and 1 word per site per turn on 8-player and 7-player saves alike, so only one player reaches the draw per turn on both — the per-player gate, not the loop bound, is what sets the count.

That last observation is the one to hand lane Y: the 2 words are not 2 × |players| and not a constant by luck — they are one per player that passes a gate, and on both corpus saves exactly one player passes. A model that assumes 2/turn will be right on our saves and wrong on a busier one, and now there is a named function to read to find out when.

6.2 The hook that measured nothing, reported as loudly as the ones that did (rule 15)

EncounterDetectProcessTeamRecordHook compared nothing, because it never ran. Its coverage notes, its gate predicate, its contact/detector classifier and its byte-vs-dword cross-check on +0xfc are all unexercised. They are kept in the tree because the moment a save produces a non-empty record vector they become the check that matters — but nothing in this report rests on them, and the fc_byte_vs_dword_disagreements counter has never returned a value. Lane I's classification rules are therefore still exactly as verified as they were: from the instruction stream, and from nowhere else.


7. Coverage — what these runs did not touch

  • Five End Turns on two saves. Human ref-turn2 turn 4 → 5 (once clean, three times in the perturbed run) and Zuul turn 23 → 25. That is a thin workload measured precisely.
  • No trade routes, no spy programs, no manufactured workload for either. §3.2 says what it would take. Until then, every statement about the eight tail draw sites is "they are not reached on a game with none of either", and the four callees' gates are located but not read.
  • ServerSpyManager slot 13 (1,869 bytes) was not read. Its loop and its gate are unexamined; it is grouped with slot 14 above only by manager, not by evidence.
  • The probe counters cannot say which caller. They record entries, not return addresses — a deliberate cost of being calling-convention-agnostic — and §4 shows that costing something real.
  • The perturbation's mechanism is unknown, and it is the largest open item this lane produced. A second, independent instance may already exist and be undetected in some other lane's build; the z2-endturn.sav → b2124798470d85ea… check is cheap enough that every future VM lane should run it.
  • No Guard region is declared by any hook in this family, the same deliberate gap lane Z reported: these hooks measure the generator and claim nothing about game state. undeclared = 0 in these traces is vacuous and must be read that way.
  • probes=8 is proved neutral on one save and one turn. It is not proved neutral in general, and the Zuul numbers in §3 and §5 inherit that limit — they were taken on a configuration validated against a Human turn, not a Zuul one.

8. Corrections to earlier findings

  • tail-rng-ledger.md §11.1: the caller list for CreateRaidEncounter is incomplete — 0x007d5150 dispatches slot 17 as well (§4).
  • tail-rng-ledger.md §11.1: "three quiet turns in a row is unremarkable" — it is a 1-in-720 event, and the measurement shows the rolls do succeed (§4).
  • tail-rng-ledger.md §11.3 and lane Y's residual table: the 2-word detection residual is not the AssignContacts family and is not blocked on the +0xfc gate; EncounterDetect_Run receives an empty vector, and the words are in 0x007d5150's subtree (§6).
  • Lane V2's per-slot notes: "never observed firing" is confirmed for the draws and wrong as a statement about the callees — all four run on every turn (§3).
  • combat-done-tail.md tier 4 / lane V2: the trade slot-13 loop is over the fleets vector, and a 23-fleet save still reaches no draw (§3.1).

9. Files

  • Predictions (pre-registered): sots-engine/docs/H-probes.md.
  • Traces: verify/traces/h-probes-human-t5.jsonl.gz, h-probes-zuul-t24-25.jsonl.gz, h-probes-human-perturbed-t3-5.jsonl.gz.
  • Reports: verify/results/shim/probes/{human-t5,zuul-t24-25,human-t3-5-perturbed}.txt; tool tools/probe_report.py.
  • Oracle pair: verify/results/shim/probes/oracle-restore/h-oracle-{endturn,autosave}.sav.
  • Addresses: ghidra/addresses.d/lane-h.json (3 entries, all mapped).
  • Shim: sots-engine/src/shim/hooks/probe_entry.{h,cpp}, the probes= config key, and EncounterDetectProcessTeamRecordHook in tail_rng.{h,cpp}; configs shim.cfg.hprobes*, shim.cfg.hp{0,4,8,9,10,11}, shim.cfg.hoff.

10. Priority insert from lane AI1 — run before the VM was released

Lane AI1 asked for its P1–P3 on the same VM session. Results are written up where they belong, in findings/subsystems/ai-turn-logic.md §8.1; in brief:

  • P2 held — and it is the roadmap-level one. ref-turn2.sav loaded in two separate process launches (pids 3400 and 2028, killed and relaunched between), shim at hooks=off so nothing is installed, one End Turn each: both autosaves byte-identical to each other and to the 2026-09-07 oracle. Byte-identity subsumes ModCount and Summary.Checksum. The AI's turn is reproducible from a save alone, so backlog.md §2's Rung B stays as written. Seven fresh processes across two saves agreed today in total, counting the z2-endturn.sav bisect runs of §2.
  • P1 could not be answered as specified. session.log after a full load + End Turn of a game with an AI player is 22 lines and contains no RunAI:, no random seed and no AI Process Turn line; the game's stdout carries the same 22 and nothing more. The level-2 messages are not emitted by this build at its default log level, so the "one log read, no build" route does not exist. A suggestive counter-indication to the all-zero-seed prediction is recorded there as a hypothesis, with the decisive test named (record the this pointer beside the return address in draw_sites.cpp).
  • P3 not run — no ModCount watchpoint was armed and no debugger session was held.

11. VM140 released

Left at the main menu, verified by screenshot, build recap-7584bad-20260908T0615Z, shim.cfg = the recap trace config, SavedGames\ back to the documented 8-file set (ref-turn2 still at (400,436), zuul-turn5 at (400,465)) with the three autosaves carrying the oracle bytes bb4fd9ac… / 978041ac… / 978041ac…. Lane H left C:\SOTS\shimdist-h and C:\SOTS\ui\h{deploy,release}.ps1 in place, plus C:\SOTS\ui\h-oracle-{endturn,autosave}.sav and ai-run{1,2}.log.

One VM note worth carrying forward. The board's advice to screenshot before clicking is right but under-specified: the intro screens are bright white, so a brightness threshold fires on them and the click batch lands nowhere. The reliable detector is redness in the main menu's Exit button — crop 100x14+462+674, require mean.r > 0.2 and mean.g < 0.15 (the menu reads 0.327 / 0.036; the white intro reads 1.0 / 1.0; a black screen reads 0 / 0). With that, every one of this lane's ten launches reached the menu on the first attempt, at t+48…54 s.