battle-load: profile filed, bug parked; sampler + resume recipe kept

This commit is contained in:
alex 2026-09-07 16:43:14 -04:00
parent 426df0232a
commit f644b83581
21 changed files with 74500 additions and 7 deletions

View file

@ -30,3 +30,6 @@ most other work. Groomed by **re-quartermaster**.
- `Mars::Buffer::gobio` VFS + NativeFileSystem override order (Ghidra, small)
- Ship design rules: section sockets/banks → `ShipDesignDef` validation (data + small Ghidra)
- Reader/verify: fold strategic-internals formulas into harness checks against the 3 real saves (Python)
## Parked (not on the critical path — resume only with a clear reason)
- **Battle-load bug** — one cheap Ghidra question when convenient: does the combat loader use the job API (`0x8fc160`/`0x8fa5b0`) whose completion poll is `TryEnterCriticalSection → Sleep(1000)` @`0x0071ea60`? Dynamic resume: add HDA audio (`qm set 140 -audio0 device=ich9-intel-hda,driver=none`), build Tankers + Extended-Range DEs, move to Markab/Junction, then `verify/harness/profiling/battle-run.sh`.

View file

@ -19,13 +19,13 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`).
| D3D9 device init | subsystem | backlog | — | 0% | 2026-09-07 | `Mars::DrawDevice`/`Camera` → Direct3DCreate9/CreateDevice |
| `Mars::Buffer::gobio` | subsystem | in-progress | — | 0% | 2026-09-07 | .gob I/O; anchors the verify/ parser |
| serializable-types vs save structs | verify | mapped | high | 100% | 2026-09-07 | findings/objects/save-editor-structs.md (~130 structs, exact order); verifier: cross-check vs binary next |
| battle-load path | subsystem | mapped | med | 60% | 2026-09-07 | mitigation = blunt affinity pin FUN_0089ee70 (SetProcessAffinityMask); NO topology detection -> no adaptive branch; cause = emergent multi-core contention |
| battle-load path | subsystem | mapped | med | 60% | 2026-09-07 | mitigation = affinity pin FUN_0089ee70; sim+load on main thread; see thread-contention row (parked) |
| class hierarchy + key vftables | meta | mapped | high | 100% | 2026-09-07 | findings/objects/ghidra-recon.md - RTTI Base_Class_Array read directly; vftables for 8 core classes |
| data-model (.gob data files) | subsystem | mapped | high | 100% | 2026-09-07 | findings/subsystems/data-model.md - tech/weapons/sections/races/AI all data-driven |
| string / config intel | meta | mapped | high | 100% | 2026-09-07 | findings/subsystems/strings-and-config.md |
| Mars brace-block parser | subsystem | verified | high | 100% | 2026-09-07 | verify/parsers/ (mars_data, flat_kv, manifest, effect_txt, verify.py): 1595/1595 files parse, 0 dangling cross-links; catalogs in verify/results/data-catalogs/ |
| networking (SNM/FNM + GameSpy) | subsystem | backlog | — | 0% | 2026-09-07 | ~60 SNM strategy msgs, FNM file-xfer/host-migration; lockstep sim |
| battle-load: thread contention | subsystem | in-progress | med | 50% | 2026-09-07 | time-boxed profile running; on landing: record verdict as a finding and DROP from active backlog (not on the reimpl critical path) |
| battle-load: thread contention | subsystem | blocked | med | 60% | 2026-09-07 | PARKED (not on reimpl critical path). Profile: findings/subsystems/battle-load-profile.md. Combat not reached (no ships/range/encounters); audio thread inert w/o audio device; candidate = TryEnterCriticalSection->Sleep(1000) job poll @0x0071ea60 + 15.6ms timer. Resume recipe + tools in verify/harness/profiling/ |
| UI screen & flow map | meta | mapped | high | 100% | 2026-09-07 | findings/subsystems/ui-screen-map.md - 36 screens; screens are C++ on Mars controls (NOT data); turn state machine recovered |
| `Game::StrategyServer` (sim block) | object | verified | high | 100% | 2026-09-07 | full member table (47 rows) confirmed vs real saves under strict parse incl. ID lists, GOWinPly, invasions, zdsc pairs, SvSctOb (present in all 3 saves) |
| stream primitive API | subsystem | mapped | high | 100% | 2026-09-07 | IStreamable vft: +0x18 string, +0x1c bool, +0x20 float, +0x24 int, +0x28 nested, +0x30 raw; FUN_00816490 = NetworkObject handle id |

View file

@ -26,3 +26,4 @@ Each links to the finding that raised it. Promoted to backlog or closed by **re-
- **Strict-parse gaps vs real save (verifier output, round 3)** — (1) `ServerSystem`: 7/28 systems carry `VFlags` where `Name`/`vnh` were expected — a conditional layout (uncolonized/special systems?); (2) `halt[].haltv` is a 31/35-byte struct, not a bool; (3) optional `indi` (IndependenceInfo?) precedes `NVE`/`PID` in some systems; (4) `PrisonerHold.prisoners[]` entries lack `PrNSp`; (5) `/createParams/key` is int; (6) `turnstats.hist.stats.tch` is int; (7) optional trailing `zdsi`/`zdst`. Padding = **joint** (settled). 7 resyncs / 2728 raw bytes ≈ RNG blob. (from [[turn2-strict-issues]])
- **RESOLVED: strict-parse gaps** — 5/8 were reader bugs (ASCII plausibility test applied to string values; empty string == int 0), 3 real: `PrNSp` only when `PrMax>0`; `indi` unconditional in `NVO` nodes (system-level gated by `hindi`); `zdsi/zdst` pairs under `zdsc`. `Key` is an empty string; `tch` int. Summary/CreateParams tag lists confirmed. Reader patch in flight. (from [[schema-gaps-resolved]])
- **RESOLVED: save framing** — padding is joint; `"."` = NULL tag (VectorHelper count+elements, Vector3 bodies); empty strings are 4 zero bytes; `CDT` is a tagged frame + opaque `CD` frames. All three real saves parse `--strict` clean. Note: `SvSctOb` IS present in all saves (patch doc §7 was wrong). (from [[SAVE_FORMAT]])
- **(parked) Combat loader job API** — does tactical-battle loading go through the job helpers `0x8fc160`/`0x8fa5b0` with the `TryEnterCriticalSection→Sleep(1000)` completion poll (`0x0071ea60`)? If yes, that is a plausible many-core stall (lost try-lock = +1 s per poll). End Turn also has a deliberate `Sleep(computed)` ≈0.85 s at `0x00838da9`. (from [[battle-load-profile]])

View file

@ -0,0 +1,138 @@
# SotS1 battle-load slowdown — dynamic profile, session 2026-09-07 (time-boxed, combat NOT reached)
Status: **tactical combat was not reached** within the coordinator's time-box (see §1 for exactly why and how to
resume in one step). Everything below is what *was* measured on the live game (VM140, 16 vCPU, DXVK+lavapipe) plus
static facts pulled from the exe to rate hypotheses (a)/(b)/(c). Raw data and tooling sit next to this file.
## 0. TL;DR
| Question | Answer (evidence) |
|---|---|
| 16-core vs 1-core battle load time | **not measured** — no battle reachable (no starting fleet, nearest star out of range, 23 turns of 100 % random encounters produced none) |
| Main-thread state on the strategy map (16 cores) | 77–84 % **waiting** in DXVK `SleepConditionVariableSRW` (`Wait/WrAlertByThreadId`) under `DemoApp::OnRender` → renderer `sub_00720050`/`sub_0072b9e0` → `d3d9.dll`; 13 % running. This is lavapipe being the bottleneck, i.e. an artefact of the GPU-less VM |
| Main-thread state during End Turn | ~40 ms of real server work, then **0.85 s of `Sleep(computed ms)`** at `0x00838da9` (`sub_00838c60` ← `StrategyClient::Update+0xfc` ← `DemoApp::OnTick+0x139`) with the *whole process at 0 % CPU* (no render either). A deliberate delay, not contention |
| Audio thread (`StreamingUpdateThreadProc`, pri 15) | **inert**: 100 % `WaitForMultipleObjects` at `0x008ef226`, 0 ms CPU over 50 s — DirectSound failed to init on this VM (no audio device), so hypothesis (a) is *untestable here as-is* |
| Net watchdog / mesh worker | `Sleep(10)`+`EnterCriticalSection(g_netCS)` poll loop, 0 CPU; mesh worker (`BackgroundWorker::ThreadProc`, pri BELOW_NORMAL) `Sleep(10)` loop, woke ~80 ms after a turn |
| Timer resolution | **15.625 ms default** (`NtQueryTimerResolution` cur=156250), no `timeBeginPeriod` import → every `Sleep(≤15)` costs 15.6 ms |
| `Sleep(0)`/`Sleep(1)` spin-yield loops | **none exist**: the exe has only 10 `Sleep` call sites, args 10/20/100/1000/computed |
| Most suspicious primitive found | `0x0071ea60`: `TryEnterCriticalSection` → check done-flag → else **`Sleep(1000)`** poll (1 s granularity, and a *failed try-lock also costs 1 s*); used as a job-completion predicate by `StarMapBlobs::BuildBlobMesh_Job` (`push 0x71ea60` at `0x732cbc`/`0x732ce8` into job submit `0x8fc160`/`0x8fa5b0`). Strategy-map path; whether the combat loader uses the same Mars job/wait helpers is the next Ghidra question |
## 1. What blocked reaching combat, and the one-step resume
Chronology (all UI driven through `C:\SOTS\ui\click_helper.ps1` / task `SOTSUI`; screenshots via `qm screendump`):
1. Custom game (Morrigi, 28 stars, 2 players, random encounters 100 %) starts with **no ships at all** (system view:
`DE 00 CR 00 DN 00`, fleet panel empty). Built 5× `DE Armor` (Build screen, ETA 2 turns; treasury is huge).
2. Turn 4: "Alpha Fleet" exists. **Move** to the two nearest stars fails: *"Alpha Fleet does not have the range to reach
Junction/Markab using nodes. Do you want to plot a sublight course?"* — fleet range 9.0 ly, sublight ETA to Markab
**85 turns** (0.2 ly/turn ⇒ ~17 ly). The map's 7 ly "distance" setting evidently is not the nearest-neighbour distance.
Order cancelled by re-targeting the fleet to Gamma Cephei (`Ctrl+S`/Special had no Stop entry pre-departure).
3. Fallback: let encounters come to the homeworld. Automated **23 End Turns (turn 4 → 27)** with screen-change detection
(`tools/turnloop.sh`, ~20 s per turn incl. helper latency). **No encounter query ever appeared**; the fleet now has 6 DE.
4. Coordinator time-box hit → stopped, game left running on **turn 27**, strategy map, Alpha Fleet (6× DE Armor) at Gamma Cephei.
Autosaves: `C:\SOTS\SavedGames\(Autosave).sav` = turn-27 state (`(Autosave EndTurn).sav` = pre-turn).
**Resume in one step** (pick one):
- *Range fix (same game)*: Build screen → queue 2× `Tanker` + 3× `Extended Range` → End Turn ×2 → `F` (Fleet Manager) to merge
into one fleet → Move → Junction (was the dashed-line neighbour, ~(523,327) at the zoom of `screens/zoomout.png`) or
Markab (650,287). If the range dialog still appears, research a drive tech first. Then End Turn until arrival (encounter odds 100 % at unexplored systems).
- *Scenario (fresh game)*: main-menu → Scenario (strings-and-config F10 list: Tourney Space / Land Grab (S) / Upstart Apes start with contact) — untested.
- **Before either, add an audio device to the VM** so hypothesis (a) becomes testable: `qm set 140 -audio0 device=ich9-intel-hda,driver=none`
(needs a full stop/start; Win10 has an inbox HDA driver; then `session.log` must no longer say "Failed to initialize DirectSound manager").
- Then run `tools/battle-run.sh <tag> <affinity|0> <FightX> <FightY>` when the encounter query (C1 screen) is up: it starts the
32-bit stack sampler + a 1 Hz screendump burst, clicks Fight, and produces `samp_<tag>.txt` + `framediff.txt` for wall-clock
load time. Affinity A/B on the live process: `(Get-Process 'Sword of the Stars').ProcessorAffinity = 1` (same effect as
`Process_PinAffinity` 0x0089ee70 → `SetProcessAffinityMask`). Caveat: on this VM the renderer is CPU (lavapipe), so a
1-core pin will also throttle rendering — compare main-thread *wait sites*, not just wall clock, or use `ProcessorAffinity = 3`/`15` as intermediate points.
Note on `ForceSingleCore`: `C:\SOTS\sots.ini` currently only holds `[Locale] CurrentProfile=…`; the key is read via the Win32
profile API (section not confirmed — community says `[CPU]`). The live-affinity flip above sidesteps that.
## 2. Environment / tooling (all in `tools/`)
- Process `Sword of the Stars.exe` pid 7892, **52 threads**, affinity mask 0xFFFF. Exe is **ASLR-relocated to base `0x00e80000`**
(`d3dx9_31.dll` grabbed 0x400000): runtime = Ghidra + 0xA80000. All addresses below are Ghidra (0x400000-based).
- `sampler.ps1` (run under `SysWOW64\powershell.exe`): 32-bit stack sampler — enumerates threads (`Win32StartAddress`), resolves
exports, samples EIP/EBP-chain (+ stack scan) every 20 ms for threads whose start address is in the exe, records
`NtQuerySystemInformation` thread state/wait-reason per sample, and per-thread CPU (GetThreadTimes) per 500 ms bucket.
`parse.py` (+`funcs.txt` from `funcs.py` prologue/call-target scan of the exe, 31 850 starts; `names.txt` from the findings)
turns that into timelines and histograms. No ETW was needed; `wpr.exe` and `tracerpt.exe` exist on the VM if wanted.
- `timerres.ps1`: `NtQueryTimerResolution` + `powercfg /energy`.
Thread inventory (start address → role):
| tid | start | role | pri | notes |
|---|---|---|---|---|
| 3568 | exe `0x00925794` (CRT) | **main** (sim, UI, render, server) | 0 | see §3 |
| 4004 | exe `0x008ef1d0` `SoundSystem::StreamingUpdateThreadProc` | audio streaming | **15 (TIME_CRITICAL)** | parked in `WaitForMultipleObjects` @`0x8ef226`; loop also has `Sleep(100)` @`0x8ef231` |
| 2192 | exe `0x00901f40` `NetworkManager::WatchdogThreadProc` | net watchdog | 0 | `Sleep(10)` @`0x901f72` then `EnterCriticalSection(g_netCS 0xb2e5f8)` @`0x901f7f`, forever |
| 2320 | exe `0x00735bb0` `BackgroundWorker::ThreadProc` | star-map mesh worker | **-1 (BELOW_NORMAL)** | `Sleep(10)` @`0x735bc0` + `EnterCriticalSection` @`0x735bcb` loop |
| 1124 + 16× | `vulkan_lvp.dll+0x1b2b71e` | lavapipe (CPU Vulkan) workers | 0 | 1124 is the busiest thread in the process (12:52 CPU at attach); 17 lvp threads had ~5:30 each |
| 1376, 5348, 7852, 6436 + 8× pri -2 | `d3d9.dll+0x290e60` (DXVK 3.1) | DXVK CS/present/shader-compiler threads | 0 / -2 | |
| 5508 | `ucrtbase` | CRT | | |
| 5216, 5132 | `ntdll!TpCallbackIndependent` | thread pool | | |
## 3. Measurements
### 3.1 Idle strategy map, 16 cores (`samp_idle.txt`, 5 s, 50 ms)
- CPU per 5 s: lavapipe 20.7 s (≈4 cores), main 0.47 s (9 %), DXVK 0.46 s, everything else 0.
- Main thread: `Wait/WrAlertByThreadId` 84 %, `Running` 16 %. Top wait: `ntdll!NtWaitForAlertByThreadId` ← `KERNELBASE!SleepConditionVariableSRW`
← `d3d9.dll` (DXVK) ← `sub_00720050@0x720267` (renderer flush/present) ← … ← `DemoApp::OnRender@0x899266` ← `sub_0090ea20` ← `Application::Run`.
### 3.2 End Turn, 16 cores (`samp_endturn1.txt`, 45 s, 20 ms, 1440 samples/thread; End Turn clicked ≈ t+12 s)
- CPU per 45 s: lavapipe 293 s (6.5 cores), main 6.3 s (14 %), DXVK 3.0 s, mesh 0.08 s, audio 0, net 0.
- Main thread over the run: `Wait/WrAlertByThreadId` 76 % (DXVK, as above), `Running` 19 %, `Wait/DelayExecution` 4 %.
Wait sites: 67 % DXVK ← `sub_00720050@0x720267`; 4.9 % DXVK ← `sub_0072b9e0@0x72bc4c`; 4.6 % DXVK ← `sub_008df3a0@0x8df3e0`;
2.5 % `Sleep(20)` ← `Application::Run@0x89f5fc` (the `ShouldSleepWhenInactive` path: `push 0x14; call Sleep` @`0x89f5f4`); 1.9 % `Sleep` ← `sub_00838c60@0x838daf`.
- **Turn-processing window t = 11.9–13.3 s** (`endturn1.window.parsed.txt`): only **1 of 45 samples** is inside server/client turn code
(`StrategyServer`/`ProcessTurn` frames → real work ≲ 40 ms for a 28-star, 2-player map); **60 % `Wait/DelayExecution`** = 27 consecutive
samples (t = 12.00–12.83 s) in `Sleep` called from `0x00838da9` (`sub_00838c60`, arg = `(int)(double × ds:0x9e22f8)`, i.e. a computed
remaining-time in ms) ← `StrategyClient::Update@0x7843ac` ← `DemoApp::OnTick@0x89a779`. During that window the CPU bucket at 12.6 s
is **0 ms for every thread** (no rendering either) — a fixed end-turn delay (`EndTurnDelay`, cf. `0x0076ab90`), not a contention wait.
Mesh worker ran 47+31 ms right after (star-map rebuild). Wall clock per End Turn incl. UI-helper latency ≈ 20 s; game-side ≈ 1 s.
### 3.3 Timer resolution / sleep granularity (hypothesis (c) inputs)
- `NtQueryTimerResolution`: min 156250, max 5000, **current 156250 (15.625 ms)**; `powercfg /energy` shows no outstanding timer request.
The exe imports `Sleep` and `SetProcessAffinityMask` but **not** `timeBeginPeriod`, `SwitchToThread`, `SetThreadAffinityMask`.
- All 10 `Sleep` call sites in the exe (`objdump`, IAT slot `0x9dd048`):
| site | arg | function |
|---|---|---|
| `0x00735bc0` | 10 | `BackgroundWorker::ThreadProc` loop |
| `0x00901f72`, `0x00901820` | 10 | `NetworkManager::WatchdogThreadProc` loop / sibling |
| `0x0071eb26` | 10 | thread join helper `0x71eac0` (see below) |
| `0x0089f5f4` | 20 | `Application::Run` when inactive |
| `0x008ef231` | 100 | `SoundSystem::StreamingUpdateThreadProc` loop |
| `0x0071eaa2` | **1000** | wait-for-job helper `0x71ea60` |
| `0x0092555c` | 1000 | CRT/startup area |
| `0x00838da9` | computed | end-turn delay (`sub_00838c60`) |
| `0x0040a766` | computed (eax) | early low-address engine code, not yet identified |
⇒ there is **no `Sleep(0)`/`Sleep(1)` yield-spin anywhere**, so the "spin loop starves on many cores" variant of (c) is out.
- Helpers (static, from the exe):
- `0x0071ea60(obj)`: `loop { if (TryEnterCriticalSection(obj)) { done=[obj+0x5a]; failed=[obj+0x58]; LeaveCriticalSection } ; if (!done && !failed) { Sleep(1000); continue } ; return done }`.
Poll granularity **1 s**, and a *failed try-lock* (worker holding the CS on another core at that instant) also costs a full second.
Referenced only by `StarMapBlobs::BuildBlobMesh_Job` (`push 0x71ea60` at `0x732cbc` and `0x732ce8`, alongside callback `0x722010`, into `0x8fc160` / `0x8fa5b0`).
- `0x0071eac0(this)`: `EnterCriticalSection; [+0x5b]=1 (stop), [+0x58]=1 if [+0x54]; Leave; while (GetExitCodeThread == STILL_ACTIVE) { TryEnter/check [+0x5c]/Leave; Sleep(10) }; CloseHandle` — thread stop/join, called from `0x00735794` (BackgroundWorker teardown).
## 4. Hypotheses — verdict on current evidence
- **(a) audio-thread critical-section contention (`g_musicCS`)** — *Untested; cannot be tested on this VM until an audio device is added.*
Dynamic: the TIME_CRITICAL thread never left `WaitForMultipleObjects` (0 CPU) because DirectSound never initialised. Static: its loop is event-wait +
`Sleep(100)`, so even when alive it wakes ≤10×/s plus buffer events; contention would need the main thread to take `g_musicCS` inside the load
path (`MusicPlayer::OpenMusicFile 0x8ef040`, `StreamingSound::FillBuffer 0x91b5a0`). Plausible mechanism (a TIME_CRITICAL waiter on a CS the
loader holds for long stretches → priority-inversion-style stalls), no evidence either way yet.
- **(b) D3D9 runtime/driver worker threads** — *No evidence; not reproducible here.* On this VM the "driver" is DXVK+lavapipe (29 threads) and the
main thread's dominant wait is DXVK's present path — an artefact. Nothing about Microsoft's d3d9 runtime threads can be inferred from it.
- **(c) `Sleep(0)`/spin loops, timer resolution** — *Partially refuted, partially supported.* Refuted: no yield-spins exist. Supported: the timer stays
at 15.6 ms (all the `Sleep(10)` loops are really 15.6 ms), and the engine's job-completion wait `0x71ea60` polls with **`Sleep(1000)`** and treats a
lost `TryEnterCriticalSection` race as "not done" — a pattern that is benign on one core (the worker cannot hold the CS while the poller runs) and
costs whole seconds per collision on many cores. It is wired to the star-map blob mesh job here; the open question — one Ghidra query — is whether
the combat loader (`CombatLoaderPainter`/`CombatLobbyClient`, C2 in the UI map) submits work through the same `0x8fc160`/`0x8fa5b0` job API with
`0x71ea60`-style waiters, or takes `g_netCS` while the watchdog polls it every 15.6 ms. That, plus (a) with audio enabled, is what the next dynamic run should discriminate.
## 5. Files alongside this report (`Z:\profiling\` = `/bulk-storage/re-lab/profiling/`)
- `samp_idle.txt`, `samp_endturn1.txt` — raw sampler output (modules, threads, per-sample stacks, per-500 ms CPU); `*.parsed.txt` — digested.
- `tools/` — `sampler.ps1`, `parse.py`, `funcs.py`, `funcs.txt`, `names.txt`, `iat.py`, `timerres.ps1`, `ui.sh`, `turnloop.sh`, `battle-run.sh`, `shots.sh`, `inspect.ps1`.
- `screens/` — build screen, fleet/move attempts (range dialog), End Turn, final turn-27 state; `turns/` — one screenshot per automated End Turn + `log.txt`.

View file

@ -240,7 +240,8 @@ class Catalog:
return cls._cache[key]
def _load_sections(self, path):
data = json.load(open(path, encoding="utf-8"))
with open(path, encoding="utf-8") as f:
data = json.load(f)
for s in data["sections"]:
groups = []
for o in _list(s.get("option")):
@ -276,7 +277,8 @@ class Catalog:
self.sections_by_id.setdefault(sd.race, {})[int(sd.id)] = sd
def _load_weapons(self, path):
data = json.load(open(path, encoding="utf-8"))
with open(path, encoding="utf-8") as f:
data = json.load(f)
for w in data["weapons"]:
wd = WeaponDef(
stem=w["stem"], file=w["file"], scope=w.get("scope", "player"), id=w.get("id"),
@ -293,7 +295,8 @@ class Catalog:
self.weapons_by_id[int(wd.id)] = wd
def _load_techs(self, path):
data = json.load(open(path, encoding="utf-8"))
with open(path, encoding="utf-8") as f:
data = json.load(f)
self.races = list(data.get("races", []))
for n in data["nodes"]:
self.techs[n["name"].lower()] = n
@ -593,17 +596,26 @@ def validate(design: dict, cat: Catalog | None = None) -> list[Violation]:
v.append(Violation("C3", "error", f"option {o} is not researched", slot))
# -- tech gating ---------------------------------------------------------------
# Engine-generated hidden designs (the per-race "Default Assault Shuttle")
# exist in every save regardless of research: the Tarkas _AssaultShuttle
# requires DRN_AdvFrm and the design is there on turn 1. So for hidden
# designs tech gating is reported, but only as a warning.
gate_level = "warn" if hidden else "error"
for slot, sd in present:
for t in sd.requires:
if cat.tech(t) is None and not t.lower().startswith("grp_"):
v.append(Violation("C1", "warn", f"{sd.stem} requires unknown tech {t}", slot))
elif known is not None and not cat.tech_known(t, known):
v.append(Violation("C1", "error", f"{sd.stem} requires {t}", slot))
v.append(Violation("C1", gate_level, f"{sd.stem} requires {t}", slot))
if rk.lower() in cat.unobtainable.get(t.lower(), ()):
v.append(Violation("C5", "error", f"{sd.stem} requires {t}, which {rk} can never research", slot))
hct = sd.hull_class_tech()
if hct and known is not None and not cat.tech_known(hct, known):
v.append(Violation("C4", "error", f"{sd.stem} is a {sd.cls} section; {hct} not researched", slot))
v.append(Violation("C4", gate_level, f"{sd.stem} is a {sd.cls} section; {hct} not researched", slot))
if hidden:
for x in v:
if x.rule in ("C2", "C3") and x.level == "error":
x.level = "warn"
return v

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,132 @@
"""stock_designs.py -- pull every ShipDesign (and each player's researched
techs) out of real saves via verify/save-reader/save_reader.py and write them
in the design_rules.py Design format.
python3 stock_designs.py [save.sav ...] -> stock_designs.json (next to this file)
Save layout decoded here (see SHIP_DESIGN_RULES.md section 7):
Player.designs / Player.legacyDesigns : [{DesID, Des{faiDes dHide dWep dName sections[5]}}]
DSec = { DSec{ int species_idx, int section_id }, DGbnk2{ int n, n x DW2 }, DOpts{ int n, n x tech } }
DW2 = { bool bID, (wid int | wfn string), int did }
Player.TechTree : per tech TNm St TResCost TResDone TAcq TiAcq Tbd Tfc TUnlck ; St == 4 -> researched
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
SAVE_READER = HERE.parent / "save-reader" / "save_reader.py"
DEFAULT_SAVES = sorted((HERE.parent / "results" / "saves").glob("*.sav"))
OUT = HERE / "stock_designs.json"
sys.path.insert(0, str(HERE))
from design_rules import Catalog, SPECIES_INDEX, design_from_save # noqa: E402
TECH_STATE_RESEARCHED = 4
def _items(node):
return node.get("_items", []) if isinstance(node, dict) else []
def _leaf_values(node):
return [it.get("value") for it in _items(node) if "value" in it]
def read_save_json(path: Path) -> dict:
res = subprocess.run([sys.executable, str(SAVE_READER), "--json", str(path)],
capture_output=True, text=True)
if res.returncode not in (0, 1):
raise RuntimeError(f"save_reader failed on {path}: {res.stderr[-500:]}")
return json.loads(res.stdout)
def decode_section_slot(sec: dict) -> dict | None:
"""One DSec frame -> {"species", "section_id", "weapons", "options"}."""
out: dict = {"species": 0, "section_id": 0, "weapons": [], "options": [], "bank_count": 0}
for c in _items(sec):
nm = c.get("_name", c.get("name"))
if nm == "DSec":
vals = _leaf_values(c)
if len(vals) >= 2:
out["species"], out["section_id"] = int(vals[0]), int(vals[1])
elif nm == "DGbnk2":
its = _items(c)
out["bank_count"] = int(its[0].get("value", 0)) if its else 0
for b in its[1:]:
entry = None
for dw in _items(b):
if dw.get("_name") == "DW2":
vals = {it["name"]: it.get("value") for it in _items(dw) if "name" in it}
if vals.get("wid") is not None:
entry = int(vals["wid"])
elif vals.get("wfn"):
entry = str(vals["wfn"])
out.setdefault("rider_design_ids", []).append(vals.get("did"))
out["weapons"].append(entry)
elif nm == "DOpts":
its = _items(c)
out["options"] = [str(it["value"]) for it in its[1:] if "value" in it]
return out
def decode_techs(player: dict) -> dict:
"""tech name -> state record; the state block follows the branch list."""
techs: dict = {}
cur = None
for it in _items(player.get("TechTree")):
n = it.get("name")
if n == "TNm":
cur = it.get("value")
elif n == "St":
techs[cur] = {"St": it.get("value")}
elif n in ("TResCost", "TResDone", "TAcq", "TiAcq", "Tbd", "Tfc", "TUnlck") and cur in techs:
techs[cur][n] = it.get("value")
return techs
def extract(path: Path, cat: Catalog) -> list[dict]:
d = read_save_json(path)
out = []
for pe in d["data"]["sim"]["players"]:
p = pe["Player"]
techs = decode_techs(p)
researched = sorted(t for t, s in techs.items() if s.get("St") == TECH_STATE_RESEARCHED)
species_idx = p.get("Species")
race = SPECIES_INDEX[species_idx] if isinstance(species_idx, int) and 0 <= species_idx < len(SPECIES_INDEX) else None
rec = {"save": path.name, "player_id": pe["PlayerID"], "player": p.get("PlryName"),
"species_idx": species_idx, "race": race, "npc": bool(p.get("NPC")),
"researched": researched, "n_techs": len(techs), "designs": []}
for kind in ("designs", "legacyDesigns"):
for des in p.get(kind) or []:
dd = des["Des"]
slots = [decode_section_slot(s) for s in dd.get("sections", [])]
design = design_from_save(dd.get("dName"), slots, cat, hidden=bool(dd.get("dHide")),
known_techs=researched, race=None)
design.update({"design_id": des["DesID"], "list": kind, "faiDes": dd.get("faiDes"),
"dHide": dd.get("dHide"), "dWep": dd.get("dWep"), "raw_slots": slots,
"player_race": race})
rec["designs"].append(design)
out.append(rec)
return out
def main(argv):
saves = [Path(a) for a in argv] or DEFAULT_SAVES
cat = Catalog.load()
all_players = []
for s in saves:
all_players.extend(extract(s, cat))
OUT.write_text(json.dumps(all_players, indent=1))
n = sum(len(p["designs"]) for p in all_players)
print(f"wrote {OUT}: {len(all_players)} player records, {n} designs from {len(saves)} saves")
for p in all_players:
print(f" {p['save']} #{p['player_id']} {p['player']!r} {p['race']} npc={p['npc']} "
f"designs={len(p['designs'])} researched={len(p['researched'])}/{p['n_techs']}")
if __name__ == "__main__":
main(sys.argv[1:])

View file

@ -0,0 +1,397 @@
"""Tests for design_rules.py.
python3 test_design_rules.py # unittest + printed ground-truth report
python3 test_design_rules.py -v
Ground truth = every design in the three real saves (stock_designs.json,
regenerated from the .sav files by stock_designs.py when missing) and the
scenario fleet templates (data/*_FleetTemplates.csv).
"""
from __future__ import annotations
import csv
import json
import sys
import unittest
from collections import Counter
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
from design_rules import (Catalog, SLOTS, applied_techs, derive_stats, validate, # noqa: E402
weapon_fits_bank)
STOCK = HERE / "stock_designs.json"
def stock_players() -> list[dict]:
if not STOCK.exists():
import stock_designs
stock_designs.main([])
return json.loads(STOCK.read_text())
def errors(viols):
return [v for v in viols if v.level == "error"]
class TestCatalog(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.cat = Catalog.load()
def test_counts(self):
c = self.cat
self.assertEqual(sum(len(v) for v in c.sections.values()), 875)
self.assertEqual(len(c.weapons), 207)
self.assertEqual(len(c.techs), 293)
self.assertEqual(len(c.turrets), 42)
self.assertEqual(len(c.default_weapons), 31)
def test_lookup_case_insensitive(self):
c = self.cat
self.assertIsNotNone(c.section("human", "decommand"))
self.assertIsNotNone(c.section("Human", "Species/Human/sections/DEArmor.shipsection"))
self.assertIs(c.weapon("BAL_GAUSS"), c.weapon(8))
self.assertIs(c.weapon("Weapons/mis.weapon"), c.weapon(21))
self.assertTrue(c.tech_known("GRP_Torps", {"wep_phottrp"}))
self.assertFalse(c.tech_known("GRP_Torps", {"wep_redlas"}))
def test_race_gating(self):
c = self.cat
self.assertIn("tarkas", c.unobtainable["drv_node"])
self.assertNotIn("human", c.unobtainable["drv_node"])
self.assertIn("human", c.unobtainable["drv_hyper"])
def test_every_player_bank_has_a_fitting_player_weapon(self):
"""Catalog self-consistency: every bank of every player-race section can
take at least one player weapon under the fit rule."""
c = self.cat
player_weapons = [w for w in c.weapons.values() if w.scope == "player"]
unfit = []
for race, secs in c.sections.items():
if race == "_NPC":
continue
for sd in secs.values():
for b in sd.banks:
if b.fixed_weapon or b.size is None:
continue
if not any(weapon_fits_bank(c, b, w)[0] for w in player_weapons):
unfit.append((race, sd.stem, b.index, b.size, b.cls))
self.assertEqual(unfit, [])
def test_every_player_weapon_fits_some_bank(self):
c = self.cat
banks = [b for race, secs in c.sections.items() if race != "_NPC"
for sd in secs.values() for b in sd.banks if b.size]
orphans = []
for w in c.weapons.values():
if w.scope != "player":
continue
if not any(weapon_fits_bank(c, b, w)[0] for b in banks):
orphans.append((w.stem, w.size, w.cls))
self.assertEqual(orphans, [])
class TestRules(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.cat = Catalog.load()
def armor(self, **over):
d = {
"name": "Armor", "race": "Human",
"command": {"section": "DECommand", "weapons": ["bal_gauss"]},
"mission": {"section": "DEArmor", "weapons": ["bal_gauss", "bal_gauss", "mis"]},
"engine": {"section": "DEFission", "weapons": ["bal_gauss"]},
"known_techs": ["DRV_Fissn", "DRV_Node", "WEP_GsDrvr"],
}
d.update(over)
return d
def rules(self, design):
return sorted({v.rule for v in errors(validate(design, self.cat))})
def test_valid(self):
self.assertEqual(validate(self.armor(), self.cat), [])
def test_A2_missing_mission(self):
self.assertEqual(self.rules(self.armor(mission=None)), ["A2"])
def test_A3_hull_needs_command_and_engine(self):
self.assertEqual(self.rules(self.armor(engine=None)), ["A3"])
def test_A3_standalone_cannot_take_partners(self):
d = self.armor(mission={"section": "DEDefencePlatform",
"weapons": ["bal_gauss"] * 4 + ["mis"]})
self.assertEqual(self.rules(d), ["A3"])
d.update(command=None, engine=None)
self.assertEqual(self.rules(d), [])
def test_A4_wrong_slot(self):
d = self.armor(command={"section": "DEArmor", "weapons": ["bal_gauss", "bal_gauss", "mis"]})
self.assertEqual(self.rules(d), ["A4"])
def test_A5_wrong_race(self):
self.assertEqual(self.rules(self.armor(race="Klingon", known_techs=None)), ["A5"])
# DENodeMissile exists only in the Human catalog
d = self.armor(race="Zuul", known_techs=None,
mission={"section": "DENodeMissile", "weapons": ["mis_node"]})
self.assertEqual(self.rules(d), ["A5"])
def test_A6_class_mix(self):
d = self.armor(engine={"section": "CRFission", "weapons": ["bal_gauss"] * 2}, known_techs=None)
# CRFission has 2 banks in the Human catalog? use whatever the catalog says
sd = self.cat.section("Human", "CRFission")
d["engine"]["weapons"] = ["bal_gauss"] * len(sd.banks)
self.assertIn("A6", self.rules(d))
def test_A7_exclude(self):
cat = self.cat
cmd = cat.section("Hiver", "DERamScoop")
eng = cat.section("Hiver", "DEFission")
mis = cat.section("Hiver", "DEArmor")
def fill(sd):
return {"section": sd.stem, "weapons": [None] * len(sd.banks)}
d = {"race": "Hiver", "command": fill(cmd), "mission": fill(mis), "engine": fill(eng)}
self.assertIn("A7", self.rules(d))
def test_A8_station_partners(self):
cat = self.cat
st = cat.section("Human", "DNStationCommand")
fore = cat.section("Human", "DNStationCommand_Fore")
aft = cat.section("Human", "DNStationCommand_Aft")
def fill(sd):
return {"section": sd.stem, "weapons": ["bal_gauss"] * len(sd.banks)}
d = {"race": "Human", "command": fill(fore), "mission": fill(st), "engine": fill(aft)}
self.assertNotIn("A8", self.rules(d))
d["command"] = fill(cat.section("Human", "DNCommand"))
self.assertIn("A8", self.rules(d))
def test_B1_bank_count(self):
d = self.armor(mission={"section": "DEArmor", "weapons": ["bal_gauss", "bal_gauss"]})
self.assertEqual(self.rules(d), ["B1", "B2"])
def test_B4_fit(self):
# large-only torpedo into a small standard bank
d = self.armor(command={"section": "DECommand", "weapons": ["trp_photon"]}, known_techs=None)
self.assertEqual(self.rules(d), ["B4"])
# PD (tiny) fits small, not medium
d = self.armor(mission={"section": "DEArmor", "weapons": ["las_pd", "las_pd", "las_pd"]}, known_techs=None)
self.assertEqual([v.bank for v in errors(validate(d, self.cat))], [2])
def test_B4_missile_in_standard(self):
d = self.armor(mission={"section": "DEArmor", "weapons": ["bal_gauss", "bal_gauss", "mis"]})
self.assertEqual(self.rules(d), [])
d = self.armor(mission={"section": "DEArmor", "weapons": ["mis", "bal_gauss", "mis"]})
self.assertEqual(self.rules(d), ["B4"]) # medium missile does not fit a small bank
def test_B6_exclusive_species(self):
d = self.armor(mission={"section": "DEArmor", "weapons": ["bal_gauss", "bal_gauss", "bal_grapple"]}, known_techs=None)
self.assertEqual(self.rules(d), ["B6"])
def test_C_tech_gating(self):
self.assertEqual(self.rules(self.armor(known_techs=["DRV_Fissn", "DRV_Node"])), ["C2"])
self.assertEqual(self.rules(self.armor(known_techs=["WEP_GsDrvr", "DRV_Node"])), ["C1"])
d = self.armor(mission={"section": "DEArmor", "weapons": ["bal_gauss", "bal_gauss", "mis"],
"options": ["IND_PlyAlloy"]})
self.assertEqual(self.rules(d), ["C3"])
def test_C4_hull_class_tech(self):
cat = self.cat
def fill(stem):
sd = cat.section("Human", stem)
return {"section": sd.stem, "weapons": ["bal_gauss"] * len(sd.banks)}
d = {"race": "Human", "command": fill("CRCommand"), "mission": fill("CRArmor"), "engine": fill("CRFission"),
"known_techs": ["DRV_Fissn", "DRV_Node", "WEP_GsDrvr"]}
self.assertEqual(self.rules(d), ["C4"])
d["known_techs"].append("IND_CruisCon")
self.assertEqual(self.rules(d), [])
def test_C5_race_can_never(self):
cat = self.cat
sd = cat.section("Tarkas", "DEFission") # requires DRV_Hyper: fine for Tarkas
self.assertTrue(all(v.rule != "C5" for v in validate(
{"race": "Tarkas", "command": {"section": "DECommand", "weapons": ["bal_gauss"] * 1},
"mission": {"section": "DEArmor", "weapons": ["bal_gauss", "mis"]},
"engine": {"section": sd.stem, "weapons": ["bal_gauss"] * len(sd.banks)}}, cat)))
def test_D1_options(self):
d = self.armor(mission={"section": "DEArmor", "weapons": ["bal_gauss", "bal_gauss", "mis"],
"options": ["IND_PlyAlloy", "IND_MagLat"]},
known_techs=["DRV_Fissn", "DRV_Node", "WEP_GsDrvr", "IND_PlyAlloy", "IND_MagLat", "SLD_MkOne"])
self.assertEqual(self.rules(d), ["D1"])
d["mission"]["options"] = ["SLD_MkOne"]
self.assertEqual(self.rules(d), ["D1"])
d["mission"]["options"] = ["IND_MagLat"]
self.assertEqual(self.rules(d), [])
def test_applied_techs_order(self):
cat = self.cat
sd = cat.section("_NPC", "_DEEngine")
self.assertEqual(applied_techs({"options": ["IND_RefCoat", "DRV_RecFiss", "IND_PlyAlloy"]}, sd, cat),
["DRV_Fissn", "DRV_Node", "IND_PlyAlloy", "IND_RefCoat", "DRV_RecFiss"])
sd = cat.section("_NPC", "_CRRipperCommand")
self.assertEqual(applied_techs({"options": ["IND_MagLat", "IND_RefCoat"]}, sd, cat),
["IND_CruisCon", "IND_MagLat", "IND_RefCoat"])
sd = cat.section("_NPC", "_AsteroidMonitor") # station: no hull-class tech
self.assertEqual(applied_techs({"options": ["IND_QrkRes"]}, sd, cat), ["IND_QrkRes"])
def test_derive_stats(self):
st = derive_stats(self.armor(), self.cat)
self.assertEqual(st["mass"], 800 + 2000 + 2500)
self.assertEqual(st["section_cost"], 2000 + 4000 + 5000)
self.assertEqual(st["turrets"], 1 + 3 + 3 + 1 + 1)
self.assertEqual(st["weapon_cost_per_bank"], 50 * 4 + 3000)
self.assertEqual(st["ftlspeed"], 0.2)
self.assertEqual(st["applied_techs"]["engine"], ["DRV_Fissn", "DRV_Node"])
class TestStockDesigns(unittest.TestCase):
"""Every design in the real saves must validate (structure + weapons +
options + tech gating against that player's researched techs) and its
DOpts must equal applied_techs()."""
@classmethod
def setUpClass(cls):
cls.cat = Catalog.load()
cls.players = stock_players()
def all_designs(self):
for p in self.players:
for d in p["designs"]:
yield p, d
def test_have_designs(self):
n = sum(1 for _ in self.all_designs())
self.assertGreater(n, 30)
def test_no_mixed_species(self):
for p, d in self.all_designs():
self.assertNotIn("mixed_species", d, f"{p['save']} {d['name']}")
def test_player_designs_validate(self):
failures = []
for p, d in self.all_designs():
if p["npc"]:
continue
errs = errors(validate(d, self.cat))
if errs:
failures.append((p["save"], p["player"], d["name"], [str(e) for e in errs]))
self.assertEqual(failures, [])
def test_hidden_default_rider_designs_bypass_tech_gating(self):
"""Code rule the data does not show: the engine creates the hidden
'Default Assault Shuttle' design for every race at game start even
when the rider section's `requires` is unresearched (Tarkas
_AssaultShuttle requires DRN_AdvFrm)."""
seen = 0
for p, d in self.all_designs():
if d["name"] != "Default Assault Shuttle" or d["race"] != "Tarkas":
continue
seen += 1
viols = validate(d, self.cat)
self.assertTrue(d["hidden"])
self.assertEqual(errors(viols), [])
self.assertIn("C1", {v.rule for v in viols if v.level == "warn"})
self.assertGreaterEqual(seen, 3)
def test_npc_designs_validate_structurally(self):
failures = []
for p, d in self.all_designs():
if not p["npc"]:
continue
dd = dict(d)
dd["known_techs"] = None if p["n_techs"] == 0 else p["researched"]
errs = errors(validate(dd, self.cat))
if errs:
failures.append((p["save"], p["player"], d["name"], [str(e) for e in errs]))
self.assertEqual(failures, [])
def test_dopts_equals_applied_techs(self):
mism = []
for p, d in self.all_designs():
for slot in SLOTS:
use = d.get(slot)
if not use:
continue
sd = self.cat.section(d["race"], use["section"])
got = applied_techs(use, sd, self.cat)
want = use.get("save_opts", [])
if [g.lower() for g in got] != [w.lower() for w in want]:
mism.append((p["save"], d["name"], slot, want, got))
self.assertEqual(mism, [])
def test_bank_count_matches_save(self):
for p, d in self.all_designs():
for slot, raw in zip(SLOTS, d["raw_slots"]):
use = d.get(slot)
if not use:
continue
sd = self.cat.section(d["race"], use["section"])
self.assertEqual(raw["bank_count"], len(sd.banks), f"{d['name']} {slot} {sd.stem}")
class TestScenarioTemplates(unittest.TestCase):
"""Scenario fleet templates are (command, mission, engine) stems; check the
structural rules for every race that carries all three sections."""
@classmethod
def setUpClass(cls):
cls.cat = Catalog.load()
def templates(self):
for f in sorted((HERE / "data").glob("*FleetTemplates.csv")):
for row in csv.reader(l for l in f.read_text().splitlines() if l.strip() and not l.startswith("#")):
if len(row) >= 4:
yield f.name, row[0].strip(), row[1].strip(), row[2].strip(), row[3].strip()
def test_templates_structurally_valid(self):
cat = self.cat
checked = 0
failures = []
for fname, fleet, c, m, e in self.templates():
for race in cat.sections:
if race == "_NPC":
continue
sds = [cat.section(race, x) for x in (c, m, e)]
if any(s is None for s in sds):
continue
d = {"race": race, "hidden": True}
for slot, sd in zip(SLOTS, sds):
d[slot] = {"section": sd.stem, "weapons": [None] * len(sd.banks)}
errs = [v for v in validate(d, cat) if v.level == "error" and v.rule not in ("B2",)]
checked += 1
if errs:
failures.append((fname, fleet, race, c, m, e, [str(x) for x in errs]))
self.assertGreater(checked, 100)
self.assertEqual(failures, [])
def report():
cat = Catalog.load()
players = stock_players()
print("\n=== ground truth: designs in real saves ===")
tot = Counter()
for p in players:
for d in p["designs"]:
viols = validate(d, cat)
errs = errors(viols)
tot["designs"] += 1
tot["with_errors"] += bool(errs)
secs = " + ".join(f"{d[s]['section']}" for s in SLOTS if d.get(s))
flag = "FAIL" if errs else "ok "
print(f"{flag} {p['save']:16} {p['player'][:18]:18} {d['race']:7} {d['name'][:24]:24} {secs}")
for v in viols:
print(f" {v}")
print(f"{tot['designs']} designs, {tot['with_errors']} with error-level violations")
if __name__ == "__main__":
if "--report" in sys.argv:
sys.argv.remove("--report")
report()
unittest.main()

View file

@ -0,0 +1,23 @@
#!/bin/bash
# usage: battle-run.sh <tag> <affinity-mask-decimal|0=leave> <clickX> <clickY> [duration]
S=/tmp/claude-1000/-home-alex/ec8e34f8-af37-4ef2-a309-ed6a15293097/scratchpad
TAG=$1; AFF=$2; X=$3; Y=$4; DUR=${5:-120}
mkdir -p $S/runs/$TAG
if [ "$AFF" != "0" ]; then
ssh -o BatchMode=yes re@192.168.10.139 "\$p=Get-Process 'Sword of the Stars'; \$p.ProcessorAffinity=$AFF; (Get-Process 'Sword of the Stars').ProcessorAffinity" | tee $S/runs/$TAG/affinity.txt
fi
ssh -o BatchMode=yes re@192.168.10.139 "C:\Windows\SysWOW64\WindowsPowerShell\v1.0\powershell.exe -ExecutionPolicy Bypass -File C:\Users\re\sampler.ps1 -DurationSec $DUR -IntervalMs 20 -Out C:\Users\re\samp_$TAG.txt" > $S/runs/$TAG/sampler.log 2>&1 &
ssh spicy "/root/shots.sh $TAG $DUR" > /dev/null 2>&1 &
sleep 4
printf 'fg\nclick %s %s\nsleep 500\n' "$X" "$Y" > $S/runs/$TAG/cmd.txt
scp -q $S/runs/$TAG/cmd.txt re@192.168.10.139:C:/SOTS/ui/cmd.txt
ssh -o BatchMode=yes re@192.168.10.139 'schtasks /Run /TN SOTSUI | Out-Null'
date +%s.%N > $S/runs/$TAG/click_issued.txt
echo "click issued $(date +%H:%M:%S.%N)"
wait
scp -q re@192.168.10.139:samp_$TAG.txt $S/runs/$TAG/
scp -q "spicy:/tmp/shots/$TAG/times.txt" $S/runs/$TAG/
ssh spicy "cd /tmp/shots/$TAG && for f in *.ppm; do convert \$f -resize 256x192 \${f%.ppm}.png; done; tar cf /tmp/shots/$TAG.tar -C /tmp/shots/$TAG --wildcards '*.png' times.txt"
scp -q spicy:/tmp/shots/$TAG.tar $S/runs/$TAG/ && tar xf $S/runs/$TAG/$TAG.tar -C $S/runs/$TAG/
cd $S/runs/$TAG && prev=""; for f in $(ls [0-9]*.png | sort); do if [ -n "$prev" ]; then d=$(compare -metric RMSE $prev $f null: 2>&1 | sed 's/.*(\(.*\))/\1/'); echo "$f $d"; fi; prev=$f; done > framediff.txt
echo "run $TAG done; frames: $(ls $S/runs/$TAG/[0-9]*.png | wc -l)"

View file

@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Heuristic function-start table for the SotS exe (no symbols): scan .text for MSVC prologues
and call targets. Output: funcs.txt with one 'ghidra_addr' per line, sorted."""
import struct, sys
data = open(sys.argv[1], 'rb').read()
pe = struct.unpack_from('<I', data, 0x3c)[0]
nsec = struct.unpack_from('<H', data, pe + 6)[0]
optsz = struct.unpack_from('<H', data, pe + 20)[0]
imgbase = struct.unpack_from('<I', data, pe + 0x34)[0]
secs = []
for i in range(nsec):
o = pe + 24 + optsz + i * 40
name = data[o:o+8].rstrip(b'\0').decode(errors='replace')
vsz, va, rsz, rp = struct.unpack_from('<IIII', data, o + 8)
secs.append((name, va, vsz, rp, rsz))
text = [s for s in secs if s[0] == '.text'][0]
_, tva, tvsz, trp, trsz = text
lo, hi = imgbase + tva, imgbase + tva + trsz
code = data[trp:trp+trsz]
starts = set()
# call rel32 targets inside .text
for i in range(len(code) - 5):
if code[i] == 0xE8:
rel = struct.unpack_from('<i', code, i + 1)[0]
tgt = lo + i + 5 + rel
if lo <= tgt < hi:
starts.add(tgt)
# prologues: push ebp; mov ebp,esp (55 8B EC) preceded by ret/int3/nop/padding
for i in range(1, len(code) - 3):
if code[i] == 0x55 and code[i+1] == 0x8B and code[i+2] == 0xEC and code[i-1] in (0xC3, 0xCC, 0x90, 0xC2, 0x00) :
starts.add(lo + i)
# also CC-padded starts of any kind: prev byte CC and this byte not CC
if code[i-1] == 0xCC and code[i] != 0xCC and code[i] in (0x55, 0x53, 0x56, 0x57, 0x83, 0x81, 0x8B, 0x6A, 0x68, 0xB8, 0x33, 0xE9, 0xA1, 0x51, 0x52):
starts.add(lo + i)
with open('funcs.txt', 'w') as f:
for a in sorted(starts):
f.write('%08x\n' % a)
print('imgbase %x text %x-%x funcs %d' % (imgbase, lo, hi, len(starts)))

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,26 @@
import struct
d=open('sots.exe','rb').read(); pe=struct.unpack_from('<I',d,0x3c)[0]
nsec=struct.unpack_from('<H',d,pe+6)[0]; optsz=struct.unpack_from('<H',d,pe+20)[0]
secs=[]
for i in range(nsec):
o=pe+24+optsz+i*40; vsz,va,rsz,rp=struct.unpack_from('<IIII',d,o+8); secs.append((va,vsz,rp))
def r2f(rva):
for va,vsz,rp in secs:
if va<=rva<va+max(vsz,1): return rp+(rva-va)
raise Exception(hex(rva))
irva=struct.unpack_from('<I',d,pe+0x80)[0]; o=r2f(irva)
want={0x9dd058,0x9dd094,0x9dd098,0x9dd05c,0x9dd034,0x9dd048,0x9dd200,0x9dd0a0,0x9dd0a4,0x9dd050,0x9dd054}
while True:
ilt,ts,fc,name,iat=struct.unpack_from('<IIIII',d,o)
if ilt==0 and iat==0: break
dll=d[r2f(name):r2f(name)+40].split(b'\0')[0].decode()
k=0
while True:
t=struct.unpack_from('<I',d,r2f((ilt or iat)+k*4))[0]
if t==0: break
addr=0x400000+iat+k*4
if addr in want:
nm='ord%d'%(t&0xffff) if t&0x80000000 else d[r2f(t)+2:r2f(t)+60].split(b'\0')[0].decode()
print('0x%08x %s!%s'%(addr,dll,nm))
k+=1
o+=20

View file

@ -0,0 +1,19 @@
$p = Get-Process | Where-Object { $_.Name -like 'Sword*' }
$p | Select-Object Id,Name,MainWindowTitle,ProcessorAffinity,StartTime,@{n='Threads';e={$_.Threads.Count}},WorkingSet64 | Format-List
$p.Threads | Select-Object Id,@{n='Start';e={'0x{0:x8}' -f [int64]$_.StartAddress}},ThreadState,WaitReason,TotalProcessorTime,BasePriority,CurrentPriority | Format-Table -AutoSize
"--- tools"
Get-Command wpr,xperf,logman,typeperf,tracelog -ErrorAction SilentlyContinue | Select-Object Name,Source | Format-Table -AutoSize
"--- SOTS dir"
Get-ChildItem C:\SOTS | Where-Object { $_.Extension -in '.ini','.cfg','.txt','.log' } | Select-Object Name,Length,LastWriteTime | Format-Table -AutoSize
"--- Z:"
Test-Path Z:\
Get-ChildItem C:\Tools | Select-Object Name
"--- tasks"
schtasks /Query /TN SOTS /FO LIST 2>&1 | Select-String Status
schtasks /Query /TN SOTSUI /FO LIST 2>&1 | Select-String Status
"--- ui"
Get-Content C:\SOTS\ui\log.txt -Tail 5 -ErrorAction SilentlyContinue
"--- saves"
Get-ChildItem C:\SOTS\SavedGames | Select-Object Name,Length,LastWriteTime | Format-Table -AutoSize
"--- display.cfg"
Get-Content C:\SOTS\display.cfg

View file

@ -0,0 +1,115 @@
00400000 ImageBase
005876c0 TechTree::ProcessResearch
00732ab0 StarMapBlobs::BuildBlobMesh_Job
00735bb0 Game::BackgroundWorker::ThreadProc
00736e30 Game::BackgroundWorker::Start
00741cd0 ctor
007424b0 ctor
00752500 ProcessBuildQueue
007537b0 ProcessSlaves
00756a90 ProcessPlague
007583b0 ProcessRebellion
007598e0 ServerSystem::ProcessTurn
0076ab90 EndTurnDelay
00783980 SendEndTurn
00783be0 Game::StrategyClient::EndTurn
00783d30 EndTurnForced
00783ee0 StrategyClient::RaiseEvent
007842b0 StrategyClient::Update
00784640 Game::StrategyNetworkClient::OnMessage
007856f0 CancelEndTurn
007893c0 StrategyServer::StorePlayerTurnCommands
00789710 StrategyServer::BroadcastEvent
00794770 StrategyNetworkServer::SendResumePlaying
007999a0 ProcessMissions
0079ac10 dispatch
007a0e20 ProcessNodeSpaceTravel
007a3310 ProcessSpecialProjects
007a7ae0 SETurnResults::Create
007ad100 ProcessAid
007ae480 ProcessStations
007af0b0 ProcessDefenceSats
007b18b0 StrategyServer::ApplyTurnCommands
007bfe60 SendEncounterQuery
007c24d0 fill
007c5850 SendTurnResultsToPlayers
007c6220 StrategyServer::SynchronizePlayer
007c8d90 StrategyServer::InitGame
007cbe80 StrategyNetworkServer::RunCombatRound
007ccb10 OnFleetArrived
007cd2a0 DispatchTurnResults
007cda40 StrategyNetworkServer::Update
007d0d10 ProcessSurrenders
007d4400 StrategyServer::ApplyEncounterResults
007d78d0 ctor
007d98e0 StrategyServer::BeginProcessTurn
007d9af0 StrategyServer::OnPlayerEndTurn
007d9ee0 MoveFleet
007da9a0 ProcessFleetMovement
007db780 BuildTurnEvents
007dc640 StrategyServer::GenerateTurnEvents
007dc6c0 StrategyServer::ProcessTurn
007dd530 StrategyServer::LoadGame
007ddc90 StrategyServer::ResumePlaying
00815f20 RaiseAIPrepareTurn
00816490 WriteHandleId
008164d0 ReadHandle
008706f0 StrategyApp::RunAI
00889dc0 RollResearchAccident
0088c7b0 factory
00890d50 BuildQueue::ProcessTurn
00891340 ServerPlayer::ProcessTurn
008986a0 ShouldSleepWhenInactive
00898800 OnUpdate
00898b00 CreateStrategyGame
00899210 OnRender
0089a640 OnTick
0089c950 DemoApp
0089d610 OnStartup
0089dd30 WinMain
0089dfb0 OnShutdown
0089ee70 Process_PinAffinity
0089f1c0 Mars::Application::PumpMessages
0089f4d0 OnConfigToken
0089f5b0 Mars::Application::Run
0089fe70 CreateAppWindow
008a0170 Mars::Application::Application
008a0e50 Mars::Application::Initialize
008a0ee6 site
008b9bc0 ReadFloat
008b9be0 WriteFloat
008b9c00 ReadBool
008b9c20 WriteBool
008b9c40 ReadInt64
008b9c60 WriteInt64
008b9cb0 WriteInt8AsInt
008b9cd0 ReadInt16
008b9d00 WriteInt16AsInt
008b9d20 ReadInt
008b9d50 WriteInt
008b9d70 WriteString
008cd820 Mars::ConfigParser::ParseFile
008d2290 Mars::NetMessageRegistry::Register
008df760 DrawDevice
008e5ac0 Mars::TimerList::Dispatch
008ef040 MusicPlayer::OpenMusicFile
008ef1d0 Mars::SoundSystem::StreamingUpdateThreadProc
00901f40 Mars::NetworkManager::WatchdogThreadProc
00902350 NetworkManager::Create
00902470 Mars::Network::Startup
0090c700 Mars::FrameTimer::Update
0090f1e0 PanelManager
0091b5a0 StreamingSound::FillBuffer
00925501 ___tmainCRTStartup
00925794 entry
009be000 region
009be0cc text
00a2298c vftable
00b2d0bc g_pDemoApp
00b2d540 g_pApplication
00b2ddf8 g_NetMsgRegistryById
00b2e4a8 g_musicCS
00b2e4c4 g_pCurrentMusicStream
00b2e5f8 g_netCS
00b2e61c g_pNetworkManager
00b2e620 g_hNetworkWatchdogThread

View file

@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Parse sampler.ps1 output. Usage: parse.py samp.txt [t0_ms t1_ms] (window for histograms)"""
import sys, re, bisect, collections
fn = sys.argv[1]
t0 = int(sys.argv[2]) if len(sys.argv) > 2 else 0
t1 = int(sys.argv[3]) if len(sys.argv) > 3 else 10**12
funcs = [int(l, 16) for l in open('funcs.txt')]
names = {}
for l in open('names.txt'):
a, n = l.split(None, 1); names[int(a, 16)] = n.strip()
for a, n in [(0x89a640, 'DemoApp::OnTick'), (0x898800, 'DemoApp::OnUpdate'), (0x899210, 'DemoApp::OnRender')]:
names[a] = n
STATE = {0: 'Init', 1: 'Ready', 2: 'Running', 3: 'Standby', 4: 'Terminated', 5: 'Wait', 6: 'Transition', 7: 'DeferredReady', 8: 'GateWait'}
WR = {0: 'Executive', 1: 'FreePage', 2: 'PageIn', 3: 'PoolAlloc', 4: 'DelayExecution', 5: 'Suspended', 6: 'UserRequest', 7: 'WrExecutive', 8: 'WrFreePage', 9: 'WrPageIn', 10: 'WrPoolAlloc', 11: 'WrDelayExecution', 12: 'WrSuspended', 13: 'WrUserRequest', 14: 'WrEventPair', 15: 'WrQueue', 16: 'WrLpcReceive', 17: 'WrLpcReply', 18: 'WrVirtualMemory', 19: 'WrPageOut', 20: 'WrRendezvous', 21: 'WrKeyedEvent', 22: 'WrTerminated', 23: 'WrProcessInSwap', 24: 'WrCpuRateControl', 25: 'WrCalloutStack', 26: 'WrKernel', 27: 'WrResource', 28: 'WrPushLock', 29: 'WrMutex', 30: 'WrQuantumEnd', 31: 'WrDispatchInt', 32: 'WrPreempted', 33: 'WrYieldExecution', 34: 'WrFastMutex', 35: 'WrGuardedMutex', 36: 'WrRundown', 37: 'WrAlertByThreadId', 38: 'WrDeferredPreempt'}
mods = {}; threads = {}; exe_base = None
lines = open(fn, encoding='utf-8', errors='replace').read().splitlines()
for l in lines:
if l.startswith('M '):
p = l.split(); base = int(p[1], 16); size = int(p[2], 16); name = ' '.join(p[3:]).split(' exports=')[0]
mods[name] = (base, size)
if name.startswith('Sword'): exe_base = base
elif l.startswith('T '):
p = l.split(); threads[p[1]] = l
delta = exe_base - 0x400000
def fname(a):
"""ghidra addr -> function name/start"""
i = bisect.bisect_right(funcs, a) - 1
st = funcs[i] if i >= 0 else a
return names.get(st, 'sub_%08x' % st)
def sym(frame):
m = re.match(r'Sword of the Stars\.exe\+0x([0-9a-f]+)\(0x([0-9a-f]+)\)', frame)
if m:
g = int(m.group(2), 16) - delta
return fname(g) + '(@%08x)' % g, 'exe'
m = re.match(r'([^!+]+)(!([^+]+))?\+0x[0-9a-f]+', frame)
if m:
return (m.group(1) + ('!' + m.group(3) if m.group(3) else '')), m.group(1)
return frame, '?'
cpu = [] # (t, {tid: ms})
samples = collections.defaultdict(list) # tid -> [(t, state, wr, frames)]
for l in lines:
if l.startswith('C '):
p = l.split(); t = int(p[1]); d = {}
for x in p[2:]:
tid, ms = x.split(':'); d[tid] = float(ms)
cpu.append((t, d))
elif l.startswith('S '):
p = l.split(' ', 4); t = int(p[1]); tid = p[2]; st = p[3][3:]
rest = p[4] if len(p) > 4 else ''
main, _, scan = rest.partition(' < |scan| ')
frames = main.split(' < ')
scanf = scan.split(' < ') if scan else []
samples[tid].append((t, st, frames, scanf))
def tclass(tid):
l = threads.get(tid, '')
if 'vulkan_lvp' in l: return 'lavapipe'
if 'd3d9.dll' in l: return 'dxvk'
if 'Sword' in l:
m = re.search(r'\(0x([0-9a-f]+)\)', l); g = int(m.group(1), 16) - delta
return {0x925794: 'MAIN', 0x8ef1d0: 'audio', 0x901f40: 'netwd', 0x735bb0: 'mesh'}.get(g, 'exe@%x' % g)
return 'other'
print('# exe base 0x%08x delta 0x%x' % (exe_base, delta))
print('# threads:')
for tid, l in threads.items():
print(' ', tid, tclass(tid), l.split('start=')[1][:60])
print('\n# CPU timeline (ms per ~500ms bucket): t MAIN lavapipe dxvk audio net mesh other')
tot = collections.Counter()
for t, d in cpu:
if t < t0 or t > t1: continue
c = collections.Counter()
for tid, ms in d.items(): c[tclass(tid)] += ms; tot[tclass(tid)] += ms
print(' %7d %6.0f %8.0f %6.0f %6.0f %5.0f %5.0f %6.0f' % (t, c['MAIN'], c['lavapipe'], c['dxvk'], c['audio'], c['netwd'], c['mesh'], c['other']))
print(' TOTAL ', dict(tot))
for tid, ss in samples.items():
ss = [s for s in ss if t0 <= s[0] <= t1]
if not ss: continue
print('\n# thread %s (%s): %d samples in window' % (tid, tclass(tid), len(ss)))
sh = collections.Counter(); top = collections.Counter(); inc = collections.Counter(); waiter = collections.Counter(); exetop = collections.Counter()
for t, st, frames, scanf in ss:
s, w = (st.split('/') + ['?'])[:2]
sh['%s/%s' % (STATE.get(int(s), s) if s.isdigit() else s, WR.get(int(w), w) if w.isdigit() else w)] += 1
syms = [sym(f) for f in frames]
top[syms[0][0]] += 1
seen = set()
for n, m in syms:
if n not in seen: seen.add(n); inc[n] += 1
# first exe frame (what game code is doing)
ex = next((n for n, m in syms if m == 'exe'), None)
if ex: exetop[ex] += 1
# if waiting in ntdll: who called the wait (first non-ntdll/kernelbase frame)
if syms[0][1] == 'ntdll.dll':
wf = next((n for n, m in syms if m not in ('ntdll.dll', 'KERNELBASE.dll', 'KERNEL32.DLL')), '?')
waiter[syms[0][0] + ' <- ' + wf + (' <- ' + ex if ex else '')] += 1
n = len(ss)
print(' state:', ', '.join('%s %.0f%%' % (k, 100.0 * v / n) for k, v in sh.most_common()))
print(' top-of-stack:')
for k, v in top.most_common(15): print(' %5.1f%% %s' % (100.0 * v / n, k))
print(' wait sites (syscall <- caller <- first exe frame):')
for k, v in waiter.most_common(12): print(' %5.1f%% %s' % (100.0 * v / n, k))
print(' first exe frame (innermost game function):')
for k, v in exetop.most_common(20): print(' %5.1f%% %s' % (100.0 * v / n, k))
print(' inclusive:')
for k, v in inc.most_common(45): print(' %5.1f%% %s' % (100.0 * v / n, k))

View file

@ -0,0 +1,94 @@
param([int]$ProcId = 0, [int]$DurationSec = 30, [int]$IntervalMs = 20, [string]$Out = 'C:\Users\re\samp.txt', [string]$StackThreads = 'exe')
# 32-bit stack sampler for a WoW64 process. Run under C:\Windows\SysWOW64\WindowsPowerShell\v1.0\powershell.exe
if ([IntPtr]::Size -ne 4) { throw 'run under 32-bit powershell' }
if ($ProcId -eq 0) { $ProcId = (Get-Process | Where-Object { $_.Name -like 'Sword*' } | Select-Object -First 1).Id }
$src = @'
using System; using System.Collections.Generic; using System.Runtime.InteropServices; using System.Text; using System.IO; using System.Diagnostics;
public class Samp {
[DllImport("kernel32")] static extern IntPtr OpenProcess(uint a, bool b, int pid);
[DllImport("kernel32")] static extern IntPtr OpenThread(uint a, bool b, uint tid);
[DllImport("kernel32")] static extern uint SuspendThread(IntPtr h);
[DllImport("kernel32")] static extern uint ResumeThread(IntPtr h);
[DllImport("kernel32")] static extern bool GetThreadContext(IntPtr h, IntPtr ctx);
[DllImport("kernel32")] static extern bool ReadProcessMemory(IntPtr h, IntPtr addr, byte[] buf, int n, out int read);
[DllImport("kernel32")] static extern bool GetThreadTimes(IntPtr h, out long c, out long e, out long k, out long u);
[DllImport("kernel32")] static extern bool CloseHandle(IntPtr h);
[DllImport("kernel32")] static extern IntPtr CreateToolhelp32Snapshot(uint f, uint pid);
[DllImport("kernel32")] static extern bool Thread32First(IntPtr s, ref THREADENTRY32 e);
[DllImport("kernel32")] static extern bool Thread32Next(IntPtr s, ref THREADENTRY32 e);
[DllImport("kernel32")] static extern int GetThreadPriority(IntPtr h);
[DllImport("psapi")] static extern bool EnumProcessModulesEx(IntPtr h, IntPtr[] mods, int cb, out int needed, uint flags);
[DllImport("psapi")] static extern bool GetModuleInformation(IntPtr h, IntPtr m, out MODULEINFO mi, int cb);
[DllImport("psapi", CharSet=CharSet.Unicode)] static extern int GetModuleFileNameExW(IntPtr h, IntPtr m, StringBuilder s, int n);
[DllImport("ntdll")] static extern int NtQueryInformationThread(IntPtr h, int cls, out IntPtr info, int len, out int ret);
[DllImport("ntdll")] static extern int NtQuerySystemInformation(int cls, IntPtr buf, int len, out int ret);
[StructLayout(LayoutKind.Sequential)] public struct THREADENTRY32 { public uint dwSize, cntUsage, th32ThreadID, th32OwnerProcessID; public int tpBasePri, tpDeltaPri; public uint dwFlags; }
[StructLayout(LayoutKind.Sequential)] public struct MODULEINFO { public IntPtr lpBaseOfDll; public uint SizeOfImage; public IntPtr EntryPoint; }
public class Mod { public uint Base, Size; public string Name; public List<KeyValuePair<uint,string>> Exp = new List<KeyValuePair<uint,string>>(); public uint CodeStart, CodeEnd; }
public class Thr { public uint Tid; public IntPtr H; public uint Start; public long Create; public long LastCpu; public double TotCpu; public bool Stack; public int Pri; }
IntPtr hp; List<Mod> mods = new List<Mod>(); List<Thr> thrs = new List<Thr>(); StreamWriter w; int pid;
byte[] tmp = new byte[8192];
uint RD(uint a){ int r; if(!ReadProcessMemory(hp,(IntPtr)a,tmp,4,out r)||r!=4) return 0xFFFFFFFF; return BitConverter.ToUInt32(tmp,0);}
bool ReadBuf(uint a, byte[] b, int n){ int r; return ReadProcessMemory(hp,(IntPtr)a,b,n,out r) && r==n; }
Mod FindMod(uint a){ foreach(var m in mods) if(a>=m.Base && a<m.Base+m.Size) return m; return null; }
public string Sym(uint a){ var m=FindMod(a); if(m==null) return "0x"+a.ToString("x8"); string best=null; uint boff=0; int lo=0,hi=m.Exp.Count-1; uint rva=a-m.Base;
// binary search last export <= rva
int idx=-1; while(lo<=hi){int mid=(lo+hi)/2; if(m.Exp[mid].Key<=rva){idx=mid;lo=mid+1;} else hi=mid-1;}
if(idx>=0 && rva-m.Exp[idx].Key < 0x10000){ best=m.Exp[idx].Value; boff=rva-m.Exp[idx].Key; return m.Name+"!"+best+"+0x"+boff.ToString("x"); }
return m.Name+"+0x"+rva.ToString("x")+"(0x"+a.ToString("x8")+")"; }
void LoadMods(){ IntPtr[] arr=new IntPtr[1024]; int need; EnumProcessModulesEx(hp,arr,arr.Length*4,out need,0x01);
int n=need/4; for(int i=0;i<n;i++){ MODULEINFO mi; GetModuleInformation(hp,arr[i],out mi,12); var sb=new StringBuilder(512); GetModuleFileNameExW(hp,arr[i],sb,512);
var m=new Mod(); m.Base=(uint)mi.lpBaseOfDll; m.Size=mi.SizeOfImage; m.Name=Path.GetFileName(sb.ToString()); mods.Add(m);
try{ uint pe=m.Base+RD(m.Base+0x3C); uint erva=RD(pe+0x78); uint esz=RD(pe+0x7C); m.CodeStart=m.Base+RD(pe+0x2C); m.CodeEnd=m.CodeStart+RD(pe+0x1C);
if(erva!=0 && erva!=0xFFFFFFFF){ uint ed=m.Base+erva; uint nf=RD(ed+0x14), nn=RD(ed+0x18), af=RD(ed+0x1C), an=RD(ed+0x20), ao=RD(ed+0x24);
if(nn<20000){ byte[] names=new byte[nn*4]; ReadBuf(m.Base+an,names,(int)nn*4); byte[] ords=new byte[nn*2]; ReadBuf(m.Base+ao,ords,(int)nn*2); byte[] funcs=new byte[nf*4]; ReadBuf(m.Base+af,funcs,(int)nf*4);
byte[] nb=new byte[96]; for(int k=0;k<nn;k++){ uint nrva=BitConverter.ToUInt32(names,k*4); ushort o=BitConverter.ToUInt16(ords,k*2); if(o>=nf) continue; uint frva=BitConverter.ToUInt32(funcs,o*4);
if(frva>=erva && frva<erva+esz) continue; int r; ReadProcessMemory(hp,(IntPtr)(m.Base+nrva),nb,96,out r); int z=Array.IndexOf(nb,(byte)0); if(z<0) z=96; m.Exp.Add(new KeyValuePair<uint,string>(frva,Encoding.ASCII.GetString(nb,0,z))); }
m.Exp.Sort((x,y)=>x.Key.CompareTo(y.Key)); } } } catch {} } }
void LoadThreads(string which){ IntPtr s=CreateToolhelp32Snapshot(4,0); var e=new THREADENTRY32(); e.dwSize=28; if(Thread32First(s,ref e)){ do{ if(e.th32OwnerProcessID==(uint)pid){ var t=new Thr(); t.Tid=e.th32ThreadID; t.H=OpenThread(0x1FFFFF,false,t.Tid); IntPtr sa; int ret; NtQueryInformationThread(t.H,9,out sa,4,out ret); t.Start=(uint)sa; long c,x,k,u; GetThreadTimes(t.H,out c,out x,out k,out u); t.Create=c; t.LastCpu=k+u; t.Pri=GetThreadPriority(t.H); thrs.Add(t);} e.dwSize=28; } while(Thread32Next(s,ref e)); } CloseHandle(s);
thrs.Sort((a,b)=>a.Create.CompareTo(b.Create)); var exe=mods[0];
foreach(var t in thrs){ bool inExe = t.Start>=exe.Base && t.Start<exe.Base+exe.Size; t.Stack = which=="all" || (which=="exe" && (inExe || t==thrs[0])) ; } }
// returns state, waitreason for tid via NtQuerySystemInformation(5)
IntPtr sysbuf=IntPtr.Zero; int sysbufLen=0;
Dictionary<uint,int[]> QueryStates(){ var d=new Dictionary<uint,int[]>(); int ret; if(sysbuf==IntPtr.Zero){ sysbufLen=1<<20; sysbuf=Marshal.AllocHGlobal(sysbufLen);} int st=NtQuerySystemInformation(5,sysbuf,sysbufLen,out ret); if(st!=0){ if(ret>sysbufLen){ Marshal.FreeHGlobal(sysbuf); sysbufLen=ret+65536; sysbuf=Marshal.AllocHGlobal(sysbufLen); st=NtQuerySystemInformation(5,sysbuf,sysbufLen,out ret);} if(st!=0) return d; }
int off=0; while(true){ int next=Marshal.ReadInt32(sysbuf,off); int nth=Marshal.ReadInt32(sysbuf,off+4); int p=Marshal.ReadInt32(sysbuf,off+68); if(p==pid){ for(int i=0;i<nth;i++){ int to=off+184+i*64; uint tid=(uint)Marshal.ReadInt32(sysbuf,to+36); d[tid]=new int[]{Marshal.ReadInt32(sysbuf,to+52),Marshal.ReadInt32(sysbuf,to+56),Marshal.ReadInt32(sysbuf,to+48),Marshal.ReadInt32(sysbuf,to+40)}; } break; } if(next==0) break; off+=next; } return d; }
bool IsCode(uint a){ var m=FindMod(a); return m!=null && a>=m.CodeStart && a<m.CodeEnd; }
bool LooksLikeRet(uint a){ byte[] b=new byte[8]; if(!ReadBuf(a-8,b,8)) return false; // call rel32 (E8), call [mem] FF15, call reg FFd0-d7, call [reg+disp8] FF5x, call [reg+disp32] FF9x
if(b[3]==0xE8) return true; if(b[2]==0xFF && (b[3]&0x38)==0x10) return true; if(b[6]==0xFF && (b[7]&0x38)==0x10) return true; if(b[5]==0xFF && (b[6]&0x38)==0x10) return true; if(b[1]==0xFF && (b[2]&0x38)==0x10) return true; if(b[4]==0xFF && (b[5]&0x38)==0x10) return true; return false; }
public void Run(int pid_, int durSec, int intervalMs, string outPath, string which){ pid=pid_; hp=OpenProcess(0x1FFFFF,false,pid); if(hp==IntPtr.Zero) throw new Exception("OpenProcess failed"); LoadMods(); LoadThreads(which);
w=new StreamWriter(outPath,false); w.WriteLine("# pid "+pid+" start "+DateTime.Now.ToString("HH:mm:ss.fff")+" interval "+intervalMs+"ms dur "+durSec+"s");
foreach(var m in mods) w.WriteLine("M 0x"+m.Base.ToString("x8")+" 0x"+m.Size.ToString("x")+" "+m.Name+" exports="+m.Exp.Count+" code=0x"+m.CodeStart.ToString("x8")+"-0x"+m.CodeEnd.ToString("x8"));
foreach(var t in thrs) w.WriteLine("T "+t.Tid+" start="+Sym(t.Start)+" pri="+t.Pri+" stack="+t.Stack+" cpu0="+(t.LastCpu/10000.0).ToString("F0")+"ms");
IntPtr ctx=Marshal.AllocHGlobal(2048); byte[] stk=new byte[4096]; var sw=Stopwatch.StartNew(); long nextCpu=0; int ns=0; var hist=new Dictionary<string,Dictionary<string,int>>(); var stateHist=new Dictionary<string,Dictionary<string,int>>();
var frameHist=new Dictionary<string,Dictionary<string,int>>();
while(sw.ElapsedMilliseconds < durSec*1000L){ long now=sw.ElapsedMilliseconds; var states=QueryStates();
foreach(var t in thrs){ if(!t.Stack) continue; int[] stt; states.TryGetValue(t.Tid,out stt); string sst = stt==null?"?":(stt[0]+"/"+stt[1]);
// suspend + context
if(SuspendThread(t.H)==0xFFFFFFFF){ continue; }
Marshal.WriteInt32(ctx,0,0x10007); bool ok=GetThreadContext(t.H,ctx); uint eip=0,ebp=0,esp=0; if(ok){ ebp=(uint)Marshal.ReadInt32(ctx,180); eip=(uint)Marshal.ReadInt32(ctx,184); esp=(uint)Marshal.ReadInt32(ctx,196); }
var frames=new List<string>(); var seen=new HashSet<uint>();
if(ok){ frames.Add(Sym(eip)); uint bp=ebp; for(int i=0;i<40 && bp!=0 && bp!=0xFFFFFFFF;i++){ uint ret=RD(bp+4); uint nb=RD(bp); if(ret==0||ret==0xFFFFFFFF) break; if(!IsCode(ret)) break; frames.Add(Sym(ret)); seen.Add(ret); if(nb<=bp) break; bp=nb; }
// stack scan fallback for frames the ebp chain misses
int got; if(ReadProcessMemory(hp,(IntPtr)esp,stk,4096,out got)){ var scan=new List<string>(); for(int i=0;i+4<=got;i+=4){ uint v=BitConverter.ToUInt32(stk,i); if(IsCode(v) && !seen.Contains(v) && LooksLikeRet(v)){ scan.Add(Sym(v)); if(scan.Count>=24) break; } } if(scan.Count>0) frames.Add("|scan| "+string.Join(" < ",scan.ToArray())); } }
ResumeThread(t.H);
string key=t.Tid.ToString(); if(!hist.ContainsKey(key)){hist[key]=new Dictionary<string,int>(); stateHist[key]=new Dictionary<string,int>(); frameHist[key]=new Dictionary<string,int>();}
string top= frames.Count>0?frames[0]:"?"; string topFn = top.Contains("+0x")? top.Substring(0,top.LastIndexOf("+0x")):top; if(!hist[key].ContainsKey(topFn)) hist[key][topFn]=0; hist[key][topFn]++;
if(!stateHist[key].ContainsKey(sst)) stateHist[key][sst]=0; stateHist[key][sst]++;
var fseen=new HashSet<string>(); foreach(var f in frames){ if(f.StartsWith("|scan|")) continue; string fn=f.Contains("+0x")? f.Substring(0,f.LastIndexOf("+0x")):f; if(fseen.Add(fn)){ if(!frameHist[key].ContainsKey(fn)) frameHist[key][fn]=0; frameHist[key][fn]++; } }
w.WriteLine("S "+now+" "+t.Tid+" st="+sst+" "+string.Join(" < ",frames.ToArray()));
}
if(now>=nextCpu){ nextCpu=now+500; var sb=new StringBuilder(); sb.Append("C "+now); foreach(var t in thrs){ long c,x,k,u; if(!GetThreadTimes(t.H,out c,out x,out k,out u)) continue; long cpu=k+u; double d=(cpu-t.LastCpu)/10000.0; t.LastCpu=cpu; t.TotCpu+=d; if(d>0.5){ sb.Append(" "+t.Tid+":"+d.ToString("F0")); } } w.WriteLine(sb.ToString()); }
ns++; int sleep=(int)(intervalMs-(sw.ElapsedMilliseconds-now)); if(sleep>0) System.Threading.Thread.Sleep(sleep); }
w.WriteLine("# end "+DateTime.Now.ToString("HH:mm:ss.fff")+" samples="+ns);
w.WriteLine("## per-thread total CPU ms over run");
foreach(var t in thrs) if(t.TotCpu>1) w.WriteLine("CPU "+t.Tid+" "+t.TotCpu.ToString("F0")+" start="+Sym(t.Start)+" pri="+t.Pri);
foreach(var kv in hist){ w.WriteLine("## thread "+kv.Key+" state hist"); var sl=new List<KeyValuePair<string,int>>(stateHist[kv.Key]); sl.Sort((a,b)=>b.Value.CompareTo(a.Value)); foreach(var s in sl) w.WriteLine(" "+s.Value+" "+s.Key);
w.WriteLine("## thread "+kv.Key+" top-of-stack hist"); var l=new List<KeyValuePair<string,int>>(kv.Value); l.Sort((a,b)=>b.Value.CompareTo(a.Value)); int c2=0; foreach(var s in l){ w.WriteLine(" "+s.Value+" "+s.Key); if(++c2>=40) break; }
w.WriteLine("## thread "+kv.Key+" inclusive frame hist"); var l2=new List<KeyValuePair<string,int>>(frameHist[kv.Key]); l2.Sort((a,b)=>b.Value.CompareTo(a.Value)); c2=0; foreach(var s in l2){ w.WriteLine(" "+s.Value+" "+s.Key); if(++c2>=60) break; } }
w.Close(); }
}
'@
Add-Type -TypeDefinition $src
$s = New-Object Samp
$s.Run($ProcId, $DurationSec, $IntervalMs, $Out, $StackThreads)
"done -> $Out"

View file

@ -0,0 +1,11 @@
#!/bin/bash
# on spicy: burst screendumps of VM140 to /tmp/shots/<tag>/ at ~1 Hz for N seconds
TAG=$1; N=${2:-90}
D=/tmp/shots/$TAG; mkdir -p $D; rm -f $D/*
END=$(( $(date +%s) + N )); i=0
while [ $(date +%s) -lt $END ]; do
i=$((i+1)); f=$(printf %03d $i)
echo "screendump $D/$f.ppm" | qm monitor 140 >/dev/null 2>&1
echo "$f $(date +%s.%N)" >> $D/times.txt
sleep 0.6
done

View file

@ -0,0 +1,11 @@
$src = @'
using System; using System.Runtime.InteropServices;
public class TR { [DllImport("ntdll.dll")] public static extern int NtQueryTimerResolution(out uint min, out uint max, out uint cur); }
'@
Add-Type -TypeDefinition $src
[uint32]$a=0; [uint32]$b=0; [uint32]$c=0
[TR]::NtQueryTimerResolution([ref]$a,[ref]$b,[ref]$c) | Out-Null
"timer resolution (100ns units): min(coarsest)=$a max(finest)=$b current=$c => current = $($c/10000.0) ms"
powercfg /energy /duration 5 /output C:\Users\re\energy.html | Out-Null
$h = Get-Content C:\Users\re\energy.html -Raw
if ($h -match '(?s)Platform Timer Resolution.{0,3000}') { ($Matches[0] -replace '<[^>]+>',' ' -replace '\s+',' ').Substring(0, [Math]::Min(1500, ($Matches[0] -replace '<[^>]+>',' ' -replace '\s+',' ').Length)) }

View file

@ -0,0 +1,24 @@
#!/bin/bash
# End Turn repeatedly; stop when the strategy-screen chrome disappears (encounter query / dialog)
S=/tmp/claude-1000/-home-alex/ec8e34f8-af37-4ef2-a309-ed6a15293097/scratchpad
N=${1:-25}
mkdir -p $S/turns
printf 'fg\nclick 100 714\nsleep 500\n' > $S/turns/cmd.txt
convert $S/move6.png -crop 260x40+5+105 $S/turns/base_hdr.png # Research/Design/Build buttons
for i in $(seq 1 $N); do
scp -q $S/turns/cmd.txt re@192.168.10.139:C:/SOTS/ui/cmd.txt
ssh -o BatchMode=yes re@192.168.10.139 'schtasks /Run /TN SOTSUI | Out-Null'
sleep 16
# wait until stable: two screenshots identical-ish
for k in 1 2 3 4 5 6; do
ssh spicy 'echo "screendump /tmp/t.ppm" | qm monitor 140' >/dev/null; scp -q spicy:/tmp/t.ppm $S/turns/t.ppm
convert $S/turns/t.ppm $S/turns/turn_$i.png
convert $S/turns/turn_$i.png -crop 260x40+5+105 $S/turns/hdr.png
d=$(compare -metric RMSE $S/turns/base_hdr.png $S/turns/hdr.png null: 2>&1 | sed 's/.*(\(.*\))/\1/')
echo "$(date +%H:%M:%S) turn-iter $i probe $k rmse=$d" >> $S/turns/log.txt
if awk -v d="$d" 'BEGIN{exit !(d<0.08)}'; then break; fi
sleep 5
done
if ! awk -v d="$d" 'BEGIN{exit !(d<0.08)}'; then echo "$(date +%H:%M:%S) SCREEN CHANGED at iter $i rmse=$d" >> $S/turns/log.txt; exit 0; fi
done
echo "$(date +%H:%M:%S) loop done" >> $S/turns/log.txt

View file

@ -0,0 +1,25 @@
import re,bisect,collections,sys
exec(open('parse.py').read().split("cpu = []")[0]) # reuse loaders/sym
lines=open(sys.argv[1],encoding='utf-8',errors='replace').read().splitlines()
# per-sample: t, is main thread in a turn-processing / server / client-update frame?
marks=collections.OrderedDict()
for l in lines:
if not l.startswith('S '): continue
p=l.split(' ',4); t=int(p[1]); tid=p[2]
if tid!='3568': continue
frames=p[4].split(' < |scan| ')[0].split(' < ') if len(p)>4 else []
names_=[sym(f)[0] for f in frames]
tags=set()
for n in names_:
if 'ProcessTurn' in n or 'StrategyServer' in n or 'StrategyNetworkServer' in n or 'RunAI' in n or 'GenerateTurnEvents' in n or 'DispatchTurnResults' in n: tags.add('SERVER')
if 'StrategyClient' in n and 'Update' not in n: tags.add('CLIENT')
if 'BackgroundWorker' in n or 'BuildBlobMesh' in n: tags.add('MESH')
if 'NtWaitForAlertByThreadId' in n: tags.add('dxvkwait')
if 'ZwDelayExecution' in n: tags.add('sleep')
marks[t]=(tags, names_[0] if names_ else '?', next((n for n in names_ if '(@' in n), '?'))
# print timeline of non-render samples
prev=None
for t,(tags,top,ex) in marks.items():
key=(tuple(sorted(tags)))
if 'SERVER' in tags or 'CLIENT' in tags or 'MESH' in tags:
print(t, sorted(tags), top, ex)

6
verify/harness/profiling/ui.sh Executable file
View file

@ -0,0 +1,6 @@
#!/bin/bash
# usage: ui.sh <cmdfile> <wait-seconds> [shotname]
S=/tmp/claude-1000/-home-alex/ec8e34f8-af37-4ef2-a309-ed6a15293097/scratchpad
scp -q "$1" re@192.168.10.139:C:/SOTS/ui/cmd.txt && ssh -o BatchMode=yes re@192.168.10.139 'schtasks /Run /TN SOTSUI | Out-Null'
sleep "$2"
ssh spicy 'echo "screendump /tmp/x.ppm" | qm monitor 140' >/dev/null && scp -q spicy:/tmp/x.ppm $S/ && convert $S/x.ppm $S/${3:-x}.png