battle-load: profile filed, bug parked; sampler + resume recipe kept
This commit is contained in:
parent
426df0232a
commit
f644b83581
21 changed files with 74500 additions and 7 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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]])
|
||||
|
|
|
|||
138
findings/subsystems/battle-load-profile.md
Normal file
138
findings/subsystems/battle-load-profile.md
Normal 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`.
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
41465
verify/design-rules/stock_designs.json
Normal file
41465
verify/design-rules/stock_designs.json
Normal file
File diff suppressed because it is too large
Load diff
132
verify/design-rules/stock_designs.py
Normal file
132
verify/design-rules/stock_designs.py
Normal 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:])
|
||||
397
verify/design-rules/test_design_rules.py
Normal file
397
verify/design-rules/test_design_rules.py
Normal 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()
|
||||
23
verify/harness/profiling/battle-run.sh
Executable file
23
verify/harness/profiling/battle-run.sh
Executable 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)"
|
||||
38
verify/harness/profiling/funcs.py
Normal file
38
verify/harness/profiling/funcs.py
Normal 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)))
|
||||
31850
verify/harness/profiling/funcs.txt
Normal file
31850
verify/harness/profiling/funcs.txt
Normal file
File diff suppressed because it is too large
Load diff
26
verify/harness/profiling/iat.py
Normal file
26
verify/harness/profiling/iat.py
Normal 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
|
||||
19
verify/harness/profiling/inspect.ps1
Normal file
19
verify/harness/profiling/inspect.ps1
Normal 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
|
||||
115
verify/harness/profiling/names.txt
Normal file
115
verify/harness/profiling/names.txt
Normal 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
|
||||
103
verify/harness/profiling/parse.py
Normal file
103
verify/harness/profiling/parse.py
Normal 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))
|
||||
94
verify/harness/profiling/sampler.ps1
Normal file
94
verify/harness/profiling/sampler.ps1
Normal 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"
|
||||
11
verify/harness/profiling/shots.sh
Normal file
11
verify/harness/profiling/shots.sh
Normal 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
|
||||
11
verify/harness/profiling/timerres.ps1
Normal file
11
verify/harness/profiling/timerres.ps1
Normal 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)) }
|
||||
24
verify/harness/profiling/turnloop.sh
Executable file
24
verify/harness/profiling/turnloop.sh
Executable 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
|
||||
25
verify/harness/profiling/turnwin.py
Normal file
25
verify/harness/profiling/turnwin.py
Normal 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
6
verify/harness/profiling/ui.sh
Executable 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
|
||||
Loading…
Add table
Reference in a new issue