diff --git a/campaign/board.md b/campaign/board.md index b627320..f89f392 100644 --- a/campaign/board.md +++ b/campaign/board.md @@ -59,7 +59,7 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`). | P2-B3 ProcessResearch (behavioral, RNG) | phase2 | mapped | high | 85% | 2026-09-08 | LIVE, PARTIAL PASS: 15 calls compared, 13 zero-divergence; RNG post-state matched 14/15 incl. every roll (validates MT19937 + draw mapping + odds together). 2 divergences are the declared SetResearched boundary. ORACLE FAILS by exactly one item across 40,300: an unposted EVENT_RESEARCH_OVERBUDGET - compare was blind because the event list was never a declared region. fpu_cw=0x127f => 53-bit double, x87 question SETTLED. No Zuul in the save: double roll still disassembly-only . RECAPTURED WITH GUARDS 2026-09-08 (lane R): the oracle gap is now a COMPARE DIVERGENCE - `side.events.after.v.next_id orig=4 ours=3` on call 0, its only divergent field, with node[144] progress 2879->5768 and flag 1->2 both reproduced and the single RNG draw identical. 15 calls over 5 turns: 3 diverged, **RNG 15/15** (better than the original 14/15 - no tech-effect draw in this session). Guards on the two completion calls map SetResearched: ConMod[0..2]/OutMod/PopMod, ResTNm, TechTree+0x20 order counter, and the undeclared otch vector | . **LANE V 2026-09-08 (build `eventlive-dd38117-20260908T0916Z`): the events divergence is CLOSED on the reference turn** - 3 calls, 3 compared, **0 diverged, exit 0**. Over 5 turns 15 calls / **2** diverged (was 3), both short by exactly 1 `next_id` = the unmodelled `EVENT_TECHS_UNLOCKED`. **CORRECTION to lane R's 'RNG 15/15': that was WORKLOAD LUCK, not a property.** On lane V's call 9 the completed tech had `research_roll_pending` set, so the original drew one word `ours` did not (`left` 374 vs 375, `next_index` 250 vs 249) - the `OnTechResearched` callback draw, a DECLARED out-of-scope boundary (P-events-wiring §3 says so in as many words). Honest statement: the `rng` region reproduces bit-for-bit on every call that does not complete a roll-triggering tech, and is short by exactly one draw on every call that does. Also live: `sizeof(ObservedTech)` = **44**, measured twice (`observed_techs.bytes` 440->484 and 484->528). Zuul double roll now CLOSED on a species-5 save lane V made - see its own row | RNG signatures (Ghidra) | meta | verified | high | 100% | 2026-09-08 | Seed/Twist/NextFloat/NextInt verified; draw = y/(2^32-1); NextInt [0,n] inclusive; lazy twist; left@+0x9c4. RUNTIME CONFIRMED: fpu_cw=0x127f (53-bit double, round-nearest) - our next_float model is right, float_from_pc24 is an unused contingency | | engine: game/effects | engine | verified | high | 100% | 2026-09-08 | merged: TechId enum (196 slots @10000+i), 44 ids with typed strategic effects, species flag bits, ApplyTechEffect; 254 checks | -| VM140 exclusivity (lab rule) | meta | verified | high | 100% | 2026-09-08 | one agent at a time. Holder: **VM140=L1, VM141=L2, VM144=L3, VM145=L4, VM146=CB (command-block capture)** (five parallel investigations 2026-09-08). VM140 remains the reference/oracle guest. Was: FREE (W3 released 2026-09-08, oracle bytes verified; **AND THE LAB IS NOW FIVE GUESTS** - VM141/144/145/146 are ZFS clones for probes and workloads, VM140 remains the exclusive reference; W2 released 2026-09-08, VM restored + oracle bytes verified; H released 2026-09-08, oracle reproduced + VM restored; N released 2026-09-08; Z released 2026-09-08; O-workloads released 2026-09-08 08:30 local, see the LANE O note at the end of this row; U-unlock released 2026-09-08 06:55 local; V-eventlive before it; F-fpucw before it, M-movefleet before that). QUEUE: empty. VM left at the MAIN MENU, `hooks=trace`, build `recap-7584bad-20260908T0615Z` restored from `C:\SOTS\shimdist-recap` (that dist also carries `shim.cfg.recap{trace,b3,b1,misc}`). Lane M also left `C:\SOTS\shimdist-mf` + `C:\SOTS\ui\mf{deploy,release}.ps1` in place - harmless, and a working template for the next lane. Windows Update DISABLED/paused on the VM. Non-holders build /srv/re-lab/build/sots-engine-, stage dist-, deploy C:\SOTS\shimdist-. GOTCHA (lane R): after `schtasks /Run /TN SOTS` the main menu can take >60 s - SCREENSHOT AND VERIFY before clicking, or the click path lands in Credits. GOTCHA (lane M): drive the load dialog ONE rui.ps1 CALL PER CLICK with a screenshot between - a single chained cmd.txt loses sync and silently ends up somewhere else. And the Load Game dialog does NOT pre-select Single Player on a fresh launch: the documented path really is Load Game (512,536) -> Single Player (512,290) -> OK (551,523) -> row -> OK (682,624) -> Launch (511,663). ref-turn2 row is at (400,436) GOTCHA (lane F, confirms lane R): the >60 s startup is REAL and cost a whole wasted run — do not sleep-and-click, **verify the main menu from a screenshot** (`verify/fpu-cw/` run scripts poll a screenshot until the Load Game / Exit buttons are bright red; 3 probes ≈ 25 s was typical). TIP (lane F): reset `SavedGames\` to a fixed file set before every run — the Load dialog row positions depend on how many files are listed, so a constant set means the click path never has to be re-derived (with the 4-file set ref-turn2 sits at (400,348), not (400,436)). PowerShell over SSH mangles quoting badly: send snippets base64 as `powershell -EncodedCommand`, or use `-ExecutionPolicy Bypass -File`. Lane F left `C:\SOTS\shimdist-fpu` + `C:\SOTS\ui\f{deploy,grab,fpu}.ps1` + `C:\SOTS\ui\preF\` (the pre-lane-F SavedGames snapshot, restored) in place. VM RESTORED: recap build `recap-7584bad-20260908T0615Z`, `hooks=trace`, SavedGames back to the 7-file pre-F set, main menu verified by screenshot. GOTCHA (lane V, third confirmation): the >60 s startup is REAL - the Kerberos intro was still on screen at t+30 s and the main menu appeared at ~t+70 s. GOTCHA (lane V): `type ` via the click helper did **NOT** register in the game's text fields this session (neither the Create Game name nor the Save Game file name took SendKeys); the workaround is to accept the default name and **rename the `.sav` on disk** - the Load dialog lists files by FILENAME. SAVEDGAMES IS NOW **8 FILES**: the pre-existing 7 plus `zuul-turn5.sav` (a species-5 save, see the Zuul row). Row positions are UNCHANGED for existing lanes because `zuul-turn5` sorts last: **ref-turn2 is still (400,436)**, zuul-turn5 is the new row 8 at (400,465), rows 29 px apart from y=262 - verified by screenshot `verify/results/shim/eventlive/load-dialog-8files.png`. The three autosaves now belong to the Zuul game. Lane V left `C:\SOTS\shimdist-v` + `C:\SOTS\ui\v{deploy,release,click}.ps1`. VM RESTORED: recap build `recap-7584bad-20260908T0615Z`, `hooks=trace`, main menu verified by screenshot. LANE U (2026-09-08): left `C:\SOTS\shimdist-u` + `C:\SOTS\ui\u{deploy,release}.ps1` in place. SavedGames is STILL 8 FILES (the three autosaves now belong to the Zuul game at turn 15) - **ref-turn2 is still (400,436)**, zuul-turn5 still (400,465). A QEMU-level screenshot (feed `screendump /tmp/vm140.ppm` to `qm monitor 140` on spicy, then scp the .ppm) is more reliable than the click helper's `shot` action and does not need the game window. FOURTH confirmation of the >60 s startup: the Kerberos intro was still on screen at t+45 s, the main menu at ~t+85 s. VM RESTORED: recap build `recap-7584bad-20260908T0615Z`, `hooks=trace`, main menu verified by screenshot. | **LANE O 2026-09-08.** **VM140 is FREE.** Holder was O-workloads; released 2026-09-08 after ~9 turns of Zuul play (turn 15 -> 23) and a fresh 3-turn Human game. **CORRECTION to lane V's `type` gotcha: the click helper's `type` DOES reach the game's text fields - you must CLICK THE FIELD FIRST.** `click 480 234` (the Save Game File Name box) then `type ` worked every time this session, and `click 500 363` + `type` worked for the Create Game name box; lane V's failure was almost certainly an unfocused field. Saves can therefore be named properly and no longer need renaming on disk (the file written is gameName + typedName). Coordinates confirmed this session at 1024x768: in-game menu = round button (1000,714) -> `Save` (937,679) / `Quit to Main Menu` (937,698) -> confirm OK (537,377); Save dialog `Save` (772,233), `Game Successfully Saved` OK (612,383), `Done` (772,542); map panel `Research` (55,124) `Design` (142,124) `Build` (221,124), `Manage Fleets` (60,464) `Move` (155,464) `Special` (229,464), `End Turn` (100,714); Build screen `Add To Queue` (125,714); **Esc leaves the Build and Research screens** - the bottom-left button on the Research screen is `Cancel research`, NOT back, and clicking it pops `Confirm Cancel`. Move mode: send `move X Y` FIRST to read the target's name / Range / ETA off the map, THEN `click`, because that readout is what tells you whether the route is a node route (2-5 turns) or a sublight crawl (37-86 turns). **Chained clicks in one `cmd.txt` DO work for repeated clicks on the SAME button** (`Add To Queue`) but drop about half at `sleep 250` and about 1 in 6 at `sleep 500` - count the result, never assume. End Turn takes 30-45 s under `hooks=trace` with ~25 ships. A mid-turn AI diplomacy popup can steal a scripted click sequence - screenshot after any batch that seems to have done nothing. **VM RESTORED and verified**: quit to the main menu (screenshot), `SavedGames` back to the ORIGINAL 8-file set with the three autosaves restored BYTE-IDENTICAL (sha256 prefixes 0630daaa / e0bf3485 / 1f0dc162), so `ref-turn2` is still (400,436) and `zuul-turn5` still (400,465); lane O's staging dir `C:\SOTS\o-stage` removed; `shim.cfg` still `hooks=trace`, build `recap-7584bad-20260908T0615Z` untouched. Lane O's 7 saves live in the REPO ONLY - push one back with `scp verify/results/saves/.sav re@192.168.10.139:C:/SOTS/SavedGames/` when a compare needs it, and remember that adding a file shifts the Load dialog rows (`MyGame*` sorts BEFORE `MyGameverify1*`) +| VM140 exclusivity (lab rule) | meta | verified | high | 100% | 2026-09-08 | one agent at a time. Holder: **VM140=FREE (L1 released, oracle bytes verified intact), VM141=L2, VM144=L3, VM145=L4, VM146=CB (command-block capture)** (five parallel investigations 2026-09-08). VM140 remains the reference/oracle guest. Was: FREE (W3 released 2026-09-08, oracle bytes verified; **AND THE LAB IS NOW FIVE GUESTS** - VM141/144/145/146 are ZFS clones for probes and workloads, VM140 remains the exclusive reference; W2 released 2026-09-08, VM restored + oracle bytes verified; H released 2026-09-08, oracle reproduced + VM restored; N released 2026-09-08; Z released 2026-09-08; O-workloads released 2026-09-08 08:30 local, see the LANE O note at the end of this row; U-unlock released 2026-09-08 06:55 local; V-eventlive before it; F-fpucw before it, M-movefleet before that). QUEUE: empty. VM left at the MAIN MENU, `hooks=trace`, build `recap-7584bad-20260908T0615Z` restored from `C:\SOTS\shimdist-recap` (that dist also carries `shim.cfg.recap{trace,b3,b1,misc}`). Lane M also left `C:\SOTS\shimdist-mf` + `C:\SOTS\ui\mf{deploy,release}.ps1` in place - harmless, and a working template for the next lane. Windows Update DISABLED/paused on the VM. Non-holders build /srv/re-lab/build/sots-engine-, stage dist-, deploy C:\SOTS\shimdist-. GOTCHA (lane R): after `schtasks /Run /TN SOTS` the main menu can take >60 s - SCREENSHOT AND VERIFY before clicking, or the click path lands in Credits. GOTCHA (lane M): drive the load dialog ONE rui.ps1 CALL PER CLICK with a screenshot between - a single chained cmd.txt loses sync and silently ends up somewhere else. And the Load Game dialog does NOT pre-select Single Player on a fresh launch: the documented path really is Load Game (512,536) -> Single Player (512,290) -> OK (551,523) -> row -> OK (682,624) -> Launch (511,663). ref-turn2 row is at (400,436) GOTCHA (lane F, confirms lane R): the >60 s startup is REAL and cost a whole wasted run — do not sleep-and-click, **verify the main menu from a screenshot** (`verify/fpu-cw/` run scripts poll a screenshot until the Load Game / Exit buttons are bright red; 3 probes ≈ 25 s was typical). TIP (lane F): reset `SavedGames\` to a fixed file set before every run — the Load dialog row positions depend on how many files are listed, so a constant set means the click path never has to be re-derived (with the 4-file set ref-turn2 sits at (400,348), not (400,436)). PowerShell over SSH mangles quoting badly: send snippets base64 as `powershell -EncodedCommand`, or use `-ExecutionPolicy Bypass -File`. Lane F left `C:\SOTS\shimdist-fpu` + `C:\SOTS\ui\f{deploy,grab,fpu}.ps1` + `C:\SOTS\ui\preF\` (the pre-lane-F SavedGames snapshot, restored) in place. VM RESTORED: recap build `recap-7584bad-20260908T0615Z`, `hooks=trace`, SavedGames back to the 7-file pre-F set, main menu verified by screenshot. GOTCHA (lane V, third confirmation): the >60 s startup is REAL - the Kerberos intro was still on screen at t+30 s and the main menu appeared at ~t+70 s. GOTCHA (lane V): `type ` via the click helper did **NOT** register in the game's text fields this session (neither the Create Game name nor the Save Game file name took SendKeys); the workaround is to accept the default name and **rename the `.sav` on disk** - the Load dialog lists files by FILENAME. SAVEDGAMES IS NOW **8 FILES**: the pre-existing 7 plus `zuul-turn5.sav` (a species-5 save, see the Zuul row). Row positions are UNCHANGED for existing lanes because `zuul-turn5` sorts last: **ref-turn2 is still (400,436)**, zuul-turn5 is the new row 8 at (400,465), rows 29 px apart from y=262 - verified by screenshot `verify/results/shim/eventlive/load-dialog-8files.png`. The three autosaves now belong to the Zuul game. Lane V left `C:\SOTS\shimdist-v` + `C:\SOTS\ui\v{deploy,release,click}.ps1`. VM RESTORED: recap build `recap-7584bad-20260908T0615Z`, `hooks=trace`, main menu verified by screenshot. LANE U (2026-09-08): left `C:\SOTS\shimdist-u` + `C:\SOTS\ui\u{deploy,release}.ps1` in place. SavedGames is STILL 8 FILES (the three autosaves now belong to the Zuul game at turn 15) - **ref-turn2 is still (400,436)**, zuul-turn5 still (400,465). A QEMU-level screenshot (feed `screendump /tmp/vm140.ppm` to `qm monitor 140` on spicy, then scp the .ppm) is more reliable than the click helper's `shot` action and does not need the game window. FOURTH confirmation of the >60 s startup: the Kerberos intro was still on screen at t+45 s, the main menu at ~t+85 s. VM RESTORED: recap build `recap-7584bad-20260908T0615Z`, `hooks=trace`, main menu verified by screenshot. | **LANE O 2026-09-08.** **VM140 is FREE.** Holder was O-workloads; released 2026-09-08 after ~9 turns of Zuul play (turn 15 -> 23) and a fresh 3-turn Human game. **CORRECTION to lane V's `type` gotcha: the click helper's `type` DOES reach the game's text fields - you must CLICK THE FIELD FIRST.** `click 480 234` (the Save Game File Name box) then `type ` worked every time this session, and `click 500 363` + `type` worked for the Create Game name box; lane V's failure was almost certainly an unfocused field. Saves can therefore be named properly and no longer need renaming on disk (the file written is gameName + typedName). Coordinates confirmed this session at 1024x768: in-game menu = round button (1000,714) -> `Save` (937,679) / `Quit to Main Menu` (937,698) -> confirm OK (537,377); Save dialog `Save` (772,233), `Game Successfully Saved` OK (612,383), `Done` (772,542); map panel `Research` (55,124) `Design` (142,124) `Build` (221,124), `Manage Fleets` (60,464) `Move` (155,464) `Special` (229,464), `End Turn` (100,714); Build screen `Add To Queue` (125,714); **Esc leaves the Build and Research screens** - the bottom-left button on the Research screen is `Cancel research`, NOT back, and clicking it pops `Confirm Cancel`. Move mode: send `move X Y` FIRST to read the target's name / Range / ETA off the map, THEN `click`, because that readout is what tells you whether the route is a node route (2-5 turns) or a sublight crawl (37-86 turns). **Chained clicks in one `cmd.txt` DO work for repeated clicks on the SAME button** (`Add To Queue`) but drop about half at `sleep 250` and about 1 in 6 at `sleep 500` - count the result, never assume. End Turn takes 30-45 s under `hooks=trace` with ~25 ships. A mid-turn AI diplomacy popup can steal a scripted click sequence - screenshot after any batch that seems to have done nothing. **VM RESTORED and verified**: quit to the main menu (screenshot), `SavedGames` back to the ORIGINAL 8-file set with the three autosaves restored BYTE-IDENTICAL (sha256 prefixes 0630daaa / e0bf3485 / 1f0dc162), so `ref-turn2` is still (400,436) and `zuul-turn5` still (400,465); lane O's staging dir `C:\SOTS\o-stage` removed; `shim.cfg` still `hooks=trace`, build `recap-7584bad-20260908T0615Z` untouched. Lane O's 7 saves live in the REPO ONLY - push one back with `scp verify/results/saves/.sav re@192.168.10.139:C:/SOTS/SavedGames/` when a compare needs it, and remember that adding a file shifts the Load dialog rows (`MyGame*` sorts BEFORE `MyGameverify1*`) | Zuul double-roll (behavioural) | verify | verified | high | 100% | 2026-09-08 | **CLOSED by lane V 2026-09-08.** No species-5 save existed, so lane V MADE one on VM140: a custom game with only Zuul left in the Available Species pool (both players Zuul), 4 End Turns under `shim.cfg.recapb3`. **8 calls, 8 compared, 0 diverged, exit 0.** All four researching calls have `species=5` and the generator advances by TWO, not one: `left` 540->538 / 522->520 / 504->502 / 485->483, `next_index` +2 each, `mt` hash unchanged; `ours` reproduced every post-state bit-for-bit. Save `verify/results/saves/zuul-turn5-species5.sav` (48559ab5b719b332, 59,131 B, turn 5, homeworld Gallandro) and on the VM as `C:\SOTS\SavedGames\zuul-turn5.sav`. NOT yet exercised by it: a Zuul tech COMPLETION (node 144 was at ~4,700 of 5,000 after 5 turns - one more End Turn gets there), so `SetResearched` and the Zuul boarding-pod grant are still untouched. Report `findings/subsystems/eventlive-verification.md` §5 **EXTENDED by lane U 2026-09-08: the double roll and the COMPLETION PATH together.** `zuul-turn5` + 10 more End Turns (turn 5 -> 15) under the same config: **20 calls, 20 compared, 0 diverged, exit 0**, with TWO Zuul completions. Call 2 (turn 7, alloc {144,1376}) advances the generator by two (`left` 449->447, `next_index` 175->177) AND runs the unlock cascade in the same call - node 144 -> state 4 / order 21 / turn_researched 7, nodes 132 and 136 -> state 2 with cost_rp 10000 / 16000 and turn_available 7, `next_id` 10->12, `observed_techs` 396->440. Note the ZUUL tree unlocks only 132 and 136 from tech 144 where the HUMAN tree also unlocks 142, so this is an independent instance of the cascade, not a repeat. Trace `verify/traces/unlock-b3-zuul.jsonl.gz` | | budget tail coverage (expenses/aid/debt) | verify | backlog | — | 0% | 2026-09-08 | 8 ComputeBudget slots were always 0 in ref-turn2 (no sliders, no aid, no debt, no handicap). Need a save with expense sliders, a debtor and a research-aid treaty to exercise ExpenseTotal + the aid/bonus tail . CONFIRMED AND WORSE 2026-09-08 (lane R, 4284 calls): **13 of 22 slots are 0 on every call** - tradeIncome, shipCarriedPop, secondaryManager, bonusIncome, systemIncomeNeg, debtInterest, construction, expenses, researchMoneyGiven, savingsGiven, tra, researchPointsGiven, trp | **LANE O 2026-09-08.** **PARTIALLY unblocked, and three of the remaining slots are now shown to be UNREACHABLE through the 1.8 UI.** Delivered: `[11] construction` and a large `[8] maintenance` are live in `zuul-turn17-orders2.sav` (build queue of 20 DEs) and `zuul-turn23-fleet23.sav` (27 DEs in service, `Maint` 14,500 vs 3,000/1,000 in every earlier save); the empire research slider is off default (`ResRate` 0.97 vs 0.25) in all five Zuul saves. NOT delivered, with reasons. **(a) `[12] expenses`**: `Nexp` is `[]` for EVERY player in ALL 11 saves across turns 1-23, human and AI alike, and no screen in the 1.8 UI (map panel, Build, Research, Ranking, Intel Analysis, Empire Population Manager, in-game menu) exposes a per-category expense slider - the only economic sliders are the empire Savings/Research split and the per-system `SRs/SRt/SRsc/SRtf/SRi/SRoh` rates, which land in `Sys`, not `Nexp`. Working hypothesis: slot 12 is dead in 1.8. **(b) `[10] debtInterest` / `[7] systemIncomeNeg`**: a debtor cannot be manufactured quickly - a ship's savings cost is deducted at QUEUE time so the queue cannot be over-committed, and `Maint` counts only ships IN SERVICE, which arrive at construction-budget/ship-cost per turn (~4.5 DE/turn here). With income floored at 78,570 (Planetary Budget pushed fully to Construction; measured 124,055 -> 78,570 on the panel) that is ~157 destroyers, i.e. ~35 more End Turns. Reachable, just not cheap. **(c) `[13]/[14]/[19]` aid**: a treaty offer DOES occur (the AI offered a non-aggression pact on turn 3 of the Human game and it was accepted - `human-turn3-noderoute.sav`), but the accepted pact leaves the player's `aid` count 0, and SotS 1.8 has no player-initiated treaty UI - proposals are AI-driven events. Research aid needs a longer game and luck, not a click path | hook GetDifficultyMods | meta | backlog | — | 0% | 2026-09-08 | B1 derived the two difficulty rows from trace values (AI maintenance divisor 3, research x1.5) instead of snapshotting them; hook it properly so they stop being constants | @@ -333,3 +333,8 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`). | Rung C sharpened: the TIE-SET claim | meta | verified | high | 100% | 2026-09-08 | User's framing adopted: if the AI's variation is a per-process TIE, our engine picks canonically and the verification is "our choice is one of exactly k outcomes the original can produce, we can name all k, and every observed run lands in the set" - stronger than behavioural equivalence, and it keeps the byte-match alive wherever the tiebreaks agree. Lane L4 briefed to capture the varying empire's CANDIDATE SET and arrival order; lane L1 to read the AI client's generator seed across two processes. Between them the mechanism gets named | | standalone on the CANONICAL pair: 108 -> 63, closed 45, regressed 0 | engine | verified | high | 100% | 2026-09-08 | First measurement on the deterministic reference pair turn2-state -> turn3-state, clean build (rule 24). Byte-match distance on the pair that has the oracle hashes. The secondary pair turn1 -> turn2 reads 209 -> 124 (closed 85) with four leaves the original itself cannot pin. Both are in status.json; the dashboard quotes the canonical one | | Rung B lanes launched: RB (replay) + CB (capture) | phase2 | in-progress | — | 0% | 2026-09-08 | Paired deliberately: a replayer with no real input is untested, a capture with no consumer is a log file. **RB** builds `sots_turn --turn-commands ` - read a recorded block set, apply it in the original's order (AI4: a flat run of 27 per-list loops in an order that is NEITHER list nor offset order), account ModCount per the 1-16/17-27 rule, and byte-match the autosave of the run that produced the stream. **CB** captures on VM146 **at the APPLY side, not the emit side** - ApplyAllTurnCommands sees every player's block including the AI's in application order, where the emit side would give only one client's. Priority: the canonical deterministic pair first, then turn1->turn2 WITH its own autosave, then ideally the SAME turn twice - two streams differing only in the varying AI pick, which would be the cleanest possible demonstration that the sim is deterministic given the stream | +| HIVE CREATION DRAWS - lane Z's zero QUALIFIED, not overturned | verify | verified | high | 100% | 2026-09-08 | Lane L1. SVSOSwarmQueen::RegisterHives takes **one strategic-generator word per new hive, inside StrategyServer::BeginProcessTurn** - inside lane Z's autosave bracket and OUTSIDE EVERY ONE OF ITS SUBTOTALS. Nothing had ever been hooked in that interval. turn1->2 (hives created): BeginProcessTurn **2**, ProcessTurn 20, tail 0, total 22, **residual outside the drivers = 2**. turn2->3 (hives exist): 0/19/0, residual 0. THREE INSTRUMENTS AGREE: the region ledger reads 170->172 across RegisterHives; the return-address draw-site table gains a row at 0x00527714 that is ABSENT the next turn; and `predict_new_hives`, computed at entry from the original's own two predicates, said 2 BEFORE the run. **AND THE DRAWS PRODUCED THE HISTORICAL VALUES**: NextQ 30/28 out of RegisterHives, slipped to 31/29 by TickHives = exactly turn2-state, then 32/30 = exactly turn3-state; word positions 192/211 match lane Z's own recorded positions. So tail-rng-ledger's "all of it inside ProcessTurn, residual exactly zero" is RIGHT FOR EVERY TURN IT MEASURED AND FALSE ABOUT THE CODE. **For the standalone: the interval to reproduce starts at BeginProcessTurn** | +| CDiff edges confirmed by playing 49 turns forward | verify | verified | high | 100% | 2026-09-08 | Lane L1 predicted a store at frame 2 and at frame 50 and nothing else, tier 2 unreachable. Played ref-turn2 forward **49 End Turns**: 50 entries, **exactly two stores** - frame 2 (-1->0) and frame 50 (0->1) - and **47 turns where the writer RAN AND STORED NOTHING**, which is rule 20's distinction demonstrated live. Frame >= 100 stays a code read, twice-disassembled: the scan exits with index 3 into the epilogue, so CDiff can only ever hold 0 or 1 | +| NextQ constants READ, not fitted - SV's blocked leaf closes | verify | verified | high | 100% | 2026-09-08 | Lane SV had declined to fit two unknowns from one constraint (r1-r2=2) and left the leaf blocked, correctly. L1 read them live: **LO = 20, HI = 30**, so NextQ = frame + 20 + NextInt(10) inclusive; gates are 10 (a frame floor), 5 and 3 - **no queen can spawn before frame 11 on any save**, which is why the spawn arm has never run. TickHives entered both turns, 0 words, `inc` only | +| SnLv: hypothesis -> verified | verify | verified | high | 100% | 2026-09-08 | One arming line, as lane W3 predicted. Spica: AFlags = 0x0, **SnLv = 0x00000200** = contact level 2 for player 4, its NVO key - the AFlags term is false, so **the sensor branch is what refreshes Spica**. Free extras: level 3 occurs on exactly the eight pairs whose AFlags bit is set, level **2 (never 1)** where it is not, correcting ComputeContactLevel's documented "else 1"; SnLv written 20x/turn from W3's site, to the byte. **Bismol is not on this map** - the same config on zuul-turn23-fleet23 answers it in one End Turn | +| hooks=off is NOT self-consistent | verify | verified | high | 100% | 2026-09-08 | Lane L1 hit the turn1->turn2 nondeterminism independently and reconciled to L5 rather than duplicating. Combined: **four outcomes over six runs, including TWO DIFFERENT hooks=off RESULTS** - which is stronger than "no hook is responsible", because the control itself varies. Bounded: **4 differing leaves out of 35,032**, identical tree structure, on every pairwise comparison. Raises a gap in rule 19: a control that is itself non-deterministic needs a stated policy. Routed to the resolver | diff --git a/findings/subsystems/multiplayer-combat.md b/findings/subsystems/multiplayer-combat.md new file mode 100644 index 0000000..cf752af --- /dev/null +++ b/findings/subsystems/multiplayer-combat.md @@ -0,0 +1,306 @@ +# Multiplayer combat, run on the real game: two humans, one battle, lockstep + +- **Type:** subsystem (live verification) +- **Status:** **verified** — a player-versus-player battle was fought inside a two-player LAN game + with no GameSpy service of any kind, and the whole run was captured outside the guest +- **Confidence:** high on everything below; every claim is a screenshot, a socket table, or a packet +- **Owner / date:** lane L2 · 2026-09-08 · guest **VM141** (`sots-re-win10-b`) +- **Predictions, committed before the run:** `sots-engine/docs/L2-predictions.md` +- **Builds on:** `multiplayer-tier0-verified.md` (lane W2) and `multiplayer-gamespy.md` (lane G2) + +--- + +## 0. The headline + +**Combat multiplayer works, and it needs no GameSpy either.** Two human players in one LAN-mode +game fought a battle at a contested colony: both got the encounter query, both chose *Fight +Manually*, both loaded the tactical combat screen, and both ran the same battle off **one shared +lockstep clock**. Over the whole session — lobby, twelve strategic turns, the encounter query and +the battle — the capture on the host side of the guest's NIC shows **zero** packets to UDP 27900, +UDP 27901, TCP 28910, TCP 28900, TCP 6667 or TCP 6500, and **zero** DNS lookups for any +`*.gamespy.com` name. + +W2 closed Tier 0 with the words "the most likely place for the direct path to still be broken". +It is not broken. **The multiplayer revival is complete end to end: create, join, launch, play, +and fight.** + +**And the one prediction that failed is the most interesting result.** `CombatHostPort` **3370 is +never bound — not at lobby time, not at turn time, and not during the battle.** G2 read the default +out of `Game_LoadNetworkConfig` (`0x005a0610`) correctly, and W2 correctly reported it unbound at +lobby time and inferred "it must be opened later, when a combat starts". That inference is wrong on +this arm: the combat ran to a shared clock over the **already-open strategy connection**, with the +socket table unchanged from the moment the lobby opened. See §4 — this matters for anyone writing +port-forwarding instructions, and it corrects the community advice. + +--- + +## 1. What was run + +One Windows guest (VM141), GOG 1.8.1, two instances of the shipped executable, `hooks=off` — **the +instrument was removed** (method rule 19). The guest was cloned from VM140 with lane W3's +`hooks=trace` shim config in place; that was replaced with `hooks=off` before the game was launched +(§8). + +- **Instance A**, pid 2256 — host. Launched by the `SOTS` scheduled task with no arguments. +- **Instance B**, pid 6620 — joiner. `"C:\SOTS\Sword of the Stars.exe" /concurrent /join 127.0.0.1:3369` + via the `SOTSB` scheduled task. **W2's P5 reconfirmed on a second guest**: two instances, two + windows, two main loops. + +Game: *Host Multi-Player* → **Custom** + **LAN**, 28 stars, **Distance 5 ly** (the minimum), 2 +players, **Initial Colonies 10**, **Random Encounters 100%**, Strategic Turn Length *Unlimited*, +Combat Turn Length *240 s*. Slot 2 was switched from *Computer* to **Open** before B joined — a +clone of VM140 comes up with slot 2 set to AI, and a joiner cannot take an AI slot. + +Three instruments, all outside the game process: + +1. `tcpdump -i tap141i0` on **spicy**, rotating into `/root/l2cap/` — the host side of the guest's + tap device, outside the guest entirely. +2. `Get-NetUDPEndpoint` / `Get-NetTCPConnection` filtered to the two SOTS pids, polled over SSH from + a separate process (`C:\SOTS\l2\socks.ps1`). +3. `qm monitor 141 screendump` for the screen, via `tools/vmshot.py` — reads the QEMU framebuffer, + nothing inside the guest. + +**Blind spot, stated up front, same as W2's:** both instances talk over 127.0.0.1, so the game's own +3369 traffic never reaches the tap. §5 turns that into a *control* rather than a hole. + +## 2. Provoking a battle — what it actually took + +This is worth recording because it cost most of the lane and the next lane should not re-derive it. + +**Random encounters at 100% do not fire on a static empire.** Eight strategic turns were played with +Random Encounters at maximum, 20 colonies between the two players, and **no encounter at all**. +The reason is visible in the system banner: `DE 00 CR 00 DN 00`. With *Initial Colonies 10* and +*Initial Technologies 0*, **neither player starts with a single ship** — so "Manage Fleets", "Move" +and "Special" are greyed out on every system, and there is nothing for an encounter to happen to. + +So a battle needs, in order: + +1. **Build warships.** *Build* → Destroyer → `Armor` → *Add To Queue*. At 16 M treasury and 17 k + industrial output a batch of four destroyers completes in **2 turns**. +2. **Order a fleet somewhere hostile.** Select the fleet row in *Fleets at <system>*, press + *Move*, then click the destination **star** (not its banner, and not where the tooltip appears). +3. Because both players were Human, node travel applies: targeting a system off the node network + raises *"Alpha Fleet cannot reach <X> using nodes. Do you want to plot a sublight course?"* + Accepting gives a **sublight** course — here 28 turns to Nu Ophiuchi at Speed 4.00 / 0.2 sublight. +4. **The sublight leg is what produced the battle**, and faster than the nominal ETA: on turn 12 the + fleet drifted into **Avalon**, a colony belonging to the *other* player, and the encounter fired. + +**Three UI traps, each of which cost a run here:** + +- **The starmap needs real mouse motion.** `SetCursorPos` alone does not move the game's cursor — + the move-mode arrow and the destination tooltip never update. The driver must follow the warp with + an actual `mouse_event(MOUSEEVENTF_MOVE, …)` relative jiggle. W2's `ui.ps1` has no such jiggle, + which is why its recipe covers only fixed-position buttons and never the map. +- **The banner is not the star.** System banners sit up and to the left of their star with a short + leader stem, and a neighbouring banner will cover the star underneath it. Clicking the banner does + nothing; clicking where the hover tooltip is drawn does nothing. Zoom in (mouse wheel) until the + banners separate, then click the star itself. +- **A modal move-warning dialog silently stops the turn loop.** An unattended "end turn on both + instances" loop ran four full cycles against a *"plot a sublight course?"* dialog and the turn + number never moved. Any automated turn driver must screenshot and check before clicking. + +`tools/vmshot.py --one 141` plus a PIL crop is the whole read-back loop; the game's own text is +legible at 3–7× upscale and that is how every reading below was taken. + +## 3. The battle — P2, P5, and the lockstep result + +**P5 — the encounter query reaches both players — confirmed.** Both instances raised the C1 +*Encounter at Avalon* screen (`SNMDoEncounterQuery`), each showing **both** player cards: the +attacker `re(2)` with *Ships: 4 / DE: 4 / 4 DE Armor*, and the defender `re` with *No ships +available* (host's own view) / *No ships detected* (attacker's view). The action strip offered the +full `ENCACTION_*` set — *Fight Manually*, auto-resolve, fight-if, auto-resolve-peacefully, and on +the attacker's side the two surrender variants. **Both players independently chose *Fight +Manually*** and pressed *Done*. + +**The combat launched.** Both instances showed the C2 loading screen — the `CL_*` versus backdrop +titled *Encounter at Avalon*, `re(2)` **VS** `re` — and then the C3 tactical HUD: + +| | Instance A (pid 2256, host, defender) | Instance B (pid 6620, joiner, attacker) | +|---|---|---| +| Title | `Avalon` | `Avalon` | +| Own forces | none — planet only | `DE Armor (1)` selected, weapons panel, 3 gun groups | +| Player list | `re(2)` in the enemy colour | `re(2)` | +| Fleet Commands strip | present | present | +| Combat clock | running | running | + +**P2 — combat resolves in lockstep — confirmed, and the clock is the proof.** The two instances +were sampled alternately, and the combat clock is a *single shared value*, not two independent +countdowns: + +| Wall clock | Instance sampled | Combat clock | +|---|---|---| +| 18:00:39 | B (attacker) | 3:51 | +| 18:01:48 | A (defender) | 3:31 | +| 18:04:33 | B (attacker) | 2:42 | +| 18:05:59 | B (attacker) | 2:18 | + +Those four readings lie on one line at **≈ 0.29 × real time** regardless of which instance is +sampled — 240 s of combat took roughly 14 minutes of wall clock on a guest running two copies of a +D3D9 game through DXVK on llvmpipe. A defender sampled at 18:01:48 sitting exactly on the line drawn +by attacker samples at 18:00:39 and 18:04:33 is what lockstep looks like from the outside: **one +simulation, two views, neither free-running.** No `@SYNC ERROR` appeared on either instance. + +**The sim does not stall when a window loses focus.** Instance B advanced 69 s of combat time during +a stretch when instance A held the foreground continuously. This matters for anyone automating the +lab: the two-instance configuration is not a focus-ping-pong problem. + +**The battle ended cleanly and both players came out of it together.** The 240 s combat timer ran to +`0:00` and the round expired (`CombatExpiredFunctor`); both instances raised the C4 end dialog +**"Round draw."** — the `COMBAT_*` string for an expired round. Dismissing it returned **both** +instances to the strategy screen on **Turn 13** (the encounter fired on turn 12), each with its own +empire intact: the attacker on Nunki with its fleet detached at Avalon, the defender on Regulus with +its own two-destroyer Alpha Fleet. `SNMAllCombatDone` → `SNMResumePlaying` did what +`ui-screen-map.md` §2.2 step 6 says it does. + +Neither instance hung at `COMBAT_WAITINGFORPLAYER` or `COMBAT_WAITINGFORSERVER`, neither crashed, +neither showed a sync error, and the two turn counters never diverged. **P2 confirmed in full.** + +## 4. P1 — `CombatHostPort` 3370 — **falsified** + +This was the prediction with a number attached, and it failed cleanly. + +The UDP socket table for the two SOTS pids was polled continuously — every 700 ms inside the guest +from lobby creation to turn 12, then every 2 s from outside across the whole battle (18:01:24 to +18:16, covering combat load, the full 240 s of tactical combat, the round-draw dialog and the return +to the strategy screen). **Both logs contain exactly one line each.** The socket table never changed: + +``` +UDP pid=2256 0.0.0.0:3369 <- host, HostPort +UDP pid=2256 0.0.0.0:50020 <- host, ephemeral +UDP pid=6620 0.0.0.0:50021 <- joiner, ephemeral +``` + +There is **no TCP at all** for either process at any point, confirming G2's import-table reading +(`bind`/`sendto`/`recvfrom` but no `listen`, no `accept`) on a second workload. + +**3370 was never bound**: not when the lobby opened, not across twelve strategic turns, not at the +encounter query, not during combat loading, and not while the tactical simulation was running with +both players in it. W2 observed it unbound at lobby time and wrote "it must be opened later, when a +combat starts". On the direct-join arm, with the strategy host also hosting the battle, **it is not**. + +The straightforward reading is that the combat lockstep is carried over the **already-established +strategy connection** — the host's 3369 socket and the two ephemeral sockets that carried +`SNMEndTurn` and `SNMDoEncounterQuery` also carry the `CNM*` combat messages. `CombatHostPort` is +presumably for the case the ini key exists to serve: a **separate** combat host, i.e. the +`SNMHostCombat` → `SNMHostCombatReply` handshake electing a *different* player (or a dedicated +server) to host the battle, which cannot happen in a two-player game where the strategy host is +already a participant. + +**Consequences.** + +- **Port-forwarding advice should say 3369 and only 3369.** PCGamingWiki and the community threads + G2 quotes say "LAN mode + forward port 3369", and that is exactly right; nothing here supports + adding 3370, at least for two players over a direct join. +- **Not generalised.** This is one battle, two players, one guest, with the strategy host as a + combatant. Three or more players, a battle between two *clients* with the host merely relaying, or + `sots_server.exe` as a dedicated host, could each elect a different combat host and bind 3370. + That is the obvious next experiment and it is **not run** here. +- `LanScanPortRange` still covers **3369..3370** for the LAN browse broadcast, so 3370 is not dead + in the binary — it is just not bound on this path. + +## 5. P3 and P4 — GameSpy at combat time, and the control + +**P3 — no GameSpy traffic at combat time — confirmed.** Across the whole run, sampled twice (once +mid-game and once with the battle in progress): + +| Sample | Total packets on `tap141i0` | GameSpy ports | `*.gamespy.com` DNS | +|---|---|---|---| +| Through turn 9 | 42,381 | **0** | **0** | +| With the battle in progress | 82,470 | **0** | **0** | +| **Whole run, both capture files** | **101,835** | **0** | **0** | + +Ports counted: UDP 27900, UDP 27901, TCP 28910, TCP 28900, TCP 6667, TCP 6500. This closes the +question G2 left open from the other direction: `GameSpy_NNBeginNegotiationWithSocket` +(`0x00412590`) has two call sites, the browser-mediated join and the host reporting path, and **the +combat host/join path is a third thing that reaches neither**. No `natneg1`/`natneg2` lookup ever +happened, at lobby time or at combat time. + +The only names resolved in the combat window were Windows telemetry (`fd.api.iris.microsoft.com`, +`v10.events.data.microsoft.com`, `wpad.home.arpa`, …) plus exactly one +`www.kerberos-productions.com` — the MOTD fetch W2 documented, once per launch. + +**P4 — combat traffic stays on loopback — confirmed, and it is the control.** Zero packets to or +from UDP 3369 or 3370 appeared on the tap, while 101,835 other packets did. The instrument is +demonstrably live and demonstrably blind to the game's own traffic, which is exactly the shape of +evidence that makes the zero in P3 mean something: **the capture cannot see SOTS's own packets, and +it still sees every other packet the guest emits — so a GameSpy packet would have been caught.** + +It also settles a smaller question: **the combat host does not announce itself on the LAN.** Nothing +was broadcast to 3369 or 3370 when the battle started, so a combat session is not discoverable the +way a lobby is. + +## 6. Adopting VM141 — what a ZFS clone of VM140 actually needed + +Recorded because four more guests exist and this took under fifteen minutes. + +The clone (`zfs clone rpool/data/vm-140-disk-0@clone-base`, fresh MAC `BC:24:11:8A:11:F0`) had **no +IPv4 address in any ARP table on spicy** and `qm agent` reported *"QEMU guest agent is not running"* +(none is installed on these guests). A 40-second `tcpdump -i tap141i0 -e "ether src "` caught +**one** packet — an IPv6 neighbour solicitation — and no DHCP at all, which looks exactly like a +clone whose inherited static IPv4 has been disabled for a duplicate-address conflict with its parent. + +**It was none of those things. The guest was simply idle.** Driving the console with +`qm sendkey 141 …` into an elevated `cmd` and reading `ipconfig /all` off a `screendump` showed a +perfectly healthy **DHCP** lease, `192.168.10.143/16`, obtained at 16:36:40 — half an hour before +anyone looked. SSH with Alex's key worked on the first try. + +**The lesson for the remaining guests: do not diagnose a cloned guest from ARP or from a short +packet capture.** An idle Windows box on a quiet LAN emits nothing for minutes at a time, and its +absence from the neighbour table only means nobody has talked to it. Take a `screendump` first. + +Console driving via `qm sendkey` works and is worth knowing: + +- `qm sendkey 141 meta_l` opens the Start menu; `meta_l-r` opens *Run* — but **key delivery lags by + tens of seconds**, so a screendump taken 2 s after a keystroke will show the *previous* state. + Send, wait, then shoot; a "nothing happened" screenshot is usually just early. +- Letters/digits map to their own names, and `shift-` gives the shifted form; there is no + string primitive, so a typing helper that expands a string into one `qm sendkey` per character + (batched into a single SSH call) is the practical unit. +- The `re` account is a local administrator with UAC on: `cmd` + `ctrl-shift-ret` in *Run* raises the + consent dialog, which `alt-y` accepts. + +What the clone carried over correctly and did **not** need fixing: the game at `C:\SOTS`, DXVK, the +`SOTS`/`SOTSUI`/`SOTSUI2` scheduled tasks, the OpenSSH host keys and authorized keys, and — usefully +— **the Windows Defender Firewall rule for the game**, so the "has blocked some features of this +app" prompt W2 had to click through on VM140 never appeared here. + +What it carried over that **had** to be fixed: + +- **`shim.cfg` was lane W3's `hooks=trace`.** Any lane that wants the shipped game must overwrite it; + `C:\SOTS\shim.cfg.l2backup` holds what was there. +- **The duplicate hostname.** Both guests answer to `SOTS-RE` and share a SID. It was left alone — + SSH by IP is unaffected and nothing in this lane needed NetBIOS — but two machines with one name on + one LAN is a latent trap for anything that resolves by name. +- **`C:\SOTS\launch-b.cmd` does not exist**, although `C:\SOTS\w2\sotsb.xml` invokes it and + `C:\SOTS\w2\runB.txt` proves it ran on VM140. It has to be recreated (it reads the argument line + from `C:\SOTS\w2\argsB.txt`); a copy is at `C:\SOTS\l2\` on VM141. +- **Slot 2 defaults to *Computer*.** A `/join` client cannot take an AI slot; click the small red + cross at the slot card's top-right corner to set it *Open* before launching the joiner. + +## 7. Files + +- Capture: `verify/results/multiplayer/l2-combat.pcap` (on spicy at `/root/l2cap/`) +- Screenshots: `verify/results/multiplayer/l2-*.png` +- Socket-table log: `verify/results/multiplayer/l2-socks.log` +- Predictions, committed before the run: `sots-engine/docs/L2-predictions.md` +- Helper left on the guest: `C:\SOTS\l2\` (`ui.ps1` with wheel/jiggle support, `socks.ps1`, + `poller.ps1`, `shim.cfg`, `l2ui.xml`); scheduled task **L2UI**; task **SOTSB** now registered. + +## 8. What this run did NOT cover (rule 15) + +- **One guest, loopback only.** No two-machine combat, no real network latency, no NAT, no packet + loss. `CombatLatency` defaults to 1000 ms and was never stressed. +- **One battle, two players, and the strategy host was a combatant.** The `SNMHostCombat` / + `SNMHostCombatReply` election never had a choice to make. Three-plus players, or a battle between + two clients, is where 3370 might yet appear (§4). +- **Very lopsided.** Four destroyers against an undefended colony. No reinforcement waves, no + reserves, no bombardment, no ceasefire negotiation, no surrender, no combat between fleets. +- **One combat round.** Multi-round battles, `SNMCombatTimeRemaining` extension, and the + querying/combat/resume loop over several successive turns are untested. +- **No sync log.** `SyncCheckCombat` is on by default but `SyncLogCombat` is off, and it was left + off; a desync would have shown as a symptom, not as a log. +- **`sots_server.exe` still not run.** The community's "the dedicated server does not seem to work" + bug remains unreproduced, and it is now the cheapest remaining multiplayer item — and, per §4, the + one most likely to be where `CombatHostPort` earns its name. +- **`hooks=off` throughout**, so nothing internal was observed: no `CNM*` message types, no wire + format, no sync-check payload. Everything here is UI, sockets and packets. diff --git a/findings/subsystems/multiplayer-gamespy.md b/findings/subsystems/multiplayer-gamespy.md index 5927109..440d092 100644 --- a/findings/subsystems/multiplayer-gamespy.md +++ b/findings/subsystems/multiplayer-gamespy.md @@ -206,7 +206,7 @@ writer for either — **[I]** they look like the SDK's `gsiSetAvailableCheckHost | Key | Default | |---|---| | `HostPort` | **3369** | -| `CombatHostPort` | **3370** | +| `CombatHostPort` | **3370** — but see the note below: never actually bound on the two-player direct-join arm | | `LanScanPort` | **3369** | | `LanScanPortRange` | **1** | | `HeartbeatPeriod` | 15000 ms | @@ -218,7 +218,15 @@ writer for either — **[I]** they look like the SDK's `gsiSetAvailableCheckHost **[V]** The import table has `connect`, `send`, `recv`, `sendto`, `recvfrom`, `bind` but **no `listen` and no `accept`** — the game's own transport is **UDP only**. TCP appears only as client -connects (peerchat, the SB list, ghttp). +connects (peerchat, the SB list, ghttp). *Confirmed live twice: lanes W2 and L2 both saw zero TCP +sockets on either SOTS process at any point, including during combat.* + +> **Live result on `CombatHostPort` (lane L2, `multiplayer-combat.md` §4).** The default is read +> correctly, but on the two-player direct-join arm **3370 is never bound** — the combat lockstep runs +> over the strategy connection the host already has open on 3369. `CombatHostPort` presumably serves +> the case where `SNMHostCombat` / `SNMHostCombatReply` elects a *different* player, or a dedicated +> server, to host the battle; that cannot happen when the strategy host is itself a combatant. +> Untested for three-plus players, for a battle between two clients, and for `sots_server.exe`. --- diff --git a/findings/subsystems/multiplayer-tier0-verified.md b/findings/subsystems/multiplayer-tier0-verified.md index 2fc7d9b..0c8ad4e 100644 --- a/findings/subsystems/multiplayer-tier0-verified.md +++ b/findings/subsystems/multiplayer-tier0-verified.md @@ -104,6 +104,12 @@ compiled-in default in this install). **No TCP anywhere**, confirming G2's impor `listen`, no `accept`). And `CombatHostPort` **3370 is not bound at lobby time** — it must be opened later, when a combat starts, which no lane has yet observed. +> **Corrected by lane L2 (`multiplayer-combat.md` §4).** The observation is right; the inference is +> wrong. L2 fought a two-human battle with the socket table polled throughout and **3370 is never +> bound at all** — not at lobby time, not during combat loading, not while the tactical simulation +> was running. The combat lockstep rides the already-open strategy connection. Port-forwarding +> advice should say **3369 and only 3369** for a two-player direct join. + The joiner appeared in slot 2 of the host's lobby within ~50 s of launch. Both instances then showed two player cards, the client's view carrying *Ready* / *Leave Game* and the host's *Launch* / *Cancel Game*. Client pressed Ready; host pressed Launch. @@ -203,6 +209,9 @@ Loudly, because the headline is a strong one and it was established on a narrow - **No combat.** Both turns resolved without a battle, so `CombatHostPort` 3370 was never bound and the combat lockstep — the part with a 1000 ms `CombatLatency` and its own sync checking — is entirely untested. This is the most likely place for the direct path to still be broken. + **Closed by lane L2 on VM141 (`multiplayer-combat.md`): it is not broken.** Two humans fought a + player-versus-player battle at a contested colony off one shared lockstep clock, both reached the + combat report, and both resumed on the same turn — with zero GameSpy packets and 3370 never bound. - **Two turns.** Long-run desync, the `SyncCheckStrategy` machinery, and reconnection are untested. - **Two players, both Human, both on defaults.** No password, no teams, no alliances, no scenario, no more than two slots. diff --git a/verify/harness/l2-ui/README.md b/verify/harness/l2-ui/README.md new file mode 100644 index 0000000..1d84e8a --- /dev/null +++ b/verify/harness/l2-ui/README.md @@ -0,0 +1,41 @@ +# L2 UI harness — driving the SOTS strategy map from outside the guest + +Written for lane L2's multiplayer-combat run on VM141. It is lane W2's `C:\SOTS\w2\ui.ps1` plus the +two things that run needed and W2's version could not do. + +## What is different from W2's `ui.ps1` + +1. **Real mouse motion.** `SetCursorPos` alone does **not** move the game's cursor as far as the + starmap is concerned — the move-mode arrow and the destination tooltip never update, and clicks + land on stale hit-test state. Every `move` and `click` here warps with `SetCursorPos` and then + emits an actual `mouse_event(MOUSEEVENTF_MOVE, …)` relative jiggle. Without this you can drive + fixed-position buttons (which is all W2 needed) but you cannot drive the map. +2. **`wheel `** — starmap zoom, via `mouse_event(MOUSEEVENTF_WHEEL)`. System banners + overlap and hide each other's stars at default zoom; you have to zoom in before you can click a + star. +3. `rclick` and `dbl`. + +Commands, one per line, read from `C:\SOTS\l2\cmd.txt`: +`fg | move X Y | click X Y | rclick X Y | dbl X Y | wheel X Y N | key | type | sleep ms` + +The target process id is read from `C:\SOTS\l2\pid.txt` (or `-1` for "do not foreground anything", +which is how a firewall or UAC dialog gets clicked). Run it from an **interactive** scheduled task +with `LogonType=InteractiveToken`; a process started straight from an SSH session lands in a +different window station and cannot click anything. + +## Gotchas that cost this lane a run each + +- **Move mode does not survive between invocations.** "Select fleet → press Move → click the + destination" has to be one `cmd.txt`, not three. +- **The banner is not the star.** Banners sit up-left of their star with a short leader stem, and a + neighbouring banner will cover the star. Clicking the banner does nothing; so does clicking where + the hover tooltip is drawn. Zoom in until they separate, then click the star. +- **A modal dialog silently eats an unattended turn loop.** Screenshot and check before each blind + click; a "plot a sublight course?" dialog swallowed four end-turn cycles without the turn moving. + +## Files + +- `ui.ps1` — the driver. Deploy to `C:\SOTS\l2\ui.ps1` and register a task pointing at it. +- `socks.ps1` — non-perturbing socket-table probe: which SOTS pid holds which UDP/TCP port, now. +- `launch-b.cmd` — second-instance launcher; reads its argument line from `C:\SOTS\w2\argsB.txt`. + `C:\SOTS\w2\sotsb.xml` invokes this path but the file itself is **not** on the VM140 image. diff --git a/verify/harness/l2-ui/launch-b.cmd b/verify/harness/l2-ui/launch-b.cmd new file mode 100644 index 0000000..1bab317 --- /dev/null +++ b/verify/harness/l2-ui/launch-b.cmd @@ -0,0 +1,11 @@ +@echo off +cd /d C:\SOTS +set DXVK_LOG_LEVEL=info +set DXVK_LOG_PATH=C:\SOTS\dxvklog +set VK_DRIVER_FILES=C:\SOTS\lvp\lvp_icd.x86.json +set VK_ICD_FILENAMES=C:\SOTS\lvp\lvp_icd.x86.json +set ARGSB= +set /p ARGSB= C:\SOTS\w2\runB.txt +"C:\SOTS\Sword of the Stars.exe" %ARGSB% >> C:\SOTS\w2\runB.txt 2>&1 +echo exit code %errorlevel% %time% >> C:\SOTS\w2\runB.txt diff --git a/verify/harness/l2-ui/socks.ps1 b/verify/harness/l2-ui/socks.ps1 new file mode 100644 index 0000000..129706e --- /dev/null +++ b/verify/harness/l2-ui/socks.ps1 @@ -0,0 +1,16 @@ +# Non-perturbing socket-table probe: which SOTS process holds which UDP port, right now. +$ts = Get-Date -Format "HH:mm:ss.fff" +$procs = @{} +Get-Process -Name 'Sword of the Stars' -ErrorAction SilentlyContinue | ForEach-Object { $procs[$_.Id] = $_.Id } +$rows = @() +foreach ($e in (Get-NetUDPEndpoint -ErrorAction SilentlyContinue)) { + if ($procs.ContainsKey([int]$e.OwningProcess)) { + $rows += "$ts UDP pid=$($e.OwningProcess) $($e.LocalAddress):$($e.LocalPort)" + } +} +foreach ($e in (Get-NetTCPConnection -ErrorAction SilentlyContinue)) { + if ($procs.ContainsKey([int]$e.OwningProcess)) { + $rows += "$ts TCP pid=$($e.OwningProcess) $($e.LocalAddress):$($e.LocalPort) -> $($e.RemoteAddress):$($e.RemotePort) $($e.State)" + } +} +if ($rows.Count -eq 0) { "$ts (no SOTS process / no sockets)" } else { $rows | Sort-Object } diff --git a/verify/harness/l2-ui/ui.ps1 b/verify/harness/l2-ui/ui.ps1 new file mode 100644 index 0000000..6078ece --- /dev/null +++ b/verify/harness/l2-ui/ui.ps1 @@ -0,0 +1,62 @@ +param([int]$TargetPid = 0, [string]$Cmd = 'C:\SOTS\l2\cmd.txt') +if ($TargetPid -eq 0 -and (Test-Path C:\SOTS\l2\pid.txt)) { $TargetPid = [int]((Get-Content C:\SOTS\l2\pid.txt -Raw).Trim()) } +Start-Transcript -Path C:\SOTS\l2\uilog.txt -Force | Out-Null +Add-Type -AssemblyName System.Windows.Forms +Add-Type @" +using System; using System.Runtime.InteropServices; +public struct RECT { public int L,T,R,B; } +public class L2 { + [DllImport("user32.dll")] public static extern bool SetCursorPos(int x,int y); + [DllImport("user32.dll")] public static extern void mouse_event(uint f,uint x,uint y,uint d,UIntPtr e); + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] public static extern int GetWindowTextW(IntPtr h, System.Text.StringBuilder s, int n); + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r); + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int n); + [DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr h); +} +"@ +$out = @() +$hw = [IntPtr]::Zero +if ($TargetPid -lt 0) { $hw = [IntPtr]::Zero } elseif ($TargetPid -gt 0) { + $p = Get-Process -Id $TargetPid -ErrorAction SilentlyContinue + if ($p) { $hw = $p.MainWindowHandle } +} +if ($hw -eq [IntPtr]::Zero) { $out += "NO WINDOW for pid $TargetPid" } +else { + $r = New-Object RECT; [L2]::GetWindowRect($hw,[ref]$r) | Out-Null + $out += "pid=$TargetPid hwnd=$hw rect=$($r.L),$($r.T)-$($r.R),$($r.B)" + [L2]::ShowWindow($hw,9)|Out-Null; [L2]::BringWindowToTop($hw)|Out-Null; [L2]::SetForegroundWindow($hw)|Out-Null + Start-Sleep -m 900 +} +foreach ($line in Get-Content $Cmd) { + if ($line.Trim() -eq '' -or $line.StartsWith('#')) { continue } + $a = $line -split ' ',4 + switch ($a[0]) { + 'fg' { [L2]::ShowWindow($hw,9)|Out-Null; [L2]::BringWindowToTop($hw)|Out-Null; [L2]::SetForegroundWindow($hw)|Out-Null; Start-Sleep -m 900 } + 'move' { $mx=[int]$a[1]; $my=[int]$a[2] + [L2]::SetCursorPos($mx-3,$my-3); Start-Sleep -m 150 + # real relative motion so DirectInput-style polling sees a move + [L2]::mouse_event(1,[uint32]1,[uint32]1,0,[UIntPtr]::Zero); Start-Sleep -m 120 + [L2]::SetCursorPos($mx,$my); Start-Sleep -m 150 + [L2]::mouse_event(1,[uint32]1,[uint32]0,0,[UIntPtr]::Zero); Start-Sleep -m 120 + [L2]::mouse_event(1,[uint32]4294967295,[uint32]0,0,[UIntPtr]::Zero); Start-Sleep -m 400 } + 'click' { [L2]::SetCursorPos([int]$a[1]-2,[int]$a[2]); Start-Sleep -m 200; + [L2]::mouse_event(1,[uint32]2,[uint32]0,0,[UIntPtr]::Zero); Start-Sleep -m 200; + [L2]::SetCursorPos([int]$a[1],[int]$a[2]); Start-Sleep -m 500; [L2]::mouse_event(2,0,0,0,[UIntPtr]::Zero); Start-Sleep -m 300; [L2]::mouse_event(4,0,0,0,[UIntPtr]::Zero); Start-Sleep -m 900 } + 'rclick' { [L2]::SetCursorPos([int]$a[1],[int]$a[2]); Start-Sleep -m 700; [L2]::mouse_event(8,0,0,0,[UIntPtr]::Zero); Start-Sleep -m 300; [L2]::mouse_event(16,0,0,0,[UIntPtr]::Zero); Start-Sleep -m 900 } + 'dbl' { [L2]::SetCursorPos([int]$a[1],[int]$a[2]); Start-Sleep -m 700; for($i=0;$i -lt 2;$i++){[L2]::mouse_event(2,0,0,0,[UIntPtr]::Zero);Start-Sleep -m 60;[L2]::mouse_event(4,0,0,0,[UIntPtr]::Zero);Start-Sleep -m 60}; Start-Sleep -m 900 } + # wheel + 'wheel' { [L2]::SetCursorPos([int]$a[1],[int]$a[2]); Start-Sleep -m 400 + $n=[int]$a[3]; $d = if($n -ge 0){120}else{-120}; $c=[Math]::Abs($n) + for($i=0;$i -lt $c;$i++){ [L2]::mouse_event(0x0800,0,0,[uint32]$d,[UIntPtr]::Zero); Start-Sleep -m 120 } + Start-Sleep -m 700 } + 'key' { [System.Windows.Forms.SendKeys]::SendWait($a[1]); Start-Sleep -m 500 } + 'type' { [System.Windows.Forms.SendKeys]::SendWait(($line.Substring(5))); Start-Sleep -m 500 } + 'sleep' { Start-Sleep -m ([int]$a[1]) } + } +} +$sb = New-Object System.Text.StringBuilder 256; [L2]::GetWindowTextW([L2]::GetForegroundWindow(),$sb,256)|Out-Null +$out += "done $(Get-Date -Format HH:mm:ss) fg='$($sb.ToString())'" +$out +Stop-Transcript | Out-Null diff --git a/verify/results/multiplayer/l2-combat-hud-client.png b/verify/results/multiplayer/l2-combat-hud-client.png new file mode 100644 index 0000000..ca02a75 Binary files /dev/null and b/verify/results/multiplayer/l2-combat-hud-client.png differ diff --git a/verify/results/multiplayer/l2-combat-hud-host.png b/verify/results/multiplayer/l2-combat-hud-host.png new file mode 100644 index 0000000..be63f5b Binary files /dev/null and b/verify/results/multiplayer/l2-combat-hud-host.png differ diff --git a/verify/results/multiplayer/l2-combat-loading.png b/verify/results/multiplayer/l2-combat-loading.png new file mode 100644 index 0000000..089e4a0 Binary files /dev/null and b/verify/results/multiplayer/l2-combat-loading.png differ diff --git a/verify/results/multiplayer/l2-combat-round-draw.png b/verify/results/multiplayer/l2-combat-round-draw.png new file mode 100644 index 0000000..38d4eb3 Binary files /dev/null and b/verify/results/multiplayer/l2-combat-round-draw.png differ diff --git a/verify/results/multiplayer/l2-combat.pcap b/verify/results/multiplayer/l2-combat.pcap new file mode 100644 index 0000000..0216a14 Binary files /dev/null and b/verify/results/multiplayer/l2-combat.pcap differ diff --git a/verify/results/multiplayer/l2-encounter-query-client.png b/verify/results/multiplayer/l2-encounter-query-client.png new file mode 100644 index 0000000..f7305a0 Binary files /dev/null and b/verify/results/multiplayer/l2-encounter-query-client.png differ diff --git a/verify/results/multiplayer/l2-encounter-query-host.png b/verify/results/multiplayer/l2-encounter-query-host.png new file mode 100644 index 0000000..5cb35bf Binary files /dev/null and b/verify/results/multiplayer/l2-encounter-query-host.png differ diff --git a/verify/results/multiplayer/l2-fleet-built.png b/verify/results/multiplayer/l2-fleet-built.png new file mode 100644 index 0000000..82e2138 Binary files /dev/null and b/verify/results/multiplayer/l2-fleet-built.png differ diff --git a/verify/results/multiplayer/l2-lobby-client-joined.png b/verify/results/multiplayer/l2-lobby-client-joined.png new file mode 100644 index 0000000..f73da6e Binary files /dev/null and b/verify/results/multiplayer/l2-lobby-client-joined.png differ diff --git a/verify/results/multiplayer/l2-lobby-host-created.png b/verify/results/multiplayer/l2-lobby-host-created.png new file mode 100644 index 0000000..96cbace Binary files /dev/null and b/verify/results/multiplayer/l2-lobby-host-created.png differ diff --git a/verify/results/multiplayer/l2-move-warning-dialog.png b/verify/results/multiplayer/l2-move-warning-dialog.png new file mode 100644 index 0000000..ecf6bdd Binary files /dev/null and b/verify/results/multiplayer/l2-move-warning-dialog.png differ diff --git a/verify/results/multiplayer/l2-socks.log b/verify/results/multiplayer/l2-socks.log new file mode 100644 index 0000000..6c81b85 --- /dev/null +++ b/verify/results/multiplayer/l2-socks.log @@ -0,0 +1,2 @@ +18:01:24 UDP pid=2256 0.0.0.0:3369 |UDP pid=2256 0.0.0.0:50020 |UDP pid=6620 0.0.0.0:50021 | +poll ended diff --git a/verify/results/multiplayer/l2-turn13-client.png b/verify/results/multiplayer/l2-turn13-client.png new file mode 100644 index 0000000..2fcd01d Binary files /dev/null and b/verify/results/multiplayer/l2-turn13-client.png differ diff --git a/verify/results/multiplayer/l2-turn13-host.png b/verify/results/multiplayer/l2-turn13-host.png new file mode 100644 index 0000000..7618e4e Binary files /dev/null and b/verify/results/multiplayer/l2-turn13-host.png differ