sots-re/findings/subsystems/battle-load-profile.md

138 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`.