Compare commits
No commits in common. "9075f60f7badf98070d35e33ef00ba57f239d8f3" and "d6469bd4c5d61ade45bf024c7a014145d67324da" have entirely different histories.
9075f60f7b
...
d6469bd4c5
18 changed files with 192 additions and 1719 deletions
File diff suppressed because one or more lines are too long
|
|
@ -1,320 +0,0 @@
|
||||||
# What writes `SvSctOb` during a turn — the script-object event bus
|
|
||||||
|
|
||||||
Lane SV, 2026-09-08. Program `sots` / "Sword of the Stars.exe", ImageBase 0x00400000, all addresses VAs.
|
|
||||||
Host + static only; VM140 was held by lane W3 and the game was never run.
|
|
||||||
|
|
||||||
Companion to `findings/objects/svsctob-variants.md` (lane W), which recovered **what the type is**.
|
|
||||||
This one answers **what moves it**, and corrects one entry in `findings/control-flow/combat-done-tail.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. The answer in one paragraph
|
|
||||||
|
|
||||||
`StrategyServer+0x1b4` holds the root script object. Nothing in the turn calls a method on a child
|
|
||||||
script object directly. Everything goes through an **event bus**: a driver notifies the root with an
|
|
||||||
integer event id, the root fans that same delivery out to **every** child, and each delivery is two
|
|
||||||
steps — a **generic** handler that receives the id, and **one event-specific virtual slot** that does
|
|
||||||
not. The `evt -> slot` map is a **33-entry dword jump table at 0x007a6480**, so "which class reacts to
|
|
||||||
which event" is exhaustive and recovered, not inferred from what the saves happen to show. A turn
|
|
||||||
sends six deliveries from five functions, and the eight diverging leaves of `/Sim/SvSctOb` are written
|
|
||||||
by exactly three handlers across two of them.
|
|
||||||
|
|
||||||
## 1. The dispatcher, read as instructions
|
|
||||||
|
|
||||||
`SVScriptObject_DispatchEvent` **0x007a60d0**, `__thiscall (this, int evt, void* arg)`, `ret 8`:
|
|
||||||
|
|
||||||
```
|
|
||||||
007a60fd mov eax,[esi] ; esi = this
|
|
||||||
007a6102 mov edx,[eax+0x10]
|
|
||||||
007a6105 push edi ; arg
|
|
||||||
007a6106 push ebx ; evt
|
|
||||||
007a6107 call edx ; this->vft[0x10](evt, arg) -- the GENERIC handler
|
|
||||||
007a610c cmp ebx,0x20
|
|
||||||
007a610f ja 0x7a6469 ; -> done
|
|
||||||
007a6115 jmp dword ptr [ebx*4 + 0x7a6480]
|
|
||||||
```
|
|
||||||
|
|
||||||
Each arm loads a different slot and pushes a different argument shape out of `arg`. The full map,
|
|
||||||
read out of the table and confirmed against the slot each arm loads:
|
|
||||||
|
|
||||||
| evt | slot | evt | slot | evt | slot |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| 0x00 | +0x14 | 0x0b | +0x40 | 0x16 | +0x70 |
|
|
||||||
| 0x01 | +0x18 | 0x0c | +0x44 | 0x17 | +0x74 |
|
|
||||||
| 0x02 | +0x1c | 0x0d | +0x48 | 0x18 | **+0x68** |
|
|
||||||
| 0x03 | +0x20 | 0x0e | +0x4c | 0x19 | +0x7c |
|
|
||||||
| 0x04 | +0x24 | 0x0f | +0x50 | 0x1a | +0x80 |
|
|
||||||
| 0x05 | +0x28 | 0x10 | +0x54 | 0x1b | +0x84 |
|
|
||||||
| 0x06 | +0x2c | 0x11 | +0x58 | 0x1c | **+0x78** |
|
|
||||||
| 0x07 | +0x30 | 0x12 | +0x5c | 0x1d | +0x88 |
|
|
||||||
| 0x08 | +0x34 | 0x13 | +0x60 | 0x1e | +0x8c |
|
|
||||||
| 0x09 | +0x38 | 0x14 | +0x64 | 0x1f | +0x90 |
|
|
||||||
| 0x0a | +0x3c | 0x15 | **+0x6c** | 0x20 | +0x94 |
|
|
||||||
|
|
||||||
Five ids are **not** in slot order (0x15, 0x16, 0x17, 0x18, 0x1c), which is exactly the kind of thing
|
|
||||||
a reader who assumed `slot = 0x14 + 4*evt` would get wrong on the two ids the tail actually sends.
|
|
||||||
|
|
||||||
The root's own handler, `SVSOSots_HandleEvent` **0x005a7e40** (vftable 0x00A063C4 slot +0x10, and the
|
|
||||||
only slot `Game::SVSOSots` overrides at all):
|
|
||||||
|
|
||||||
```
|
|
||||||
evt == 3 || evt == 0x1b -> call 0x005a7d70 (lane W's new-game seeder)
|
|
||||||
evt == 0x1a -> call 0x005a37e0
|
|
||||||
always: for (i = 0; i < (this->+0x20 - this->+0x1c)/4; ++i)
|
|
||||||
SVScriptObject_DispatchEvent(this->children[i], evt, arg)
|
|
||||||
```
|
|
||||||
|
|
||||||
The loop re-reads both bounds each iteration, so a child may resize the child vector under it — the
|
|
||||||
same shape as tail phase 6.
|
|
||||||
|
|
||||||
**This is what proves the hand-written pairs in the turn drivers are event deliveries.** Lane K read
|
|
||||||
tail phase 8 as `if (S->+0x1b4) { script->vft[0x10](8,0); script->vft[0x34](); }` and phase 20 as the
|
|
||||||
same shape with `0x14`/`+0x64` and `0x15`/`+0x6c`. Those are precisely rows 8, 0x14 and 0x15 of the
|
|
||||||
table above: the drivers open-code the two-step on the **root**, and the root's generic handler then
|
|
||||||
does the full two-step per **child**.
|
|
||||||
|
|
||||||
## 2. Where a turn delivers, and what it sends
|
|
||||||
|
|
||||||
Every site in the image that reads `StrategyServer+0x1b4` and dispatches, with the id it pushes. Found
|
|
||||||
by scanning `.text` at instruction boundaries for `mov r32,[r32+0x1b4]` and filtering to the
|
|
||||||
StrategyServer range, then reading the id off the `push` before the indirect call.
|
|
||||||
|
|
||||||
| function | VA of the site | evt | in a turn? |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `BeginProcessTurn` 0x007d98e0 | 0x007d9ab8 | **0x13** | **yes — the first delivery of the turn** |
|
|
||||||
| `StrategyServer::ProcessTurn` 0x007dc6c0 | 0x007dcb7a | 6 | yes |
|
|
||||||
| `StrategyServer::ProcessTurn` | 0x007dcb9f | 0x1c | yes |
|
|
||||||
| `StrategyServer::MoveFleet` 0x007d9ee0 | 0x007d9faa | 0xe | per move |
|
|
||||||
| `ApplyEncounterResult` 0x007d8920 | 0x007d8e5a | 7 | per encounter |
|
|
||||||
| `OnAllCombatDone_Tail` 0x007d92a0 | 0x007d96bf | 8 | yes (phase 8) |
|
|
||||||
| `OnAllCombatDone_Tail` | 0x007d9752 | 0x14 | yes (phase 20) |
|
|
||||||
| `OnAllCombatDone_Tail` | 0x007d9772 | 0x15 | yes (phase 20) |
|
|
||||||
| `OnAllCombatDone_Tail` | **0x007d9820** | **0x1c** | **yes — see §2.1** |
|
|
||||||
| `BuildTurnEvents` 0x007db780 | 0x007db81f / 0x007dbd9d | 0x1a / 0x1b | after the tail |
|
|
||||||
| `SynchronizePlayer`, `LoadGame`, `ResumePlaying`, `Write`, `Read` and eight others | — | 1..5, 0xd, 0x17, 0x18 | not a turn |
|
|
||||||
|
|
||||||
### 2.1 Correction to `combat-done-tail.md`
|
|
||||||
|
|
||||||
Lane K's phase map lists **three** script-hook sites in the tail (phases 8 and 20) and its tier-4 note
|
|
||||||
attributes event **0x1c** to `ProcessTurn`. There is a **fourth** site in the tail, at **0x007d9820**,
|
|
||||||
immediately after the maintenance/research recompute and the call at 0x007d981b, and it sends **0x1c**
|
|
||||||
as well:
|
|
||||||
|
|
||||||
```
|
|
||||||
007d9820 mov esi,[ebx+0x1b4]
|
|
||||||
007d9829 test esi,esi
|
|
||||||
007d982b je 0x7d9843
|
|
||||||
007d982d mov eax,[esi]
|
|
||||||
007d982f mov edx,[eax+0x10]
|
|
||||||
007d9832 push 0x0
|
|
||||||
007d9834 push 0x1c
|
|
||||||
007d9838 call edx
|
|
||||||
007d983a mov eax,[esi]
|
|
||||||
007d983c mov edx,[eax+0x78] ; +0x78 is evt 0x1c's slot -- consistent with the table
|
|
||||||
```
|
|
||||||
|
|
||||||
So event 0x1c is sent **twice** in a turn, once from each driver. On our corpus only
|
|
||||||
`Game::SVSOVonNeumann` overrides that slot (0x00527fd0), and it moves nothing that diverges.
|
|
||||||
|
|
||||||
## 3. Which classes react, by event
|
|
||||||
|
|
||||||
Over the twelve classes our saves carry, comparing each vtable slot against the modal value across all
|
|
||||||
30 `SVScriptObject` subclasses (the base default). Only the four events a turn's *tail* and *begin*
|
|
||||||
send are shown; the shared no-op is 0x0080c5a0.
|
|
||||||
|
|
||||||
| class | evt 0x13 (begin) | evt 8 | evt 0x14 | evt 0x15 | evt 0x1c |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| VonNeumann (1) | 0x00521de0 | 0x00522100 | — | — | 0x00527fd0 |
|
|
||||||
| Swarm (3) | 0x00504c90 | — | — | — | — |
|
|
||||||
| Derelict (4) | — | — | — | — | — |
|
|
||||||
| Monitor (5) | — | — | — | — | — |
|
|
||||||
| **SlaversRefuel (9)** | — | — | **generic 0x0051a800** | — | — |
|
|
||||||
| **SwarmQueen (10)** | **0x00529930** | — | **0x005275d0** | — | — |
|
|
||||||
| CrowRuins (17) | — | — | — | — | — |
|
|
||||||
| **Refugees (20)** | **0x00511260** | 0x00511310 | — | — | — |
|
|
||||||
| Traps | 0x0051a0e0 | 0x0051a0e0 | 0x0051a020 | — | — |
|
|
||||||
| CrowDefenders | 0x004f8d60 | 0x0052b2a0 | 0x005138a0 | — | — |
|
|
||||||
| IndependentSystems | 0x00750c40 | 0x0075cb20 | — | — | — |
|
|
||||||
| GrandMenaceTrigger | 0x0050dc80 | — | — | — | — |
|
|
||||||
| SVSOSots (root) | — | — | — | — | — |
|
|
||||||
|
|
||||||
**Event 0x15 is overridden by nobody.** The tail's second phase-20 pair is dead in every class our
|
|
||||||
saves hold — read off the vtables, not inferred from the bytes.
|
|
||||||
|
|
||||||
`SVSOSlaversRefuel` is the one class that reacts through the **generic** handler and overrides no
|
|
||||||
event-specific slot at all: 0x0051a800 is `if (evt == 0x14) call 0x00515820`, seven instructions.
|
|
||||||
|
|
||||||
## 4. The three writers behind the eight diverging leaves
|
|
||||||
|
|
||||||
`EncObj[3]` is EncID 9, `EncObj[5]` is EncID 10, `EncObj[6]` is EncID 20.
|
|
||||||
|
|
||||||
### 4.1 `CDiff` — `SVSOSlaversRefuel_UpdateDifficultyTier` 0x00515820, evt 0x14, tail phase 20
|
|
||||||
|
|
||||||
Builds a **3x3-dword table on the stack** and scans it against `GetGame()->+0xc`:
|
|
||||||
|
|
||||||
| threshold | payload |
|
|
||||||
|---|---|
|
|
||||||
| 1 | (1, 1) |
|
|
||||||
| 50 | (2, 3) |
|
|
||||||
| 100 | (2, 5) |
|
|
||||||
|
|
||||||
```
|
|
||||||
00515895 lea ecx,[ebp-0x34]
|
|
||||||
00515898 cmp [ecx],edx ; edx = frame
|
|
||||||
0051589a jg 0x5158c2 ; found
|
|
||||||
0051589c add eax,ebx ; ++i
|
|
||||||
0051589e add ecx,0xc ; next record
|
|
||||||
005158a1 cmp eax,0x3
|
|
||||||
005158a4 jl 0x515898
|
|
||||||
...fall through to the epilogue: NO STORE
|
|
||||||
005158c2 test eax,eax
|
|
||||||
005158c4 jle 0x5158a6 ; index 0: NO STORE
|
|
||||||
005158c6 dec eax
|
|
||||||
005158cc cmp [edi+0x38],eax
|
|
||||||
005158cf je 0x5158a6 ; unchanged: NO STORE
|
|
||||||
005158d1 mov [edi+0x38],eax ; CDiff = index - 1
|
|
||||||
```
|
|
||||||
|
|
||||||
Three consequences, none of them visible in the data:
|
|
||||||
|
|
||||||
* frame ≤ 0 → no write; frame 1..49 → tier 0; frame 50..99 → tier 1;
|
|
||||||
* **frame ≥ 100 → the scan runs off the end and there is no write at all**, so the tier can never
|
|
||||||
reach 2 through this path. That reads as an off-by-one in the original; it is recorded as what the
|
|
||||||
code does.
|
|
||||||
* Only on a change does the function continue into the per-system pass at 0x005158d4.
|
|
||||||
|
|
||||||
`CDiff` is `SVSOSlaversRefuel+0x38`, confirmed against `SVSOSlaversRefuel::Write` 0x004fdf80
|
|
||||||
(`lea eax,[esi+0x38]; push "CDiff"; call WriteInt`).
|
|
||||||
|
|
||||||
### 4.2 `ini` / `dids` — `SVSORefugees_OnTurnBegin` 0x00511260, evt 0x13, `BeginProcessTurn`
|
|
||||||
|
|
||||||
```
|
|
||||||
if (!this->ini(+0x14)) {
|
|
||||||
this->ini = 1; // the SECOND instruction of the block
|
|
||||||
obj = <lookup "Mission" / "_Refugee_Trader" in the data files>;
|
|
||||||
if (obj) this->dids(+0x18).push_back(obj->handle(+0xa0)->id(+4));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Offsets confirmed against `SVSORefugees::Write` 0x00509640: `didc` counts the vector at
|
|
||||||
`+0x18..+0x1c`, `ini` is the bool at `+0x14`.
|
|
||||||
|
|
||||||
The store to the latch is **unconditional on the lookup's result**, which matters for anyone
|
|
||||||
modelling this without the data files: the latch is not conditional on the design.
|
|
||||||
|
|
||||||
`Game::SVSORefugees` also overrides evt 8 (0x00511310), and that handler **drains** an object vector
|
|
||||||
at `+0x28..+0x2c` rather than appending to `dids`. Reading the two the other way round is the obvious
|
|
||||||
trap here and would put the writer in the tail instead of at turn begin.
|
|
||||||
|
|
||||||
### 4.3 `Hives` — `SVSOSwarmQueen_RegisterHives` 0x00527630 + `_TickHives` 0x00527770, evt 0x13
|
|
||||||
|
|
||||||
The constructor 0x0051ae20 settles who the queen works for:
|
|
||||||
|
|
||||||
```
|
|
||||||
0051ae30 mov [eax],0x9f49e4 ; vftable
|
|
||||||
0051ae36 mov [eax+0x4],0x3 ; the SCENARIO TAG it selects systems by -- the SWARM's
|
|
||||||
0051ae3d mov [eax+0x8],0xa ; its own EncID, 10
|
|
||||||
```
|
|
||||||
|
|
||||||
Registration walks `GetGame()->+0x44..+0x48` (`Systems`), matches `sys->+0x184` against the scenario
|
|
||||||
tag, skips systems a hive already references, and appends a `HiveInfo`:
|
|
||||||
|
|
||||||
```
|
|
||||||
005276e1 mov ecx,ds:0xae0204
|
|
||||||
005276e7 mov edx,ds:0xae0208
|
|
||||||
005276ed mov eax,[eax+0xc] ; frame
|
|
||||||
005276f0 mov edi,[ecx] ; LO
|
|
||||||
005276f2 mov esi,[edx] ; HI
|
|
||||||
005276f4 add edi,eax ; frame + LO
|
|
||||||
005276f6 add esi,eax
|
|
||||||
005276f8 call GetGame
|
|
||||||
005276fd mov eax,[eax+0x16c] ; the strategic RNG
|
|
||||||
0052770c call 0x4271c0 ; NextIntInclusive(HI - LO) <-- ONE DRAW PER NEW HIVE
|
|
||||||
00527717 add eax,edi ; nextQ = frame + LO + draw
|
|
||||||
0052771f call 0x523080 ; push_back
|
|
||||||
```
|
|
||||||
|
|
||||||
`HiveInfo` layout, from its `Write` 0x004fe730 (vftable 0x009f1a68): `+0x4` HiveID (handle),
|
|
||||||
`+0x8` NextQ (int), `+0xc` QueenID (handle) — **the wire order is not the member order**.
|
|
||||||
|
|
||||||
`sys->+0x184` is the save's **`EggScio`**, and the data says so as loudly as the code does. In
|
|
||||||
`turn1-state.sav`, the only systems with `EggScio == 3` are 336 and 400 — exactly the two the swarm
|
|
||||||
has infested (`SVSOSwarm.infest` sysid 336, 400) and exactly the two that get hives; `EggScio == 4`
|
|
||||||
are 448 and 480, the two systems `SVSODerelict`'s `NAsg` names; `EggScio == 5` is 64, the Monitor's
|
|
||||||
one system. Every other system is -1.
|
|
||||||
|
|
||||||
**`++NextQ` is the whole explanation of a number that looked impossible.** `NextQ` reads 31/29 after
|
|
||||||
turn 1 and 32/30 after turn 2, and re-rolling cannot produce a +1 on two hives at once. The tick:
|
|
||||||
|
|
||||||
```
|
|
||||||
0052785a inc DWORD PTR [esi+0x8] ; every gate-failure path lands here
|
|
||||||
```
|
|
||||||
|
|
||||||
Per hive with `QueenID == 0`, if any spawn gate fails the target turn **slips forward by one**; only
|
|
||||||
if the gates open and `NextQ <= frame` does a queen spawn. So the date walks away from the hive until
|
|
||||||
the gates open. The gates read config pointers at 0x00ae0210, 0x00ae0228, 0x00ae0220.
|
|
||||||
|
|
||||||
## 5. What could not be closed, and precisely why
|
|
||||||
|
|
||||||
| leaf | blocked on |
|
|
||||||
|---|---|
|
|
||||||
| `EncObj[6]/didc`, `.../did` | **design instantiation**, not a workload. The id appended is `1712`, and the same turn also allocates ship `1728` and fleet `1744` (`NMnx` 106 -> 109) — the refugee-trader convoy, three consecutive handles. A standalone that does not allocate handles cannot produce it, and no different save would help. |
|
|
||||||
| `EncObj[5]/Hives/.[1]/NextQ`, `.[2]/NextQ` (on the creation turn) | **one MT draw and two data-file constants**. `frame + LO + NextIntInclusive(HI - LO)`, `LO` and `HI` behind pointers at 0x00ae0204/0x00ae0208 that no `.text` or `.data` reference initialises in a form this lane could follow. Fitting `LO` and `HI` from a single two-hive observation would have been fitting, not derivation (r1 - r2 = 2 is one constraint on two unknowns), so it was not done. |
|
|
||||||
|
|
||||||
The `NextQ` **slip** is not blocked and is exact: on `turn2 -> turn3`, where the hives already exist,
|
|
||||||
the modelled rule reproduces both target turns (31 -> 32, 29 -> 30) with no draw and no fitting.
|
|
||||||
|
|
||||||
**An RNG claim this lane did not measure.** Lane Z measured a strategic turn at 18–22 generator words,
|
|
||||||
*all* inside `ProcessTurn`, with the residual outside the two turn drivers **exactly zero** — on turns
|
|
||||||
6 and 64, where the swarm hives already existed. Hive creation draws **inside `BeginProcessTurn`**,
|
|
||||||
which is outside both drivers and before either of them. So on a turn that creates hives the residual
|
|
||||||
should be **at least two words**, and lane Z's zero is a statement about the turns it measured. This
|
|
||||||
is cheap to falsify: hook the turn-begin delivery on a save whose swarm hives do not yet exist.
|
|
||||||
|
|
||||||
## 6. What no save exercises (rule 6)
|
|
||||||
|
|
||||||
* **evt 0x15** — no class in any save we hold overrides it.
|
|
||||||
* **The queen spawn arm.** No hive in the corpus has a queen, so only the slip arm has ever run.
|
|
||||||
Workload: a swarm game run past the spawn gates.
|
|
||||||
* **`CDiff` tiers 1 and 2.** Tier 1 needs frame ≥ 50; the Zuul saves reach turn 23. Tier 2 is
|
|
||||||
unreachable at any frame, which is a property of the code and not of the corpus.
|
|
||||||
* **The per-system pass after a `CDiff` change** (0x005158d4 onward). It runs on the reference pair,
|
|
||||||
and it writes nothing this object serialises (`NAsg`, `NTD`, `NAD` are unchanged across both pairs).
|
|
||||||
Whether it writes anything **elsewhere** is a labelled hypothesis; a regression outside `SvSctOb`
|
|
||||||
would falsify it, and the measured run has none.
|
|
||||||
* **The alliance and two-empire-contact events.** The corpus has neither, so the deliveries that
|
|
||||||
carry them (`SynchronizePlayer` and the 1..5 family) are unreachable from any save we hold.
|
|
||||||
|
|
||||||
## 7. Engine
|
|
||||||
|
|
||||||
`sots-engine` `wip/svsctob`: `src/game/sim/scriptobjects.{h,cpp}` (the rules, pure), `src/app/
|
|
||||||
script_phase.{h,cpp}` (the bridge), a new host phase **H03 ScriptHookTurnBegin** run right after the
|
|
||||||
frame counter, and tail phase **T20** implemented. Prediction and falsification in
|
|
||||||
`sots-engine/docs/SV-script-objects.md`, committed before the build.
|
|
||||||
|
|
||||||
Measured on CT111, closed and regressed stated separately:
|
|
||||||
|
|
||||||
| configuration | pair | result | closed | regressed |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| default | turn1 -> turn2 | 209 -> **126** (was 128) | 83 | 0 |
|
|
||||||
| default | turn2 -> turn3 | 108 -> **67** (was 69) | 41 | 0 |
|
|
||||||
| `--commit-blocked=H03` | turn1 -> turn2 | 209 -> **124** | 87 | **2** |
|
|
||||||
| `--commit-blocked=H03` | turn2 -> turn3 | 108 -> **67** | 41 | 0 |
|
|
||||||
|
|
||||||
Writing the hives closes the four leaves that say which systems have them and that they have no
|
|
||||||
queens, and opens two carrying a `NextQ` known to be wrong. That trade is a flag, not a default.
|
|
||||||
|
|
||||||
## 8. Addresses
|
|
||||||
|
|
||||||
`ghidra/addresses.d/lane-sv.json` — **13** entries (the dispatcher and its jump table, the root
|
|
||||||
handler, the three writers plus the two handlers they are easily confused with, the `HiveInfo`
|
|
||||||
writer, and the swarm-queen constructor). `gen_addresses.py` merges to 1,204 entries with no
|
|
||||||
duplicate name.
|
|
||||||
|
|
||||||
**A fourteenth entry was dropped, and the agreement recorded instead.** This lane reached the hive
|
|
||||||
draw at 0x004271c0 independently and was about to file it as `RNG_NextIntInclusive`. `addresses.json`
|
|
||||||
already carries it as **`RNG_NextInt`**, same address, same bound-by-pointer convention, same
|
|
||||||
inclusive semantics — and lanes I, J, K and AI1 all depend on that name. The fragment merger only
|
|
||||||
detects duplicate *names*, so a second name for the same address would have merged silently and
|
|
||||||
quietly forked the campaign's vocabulary for its most-used RNG primitive. Dropped per the
|
|
||||||
`addresses.d/README` rule; the existing entry is correct and the two readings agree.
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"name": "StrategyApp_OnClientEvent",
|
"name": "StrategyApp_OnClientEvent",
|
||||||
"addr": "0x00838e10",
|
"addr": "0x00838e10",
|
||||||
"convention": "cdecl",
|
"convention": "cdecl",
|
||||||
"prototype": "void __cdecl Game::StrategyApp::OnClientEvent(int netId, int eventId, void* ev) -- THE AI ENQUEUE SITE (lane AI2 \u00a76.1 open item, closed). Operates on the STATIC StrategyApp at 0x00b29f98 (not a pointer -- `mov ecx,0xb29f98` at 0x007843a2 proves the object itself lives there). Body: (1) `if (*(void**)0x00b29f9c == 0) return` -- app+0x4, the StrategyServer; (2) `if (netId == 0) return`; (3) linear search of the client vector at app+0xc/+0x10 (absolutes 0x00b29fa4/0x00b29fa8) for `client->+0x148 == netId`; not found -> return; (4) `p = client->+0x150`; (5) `if (eventId == 0x26 (SEResumePlaying) && p->+0xf9 != 0 && p->+0xfa == 0)` then `if (!0x00438fe0(&pending, &netId)) 0x0059f1a0(&pending, &netId)` -- a DEDUPLICATED push_back onto the pending-AI vector at app+0x1c (absolute 0x00b29fb4), and RETURN; (6) otherwise `StrategyClient::RaiseEvent 0x00783ee0(client, eventId, ev)` inline. So event 0x26 for an AI player is the ONLY deferred event; everything else is delivered synchronously. Registered as StrategyServer+0x170 by CreateGame at 0x00889177; it has ZERO direct callers and no vtable slot (lane B6's third blind spot)",
|
"prototype": "void __cdecl Game::StrategyApp::OnClientEvent(int netId, int eventId, void* ev) -- THE AI ENQUEUE SITE (lane AI2 §6.1 open item, closed). Operates on the STATIC StrategyApp at 0x00b29f98 (not a pointer -- `mov ecx,0xb29f98` at 0x007843a2 proves the object itself lives there). Body: (1) `if (*(void**)0x00b29f9c == 0) return` -- app+0x4, the StrategyServer; (2) `if (netId == 0) return`; (3) linear search of the client vector at app+0xc/+0x10 (absolutes 0x00b29fa4/0x00b29fa8) for `client->+0x148 == netId`; not found -> return; (4) `p = client->+0x150`; (5) `if (eventId == 0x26 (SEResumePlaying) && p->+0xf9 != 0 && p->+0xfa == 0)` then `if (!0x00438fe0(&pending, &netId)) 0x0059f1a0(&pending, &netId)` -- a DEDUPLICATED push_back onto the pending-AI vector at app+0x1c (absolute 0x00b29fb4), and RETURN; (6) otherwise `StrategyClient::RaiseEvent 0x00783ee0(client, eventId, ev)` inline. So event 0x26 for an AI player is the ONLY deferred event; everything else is delivered synchronously. Registered as StrategyServer+0x170 by CreateGame at 0x00889177; it has ZERO direct callers and no vtable slot (lane B6's third blind spot)",
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/subsystems/ai-stepping-and-passes.md#1 -- lane AI3 2026-09-08, instruction-stream read of dumps/sots.exe swept to the next function start (rule 17); enqueue located by absolute-reference scan for 0x00b29fb4, which has exactly 2 references in the image, both here"
|
"source": "findings/subsystems/ai-stepping-and-passes.md#1 -- lane AI3 2026-09-08, instruction-stream read of dumps/sots.exe swept to the next function start (rule 17); enqueue located by absolute-reference scan for 0x00b29fb4, which has exactly 2 references in the image, both here"
|
||||||
},
|
},
|
||||||
|
|
@ -84,7 +84,7 @@
|
||||||
"name": "StrategyAIAgent_IsClaimedByAnotherTask",
|
"name": "StrategyAIAgent_IsClaimedByAnotherTask",
|
||||||
"addr": "0x006a8d20",
|
"addr": "0x006a8d20",
|
||||||
"convention": "thiscall",
|
"convention": "thiscall",
|
||||||
"prototype": "bool __thiscall Game::StrategyAIAgent::IsClaimedByAnotherTask(void* obj) -- RET 4. NAMES IAITask VTABLE SLOT 12 (lane AI2 \u00a710.2). Looks `obj->+4` up in the 8-byte-stride claim registry at agent->+0x2e8..+0x2ec (pairs of {IAITask* owner, int objectId}) and in the 4-byte set at agent->+0x2d8..+0x2dc; if the object is in neither, returns false (free). Otherwise `cur = back(agent->+0x12c /*the task call stack*/)`; with an empty stack or a null top it returns TRUE (claimed). Then at 0x006a8db3: `if (!cur->vt[12]()) return true;` -- and when slot 12 IS set, it returns false (i.e. lets the task take the object) only when the owner exists, `cur->GetTypeId() != owner->GetTypeId()`, and `cur->GetPriority() > owner->GetPriority()`. So SLOT 12 IS A PREEMPTION PERMISSION: 'this task may take an object already claimed by a strictly lower-priority task of a different type'. Default false; five classes set it. Caller 0x006abb00 skips the candidate when this returns true",
|
"prototype": "bool __thiscall Game::StrategyAIAgent::IsClaimedByAnotherTask(void* obj) -- RET 4. NAMES IAITask VTABLE SLOT 12 (lane AI2 §10.2). Looks `obj->+4` up in the 8-byte-stride claim registry at agent->+0x2e8..+0x2ec (pairs of {IAITask* owner, int objectId}) and in the 4-byte set at agent->+0x2d8..+0x2dc; if the object is in neither, returns false (free). Otherwise `cur = back(agent->+0x12c /*the task call stack*/)`; with an empty stack or a null top it returns TRUE (claimed). Then at 0x006a8db3: `if (!cur->vt[12]()) return true;` -- and when slot 12 IS set, it returns false (i.e. lets the task take the object) only when the owner exists, `cur->GetTypeId() != owner->GetTypeId()`, and `cur->GetPriority() > owner->GetPriority()`. So SLOT 12 IS A PREEMPTION PERMISSION: 'this task may take an object already claimed by a strictly lower-priority task of a different type'. Default false; five classes set it. Caller 0x006abb00 skips the candidate when this returns true",
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/subsystems/ai-stepping-and-passes.md#4 -- lane AI3 2026-09-08, instruction-stream read"
|
"source": "findings/subsystems/ai-stepping-and-passes.md#4 -- lane AI3 2026-09-08, instruction-stream read"
|
||||||
},
|
},
|
||||||
|
|
@ -96,6 +96,14 @@
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/subsystems/ai-stepping-and-passes.md#4 -- lane AI3 2026-09-08, instruction-stream read; jump table and byte index read from the image at instruction boundaries"
|
"source": "findings/subsystems/ai-stepping-and-passes.md#4 -- lane AI3 2026-09-08, instruction-stream read; jump table and byte index read from the image at instruction boundaries"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "AITask_GetPriorityDefaultThunk",
|
||||||
|
"addr": "0x00694220",
|
||||||
|
"convention": "thiscall",
|
||||||
|
"prototype": "int __thiscall Game::IAITask::GetPriority_Default() -- 17 bytes: `return AITask_PriorityForType(this->vt[1]() /*GetTypeId*/);`, i.e. the vt[1] dispatch followed by a direct call to the 33-arm table at 0x00691f00. This is what the two tuned GetPriority overrides tail-jump to when their flag bit is CLEAR, so lane AI2's priority table stands with an extra hop in front of it",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/subsystems/ai-stepping-and-passes.md#3 -- lane AI3 2026-09-08, instruction-stream read"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "g_AITInvadeUncommittedPriority",
|
"name": "g_AITInvadeUncommittedPriority",
|
||||||
"addr": "0x00a1795c",
|
"addr": "0x00a1795c",
|
||||||
|
|
@ -112,6 +120,5 @@
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/subsystems/ai-stepping-and-passes.md#3 -- lane AI3 2026-09-08; value read from the PE image, reference count from an exhaustive 4-byte absolute-reference scan"
|
"source": "findings/subsystems/ai-stepping-and-passes.md#3 -- lane AI3 2026-09-08; value read from the PE image, reference count from an exhaustive 4-byte absolute-reference scan"
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"_note": "AITask_GetPriorityDefaultThunk dropped 2026-09-08: same address 0x00694220 as lane-ai2's AITask_slot10_GetPriority, reached independently (AI2 via the slot-10 priority read, AI3 via the default thunk). Agreement recorded; AI2's name kept because the merged game/ai code already uses that vocabulary. Caught by gen_addresses.py's new same-address check, not by hand."
|
}
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
{
|
{
|
||||||
"entries": [
|
"entries": [
|
||||||
|
{
|
||||||
|
"name": "BuildQueue_ProcessTurn_RealEnd",
|
||||||
|
"addr": "0x00891240",
|
||||||
|
"convention": "site",
|
||||||
|
"prototype": "The byte AFTER Game::BuildQueue::ProcessTurn's last instruction (`ret 0x8` at 0x00891226, then int3 padding). Ghidra reports the function as 1230 bytes from 0x00890d50, i.e. ending at 0x0089121e -- INSIDE the epilogue, before the security cookie check. The body is 1264 bytes. Earned rule 17. Note that this address is ALSO the entry of SystemBuildQueue_AttachBuiltShip, the function's own slot-10 callee, which is why the fleet half of construction looked absent from the pass",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/subsystems/ship-construction.md (lane B6 2026-09-08, instruction stream 0x00890d50-0x00891240)"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "BuildQueue_off_Owner",
|
"name": "BuildQueue_off_Owner",
|
||||||
"offset": "0xc",
|
"offset": "0xc",
|
||||||
|
|
@ -160,6 +168,5 @@
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/subsystems/ship-construction.md (lane B6 2026-09-08)"
|
"source": "findings/subsystems/ship-construction.md (lane B6 2026-09-08)"
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"_note": "BuildQueue_ProcessTurn_RealEnd dropped 2026-09-08: it named 0x00891240, which is the SAME address as SystemBuildQueue_AttachBuiltShip in this same file - because the 'next function start' bounding ProcessTurn IS that function. A boundary marker and the function it bounds are not two facts. The real end of ProcessTurn is documented in findings/subsystems/ship-construction.md instead."
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
{
|
{
|
||||||
"entries": [
|
"entries": [
|
||||||
|
{
|
||||||
|
"name": "ServerSystem_GrowCivilianPops_G3",
|
||||||
|
"addr": "0x00754220",
|
||||||
|
"convention": "thiscall",
|
||||||
|
"prototype": "void (ServerSystem* sys) // plain `ret`, REAL END 0x00754b59 -- the body is a loop over group types 0,1,2 whose back edge is at 0x00754b2e (`inc esi; cmp esi,3; jl 0x7543e9`) and lies outside every decompiler `if`, so the function reads as straight-line code if you stop at the first `ret`. Returns immediately when the system has no owner. Per group type: skip when Population::TotalOfType(Pop2, t) <= 0, then skip unless t == 1 -- so only CIVILIANS grow here. Per species: delta = PopGrowthDelta(1, sp); cur = Count(Pop2,1,sp) + Count(pbon2,1,sp); cap = MaxPopGeneric(1, sp, PID, NULL); soft = CivilianSettleLimit(sp); limit = soft < cap ? soft : cap (and soft < cap with cur+delta > soft raises a per-species settle flag); applied = min(delta, limit - cur); haltv[1] with applied > 0 zeroes both. The SYSTEM TOTAL is then clamped into [-50,000,000 (a literal here), POPTYPE[1]+0x08 = 20,000,000] and, when the clamp bit, every entry of the clamp's own sign is rescaled by trunc(applied x (clamped / total)) with NO renormalisation. Write-back is Population::SetCount(Pop2, 1, sp, Count(Pop2,1,sp) + applied) -- note the headroom counted pbon2 and the write does not. THE 20,000,000 CLAMP IS WHAT DECIDES THE VALUE on both reference pairs: the uncapped delta is 7.5x it and the capacity headroom 25x it",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "ServerSystem_CivilianSettleLimit",
|
"name": "ServerSystem_CivilianSettleLimit",
|
||||||
"addr": "0x0074a9a0",
|
"addr": "0x0074a9a0",
|
||||||
|
|
@ -8,6 +16,14 @@
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)"
|
"source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "ServerSystem_MaxPopGeneric_G3",
|
||||||
|
"addr": "0x0074a4a0",
|
||||||
|
"convention": "thiscall",
|
||||||
|
"prototype": "int64 (ServerSystem* sys, int groupType, int species, ServerPlayer* p, float* suitOverride) // `ret 0x10`, REAL END 0x0074a6cd -- 0x0074a6d0 is a DIFFERENT function (lane N and E1 both cite 0x0074a6d0 for the capacity-surplus pair; that is the next one along, not this). Returns 0 when species == 4; when the OWNER species' SpeciesDef+0x168[groupType] is not > 0 (which is why Zuul have no civilians); when groupType != 0 and the owner is RebAI; when groupType == 1 and bit 3 of ServerPlayer+0x348[species] is clear; and when groupType == 2 and 0x0082bdf0(p, species) is false. Otherwise ftoi64( (int64)Size x 1e8 x [ hazard x (POPTYPE[t]+0x20 x SpeciesDef(sp)+0x168[t] x (crossSpecies ? SpeciesDef(sp)+0x174[t] : 1)) ] ) via 0x00535eb0, plus the arcology flat bonus 0x0080dd30(p, t), then clamped to the int64 at POPTYPE[t]+0x28 -- see PopTypeTableStaticInit, that clamp is always a no-op -- and finally, for groupType 0 ONLY, scaled by INDSYS_IMPERIAL_POPULATION_MOD when the owner species is 4",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "SpeciesDef_GroupCapacityFactor",
|
"name": "SpeciesDef_GroupCapacityFactor",
|
||||||
"addr": "0x0053bb00",
|
"addr": "0x0053bb00",
|
||||||
|
|
@ -56,6 +72,30 @@
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)"
|
"source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Ship_RepairCost_G3",
|
||||||
|
"addr": "0x00815180",
|
||||||
|
"convention": "thiscall",
|
||||||
|
"prototype": "int (StarShip* sh, bool useAllowance) // `ret 4`, REAL END 0x008151b7 (0x008151ba..0x008151bf is int3). max(0, design->+0xcc - (sh->ConCap /*+0x68*/ + (useAllowance ? design->+0xd0 : 0))). No floating point, no clamp but the floor at zero. Its only caller, RepairShipsInOrbit, always passes 1. UNEXERCISED on the corpus, and that is a measurement rather than an absence: the independent colony's fleet sits over Koa'Vo on both reference pairs and that player's Sav closes exactly with the demand taken as zero",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ship_ApplyRepair_G3",
|
||||||
|
"addr": "0x008151c0",
|
||||||
|
"convention": "thiscall",
|
||||||
|
"prototype": "void (StarShip* sh, int points) // `ret 4`, ends 0x00815221. Does NOTHING unless the ship's cached role word (+0x18, not on the wire) carries bit 0x400000 -- a different bit from the one RepairShipsInOrbit's candidate filter tests, so a ship can be charged points that never reach it. Otherwise sh->ConCap += max(points, 0) and then ConCap = min(max(ConCap, 0), design->+0xcc). THIS IS WHAT NAMES THE TWO FIELDS: ConCap is the construction invested in the hull so far -- not a per-turn capacity, despite the save-format name -- and design+0xcc is its ceiling",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ServerSystem_ShipCarriedPopIncome",
|
||||||
|
"addr": "0x007460b0",
|
||||||
|
"convention": "cdecl-through-register",
|
||||||
|
"prototype": "int (ServerPlayer* owner) /* the system arrives in EBX */ // ends 0x0074615a. CORRECTION to output-turn-path.md, which names this SystemRepairDemandForOwner: it is NOT a repair function. It walks the fleets at the system through the system's own vtable slots 2 and 3, skips a fleet whose owner is not the argument or whose +0x78 byte is clear, and sums 0x0081f8c0 over each ship -- and 0x0081f8c0 gates on the design's CARRIED-POPULATION bit (design->+0xb8 & 0x04000000) and computes GroupIncome over the ship's Population at ship+0x9c. So ComputeOutput's out[6] is the income of population carried in slaver/colony hulls in orbit, which is a slot the engine's BudgetInputs already has, and it feeds no save leaf",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "ShipCarriedPopIncomePerShip",
|
"name": "ShipCarriedPopIncomePerShip",
|
||||||
"addr": "0x0081f8c0",
|
"addr": "0x0081f8c0",
|
||||||
|
|
@ -96,6 +136,5 @@
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)"
|
"source": "findings/subsystems/population-growth.md (lane G3 2026-09-08)"
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"_note": "ServerSystem_GrowCivilianPops_G3 dropped 2026-09-08: same address 0x00754220 as addresses.json's ServerSystem_GrowCivilianPops. A lane-suffixed alias of an existing entry is a fork of the vocabulary, not a new fact - if the prototype needed correcting, correct the entry. G3's real end finding (0x00754b59, past Ghidra's reported end) stands in findings/subsystems/population-growth.md. Dropped 2026-09-08 as same-address duplicates, agreement recorded: ServerSystem_MaxPopGeneric_G3 -> ServerSystem_MaxPopGeneric (addresses.json); Ship_RepairCost_G3 -> Ship_RepairCost (lane-c3.json); Ship_ApplyRepair_G3 -> Ship_ApplyRepair (lane-c3.json); ServerSystem_ShipCarriedPopIncome -> SystemRepairDemandForOwner (lane-c3.json)."
|
}
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
"convention": "site",
|
"convention": "site",
|
||||||
"prototype": "site, phase 7: S->encounters.clear(). The bytes are `if (_Myfirst != _Mylast) { newEnd = FUN_007c5780(_Mylast,_Mylast,_Myfirst,c); FUN_00679c80(newEnd,_Mylast,&vec+0xc,c); _Mylast = newEnd; }`, MSVC's vector::erase(begin,end). FUN_007c5780 is std::_Uninit_move over 0x74-byte Encounters and is handed the EMPTY range [_Mylast,_Mylast), so it copies nothing and returns _Myfirst; FUN_00679c80 is std::_Destroy_range. THE IDENTICAL FOUR-ARGUMENT SHAPE appears at 0x007cd147/0x007cd15b inside FUN_007cd100 (vector<Encounter>::operator= taking the empty-source path), which is what identifies it. NO PREDICATE, NO FILTER: every encounter is erased. The `if` is the empty-vector guard erase always carries and both arms converge at 0x007d96bf",
|
"prototype": "site, phase 7: S->encounters.clear(). The bytes are `if (_Myfirst != _Mylast) { newEnd = FUN_007c5780(_Mylast,_Mylast,_Myfirst,c); FUN_00679c80(newEnd,_Mylast,&vec+0xc,c); _Mylast = newEnd; }`, MSVC's vector::erase(begin,end). FUN_007c5780 is std::_Uninit_move over 0x74-byte Encounters and is handed the EMPTY range [_Mylast,_Mylast), so it copies nothing and returns _Myfirst; FUN_00679c80 is std::_Destroy_range. THE IDENTICAL FOUR-ARGUMENT SHAPE appears at 0x007cd147/0x007cd15b inside FUN_007cd100 (vector<Encounter>::operator= taking the empty-source path), which is what identifies it. NO PREDICATE, NO FILTER: every encounter is erased. The `if` is the empty-vector guard erase always carries and both arms converge at 0x007d96bf",
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/control-flow/combat-done-tail.md \u00a72 (lane K 2026-09-08)"
|
"source": "findings/control-flow/combat-done-tail.md §2 (lane K 2026-09-08)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "StrategyServer_OnAllCombatDone_Tail_NodeDecayRoll",
|
"name": "StrategyServer_OnAllCombatDone_Tail_NodeDecayRoll",
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
"convention": "site",
|
"convention": "site",
|
||||||
"prototype": "site in FUN_007ae010 (phase 11 of OnAllCombatDone_Tail): `mov ecx,[esi+0x16c]; fld dword [0x009e2ea0] /*0.5f*/; push ecx; fstp [esp]; call 0x008e6dd0` = Mars::RNG::Chance(0.5f) on the strategic generator at S+0x16c. Chance early-outs WITHOUT a draw at p<=0 and p>=1 but takes neither at 0.5f, so this is EXACTLY ONE NextFloat PER EXPIRED NODE LINE PER TURN. State-dependent draw count, in the combat-done tail, BEFORE the autosave. Every RNG account in the repo assumes the strategic generator advances only inside StrategyServer::ProcessTurn; it also advances here, and again inside the combat resolver FUN_007d5af0 (RNG_NextInt on the node-cannon path, RNG_Twist + RNG_NextInt on the salvage path)",
|
"prototype": "site in FUN_007ae010 (phase 11 of OnAllCombatDone_Tail): `mov ecx,[esi+0x16c]; fld dword [0x009e2ea0] /*0.5f*/; push ecx; fstp [esp]; call 0x008e6dd0` = Mars::RNG::Chance(0.5f) on the strategic generator at S+0x16c. Chance early-outs WITHOUT a draw at p<=0 and p>=1 but takes neither at 0.5f, so this is EXACTLY ONE NextFloat PER EXPIRED NODE LINE PER TURN. State-dependent draw count, in the combat-done tail, BEFORE the autosave. Every RNG account in the repo assumes the strategic generator advances only inside StrategyServer::ProcessTurn; it also advances here, and again inside the combat resolver FUN_007d5af0 (RNG_NextInt on the node-cannon path, RNG_Twist + RNG_NextInt on the salvage path)",
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/control-flow/combat-done-tail.md \u00a73 (lane K 2026-09-08)"
|
"source": "findings/control-flow/combat-done-tail.md §3 (lane K 2026-09-08)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "StrategyHost_Autosave",
|
"name": "StrategyHost_Autosave",
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
"convention": "thiscall",
|
"convention": "thiscall",
|
||||||
"prototype": "void (StrategyHost* this /*the global at 0x00b29f98*/, std::string* outName, bool endTurn) // THE AUTOSAVE. Exactly two call sites: SendEndTurn 0x007839d7 with endTurn=1 -> (Autosave EndTurn).sav, the PRE-turn state; StrategyHost::OnMessage 0x00784e59 with endTurn=0 -> (Autosave).sav, the POST-turn state. Body: null-check this->+0x4 (the strat game) -> log \"Can't autosave- Strat game doesn't exist.\"; build FOUR paths as _snprintf(buf,0x3ff,\"%s/%s.%s\", dir, name, ext) with dir=FUN_007a05a0(game) (\"SavedGames\") and ext=FUN_007a0620(game) (\"sav\") and the four localized names registered at 0x009bed00..0x009bed7f (SOTS_GAME_AUTOSAVE @0xaf092c, _AUTOSAVEBACKUP @0xaf0934, _ENDTURN_AUTOSAVE @0xaf093c, _ENDTURN_AUTOSAVEBACKUP @0xaf0944); if (!IsSinglePlayerHost()) remove both ENDTURN files; pick (cur,bak) by endTurn; mkdir(dir); ROTATE remove(bak)+rename(cur,bak) ONLY WHEN endTurn==0 (the flag byte at [ebp-0x14a1] is set to 1 and the je at 0x00895266 SKIPS the store of 0 when the arg is zero); gate on (this->flags & 4) && this->+0x4; DETACH each player's connection at pl->+0x12c via conn->vft[0x14] and reattach via conn->vft[0x18] after; call SaveGame_WriteFile(this->+0x4, curPath, 1, &agentNames) at 0x0089595d",
|
"prototype": "void (StrategyHost* this /*the global at 0x00b29f98*/, std::string* outName, bool endTurn) // THE AUTOSAVE. Exactly two call sites: SendEndTurn 0x007839d7 with endTurn=1 -> (Autosave EndTurn).sav, the PRE-turn state; StrategyHost::OnMessage 0x00784e59 with endTurn=0 -> (Autosave).sav, the POST-turn state. Body: null-check this->+0x4 (the strat game) -> log \"Can't autosave- Strat game doesn't exist.\"; build FOUR paths as _snprintf(buf,0x3ff,\"%s/%s.%s\", dir, name, ext) with dir=FUN_007a05a0(game) (\"SavedGames\") and ext=FUN_007a0620(game) (\"sav\") and the four localized names registered at 0x009bed00..0x009bed7f (SOTS_GAME_AUTOSAVE @0xaf092c, _AUTOSAVEBACKUP @0xaf0934, _ENDTURN_AUTOSAVE @0xaf093c, _ENDTURN_AUTOSAVEBACKUP @0xaf0944); if (!IsSinglePlayerHost()) remove both ENDTURN files; pick (cur,bak) by endTurn; mkdir(dir); ROTATE remove(bak)+rename(cur,bak) ONLY WHEN endTurn==0 (the flag byte at [ebp-0x14a1] is set to 1 and the je at 0x00895266 SKIPS the store of 0 when the arg is zero); gate on (this->flags & 4) && this->+0x4; DETACH each player's connection at pl->+0x12c via conn->vft[0x14] and reattach via conn->vft[0x18] after; call SaveGame_WriteFile(this->+0x4, curPath, 1, &agentNames) at 0x0089595d",
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/control-flow/combat-done-tail.md \u00a76 (lane K 2026-09-08, read from the instruction stream)"
|
"source": "findings/control-flow/combat-done-tail.md §6 (lane K 2026-09-08, read from the instruction stream)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "StrategyHost_IsSinglePlayerHost",
|
"name": "StrategyHost_IsSinglePlayerHost",
|
||||||
|
|
@ -38,7 +38,7 @@
|
||||||
"convention": "cdecl",
|
"convention": "cdecl",
|
||||||
"prototype": "bool () // whole 27-byte body: `g = *(void**)0x00b2d540; net = g->+0x148; return net != 0 && net->+0x4 == 0;`. Gates SendEndTurn's pre-turn autosave and the AI-agent sidecar branch inside StrategyHost::Autosave; its NEGATION gates the deletion of the ENDTURN autosave pair (so the pre-turn autosave is a single-player-only feature)",
|
"prototype": "bool () // whole 27-byte body: `g = *(void**)0x00b2d540; net = g->+0x148; return net != 0 && net->+0x4 == 0;`. Gates SendEndTurn's pre-turn autosave and the AI-agent sidecar branch inside StrategyHost::Autosave; its NEGATION gates the deletion of the ENDTURN autosave pair (so the pre-turn autosave is a single-player-only feature)",
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/control-flow/combat-done-tail.md \u00a76.1 (lane K 2026-09-08)"
|
"source": "findings/control-flow/combat-done-tail.md §6.1 (lane K 2026-09-08)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "StrategyHost_HasNetworkSession",
|
"name": "StrategyHost_HasNetworkSession",
|
||||||
|
|
@ -46,7 +46,7 @@
|
||||||
"convention": "thiscall",
|
"convention": "thiscall",
|
||||||
"prototype": "bool (void* this) // `return this->+0x148 != 0;`. Called on the global at 0x00b2d540 from StrategyHost::OnMessage 0x00784e3f -- this is the gate on the POST-turn autosave",
|
"prototype": "bool (void* this) // `return this->+0x148 != 0;`. Called on the global at 0x00b2d540 from StrategyHost::OnMessage 0x00784e3f -- this is the gate on the POST-turn autosave",
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/control-flow/combat-done-tail.md \u00a76 (lane K 2026-09-08)"
|
"source": "findings/control-flow/combat-done-tail.md §6 (lane K 2026-09-08)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "SaveGame_WriteFile",
|
"name": "SaveGame_WriteFile",
|
||||||
|
|
@ -54,7 +54,7 @@
|
||||||
"convention": "cdecl",
|
"convention": "cdecl",
|
||||||
"prototype": "bool (void* game, const char* path, bool write, std::vector<AgentName>* agentNames) // the save-file ROOT that verify/save-reader/save_reader.py already models (its comment at line 694 names this address). Opens the stream with OpenSaveStream(path,&stream,write); with write=1 that is operator new(0x118) + ctor 0x008d10c0 + FUN_008d1090(path,\"wb\"), the gzip writer. Then writes four named top-level sections through stream->vft[0x28](tag,&ref): \"Summary\", \"CreateParams\", \"Sim\", \"CDT\"; then one \"CD\" record (tag at 0x00a2b9d4) per entry of the 0x20-stride agentNames vector whose +0x1c is non-null. NOTHING TIME-, NAME- OR MACHINE-DEPENDENT ENTERS THE PAYLOAD: the file NAME is built by StrategyHost::Autosave and never reaches here",
|
"prototype": "bool (void* game, const char* path, bool write, std::vector<AgentName>* agentNames) // the save-file ROOT that verify/save-reader/save_reader.py already models (its comment at line 694 names this address). Opens the stream with OpenSaveStream(path,&stream,write); with write=1 that is operator new(0x118) + ctor 0x008d10c0 + FUN_008d1090(path,\"wb\"), the gzip writer. Then writes four named top-level sections through stream->vft[0x28](tag,&ref): \"Summary\", \"CreateParams\", \"Sim\", \"CDT\"; then one \"CD\" record (tag at 0x00a2b9d4) per entry of the 0x20-stride agentNames vector whose +0x1c is non-null. NOTHING TIME-, NAME- OR MACHINE-DEPENDENT ENTERS THE PAYLOAD: the file NAME is built by StrategyHost::Autosave and never reaches here",
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/control-flow/combat-done-tail.md \u00a76.2 (lane K 2026-09-08)"
|
"source": "findings/control-flow/combat-done-tail.md §6.2 (lane K 2026-09-08)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "OpenSaveStream",
|
"name": "OpenSaveStream",
|
||||||
|
|
@ -62,7 +62,7 @@
|
||||||
"convention": "cdecl",
|
"convention": "cdecl",
|
||||||
"prototype": "bool (const char* path, Stream** out, bool write) // write -> operator new(0x118), ctor 0x008d10c0, open FUN_008d1090(path, \"wb\" @0x009e150c); read -> OpenFile(path, @0x00a2ec2c). Returns *out != 0. The \"wb\" is the gzip container the determinism note measured as header-deterministic (MTIME 0, XFL 0, OS 11)",
|
"prototype": "bool (const char* path, Stream** out, bool write) // write -> operator new(0x118), ctor 0x008d10c0, open FUN_008d1090(path, \"wb\" @0x009e150c); read -> OpenFile(path, @0x00a2ec2c). Returns *out != 0. The \"wb\" is the gzip container the determinism note measured as header-deterministic (MTIME 0, XFL 0, OS 11)",
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/control-flow/combat-done-tail.md \u00a76.2 (lane K 2026-09-08)"
|
"source": "findings/control-flow/combat-done-tail.md §6.2 (lane K 2026-09-08)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "StrategyServer_GenerateTurnEvents",
|
"name": "StrategyServer_GenerateTurnEvents",
|
||||||
|
|
@ -70,7 +70,7 @@
|
||||||
"convention": "thiscall",
|
"convention": "thiscall",
|
||||||
"prototype": "void (StrategyServer* this) // 122 BYTES, AND IT GENERATES NOTHING. (1) FUN_007c5610(&scratch, S->+0x304, S->+0x308) with ecx = &S->+0x304 -- erase-to-empty of the vector<SETurnResults> OUTBOX at S+0x304. (2) if (S->+0x244 != S->+0x248) S->+0x248 = S->+0x244 -- clear of a 0xc-stride vector; THE COPY LOOP AT 0x007dc680 IS DEAD CODE, `cmp edx,edx; je` at 0x007dc676 is unconditionally taken. (3) FUN_00792a20(S) prunes two intrusive lists at S+0x2d8 and S+0x2e4. (4) if (S->+0x128 & 4) BuildTurnEvents(S) -- normally FALSE. One caller: StrategyHost::OnMessage 0x00784e34",
|
"prototype": "void (StrategyServer* this) // 122 BYTES, AND IT GENERATES NOTHING. (1) FUN_007c5610(&scratch, S->+0x304, S->+0x308) with ecx = &S->+0x304 -- erase-to-empty of the vector<SETurnResults> OUTBOX at S+0x304. (2) if (S->+0x244 != S->+0x248) S->+0x248 = S->+0x244 -- clear of a 0xc-stride vector; THE COPY LOOP AT 0x007dc680 IS DEAD CODE, `cmp edx,edx; je` at 0x007dc676 is unconditionally taken. (3) FUN_00792a20(S) prunes two intrusive lists at S+0x2d8 and S+0x2e4. (4) if (S->+0x128 & 4) BuildTurnEvents(S) -- normally FALSE. One caller: StrategyHost::OnMessage 0x00784e34",
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/control-flow/combat-done-tail.md \u00a75B (lane K 2026-09-08, read from the instruction stream)"
|
"source": "findings/control-flow/combat-done-tail.md §5B (lane K 2026-09-08, read from the instruction stream)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "StrategyServer_BuildTurnEvents_isResync",
|
"name": "StrategyServer_BuildTurnEvents_isResync",
|
||||||
|
|
@ -78,14 +78,14 @@
|
||||||
"convention": "note",
|
"convention": "note",
|
||||||
"prototype": "MISNAMED. BuildTurnEvents 0x007db780 is NOT a per-turn turn-event builder: it is the FULL-STATE RESYNC PUSH for setup / load / rejoin. Its entire 3701-byte body is under `if (this->+0x12c != 0)`, and both that descriptor and bit 2 of +0x128 are set in exactly one place in the image -- FUN_007bd1b0 at 0x007bd204/0x007bd23a. It references NO EVENT_* string at all; its only string immediates are \"vector<T> too long\" and \"StrategyServer: OnEvent() called, but no callback function specified.\" It sends SEResetMap (0x29), SEAddPlayer (0x01), SEInitTrade (0x2a), SETurnEvents (0x28), SESyncDesign (0x19) and calls SynchronizePlayer. It CALLS FUN_0081b390 (the previous-turn snapshot) at 0x007dbc7c to ESTABLISH the baseline and never diffs against it. findings/control-flow/turn-spine.md reads as if this were a per-turn diff step -- it is not",
|
"prototype": "MISNAMED. BuildTurnEvents 0x007db780 is NOT a per-turn turn-event builder: it is the FULL-STATE RESYNC PUSH for setup / load / rejoin. Its entire 3701-byte body is under `if (this->+0x12c != 0)`, and both that descriptor and bit 2 of +0x128 are set in exactly one place in the image -- FUN_007bd1b0 at 0x007bd204/0x007bd23a. It references NO EVENT_* string at all; its only string immediates are \"vector<T> too long\" and \"StrategyServer: OnEvent() called, but no callback function specified.\" It sends SEResetMap (0x29), SEAddPlayer (0x01), SEInitTrade (0x2a), SETurnEvents (0x28), SESyncDesign (0x19) and calls SynchronizePlayer. It CALLS FUN_0081b390 (the previous-turn snapshot) at 0x007dbc7c to ESTABLISH the baseline and never diffs against it. findings/control-flow/turn-spine.md reads as if this were a per-turn diff step -- it is not",
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/control-flow/combat-done-tail.md \u00a75B (lane K 2026-09-08)"
|
"source": "findings/control-flow/combat-done-tail.md §5B (lane K 2026-09-08)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "StrategyServer_off_TurnResultsPending",
|
"name": "StrategyServer_off_TurnResultsPending",
|
||||||
"convention": "offset",
|
"convention": "offset",
|
||||||
"prototype": "StrategyServer+0x2f4 (S frame) = std::vector<Game::SETurnResults> ACCUMULATOR, stride 0x11c, one record per player indexed by PlyrIdx. Written during the turn by (at least) ApplyEncounterResult 0x007d8f9e, FUN_007ae010 0x007ae286/0x007ae3ce, FUN_007a4ff0 0x007a516f, FUN_007a4700, FUN_007b9df0, ProcessAid (3 sites) and ApplyEncounterResults itself -- found by a whole-image scan for `imul r32,r32,0x11c` / `add r32,0x11c` at real instruction boundaries. ApplyEncounterResults' tail (0x007d4fa0-0x007d505f) destroys S+0x304, SWAPS the two vector headers so this turn's accumulation becomes the outbox, then resize(0)+resize(nPlayers) here for the next turn",
|
"prototype": "StrategyServer+0x2f4 (S frame) = std::vector<Game::SETurnResults> ACCUMULATOR, stride 0x11c, one record per player indexed by PlyrIdx. Written during the turn by (at least) ApplyEncounterResult 0x007d8f9e, FUN_007ae010 0x007ae286/0x007ae3ce, FUN_007a4ff0 0x007a516f, FUN_007a4700, FUN_007b9df0, ProcessAid (3 sites) and ApplyEncounterResults itself -- found by a whole-image scan for `imul r32,r32,0x11c` / `add r32,0x11c` at real instruction boundaries. ApplyEncounterResults' tail (0x007d4fa0-0x007d505f) destroys S+0x304, SWAPS the two vector headers so this turn's accumulation becomes the outbox, then resize(0)+resize(nPlayers) here for the next turn",
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/control-flow/combat-done-tail.md \u00a75A (lane K 2026-09-08)",
|
"source": "findings/control-flow/combat-done-tail.md §5A (lane K 2026-09-08)",
|
||||||
"offset": "0x2f4"
|
"offset": "0x2f4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -93,7 +93,7 @@
|
||||||
"convention": "offset",
|
"convention": "offset",
|
||||||
"prototype": "StrategyServer+0x304 (S frame) = std::vector<Game::SETurnResults> OUTBOX, stride 0x11c, filled by the swap in ApplyEncounterResults' tail. Read by SynchronizePlayer 0x007c865f: `if (size() == Players.size()) { r = base + i*0x11c; r->+0x20 = S->+0x1fc; OnEventCallback(netId, 0x25, r); r->+0x20 = 0; }`. Cleared by GenerateTurnEvents' first statement. SETurnResults is strategy-event id 0x25, unicast per player, and is NOT serialized -- its vtable 0x00a24b00 has no Read/Write pair and it appears in no save schema",
|
"prototype": "StrategyServer+0x304 (S frame) = std::vector<Game::SETurnResults> OUTBOX, stride 0x11c, filled by the swap in ApplyEncounterResults' tail. Read by SynchronizePlayer 0x007c865f: `if (size() == Players.size()) { r = base + i*0x11c; r->+0x20 = S->+0x1fc; OnEventCallback(netId, 0x25, r); r->+0x20 = 0; }`. Cleared by GenerateTurnEvents' first statement. SETurnResults is strategy-event id 0x25, unicast per player, and is NOT serialized -- its vtable 0x00a24b00 has no Read/Write pair and it appears in no save schema",
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/control-flow/combat-done-tail.md \u00a75A (lane K 2026-09-08)",
|
"source": "findings/control-flow/combat-done-tail.md §5A (lane K 2026-09-08)",
|
||||||
"offset": "0x304"
|
"offset": "0x304"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -102,7 +102,7 @@
|
||||||
"convention": "thiscall",
|
"convention": "thiscall",
|
||||||
"prototype": "void (SETurnResults* this) // the DEFAULT CONSTRUCTOR of Game::SETurnResults (Ghidra calls it Create; it is not a factory). sizeof == 0x11c, enumerated five ways: the 0x11c stride and its reciprocal 0xe6c2b449/sar 8 in vector<SETurnResults>::resize 0x007cd2a0, the `add esi,0x11c` in _Ufill 0x007c5850, the accessor 0x00788cb0 (base[PlyrIdx*0x11c]), the operator new[] in 0x0078b0c0, and this ctor closing at +0x118 (the _Alval of a vector member at +0x10c). Layout: +0x00 vptr; +0x04 bool; +0x08 EMBEDDED Game::EventStorage::TurnEvents (vptr +0x08, int EvTurn +0x0c, vector<Event> +0x10/+0x14/+0x18, _Alval +0x1c); +0x20 int stamped by SynchronizePlayer from S+0x1fc and cleared after; +0x24 vector<ClientEncounterResults> (what ApplyEncounterResult publishes into); +0x34 byte with two bit-flags; strings at +0x38/+0x54/+0xa4/+0xc0/+0xdc; list at +0x70; vectors at +0x80/+0x90/+0xfc/+0x10c; two bools at +0xa0/+0xa1; int at +0xf8",
|
"prototype": "void (SETurnResults* this) // the DEFAULT CONSTRUCTOR of Game::SETurnResults (Ghidra calls it Create; it is not a factory). sizeof == 0x11c, enumerated five ways: the 0x11c stride and its reciprocal 0xe6c2b449/sar 8 in vector<SETurnResults>::resize 0x007cd2a0, the `add esi,0x11c` in _Ufill 0x007c5850, the accessor 0x00788cb0 (base[PlyrIdx*0x11c]), the operator new[] in 0x0078b0c0, and this ctor closing at +0x118 (the _Alval of a vector member at +0x10c). Layout: +0x00 vptr; +0x04 bool; +0x08 EMBEDDED Game::EventStorage::TurnEvents (vptr +0x08, int EvTurn +0x0c, vector<Event> +0x10/+0x14/+0x18, _Alval +0x1c); +0x20 int stamped by SynchronizePlayer from S+0x1fc and cleared after; +0x24 vector<ClientEncounterResults> (what ApplyEncounterResult publishes into); +0x34 byte with two bit-flags; strings at +0x38/+0x54/+0xa4/+0xc0/+0xdc; list at +0x70; vectors at +0x80/+0x90/+0xfc/+0x10c; two bools at +0xa0/+0xa1; int at +0xf8",
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/control-flow/combat-done-tail.md \u00a75A (lane K 2026-09-08)"
|
"source": "findings/control-flow/combat-done-tail.md §5A (lane K 2026-09-08)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "vector_SETurnResults_resize",
|
"name": "vector_SETurnResults_resize",
|
||||||
|
|
@ -110,7 +110,15 @@
|
||||||
"convention": "thiscall",
|
"convention": "thiscall",
|
||||||
"prototype": "void (std::vector<SETurnResults>* this, int n) // MISNAMED as DispatchTurnResults: it dispatches nothing. std::vector<Game::SETurnResults>::resize(n) -- shrink to _Erase 0x007c5610, grow to _Reserve 0x007cb340 + _Ufill 0x007c5850. Likewise 0x007c5850 (\"SendTurnResultsToPlayers\") is _Ufill: per element default-construct a stack temp with 0x007a7ae0, copy-construct into the destination with 0x007c24d0, destroy the temp with 0x0079ac10, dest += 0x11c. The ONLY send of an SETurnResults in the image is SynchronizePlayer 0x007c86d1 (push 0x25)",
|
"prototype": "void (std::vector<SETurnResults>* this, int n) // MISNAMED as DispatchTurnResults: it dispatches nothing. std::vector<Game::SETurnResults>::resize(n) -- shrink to _Erase 0x007c5610, grow to _Reserve 0x007cb340 + _Ufill 0x007c5850. Likewise 0x007c5850 (\"SendTurnResultsToPlayers\") is _Ufill: per element default-construct a stack temp with 0x007a7ae0, copy-construct into the destination with 0x007c24d0, destroy the temp with 0x0079ac10, dest += 0x11c. The ONLY send of an SETurnResults in the image is SynchronizePlayer 0x007c86d1 (push 0x25)",
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/control-flow/combat-done-tail.md \u00a77.2a (lane K 2026-09-08)"
|
"source": "findings/control-flow/combat-done-tail.md §7.2a (lane K 2026-09-08)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TurnEvents_serializer_direction",
|
||||||
|
"addr": "0x00825c40",
|
||||||
|
"convention": "note",
|
||||||
|
"prototype": "THE GHIDRA SYMBOL NAMES ON 0x00825bb0 / 0x00825c40 ARE SWAPPED, and so is EventStorage_Read 0x00825cc0 (which is the Write). 0x00825c40 is the WRITE: it calls 0x008b9d50, which invokes stream vtable slot +0x24 and pushes the MEMBER'S VALUE -- identical in shape to the golden Game::ObservedTech::Write 0x00817cf0. 0x00825bb0 is the READ: it calls 0x008b9d20, which invokes slot +0x10 and passes a stack scratch as a DESTINATION. objects/layouts.json and objects/streams.json already have the direction right (write 0x825c40, read 0x825bb0); the Ghidra names and findings/subsystems/events.md repeat the swap. Wire schema of Game::EventStorage::TurnEvents, in order: \"EvTurn\" by WriteInt (FOUR BYTES ON THE WIRE, default -1) at this+0x04; then \"Events\" through slot +0x28 as a framed counted array of Game::EventStorage::Event bound via Mars::VectorHelper<Game::EventStorage::Event> (vtable 0x00a2da7c) at this+0x08. sizeof == 0x18 by enumeration four ways: serializer span (0x08+0x10), the SETurnResults default ctor (subobject 0x08..0x1f, next member at +0x20), its copy ctor, and the 0x18 container stride in EventStorage::FindTurnBucket 0x00811f70",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/control-flow/combat-done-tail.md §5B.1 (lane K 2026-09-08)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "GetGame",
|
"name": "GetGame",
|
||||||
|
|
@ -118,7 +126,7 @@
|
||||||
"convention": "cdecl",
|
"convention": "cdecl",
|
||||||
"prototype": "void* () // whole 10-byte body: `mov ecx,0x00b29f98; jmp 0x005f6450` and 0x005f6450 is `mov eax,[ecx+4]; ret`, i.e. `return *(void**)0x00b29f9c`. 0x00b29f98 is the SAME global StrategyHost::Autosave takes as its `this`, and +0x4 is the same strat-game pointer it null-checks and hands to SaveGame_WriteFile. GetGame()+0x84 is the game's global handle map. 670 xrefs",
|
"prototype": "void* () // whole 10-byte body: `mov ecx,0x00b29f98; jmp 0x005f6450` and 0x005f6450 is `mov eax,[ecx+4]; ret`, i.e. `return *(void**)0x00b29f9c`. 0x00b29f98 is the SAME global StrategyHost::Autosave takes as its `this`, and +0x4 is the same strat-game pointer it null-checks and hands to SaveGame_WriteFile. GetGame()+0x84 is the game's global handle map. 670 xrefs",
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/control-flow/combat-done-tail.md \u00a72A (lane K 2026-09-08)"
|
"source": "findings/control-flow/combat-done-tail.md §2A (lane K 2026-09-08)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "HandleMap_Resolve",
|
"name": "HandleMap_Resolve",
|
||||||
|
|
@ -126,7 +134,7 @@
|
||||||
"convention": "thiscall",
|
"convention": "thiscall",
|
||||||
"prototype": "void* (HandleMap* this, uint id) // `if (!id) return 0; slot = id & 0xF; if (slot >= (this->+0xc - this->+0x8)/0x14) return 0; b = this->+0x8 + slot*0x14; lower_bound(b, &it, &id); return it == b->+0x4 ? 0 : *(void**)(it + 0x10);`. A 16-BUCKET stdext::hash_map<uint32 handle, Object*>: vector<Bucket> at +0x8/+0xc/+0x10 with 0x14-byte stride, bucket index = id & 0xF, each bucket a red-black tree whose head is at bucket+0x4 (node layout _Left@0 _Parent@4 _Right@8 key@0xc value@0x10 _Color@0x14 _Isnil@0x15). NOTE the `this` at the call site is &bucketVector, i.e. map+0x84 on the game root, not the map object",
|
"prototype": "void* (HandleMap* this, uint id) // `if (!id) return 0; slot = id & 0xF; if (slot >= (this->+0xc - this->+0x8)/0x14) return 0; b = this->+0x8 + slot*0x14; lower_bound(b, &it, &id); return it == b->+0x4 ? 0 : *(void**)(it + 0x10);`. A 16-BUCKET stdext::hash_map<uint32 handle, Object*>: vector<Bucket> at +0x8/+0xc/+0x10 with 0x14-byte stride, bucket index = id & 0xF, each bucket a red-black tree whose head is at bucket+0x4 (node layout _Left@0 _Parent@4 _Right@8 key@0xc value@0x10 _Color@0x14 _Isnil@0x15). NOTE the `this` at the call site is &bucketVector, i.e. map+0x84 on the game root, not the map object",
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/control-flow/combat-done-tail.md \u00a72A (lane K 2026-09-08)"
|
"source": "findings/control-flow/combat-done-tail.md §2A (lane K 2026-09-08)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "ServerPlayer_MarkPlayerEncountered",
|
"name": "ServerPlayer_MarkPlayerEncountered",
|
||||||
|
|
@ -134,7 +142,7 @@
|
||||||
"convention": "thiscall",
|
"convention": "thiscall",
|
||||||
"prototype": "void (ServerPlayer* this, ServerPlayer* other) // `if (other) this->HasEnc(+0x1a8) |= 1 << other->PlyrIdx(+0x28);`. Called twice symmetrically per ordered combatant pair in OnAllCombatDone_Tail phase 2",
|
"prototype": "void (ServerPlayer* this, ServerPlayer* other) // `if (other) this->HasEnc(+0x1a8) |= 1 << other->PlyrIdx(+0x28);`. Called twice symmetrically per ordered combatant pair in OnAllCombatDone_Tail phase 2",
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/control-flow/combat-done-tail.md \u00a72A (lane K 2026-09-08)"
|
"source": "findings/control-flow/combat-done-tail.md §2A (lane K 2026-09-08)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "ServerPlayer_MarkSpeciesDiscovered",
|
"name": "ServerPlayer_MarkSpeciesDiscovered",
|
||||||
|
|
@ -142,7 +150,7 @@
|
||||||
"convention": "thiscall",
|
"convention": "thiscall",
|
||||||
"prototype": "void (ServerPlayer* this, uint species) // `if (species < 7 && species != 4) this->HasDiscCl(+0x1a4) |= 1 << species;`. Species index 4 is permanently excluded. Called in OnAllCombatDone_Tail phase 2 as MarkSpeciesDiscovered(other->Species(+0x5c))",
|
"prototype": "void (ServerPlayer* this, uint species) // `if (species < 7 && species != 4) this->HasDiscCl(+0x1a4) |= 1 << species;`. Species index 4 is permanently excluded. Called in OnAllCombatDone_Tail phase 2 as MarkSpeciesDiscovered(other->Species(+0x5c))",
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/control-flow/combat-done-tail.md \u00a72A (lane K 2026-09-08)"
|
"source": "findings/control-flow/combat-done-tail.md §2A (lane K 2026-09-08)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "PickDominantEncounterType",
|
"name": "PickDominantEncounterType",
|
||||||
|
|
@ -150,7 +158,7 @@
|
||||||
"convention": "cdecl",
|
"convention": "cdecl",
|
||||||
"prototype": "int (uint typeMask) // A RANKER OVER GROUPS, NOT A FILTER OF ONE. Clears bit 0 (Standard); if the mask hits the boss group {SystemKiller 7, PuppetMaster 8, Locust 14, 21} the mask is RESTRICTED to that group; otherwise the ambient groups {Swarm 3, Derelict 4, Monitor 5, SlaversRefuel 9, CrowRuins 17}, {CrowsNest 12, GravTrap 13} and {GasCloud 11, Meteor 2, Pirate 6, TradeRaiders 18, 20, 23} are each dropped IF ANYTHING ELSE REMAINS. Returns the index of the lowest surviving set bit in [0,0x18), else 0. The four group masks are lazily built once into 0x00b0e96c..0x00b0e988. FUN_004f4970 is the id->name switch (Standard/VonNeumann/Meteor/Swarm/Derelict/Monitor/Pirate/SystemKiller/PuppetMaster/SlaversRefuel/SwarmQueen/GasCloud/CrowsNest/GravTrap/Locust/Berserker/CrowDefenders/CrowRuins/TradeRaiders), which fixes the return type as an EncounterType enum",
|
"prototype": "int (uint typeMask) // A RANKER OVER GROUPS, NOT A FILTER OF ONE. Clears bit 0 (Standard); if the mask hits the boss group {SystemKiller 7, PuppetMaster 8, Locust 14, 21} the mask is RESTRICTED to that group; otherwise the ambient groups {Swarm 3, Derelict 4, Monitor 5, SlaversRefuel 9, CrowRuins 17}, {CrowsNest 12, GravTrap 13} and {GasCloud 11, Meteor 2, Pirate 6, TradeRaiders 18, 20, 23} are each dropped IF ANYTHING ELSE REMAINS. Returns the index of the lowest surviving set bit in [0,0x18), else 0. The four group masks are lazily built once into 0x00b0e96c..0x00b0e988. FUN_004f4970 is the id->name switch (Standard/VonNeumann/Meteor/Swarm/Derelict/Monitor/Pirate/SystemKiller/PuppetMaster/SlaversRefuel/SwarmQueen/GasCloud/CrowsNest/GravTrap/Locust/Berserker/CrowDefenders/CrowRuins/TradeRaiders), which fixes the return type as an EncounterType enum",
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/control-flow/combat-done-tail.md \u00a72A (lane K 2026-09-08)"
|
"source": "findings/control-flow/combat-done-tail.md §2A (lane K 2026-09-08)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "StrategyServer_AnnounceEncounterSighting",
|
"name": "StrategyServer_AnnounceEncounterSighting",
|
||||||
|
|
@ -158,7 +166,7 @@
|
||||||
"convention": "thiscall",
|
"convention": "thiscall",
|
||||||
"prototype": "void (StrategyServer* this, Node* node /*= enc->+0xc*/, int encType /*= PickDominantEncounterType(enc->+0x38)*/) // GHIDRA'S DECOMPILE OF THIS FUNCTION IS UNUSABLE -- 19 'removing unreachable block' warnings delete the entire event-posting body; read it as instructions. mask = 0; if (!FUN_00788cd0(node, encType, &mask)) return; -- that gate is true only for VonNeumann(1), Meteor(2), Pirate(6), GasCloud(0xb), Berserker(0xf), 0x17, and fills mask with the players who can see it. Then per set player it composes the event key as the LITERAL \"EVENT_\" (0x00a24bf8, length 6) CONCATENATED WITH THE TYPE NAME -- EVENT_PIRATE, EVENT_TRADERAIDERS, ... -- and posts through ServerPlayer_GetEventStorage + EventStorage_PostEvent. Finally push_backs a 0x10-byte {system, encType, turn, turn+1} record into the vector at S+0x2c8/+0x2cc/+0x2d0. No RNG",
|
"prototype": "void (StrategyServer* this, Node* node /*= enc->+0xc*/, int encType /*= PickDominantEncounterType(enc->+0x38)*/) // GHIDRA'S DECOMPILE OF THIS FUNCTION IS UNUSABLE -- 19 'removing unreachable block' warnings delete the entire event-posting body; read it as instructions. mask = 0; if (!FUN_00788cd0(node, encType, &mask)) return; -- that gate is true only for VonNeumann(1), Meteor(2), Pirate(6), GasCloud(0xb), Berserker(0xf), 0x17, and fills mask with the players who can see it. Then per set player it composes the event key as the LITERAL \"EVENT_\" (0x00a24bf8, length 6) CONCATENATED WITH THE TYPE NAME -- EVENT_PIRATE, EVENT_TRADERAIDERS, ... -- and posts through ServerPlayer_GetEventStorage + EventStorage_PostEvent. Finally push_backs a 0x10-byte {system, encType, turn, turn+1} record into the vector at S+0x2c8/+0x2cc/+0x2d0. No RNG",
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/control-flow/combat-done-tail.md \u00a72A (lane K 2026-09-08). Call shape instruction-verified; body from a delegated instruction read",
|
"source": "findings/control-flow/combat-done-tail.md §2A (lane K 2026-09-08). Call shape instruction-verified; body from a delegated instruction read",
|
||||||
"confidence": "med"
|
"confidence": "med"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -167,7 +175,7 @@
|
||||||
"convention": "thiscall",
|
"convention": "thiscall",
|
||||||
"prototype": "void (StrategyServer* this, std::vector<Encounter>* encounters, std::vector<EncounterResults>* results) // two passes over ServerPlayer+0x230 vector<DiplomacyStats>, no RNG, no events. PASS A (dead homeworld): if the battle was at a player's own HomeSys(+0x2c), was a real battle (result->+0x4 == 0), had planet stats (result->+0x10c != 0) and the INT64 at result+0x120 is <= 0, then every participant that actually fought them gets deadhome(+0x20)++. PASS B (treaty betrayal): for every ordered pair with GetRelation < 1, a treaty slot signed within the last 3 turns and not yet betrayed since signing (`last != -1 && turn-last < 3 && (bty == -1 || bty < last)`), where the other side actually fought -- bty++ and lastXbty = turn, independently for NAP (+0x8/+0xa/+0xe), alliance (+0x10/+0x12/+0x16) and ceasefire (+0x18/+0x1a/+0x1e)",
|
"prototype": "void (StrategyServer* this, std::vector<Encounter>* encounters, std::vector<EncounterResults>* results) // two passes over ServerPlayer+0x230 vector<DiplomacyStats>, no RNG, no events. PASS A (dead homeworld): if the battle was at a player's own HomeSys(+0x2c), was a real battle (result->+0x4 == 0), had planet stats (result->+0x10c != 0) and the INT64 at result+0x120 is <= 0, then every participant that actually fought them gets deadhome(+0x20)++. PASS B (treaty betrayal): for every ordered pair with GetRelation < 1, a treaty slot signed within the last 3 turns and not yet betrayed since signing (`last != -1 && turn-last < 3 && (bty == -1 || bty < last)`), where the other side actually fought -- bty++ and lastXbty = turn, independently for NAP (+0x8/+0xa/+0xe), alliance (+0x10/+0x12/+0x16) and ceasefire (+0x18/+0x1a/+0x1e)",
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/control-flow/combat-done-tail.md \u00a72A (lane K 2026-09-08). Call shape instruction-verified; body decompiler-derived",
|
"source": "findings/control-flow/combat-done-tail.md §2A (lane K 2026-09-08). Call shape instruction-verified; body decompiler-derived",
|
||||||
"confidence": "med"
|
"confidence": "med"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -176,7 +184,7 @@
|
||||||
"convention": "thiscall",
|
"convention": "thiscall",
|
||||||
"prototype": "void (StrategyServer* this, Encounter* enc, EncounterResults* res) // phase 6 of OnAllCombatDone_Tail. Dispatch on three result bytes: res->+0x4 != 0 makes the WHOLE FUNCTION A NO-OP (that flag means 'no battle happened', which is exactly what phase 3's sighting arm keys on); res->+0x6 != 0 -> FUN_007a06a0 (posts EVENT_PEACEFUL_ENCOUNTER); res->+0x7 != 0 -> FUN_007d3eb0 (posts EVENT_SYSTEM_SURRENDERED); otherwise the full path -- stamp StarShip+0x5c = turn on every participating ship; set the pairwise HasEng(+0x1ac) engagement bits; if enc->+0x3c stamp ServerPlayer+0x3d4 = turn; build a ~0xea0-byte combat report and run THE REAL RESOLVER FUN_007d5af0 (7499 B, UNREAD); append a Game::CombatReport to the std::list at S+0x1fc; fire the script hook vt[0x10](7,..)/vt[0x30](..). ALL branches then run a publication tail that push_backs a 0x30-byte Game::ClientEncounterResults into *(S+0x2f4) + PlyrIdx*0x11c + 0x24. DRAWS RNG through its subtree: FUN_007d5af0 -> FUN_007bb530 -> RNG_NextInt (node cannon), and -> FUN_007a7f30 -> RNG_Twist plus -> FUN_007a0540 -> FUN_00852d30 -> RNG_NextInt (salvage / back-engineering)",
|
"prototype": "void (StrategyServer* this, Encounter* enc, EncounterResults* res) // phase 6 of OnAllCombatDone_Tail. Dispatch on three result bytes: res->+0x4 != 0 makes the WHOLE FUNCTION A NO-OP (that flag means 'no battle happened', which is exactly what phase 3's sighting arm keys on); res->+0x6 != 0 -> FUN_007a06a0 (posts EVENT_PEACEFUL_ENCOUNTER); res->+0x7 != 0 -> FUN_007d3eb0 (posts EVENT_SYSTEM_SURRENDERED); otherwise the full path -- stamp StarShip+0x5c = turn on every participating ship; set the pairwise HasEng(+0x1ac) engagement bits; if enc->+0x3c stamp ServerPlayer+0x3d4 = turn; build a ~0xea0-byte combat report and run THE REAL RESOLVER FUN_007d5af0 (7499 B, UNREAD); append a Game::CombatReport to the std::list at S+0x1fc; fire the script hook vt[0x10](7,..)/vt[0x30](..). ALL branches then run a publication tail that push_backs a 0x30-byte Game::ClientEncounterResults into *(S+0x2f4) + PlyrIdx*0x11c + 0x24. DRAWS RNG through its subtree: FUN_007d5af0 -> FUN_007bb530 -> RNG_NextInt (node cannon), and -> FUN_007a7f30 -> RNG_Twist plus -> FUN_007a0540 -> FUN_00852d30 -> RNG_NextInt (salvage / back-engineering)",
|
||||||
"status": "mapped",
|
"status": "mapped",
|
||||||
"source": "findings/control-flow/combat-done-tail.md \u00a72A.1 (lane K 2026-09-08). Call shape instruction-verified; body decompiler-derived and FUN_007d5af0 unread",
|
"source": "findings/control-flow/combat-done-tail.md §2A.1 (lane K 2026-09-08). Call shape instruction-verified; body decompiler-derived and FUN_007d5af0 unread",
|
||||||
"confidence": "med"
|
"confidence": "med"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -185,7 +193,7 @@
|
||||||
"convention": "thiscall",
|
"convention": "thiscall",
|
||||||
"prototype": "void (Node* this /*= enc->+0xc, the encounter's system*/, byte mask) // `if (!this->+0x100 /*owner*/) return; for each fleet at the node (vt[8] count, vt[0x10] get): if (owner->GetRelation(fleet->PID(+0x58)) == 3) for each ship in fleet->NShips(+0xa4/+0xa8) StarShip::RefreshFromDesign(ship, mask);`. FUN_00854680(ship,1) copies ship->+0x20 = design->+0xe8 and ship->+0x6c = design->+0xd8 then runs five recompute helpers -- a repair/refuel/stat refresh, not a movement step. strategic-turn-internals.md line 320 already calls it RefuelInOrbit(1); called from OnAllCombatDone_Tail phase 6 with mask = 1",
|
"prototype": "void (Node* this /*= enc->+0xc, the encounter's system*/, byte mask) // `if (!this->+0x100 /*owner*/) return; for each fleet at the node (vt[8] count, vt[0x10] get): if (owner->GetRelation(fleet->PID(+0x58)) == 3) for each ship in fleet->NShips(+0xa4/+0xa8) StarShip::RefreshFromDesign(ship, mask);`. FUN_00854680(ship,1) copies ship->+0x20 = design->+0xe8 and ship->+0x6c = design->+0xd8 then runs five recompute helpers -- a repair/refuel/stat refresh, not a movement step. strategic-turn-internals.md line 320 already calls it RefuelInOrbit(1); called from OnAllCombatDone_Tail phase 6 with mask = 1",
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/control-flow/combat-done-tail.md \u00a72A (lane K 2026-09-08)"
|
"source": "findings/control-flow/combat-done-tail.md §2A (lane K 2026-09-08)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "StrategyServer_FinalizeTurnRecords",
|
"name": "StrategyServer_FinalizeTurnRecords",
|
||||||
|
|
@ -193,7 +201,7 @@
|
||||||
"convention": "thiscall",
|
"convention": "thiscall",
|
||||||
"prototype": "void (StrategyServer* this) // the LAST call of OnAllCombatDone_Tail (0x007d98ba), and also called from LoadGame 0x007ddc40 -- so the per-player turn record is rebuilt at the end of every turn AND on load, and never has to survive a save round-trip. Per player, rec = P->+0x3d8: rec+0x0c (32) = P->Sav(+0x284) - P->PvSav(+0x188); rec+0x14 (32) = P->Sav; rec+0x18 (16, mov WORD) = (P->+0x34 - P->+0x30)>>2 owned systems; rec+0x28 (16) = completed-tech count from FUN_0057d980 over the tree's +0x10/+0x14 with state == 4; rec+0x20/+0x24 (int64, cdq/add/adc) = SUM over owned systems of (sys->+0x194 + sys->+0x18c) total population; rec+0x2a/+0x2c/+0x2e (16) = ship counts by hull size 0/1/2 for designs WITHOUT flag 0x400; rec+0x30/+0x32/+0x34 (16) = the same for designs WITH flag 0x400. The census comes from FUN_00818a50(P, int[8]) whose slots [0] and [1] (the grand totals) are computed and DISCARDED. Second loop: FUN_00894260(S->+0x200, i, S->+0xc, rec) archives the record by turn; the archive's copy-assign FUN_008712a0 deliberately does NOT copy +0x1c",
|
"prototype": "void (StrategyServer* this) // the LAST call of OnAllCombatDone_Tail (0x007d98ba), and also called from LoadGame 0x007ddc40 -- so the per-player turn record is rebuilt at the end of every turn AND on load, and never has to survive a save round-trip. Per player, rec = P->+0x3d8: rec+0x0c (32) = P->Sav(+0x284) - P->PvSav(+0x188); rec+0x14 (32) = P->Sav; rec+0x18 (16, mov WORD) = (P->+0x34 - P->+0x30)>>2 owned systems; rec+0x28 (16) = completed-tech count from FUN_0057d980 over the tree's +0x10/+0x14 with state == 4; rec+0x20/+0x24 (int64, cdq/add/adc) = SUM over owned systems of (sys->+0x194 + sys->+0x18c) total population; rec+0x2a/+0x2c/+0x2e (16) = ship counts by hull size 0/1/2 for designs WITHOUT flag 0x400; rec+0x30/+0x32/+0x34 (16) = the same for designs WITH flag 0x400. The census comes from FUN_00818a50(P, int[8]) whose slots [0] and [1] (the grand totals) are computed and DISCARDED. Second loop: FUN_00894260(S->+0x200, i, S->+0xc, rec) archives the record by turn; the archive's copy-assign FUN_008712a0 deliberately does NOT copy +0x1c",
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/control-flow/combat-done-tail.md \u00a75 (lane K 2026-09-08, field-by-field instruction read)"
|
"source": "findings/control-flow/combat-done-tail.md §5 (lane K 2026-09-08, field-by-field instruction read)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "ServerSystem_ComputeMaxIncome",
|
"name": "ServerSystem_ComputeMaxIncome",
|
||||||
|
|
@ -209,8 +217,7 @@
|
||||||
"convention": "note",
|
"convention": "note",
|
||||||
"prototype": "Game::SNMAllCombatDone RTTI vtable, four slots (0x0079e590, 0x0082a100, 0x0082a170, 0x0079e500 -- the middle pair are the network Read/Write). Layout by enumeration from the two stack constructors and from every offset OnAllCombatDone_Tail reads: `struct SNMAllCombatDone { void* vptr; std::vector<EncounterResults> results; }`, 0x10 bytes -- which is why the handler passes msg+4 and not msg. Three construction sites: RunCombatRound 0x007cc847 (stack), the combat server FUN_007cfd00+0x541 = 0x007d0241 (stack; sends it to every player whose +0x44 is 4 or 5, then sets combatServer->+0x60 = 9; NOTE Ghidra sizes FUN_007cfd00 at 384 B but its real body runs to the ret at 0x007d02b9), and the deserialization factory 0x008663b0 (operator new(0x14) -- 4 bytes larger than the enumerated size, UNEXPLAINED)",
|
"prototype": "Game::SNMAllCombatDone RTTI vtable, four slots (0x0079e590, 0x0082a100, 0x0082a170, 0x0079e500 -- the middle pair are the network Read/Write). Layout by enumeration from the two stack constructors and from every offset OnAllCombatDone_Tail reads: `struct SNMAllCombatDone { void* vptr; std::vector<EncounterResults> results; }`, 0x10 bytes -- which is why the handler passes msg+4 and not msg. Three construction sites: RunCombatRound 0x007cc847 (stack), the combat server FUN_007cfd00+0x541 = 0x007d0241 (stack; sends it to every player whose +0x44 is 4 or 5, then sets combatServer->+0x60 = 9; NOTE Ghidra sizes FUN_007cfd00 at 384 B but its real body runs to the ret at 0x007d02b9), and the deserialization factory 0x008663b0 (operator new(0x14) -- 4 bytes larger than the enumerated size, UNEXPLAINED)",
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/control-flow/combat-done-tail.md \u00a70.1 (lane K 2026-09-08)"
|
"source": "findings/control-flow/combat-done-tail.md §0.1 (lane K 2026-09-08)"
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"_note": "Dropped 2026-09-08 as same-address duplicates, agreement recorded: TurnEvents_serializer_direction -> Game_EventStorage_TurnEvents_Write (addresses.json)."
|
}
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,14 @@
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/subsystems/output-term.md (lane N 2026-09-08)"
|
"source": "findings/subsystems/output-term.md (lane N 2026-09-08)"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "SpeciesDefTable_Get",
|
||||||
|
"addr": "0x00545cc0",
|
||||||
|
"convention": "cdecl",
|
||||||
|
"prototype": "SpeciesDef* (int species) // table base 0x00b10a00, stride 0x184, seven rows; species > 6 returns a lazily-constructed default at 0x00b105b0. Fields read by the output chain: +0x4c the base resource demand (an int) and +0x50 the resource output factor (a float). The table is .bss, so both come from the data files",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/subsystems/output-term.md (lane N 2026-09-08)"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "SignedCubeRoot",
|
"name": "SignedCubeRoot",
|
||||||
"addr": "0x008e5680",
|
"addr": "0x008e5680",
|
||||||
|
|
@ -120,6 +128,14 @@
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/subsystems/output-term.md (lane N 2026-09-08)"
|
"source": "findings/subsystems/output-term.md (lane N 2026-09-08)"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "SpeciesDefTable_base",
|
||||||
|
"addr": "0x00b10a00",
|
||||||
|
"convention": "data",
|
||||||
|
"prototype": "SpeciesDef[7] -- .bss, filled from the data files. Stride 0x184",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/subsystems/output-term.md (lane N 2026-09-08)"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "GlobalConst_slot_STATION_BONUS_IMPERIAL_OUTPUT",
|
"name": "GlobalConst_slot_STATION_BONUS_IMPERIAL_OUTPUT",
|
||||||
"addr": "0x00af08f4",
|
"addr": "0x00af08f4",
|
||||||
|
|
@ -168,6 +184,5 @@
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/subsystems/output-term.md (lane N 2026-09-08)"
|
"source": "findings/subsystems/output-term.md (lane N 2026-09-08)"
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"_note": "Dropped 2026-09-08 as same-address duplicates, agreement recorded: SpeciesDefTable_Get -> SpeciesDef_Get (addresses.json); SpeciesDefTable_base -> g_SpeciesDefTable (addresses.json)."
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
{
|
|
||||||
"entries": [
|
|
||||||
{
|
|
||||||
"name": "SVScriptObject_DispatchEvent",
|
|
||||||
"addr": "0x007a60d0",
|
|
||||||
"convention": "thiscall",
|
|
||||||
"prototype": "int (Game::SVScriptObject* this, int evt, void* arg) // the script-object event bus. Calls this->vft[0x10](evt, arg) -- the GENERIC handler every object sees -- then `cmp evt,0x20; ja done; jmp dword [evt*4 + SVScriptObject_EventSlotJumpTable]`, which dispatches to ONE event-specific vtable slot with the argument shape that event carries. Every hand-written `vft[0x10](id,0); vft[slot]()` pair in the two turn drivers is this same two-step done on the root object",
|
|
||||||
"status": "verified",
|
|
||||||
"source": "findings/objects/svsctob-writers.md (lane SV 2026-09-08)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "SVScriptObject_EventSlotJumpTable",
|
|
||||||
"addr": "0x007a6480",
|
|
||||||
"convention": "data",
|
|
||||||
"prototype": "void* [33] // evt (0..0x20) -> the vtable slot SVScriptObject_DispatchEvent calls. Slot byte offsets in evt order: 0x14 0x18 0x1c 0x20 0x24 0x28 0x2c 0x30 0x34 0x38 0x3c 0x40 0x44 0x48 0x4c 0x50 0x54 0x58 0x5c 0x60 0x64 0x6c 0x70 0x74 0x68 0x7c 0x80 0x84 0x78 0x88 0x8c 0x90 0x94. Note 0x15->+0x6c, 0x16->+0x70, 0x17->+0x74, 0x18->+0x68 and 0x1c->+0x78 are NOT in slot order",
|
|
||||||
"status": "verified",
|
|
||||||
"source": "findings/objects/svsctob-writers.md (lane SV 2026-09-08)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "SVSOSots_HandleEvent",
|
|
||||||
"addr": "0x005a7e40",
|
|
||||||
"convention": "thiscall",
|
|
||||||
"prototype": "int (Game::SVSOSots* this, int evt, void* arg) // Game::SVSOSots vftable 0x00A063C4 slot +0x10, the ONLY slot that class overrides. evt 3 or 0x1b re-runs the new-game seeder 0x005a7d70; evt 0x1a runs 0x005a37e0; then it fans the delivery out to every child in the pointer vector at this+0x1c..0x20 through SVScriptObject_DispatchEvent. The loop re-reads both bounds every iteration, so a callee may resize the child vector under it",
|
|
||||||
"status": "verified",
|
|
||||||
"source": "findings/objects/svsctob-writers.md (lane SV 2026-09-08)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "SVSOSlaversRefuel_HandleEvent",
|
|
||||||
"addr": "0x0051a800",
|
|
||||||
"convention": "thiscall",
|
|
||||||
"prototype": "int (Game::SVSOSlaversRefuel* this, int evt, void* arg) // generic handler; the class overrides no event-specific slot at all. Body is `if (evt == 0x14) SVSOSlaversRefuel_UpdateDifficultyTier(this)` and nothing else",
|
|
||||||
"status": "verified",
|
|
||||||
"source": "findings/objects/svsctob-writers.md (lane SV 2026-09-08)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "SVSOSlaversRefuel_UpdateDifficultyTier",
|
|
||||||
"addr": "0x00515820",
|
|
||||||
"convention": "thiscall",
|
|
||||||
"prototype": "void (Game::SVSOSlaversRefuel* this) // writes CDiff at this+0x38. Builds a 3x3-dword table on the stack -- thresholds 1 / 50 / 100 -- and scans for the FIRST threshold GREATER than StrategyServer+0xc (the frame), then stores index-1 if it differs from the stored value. Frame <= 0 exits at index 0; frame >= 100 runs off the end and stores NOTHING, so the tier can never reach 2. Only on a change does it continue into the per-system pass at 0x005158d4",
|
|
||||||
"status": "verified",
|
|
||||||
"source": "findings/objects/svsctob-writers.md (lane SV 2026-09-08)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "SVSORefugees_OnTurnBegin",
|
|
||||||
"addr": "0x00511260",
|
|
||||||
"convention": "thiscall",
|
|
||||||
"prototype": "void (Game::SVSORefugees* this) // vtable slot +0x60, event 0x13, sent from BeginProcessTurn. `if (!this->ini(+0x14)) { this->ini = 1; obj = <instantiate \"Mission\" / \"_Refugee_Trader\" from the data files>; if (obj) this->dids(+0x18).push_back(obj->handle(+0xa0)->id(+4)); }`. The store to the latch is unconditional on the lookup's result",
|
|
||||||
"status": "verified",
|
|
||||||
"source": "findings/objects/svsctob-writers.md (lane SV 2026-09-08)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "SVSORefugees_OnCombatDone",
|
|
||||||
"addr": "0x00511310",
|
|
||||||
"convention": "thiscall",
|
|
||||||
"prototype": "void (Game::SVSORefugees* this) // vtable slot +0x34, event 8, sent from OnAllCombatDone_Tail phase 8. Walks StrategyServer+0x44 (Systems) and drains the object vector at this+0x28..0x2c, destroying what it resolves. It does NOT write dids -- that is SVSORefugees_OnTurnBegin",
|
|
||||||
"status": "verified",
|
|
||||||
"source": "findings/objects/svsctob-writers.md (lane SV 2026-09-08)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "SVSOSwarmQueen_OnTurnBegin",
|
|
||||||
"addr": "0x00529930",
|
|
||||||
"convention": "thiscall",
|
|
||||||
"prototype": "void (Game::SVSOSwarmQueen* this) // vtable slot +0x60, event 0x13. Runs SVSOSwarmQueen_RegisterHives, then prunes hives whose system's EggScio (+0x184) no longer equals this->scenarioTag (+0x4), then SVSOSwarmQueen_TickHives and 0x00505100",
|
|
||||||
"status": "verified",
|
|
||||||
"source": "findings/objects/svsctob-writers.md (lane SV 2026-09-08)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "SVSOSwarmQueen_RegisterHives",
|
|
||||||
"addr": "0x00527630",
|
|
||||||
"convention": "thiscall",
|
|
||||||
"prototype": "void (Game::SVSOSwarmQueen* this) // for every system in StrategyServer+0x44 whose EggScio (+0x184) equals this->scenarioTag (+0x4, which the ctor sets to 3 -- the SWARM's tag, not the queen's own EncID 10), and that no hive already references, appends a HiveInfo {vptr 0x009f1a68, sys, nextQ, queen=0} to the vector at this+0x10..0x14, stride 0x10. nextQ = frame + *(int*)[0x00ae0204] + RNG_NextInt (0x004271c0, already in addresses.json; INCLUSIVE of its bound) over (*(int*)[0x00ae0208] - *(int*)[0x00ae0204]) -- ONE strategic-generator draw per new hive, taken inside BeginProcessTurn and therefore OUTSIDE both turn drivers",
|
|
||||||
"status": "verified",
|
|
||||||
"source": "findings/objects/svsctob-writers.md (lane SV 2026-09-08)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "SVSOSwarmQueen_TickHives",
|
|
||||||
"addr": "0x00527770",
|
|
||||||
"convention": "thiscall",
|
|
||||||
"prototype": "void (Game::SVSOSwarmQueen* this) // per hive with queen (+0xc) == 0: if any spawn gate fails, `inc [hive+8]` -- the target turn SLIPS FORWARD BY ONE, which is why NextQ reads 31 after turn 1 and 32 after turn 2. Otherwise, if nextQ <= frame, spawn a queen (0x0050dfc0 then 0x004fe810) and append to the Queens vector at this+0x20. Gates read config pointers at 0x00ae0210, 0x00ae0228 and 0x00ae0220",
|
|
||||||
"status": "verified",
|
|
||||||
"source": "findings/objects/svsctob-writers.md (lane SV 2026-09-08)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "SVSOSwarmQueen_OnTurnEnd",
|
|
||||||
"addr": "0x005275d0",
|
|
||||||
"convention": "thiscall",
|
|
||||||
"prototype": "void (Game::SVSOSwarmQueen* this) // vtable slot +0x64, event 0x14, sent from OnAllCombatDone_Tail phase 20. The same hive prune as the turn-begin handler, then 0x00521150. It does NOT register hives",
|
|
||||||
"status": "verified",
|
|
||||||
"source": "findings/objects/svsctob-writers.md (lane SV 2026-09-08)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "SVSOSwarmQueen_Ctor",
|
|
||||||
"addr": "0x0051ae20",
|
|
||||||
"convention": "cdecl",
|
|
||||||
"prototype": "Game::SVSOSwarmQueen* () // operator new(0x40); vftable 0x009f49e4; and the two ids that settle who the queen works for: [+0x4] = 3 (the scenario tag it selects systems by) and [+0x8] = 10 (its own EncID)",
|
|
||||||
"status": "verified",
|
|
||||||
"source": "findings/objects/svsctob-writers.md (lane SV 2026-09-08)"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"_note": "Dropped 2026-09-08 as same-address duplicates, agreement recorded: SVSOSwarmQueen_HiveInfo_Write -> Game_SVSOSwarmQueen_HiveInfo_Write (addresses.json)."
|
|
||||||
}
|
|
||||||
|
|
@ -392,6 +392,14 @@
|
||||||
"status": "mapped",
|
"status": "mapped",
|
||||||
"source": "findings/control-flow/turn-driver.md \u00a71, \u00a75 (lane T 2026-09-08, ReVa)"
|
"source": "findings/control-flow/turn-driver.md \u00a71, \u00a75 (lane T 2026-09-08, ReVa)"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "g_flt_ResearchRollProgressThreshold",
|
||||||
|
"addr": "0x00a2c788",
|
||||||
|
"convention": "data",
|
||||||
|
"prototype": "const float = 0.5f. The ONLY consumer is the ResearchRollPending block in ServerPlayer::ProcessTurn: the roll fires when 0.5f < progress/Cost, strictly. Not a registered config key - it is an image literal",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/control-flow/turn-driver.md \u00a73 (lane T 2026-09-08, bytes read from the image)"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "g_flt_RebOutModDecay",
|
"name": "g_flt_RebOutModDecay",
|
||||||
"addr": "0x00a17870",
|
"addr": "0x00a17870",
|
||||||
|
|
@ -416,6 +424,5 @@
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08)"
|
"source": "findings/control-flow/turn-driver.md \u00a72.1 (lane T 2026-09-08)"
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"_note": "Dropped 2026-09-08 as same-address duplicates, agreement recorded: g_flt_ResearchRollProgressThreshold -> ResearchRollProgressThreshold (lane-o.json)."
|
|
||||||
}
|
}
|
||||||
|
|
@ -79,7 +79,22 @@
|
||||||
"prototype": "site inside TechTree::ProcessResearch: the tail loop that collects the newly available nodes for EVENT_TECHS_UNLOCKED. Runs only when tree->owner != 0, after the per-node loop AND after the decay sweep. Collects every node n with n != NULL, n->def != NULL, p = tree->nodes[n->def->techId] != NULL, p->state (+0x14) == 2, and n->turnAvailable (+0x20) == the owner's ModCount. Posts once if the collected vector is non-empty. NOTE the asymmetry: the state test is on the SELF-RESOLVED node p, the turn test on the iterated node n",
|
"prototype": "site inside TechTree::ProcessResearch: the tail loop that collects the newly available nodes for EVENT_TECHS_UNLOCKED. Runs only when tree->owner != 0, after the per-node loop AND after the decay sweep. Collects every node n with n != NULL, n->def != NULL, p = tree->nodes[n->def->techId] != NULL, p->state (+0x14) == 2, and n->turnAvailable (+0x20) == the owner's ModCount. Posts once if the collected vector is non-empty. NOTE the asymmetry: the state test is on the SELF-RESOLVED node p, the turn test on the iterated node n",
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/subsystems/unlock-cascade.md (lane U 2026-09-08, get-decompilation 0x005876c0 lines 294-309)"
|
"source": "findings/subsystems/unlock-cascade.md (lane U 2026-09-08, get-decompilation 0x005876c0 lines 294-309)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ServerPlayer_OnTechResearched_RecordObservedTech",
|
||||||
|
"addr": "0x00891790",
|
||||||
|
"convention": "site",
|
||||||
|
"prototype": "site at the very head of ServerPlayer::OnTechResearched: RecordObservedTech is the FIRST statement, called unconditionally on every completion -- before the ResT/roll block and before the !silent event post. It de-duplicates by tech name, so the observed-tech vector grows by one 0x2c element per completion of a tech not already observed and by nothing otherwise",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/subsystems/unlock-cascade.md (lane U 2026-09-08, decompilation of 0x00891790 line 78)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ServerPlayer_OnTechResearched_ResearchRollBlock",
|
||||||
|
"addr": "0x00891790",
|
||||||
|
"convention": "site",
|
||||||
|
"prototype": "site in ServerPlayer::OnTechResearched, second statement: `if (this->ResT(+0x294) == def) { if (this->ResearchRollPending(+0x3b4)) RollResearchEvent(this); this->ResearchRollPending = 0; this->ResT = 0; }`. RollResearchEvent (0x0088df20) draws ONE NextFloat unconditionally and then enters ServerPlayer_OnResearchRollSucceeded (0x00889d60) only when roll < ResearchEventOdds -- the odds are 0 for every tech outside the plague and AI-rebellion families, so that branch is normally dead. CORRECTED BY LANE K 2026-09-08: that one word is the cost of REACHING the branch, not of a fired roll -- the plague path draws a SECOND word (NextInt) and posts EVENT_PLAGUE_OUTBREAK, the rebellion path cancels the research. A fired roll costs one or two words. This is the extra RNG a completion consumes, and clearing ResT means a second completion in the same pass consumes none",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/subsystems/unlock-cascade.md (lane U 2026-09-08, decompilation of 0x00891790 lines 79-85, 0x0088df20, 0x00889d60)"
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"_note": "Dropped 2026-09-08 as same-address duplicates, agreement recorded: ServerPlayer_OnTechResearched_RecordObservedTech -> ServerPlayer_OnTechResearched (addresses.json); ServerPlayer_OnTechResearched_ResearchRollBlock -> ServerPlayer_OnTechResearched (addresses.json)."
|
}
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,14 @@
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/objects/svsctob-variants.md (lane W 2026-09-08)"
|
"source": "findings/objects/svsctob-variants.md (lane W 2026-09-08)"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "SVSOCrowDefenders_Write",
|
||||||
|
"addr": "0x004f8c90",
|
||||||
|
"convention": "thiscall",
|
||||||
|
"prototype": "void (Game::SVSOCrowDefenders* this, Mars::IStream* s) // sys; ndsys count then a loop writing dsys; ndes count then a loop writing des; drad. NOTE: `dsys` is INSIDE the ndsys loop -- objects/layouts.json records it as a plain member, which is wrong, and no save can settle it because both counts are 0 everywhere",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/objects/svsctob-variants.md (lane W 2026-09-08)"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "SVSOMonitor_Write",
|
"name": "SVSOMonitor_Write",
|
||||||
"addr": "0x004fd810",
|
"addr": "0x004fd810",
|
||||||
|
|
@ -96,6 +104,5 @@
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/objects/svsctob-variants.md (lane W 2026-09-08)"
|
"source": "findings/objects/svsctob-variants.md (lane W 2026-09-08)"
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"_note": "Dropped 2026-09-08 as same-address duplicates, agreement recorded: SVSOCrowDefenders_Write -> Game_SVSOCrowDefenders_Write (addresses.json)."
|
}
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,14 @@
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/control-flow/tail-rng-ledger.md (lane Z 2026-09-08)"
|
"source": "findings/control-flow/tail-rng-ledger.md (lane Z 2026-09-08)"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "StrategyServer_wire_ModCount_vs_Frame",
|
||||||
|
"addr": "0x0079fb2f",
|
||||||
|
"convention": "note",
|
||||||
|
"prototype": "NAME CORRECTION, from StrategyServer::Write's own wire tags. At 0x0079fb2f `lea edx,[edi+0x08]; push \"ModCount\"` and at 0x0079fb40 `lea eax,[edi+0x0c]; push \"Frame\"`, with edi = S (the same edi that indexes the players vector at +0x54). So in the S frame **S+0x8 is ModCount and S+0xc is Frame**, i.e. in the stored (S+4) frame +0x4 is ModCount and +0x8 is Frame. `StrategyServer_off_ModCount = 0x8` therefore carries the WRONG NAME: that word is Frame, the turn number. The word it names is the one lane T recorded as StrategyServer_off_PhaseCounter = 0x4 and lane K called 'never named' -- it has a name, and it is ModCount. CONFIRMED FROM THE SAVES, which is an independent instrument: Frame reads 1/2/3 on turn1/2/3-state, 16 on zuul-turn16, 23 on zuul-turn23, while ModCount reads 0/12/24/241/412. And CONFIRMED LIVE: lane Z measured S+0x8 advancing 12, 14, 12 per turn on the early Human game (the saves say +12/turn) and 16, 21, 44 on the Zuul one (the saves say ~24/turn average). A modification counter is exactly what those numbers look like, and it explains why only 2 of the 12-44 increments come from the two turn drivers. Integrator: reconcile StrategyServer_off_ModCount / StrategyServer_off_PhaseCounter rather than adding a third name",
|
||||||
|
"status": "verified",
|
||||||
|
"source": "findings/control-flow/tail-rng-ledger.md (lane Z 2026-09-08; wire tags from the instruction stream, values from save_reader over five saves, deltas from the live trace)"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "StrategyServer_ctor_VectorBlock",
|
"name": "StrategyServer_ctor_VectorBlock",
|
||||||
"addr": "0x0085b120",
|
"addr": "0x0085b120",
|
||||||
|
|
@ -136,6 +144,5 @@
|
||||||
"status": "verified",
|
"status": "verified",
|
||||||
"source": "findings/control-flow/tail-rng-ledger.md \u00a711.1 (lane Z 2026-09-08)"
|
"source": "findings/control-flow/tail-rng-ledger.md \u00a711.1 (lane Z 2026-09-08)"
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"_note": "Dropped 2026-09-08 as same-address duplicates, agreement recorded: StrategyServer_wire_ModCount_vs_Frame -> StrategyServer_Write_ModCountFrameTags (lane-a2.json)."
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
|
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
|
||||||
// Source: sots-re ghidra/addresses.json @ 2e935b8, generated 2026-09-08 by tools/gen_addresses.py
|
// Source: sots-re ghidra/addresses.json @ 471d6cb, generated 2026-09-08 by tools/gen_addresses.py
|
||||||
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
|
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
|
@ -1439,6 +1439,8 @@ constexpr uint32_t StrategyAIAgent_AssignFleetsAndIssueOrders = 0x002c16c0;
|
||||||
constexpr uint32_t StrategyAIAgent_IsClaimedByAnotherTask = 0x002a8d20;
|
constexpr uint32_t StrategyAIAgent_IsClaimedByAnotherTask = 0x002a8d20;
|
||||||
// thiscall int __thiscall Game::StrategyAIAgent::RangePenaltyForTask() -- the ONLY consumer of IAITask vtable slot 13 found in the image, dispatched at 0x00696630 on the `this` receiver. `budget = this ? this->vt[13]() : 15; n = max(1, agent->+0x10->+0x8 - 0x0080da80(player) + 1); if (n < budget) return 0;` else a 7-arm species switch on player->+0x5c through the byte index at 0x006966a8 = [0,0,0,0,2,1,0] and the table at 0x0069669c: species 0,1,2,3,4,6 and out-of-range -> 1000000 (0x000f4240), species 5 (Zuul) -> 0. So slot 13 is a RANGE/HOP BUDGET compared against a count, with a prohibitive penalty past it -- and the Zuul are exempt, a FOURTH independent cross-check on lane AI2's species reading (after Hiver gates, Zuul node-bore and NPC building nothing). The two 'Incoming' defence tasks return INT_MAX from slot 13, so they never take the penalty [verified]
|
// thiscall int __thiscall Game::StrategyAIAgent::RangePenaltyForTask() -- the ONLY consumer of IAITask vtable slot 13 found in the image, dispatched at 0x00696630 on the `this` receiver. `budget = this ? this->vt[13]() : 15; n = max(1, agent->+0x10->+0x8 - 0x0080da80(player) + 1); if (n < budget) return 0;` else a 7-arm species switch on player->+0x5c through the byte index at 0x006966a8 = [0,0,0,0,2,1,0] and the table at 0x0069669c: species 0,1,2,3,4,6 and out-of-range -> 1000000 (0x000f4240), species 5 (Zuul) -> 0. So slot 13 is a RANGE/HOP BUDGET compared against a count, with a prohibitive penalty past it -- and the Zuul are exempt, a FOURTH independent cross-check on lane AI2's species reading (after Hiver gates, Zuul node-bore and NPC building nothing). The two 'Incoming' defence tasks return INT_MAX from slot 13, so they never take the penalty [verified]
|
||||||
constexpr uint32_t StrategyAIAgent_RangePenaltyForTask = 0x00296620;
|
constexpr uint32_t StrategyAIAgent_RangePenaltyForTask = 0x00296620;
|
||||||
|
// thiscall int __thiscall Game::IAITask::GetPriority_Default() -- 17 bytes: `return AITask_PriorityForType(this->vt[1]() /*GetTypeId*/);`, i.e. the vt[1] dispatch followed by a direct call to the 33-arm table at 0x00691f00. This is what the two tuned GetPriority overrides tail-jump to when their flag bit is CLEAR, so lane AI2's priority table stands with an extra hop in front of it [verified]
|
||||||
|
constexpr uint32_t AITask_GetPriorityDefaultThunk = 0x00294220;
|
||||||
// data int -- image-initialised value 650 (0x0000028a). AITInvade::GetPriority 0x00683670 is `movzx eax,byte [ecx+4]; not al; test al,1; je +5; jmp 0x00694220; mov eax,ds:0xa1795c; ret` -- so the tunable is returned when BIT 0 OF this->+0x4 IS SET, which is the OPPOSITE of lane AI2's stated `if (!(this->+0x4 & 1))`. It has EXACTLY ONE reference in the whole image (this load) and no writer anywhere: no loader, no CSV path. It is a code constant that happens to live in the writable data section. AITInvade's table priority is 500, so the flag raises it to 650 [verified]
|
// data int -- image-initialised value 650 (0x0000028a). AITInvade::GetPriority 0x00683670 is `movzx eax,byte [ecx+4]; not al; test al,1; je +5; jmp 0x00694220; mov eax,ds:0xa1795c; ret` -- so the tunable is returned when BIT 0 OF this->+0x4 IS SET, which is the OPPOSITE of lane AI2's stated `if (!(this->+0x4 & 1))`. It has EXACTLY ONE reference in the whole image (this load) and no writer anywhere: no loader, no CSV path. It is a code constant that happens to live in the writable data section. AITInvade's table priority is 500, so the flag raises it to 650 [verified]
|
||||||
constexpr uint32_t g_AITInvadeUncommittedPriority = 0x0061795c;
|
constexpr uint32_t g_AITInvadeUncommittedPriority = 0x0061795c;
|
||||||
// data int -- image-initialised value 750 (0x000002ee), the twin of 0x00a1795c. AITEscortGateInvade::GetPriority 0x006835e0 has the identical shape and the identical inverted polarity: the tunable applies when bit 0 of this->+0x4 IS SET. Exactly one reference in the image, no writer. AITEscortGateInvade's table priority is 400, so the flag raises it to 750 [verified]
|
// data int -- image-initialised value 750 (0x000002ee), the twin of 0x00a1795c. AITEscortGateInvade::GetPriority 0x006835e0 has the identical shape and the identical inverted polarity: the tunable applies when bit 0 of this->+0x4 IS SET. Exactly one reference in the image, no writer. AITEscortGateInvade's table priority is 400, so the flag raises it to 750 [verified]
|
||||||
|
|
@ -1537,6 +1539,8 @@ constexpr uint32_t StrategyServer_DestroyFleet = 0x0048b980;
|
||||||
constexpr uint32_t StrategyServer_OrderFleetMove = 0x004653c0;
|
constexpr uint32_t StrategyServer_OrderFleetMove = 0x004653c0;
|
||||||
// thiscall void** (std::map<int, void*>* this, const int* key) // 125 B, ret 4. MSVC std::map<int,T*>::operator[]: _Lbound over the tree from this->_Myhead(+0x04)->_Parent, testing _Isnil at node+0x15 and the key at node+0x0c; if found returns &node->_Myval.second (node+0x10), else default-inserts the pair {key, 0} via _Buynode 0x008b91d0 + _Insert 0x0072b400 and returns the same. NODE IS 0x18 BY ENUMERATION from _Buynode's operator new(0x18): _Left +0x00, _Parent +0x04, _Right +0x08, pair<int,void*> at +0x0c/+0x10, and _Color/_Isnil written as ONE 16-bit store at +0x14/+0x15, plus 2 bytes padding. DELEGATED instruction-level read [verified]
|
// thiscall void** (std::map<int, void*>* this, const int* key) // 125 B, ret 4. MSVC std::map<int,T*>::operator[]: _Lbound over the tree from this->_Myhead(+0x04)->_Parent, testing _Isnil at node+0x15 and the key at node+0x0c; if found returns &node->_Myval.second (node+0x10), else default-inserts the pair {key, 0} via _Buynode 0x008b91d0 + _Insert 0x0072b400 and returns the same. NODE IS 0x18 BY ENUMERATION from _Buynode's operator new(0x18): _Left +0x00, _Parent +0x04, _Right +0x08, pair<int,void*> at +0x0c/+0x10, and _Color/_Isnil written as ONE 16-bit store at +0x14/+0x15, plus 2 bytes padding. DELEGATED instruction-level read [verified]
|
||||||
constexpr uint32_t Map_IntPtr_Subscript = 0x0036bce0;
|
constexpr uint32_t Map_IntPtr_Subscript = 0x0036bce0;
|
||||||
|
// site The byte AFTER Game::BuildQueue::ProcessTurn's last instruction (`ret 0x8` at 0x00891226, then int3 padding). Ghidra reports the function as 1230 bytes from 0x00890d50, i.e. ending at 0x0089121e -- INSIDE the epilogue, before the security cookie check. The body is 1264 bytes. Earned rule 17. Note that this address is ALSO the entry of SystemBuildQueue_AttachBuiltShip, the function's own slot-10 callee, which is why the fleet half of construction looked absent from the pass [verified]
|
||||||
|
constexpr uint32_t BuildQueue_ProcessTurn_RealEnd = 0x00491240;
|
||||||
// field ServerPlayer* -- the player that owns the queue. Read at 0x00890de9 (passed to the ship factory), 0x00890ec9 (passed to the system's post-build hook), 0x00890ef4 (the base of the ShipRecords update) and 0x00890f75 (the build-completed event's owner field) [verified]
|
// field ServerPlayer* -- the player that owns the queue. Read at 0x00890de9 (passed to the ship factory), 0x00890ec9 (passed to the system's post-build hook), 0x00890ef4 (the base of the ShipRecords update) and 0x00890f75 (the build-completed event's owner field) [verified]
|
||||||
constexpr uint32_t BuildQueue_off_Owner = 0x0000000c;
|
constexpr uint32_t BuildQueue_off_Owner = 0x0000000c;
|
||||||
// field std::list<ShipBuildOrder> head sentinel. The pass walks it as `node = *(head); while (node != head) node = *node`, so it is the MSVC circular list. Read at 0x00890d9a and re-read every iteration at 0x00890d9d / 0x00891020 [verified]
|
// field std::list<ShipBuildOrder> head sentinel. The pass walks it as `node = *(head); while (node != head) node = *node`, so it is the MSVC circular list. Read at 0x00890d9a and re-read every iteration at 0x00890d9d / 0x00891020 [verified]
|
||||||
|
|
@ -1769,8 +1773,12 @@ constexpr uint32_t g_LanScanPortRange = 0x00713c70;
|
||||||
constexpr uint32_t g_GameSpyAvailableHostOverride = 0x007085b0;
|
constexpr uint32_t g_GameSpyAvailableHostOverride = 0x007085b0;
|
||||||
// data const char* /* when non-NULL, replaces "<gamename>.ms<N>.gamespy.com" in SBServerListConnect */ [mapped]
|
// data const char* /* when non-NULL, replaces "<gamename>.ms<N>.gamespy.com" in SBServerListConnect */ [mapped]
|
||||||
constexpr uint32_t g_GameSpyMasterHostOverride = 0x00709440;
|
constexpr uint32_t g_GameSpyMasterHostOverride = 0x00709440;
|
||||||
|
// thiscall void (ServerSystem* sys) // plain `ret`, REAL END 0x00754b59 -- the body is a loop over group types 0,1,2 whose back edge is at 0x00754b2e (`inc esi; cmp esi,3; jl 0x7543e9`) and lies outside every decompiler `if`, so the function reads as straight-line code if you stop at the first `ret`. Returns immediately when the system has no owner. Per group type: skip when Population::TotalOfType(Pop2, t) <= 0, then skip unless t == 1 -- so only CIVILIANS grow here. Per species: delta = PopGrowthDelta(1, sp); cur = Count(Pop2,1,sp) + Count(pbon2,1,sp); cap = MaxPopGeneric(1, sp, PID, NULL); soft = CivilianSettleLimit(sp); limit = soft < cap ? soft : cap (and soft < cap with cur+delta > soft raises a per-species settle flag); applied = min(delta, limit - cur); haltv[1] with applied > 0 zeroes both. The SYSTEM TOTAL is then clamped into [-50,000,000 (a literal here), POPTYPE[1]+0x08 = 20,000,000] and, when the clamp bit, every entry of the clamp's own sign is rescaled by trunc(applied x (clamped / total)) with NO renormalisation. Write-back is Population::SetCount(Pop2, 1, sp, Count(Pop2,1,sp) + applied) -- note the headroom counted pbon2 and the write does not. THE 20,000,000 CLAMP IS WHAT DECIDES THE VALUE on both reference pairs: the uncapped delta is 7.5x it and the capacity headroom 25x it [verified]
|
||||||
|
constexpr uint32_t ServerSystem_GrowCivilianPops_G3 = 0x00354220;
|
||||||
// thiscall int64 (ServerSystem* sys, int species) // `ret 4`, ends 0x0074aa2d. Returns 0 without an owner. Otherwise min( MaxPopGeneric(1, sp, PID, &StrategyServer::IdealSuit(server, sp)) , Population::Count(sys->dcs /*+0x104*/, 1, sp) ). The first term is the capacity the colony would have if the planet were perfectly suited; the second is the system's own `dcs` Population, which is on the wire. On Gamma Cephei `dcs` = 1e9 and it is the binding limit, not either capacity [verified]
|
// thiscall int64 (ServerSystem* sys, int species) // `ret 4`, ends 0x0074aa2d. Returns 0 without an owner. Otherwise min( MaxPopGeneric(1, sp, PID, &StrategyServer::IdealSuit(server, sp)) , Population::Count(sys->dcs /*+0x104*/, 1, sp) ). The first term is the capacity the colony would have if the planet were perfectly suited; the second is the system's own `dcs` Population, which is on the wire. On Gamma Cephei `dcs` = 1e9 and it is the binding limit, not either capacity [verified]
|
||||||
constexpr uint32_t ServerSystem_CivilianSettleLimit = 0x0034a9a0;
|
constexpr uint32_t ServerSystem_CivilianSettleLimit = 0x0034a9a0;
|
||||||
|
// thiscall int64 (ServerSystem* sys, int groupType, int species, ServerPlayer* p, float* suitOverride) // `ret 0x10`, REAL END 0x0074a6cd -- 0x0074a6d0 is a DIFFERENT function (lane N and E1 both cite 0x0074a6d0 for the capacity-surplus pair; that is the next one along, not this). Returns 0 when species == 4; when the OWNER species' SpeciesDef+0x168[groupType] is not > 0 (which is why Zuul have no civilians); when groupType != 0 and the owner is RebAI; when groupType == 1 and bit 3 of ServerPlayer+0x348[species] is clear; and when groupType == 2 and 0x0082bdf0(p, species) is false. Otherwise ftoi64( (int64)Size x 1e8 x [ hazard x (POPTYPE[t]+0x20 x SpeciesDef(sp)+0x168[t] x (crossSpecies ? SpeciesDef(sp)+0x174[t] : 1)) ] ) via 0x00535eb0, plus the arcology flat bonus 0x0080dd30(p, t), then clamped to the int64 at POPTYPE[t]+0x28 -- see PopTypeTableStaticInit, that clamp is always a no-op -- and finally, for groupType 0 ONLY, scaled by INDSYS_IMPERIAL_POPULATION_MOD when the owner species is 4 [verified]
|
||||||
|
constexpr uint32_t ServerSystem_MaxPopGeneric_G3 = 0x0034a4a0;
|
||||||
// thiscall float (SpeciesDef* d, int groupType) // `ret 4`, 15 bytes. Returns d->float[0x168 + groupType*4]. The per-species, per-population-group capacity factor -- a DATA FILE value. Its imperial entry for Human is pinned at exactly 1.0 by the corpus (Gamma Cephei's pbon never drains and its Pop never shrinks, which bracket the imperial capacity at Size x 1e8); the civilian entry is bounded below at 0.27 by the observed growth and is otherwise unmeasured [verified]
|
// thiscall float (SpeciesDef* d, int groupType) // `ret 4`, 15 bytes. Returns d->float[0x168 + groupType*4]. The per-species, per-population-group capacity factor -- a DATA FILE value. Its imperial entry for Human is pinned at exactly 1.0 by the corpus (Gamma Cephei's pbon never drains and its Pop never shrinks, which bracket the imperial capacity at Size x 1e8); the civilian entry is bounded below at 0.27 by the observed growth and is otherwise unmeasured [verified]
|
||||||
constexpr uint32_t SpeciesDef_GroupCapacityFactor = 0x0013bb00;
|
constexpr uint32_t SpeciesDef_GroupCapacityFactor = 0x0013bb00;
|
||||||
// thiscall float (SpeciesDef* d, int groupType) // `ret 4`, 15 bytes. Returns d->float[0x174 + groupType*4]; applied by MaxPopGeneric only when the system owner's species differs from the population species and is not 4 [verified]
|
// thiscall float (SpeciesDef* d, int groupType) // `ret 4`, 15 bytes. Returns d->float[0x174 + groupType*4]; applied by MaxPopGeneric only when the system owner's species differs from the population species and is not 4 [verified]
|
||||||
|
|
@ -1783,6 +1791,12 @@ constexpr uint32_t PopGrowthSuitabilityDistance = 0x00136eb0;
|
||||||
constexpr uint32_t PopGrowthDeltaHelper = 0x00137140;
|
constexpr uint32_t PopGrowthDeltaHelper = 0x00137140;
|
||||||
// cdecl void () // the CRT static initialiser for the population-type table at 0x00b104e8, and THE ONLY WRITER of the group-ceiling field. It sets every row's +0x28 to 0xffffffff and +0x2c to 0x7fffffff -- the int64 INT64_MAX -- along with +0x00/+0x04/+0x08/+0x0c to -1/1.0/-1/-1 and the float columns to 1.0. InitPopTypeTable 0x00535ca0 runs later and overwrites +0x00 through +0x20 but NEVER touches +0x24/+0x28/+0x2c. So MaxPopGeneric's group-ceiling clamp is present, always enabled, and always a no-op; a reader who opens only InitPopTypeTable sees zero there and would cap every carrying capacity in the game at nothing [verified]
|
// cdecl void () // the CRT static initialiser for the population-type table at 0x00b104e8, and THE ONLY WRITER of the group-ceiling field. It sets every row's +0x28 to 0xffffffff and +0x2c to 0x7fffffff -- the int64 INT64_MAX -- along with +0x00/+0x04/+0x08/+0x0c to -1/1.0/-1/-1 and the float columns to 1.0. InitPopTypeTable 0x00535ca0 runs later and overwrites +0x00 through +0x20 but NEVER touches +0x24/+0x28/+0x2c. So MaxPopGeneric's group-ceiling clamp is present, always enabled, and always a no-op; a reader who opens only InitPopTypeTable sees zero there and would cap every carrying capacity in the game at nothing [verified]
|
||||||
constexpr uint32_t PopTypeTableStaticInit = 0x005abe20;
|
constexpr uint32_t PopTypeTableStaticInit = 0x005abe20;
|
||||||
|
// thiscall int (StarShip* sh, bool useAllowance) // `ret 4`, REAL END 0x008151b7 (0x008151ba..0x008151bf is int3). max(0, design->+0xcc - (sh->ConCap /*+0x68*/ + (useAllowance ? design->+0xd0 : 0))). No floating point, no clamp but the floor at zero. Its only caller, RepairShipsInOrbit, always passes 1. UNEXERCISED on the corpus, and that is a measurement rather than an absence: the independent colony's fleet sits over Koa'Vo on both reference pairs and that player's Sav closes exactly with the demand taken as zero [verified]
|
||||||
|
constexpr uint32_t Ship_RepairCost_G3 = 0x00415180;
|
||||||
|
// thiscall void (StarShip* sh, int points) // `ret 4`, ends 0x00815221. Does NOTHING unless the ship's cached role word (+0x18, not on the wire) carries bit 0x400000 -- a different bit from the one RepairShipsInOrbit's candidate filter tests, so a ship can be charged points that never reach it. Otherwise sh->ConCap += max(points, 0) and then ConCap = min(max(ConCap, 0), design->+0xcc). THIS IS WHAT NAMES THE TWO FIELDS: ConCap is the construction invested in the hull so far -- not a per-turn capacity, despite the save-format name -- and design+0xcc is its ceiling [verified]
|
||||||
|
constexpr uint32_t Ship_ApplyRepair_G3 = 0x004151c0;
|
||||||
|
// cdecl-through-register int (ServerPlayer* owner) /* the system arrives in EBX */ // ends 0x0074615a. CORRECTION to output-turn-path.md, which names this SystemRepairDemandForOwner: it is NOT a repair function. It walks the fleets at the system through the system's own vtable slots 2 and 3, skips a fleet whose owner is not the argument or whose +0x78 byte is clear, and sums 0x0081f8c0 over each ship -- and 0x0081f8c0 gates on the design's CARRIED-POPULATION bit (design->+0xb8 & 0x04000000) and computes GroupIncome over the ship's Population at ship+0x9c. So ComputeOutput's out[6] is the income of population carried in slaver/colony hulls in orbit, which is a slot the engine's BudgetInputs already has, and it feeds no save leaf [verified]
|
||||||
|
constexpr uint32_t ServerSystem_ShipCarriedPopIncome = 0x003460b0;
|
||||||
// thiscall int (StarShip* sh) // returns 0 unless design->+0xb8 & 0x04000000 (the carried-population bit lane B6 named); otherwise sums GroupIncome over the ship's `hsp` Population at ship+0x9c for group types 0 and 1. Body read to the second GroupIncome call only [unverified]
|
// thiscall int (StarShip* sh) // returns 0 unless design->+0xb8 & 0x04000000 (the carried-population bit lane B6 named); otherwise sums GroupIncome over the ship's `hsp` Population at ship+0x9c for group types 0 and 1. Body read to the second GroupIncome call only [unverified]
|
||||||
constexpr uint32_t ShipCarriedPopIncomePerShip = 0x0041f8c0;
|
constexpr uint32_t ShipCarriedPopIncomePerShip = 0x0041f8c0;
|
||||||
// data double 0.009999999776482582 = (double)0.01f -- the savings-interest multiplier ComputeBudget uses at 0x008631ce, then truncated by _ftol2. It is a WIDENED FLOAT, not the exact decimal, and the difference is not cosmetic: a treasury of exactly 50,000 earns 499, not 500. That one money was the whole residual on the human's Sav after civilian growth landed [verified]
|
// data double 0.009999999776482582 = (double)0.01f -- the savings-interest multiplier ComputeBudget uses at 0x008631ce, then truncated by _ftol2. It is a WIDENED FLOAT, not the exact decimal, and the difference is not cosmetic: a treasury of exactly 50,000 earns 499, not 500. That one money was the whole residual on the human's Sav after civilian growth landed [verified]
|
||||||
|
|
@ -1889,6 +1903,8 @@ constexpr uint32_t StrategyServer_off_TurnResultsOutbox = 0x00000304;
|
||||||
constexpr uint32_t SETurnResults_ctor = 0x003a7ae0;
|
constexpr uint32_t SETurnResults_ctor = 0x003a7ae0;
|
||||||
// thiscall void (std::vector<SETurnResults>* this, int n) // MISNAMED as DispatchTurnResults: it dispatches nothing. std::vector<Game::SETurnResults>::resize(n) -- shrink to _Erase 0x007c5610, grow to _Reserve 0x007cb340 + _Ufill 0x007c5850. Likewise 0x007c5850 ("SendTurnResultsToPlayers") is _Ufill: per element default-construct a stack temp with 0x007a7ae0, copy-construct into the destination with 0x007c24d0, destroy the temp with 0x0079ac10, dest += 0x11c. The ONLY send of an SETurnResults in the image is SynchronizePlayer 0x007c86d1 (push 0x25) [verified]
|
// thiscall void (std::vector<SETurnResults>* this, int n) // MISNAMED as DispatchTurnResults: it dispatches nothing. std::vector<Game::SETurnResults>::resize(n) -- shrink to _Erase 0x007c5610, grow to _Reserve 0x007cb340 + _Ufill 0x007c5850. Likewise 0x007c5850 ("SendTurnResultsToPlayers") is _Ufill: per element default-construct a stack temp with 0x007a7ae0, copy-construct into the destination with 0x007c24d0, destroy the temp with 0x0079ac10, dest += 0x11c. The ONLY send of an SETurnResults in the image is SynchronizePlayer 0x007c86d1 (push 0x25) [verified]
|
||||||
constexpr uint32_t vector_SETurnResults_resize = 0x003cd2a0;
|
constexpr uint32_t vector_SETurnResults_resize = 0x003cd2a0;
|
||||||
|
// note THE GHIDRA SYMBOL NAMES ON 0x00825bb0 / 0x00825c40 ARE SWAPPED, and so is EventStorage_Read 0x00825cc0 (which is the Write). 0x00825c40 is the WRITE: it calls 0x008b9d50, which invokes stream vtable slot +0x24 and pushes the MEMBER'S VALUE -- identical in shape to the golden Game::ObservedTech::Write 0x00817cf0. 0x00825bb0 is the READ: it calls 0x008b9d20, which invokes slot +0x10 and passes a stack scratch as a DESTINATION. objects/layouts.json and objects/streams.json already have the direction right (write 0x825c40, read 0x825bb0); the Ghidra names and findings/subsystems/events.md repeat the swap. Wire schema of Game::EventStorage::TurnEvents, in order: "EvTurn" by WriteInt (FOUR BYTES ON THE WIRE, default -1) at this+0x04; then "Events" through slot +0x28 as a framed counted array of Game::EventStorage::Event bound via Mars::VectorHelper<Game::EventStorage::Event> (vtable 0x00a2da7c) at this+0x08. sizeof == 0x18 by enumeration four ways: serializer span (0x08+0x10), the SETurnResults default ctor (subobject 0x08..0x1f, next member at +0x20), its copy ctor, and the 0x18 container stride in EventStorage::FindTurnBucket 0x00811f70 [verified]
|
||||||
|
constexpr uint32_t TurnEvents_serializer_direction = 0x00425c40;
|
||||||
// cdecl void* () // whole 10-byte body: `mov ecx,0x00b29f98; jmp 0x005f6450` and 0x005f6450 is `mov eax,[ecx+4]; ret`, i.e. `return *(void**)0x00b29f9c`. 0x00b29f98 is the SAME global StrategyHost::Autosave takes as its `this`, and +0x4 is the same strat-game pointer it null-checks and hands to SaveGame_WriteFile. GetGame()+0x84 is the game's global handle map. 670 xrefs [verified]
|
// cdecl void* () // whole 10-byte body: `mov ecx,0x00b29f98; jmp 0x005f6450` and 0x005f6450 is `mov eax,[ecx+4]; ret`, i.e. `return *(void**)0x00b29f9c`. 0x00b29f98 is the SAME global StrategyHost::Autosave takes as its `this`, and +0x4 is the same strat-game pointer it null-checks and hands to SaveGame_WriteFile. GetGame()+0x84 is the game's global handle map. 670 xrefs [verified]
|
||||||
constexpr uint32_t GetGame = 0x00178050;
|
constexpr uint32_t GetGame = 0x00178050;
|
||||||
// thiscall void* (HandleMap* this, uint id) // `if (!id) return 0; slot = id & 0xF; if (slot >= (this->+0xc - this->+0x8)/0x14) return 0; b = this->+0x8 + slot*0x14; lower_bound(b, &it, &id); return it == b->+0x4 ? 0 : *(void**)(it + 0x10);`. A 16-BUCKET stdext::hash_map<uint32 handle, Object*>: vector<Bucket> at +0x8/+0xc/+0x10 with 0x14-byte stride, bucket index = id & 0xF, each bucket a red-black tree whose head is at bucket+0x4 (node layout _Left@0 _Parent@4 _Right@8 key@0xc value@0x10 _Color@0x14 _Isnil@0x15). NOTE the `this` at the call site is &bucketVector, i.e. map+0x84 on the game root, not the map object [verified]
|
// thiscall void* (HandleMap* this, uint id) // `if (!id) return 0; slot = id & 0xF; if (slot >= (this->+0xc - this->+0x8)/0x14) return 0; b = this->+0x8 + slot*0x14; lower_bound(b, &it, &id); return it == b->+0x4 ? 0 : *(void**)(it + 0x10);`. A 16-BUCKET stdext::hash_map<uint32 handle, Object*>: vector<Bucket> at +0x8/+0xc/+0x10 with 0x14-byte stride, bucket index = id & 0xF, each bucket a red-black tree whose head is at bucket+0x4 (node layout _Left@0 _Parent@4 _Right@8 key@0xc value@0x10 _Color@0x14 _Isnil@0x15). NOTE the `this` at the call site is &bucketVector, i.e. map+0x84 on the game root, not the map object [verified]
|
||||||
|
|
@ -1935,6 +1951,8 @@ constexpr uint32_t PopTypeRow = 0x00135e00;
|
||||||
constexpr uint32_t InitPopTypeTable = 0x00135ca0;
|
constexpr uint32_t InitPopTypeTable = 0x00135ca0;
|
||||||
// cdecl int (int groupType, int64 count) // ftol(POPTYPE[groupType].incomeModifier x (count / 14000.0)). The income analogue of GroupOutput; note the divisor is 14000, not the output law's 500000, and there is no 1.8 factor [verified]
|
// cdecl int (int groupType, int64 count) // ftol(POPTYPE[groupType].incomeModifier x (count / 14000.0)). The income analogue of GroupOutput; note the divisor is 14000, not the output law's 500000, and there is no 1.8 factor [verified]
|
||||||
constexpr uint32_t GroupIncome = 0x00135e80;
|
constexpr uint32_t GroupIncome = 0x00135e80;
|
||||||
|
// cdecl SpeciesDef* (int species) // table base 0x00b10a00, stride 0x184, seven rows; species > 6 returns a lazily-constructed default at 0x00b105b0. Fields read by the output chain: +0x4c the base resource demand (an int) and +0x50 the resource output factor (a float). The table is .bss, so both come from the data files [verified]
|
||||||
|
constexpr uint32_t SpeciesDefTable_Get = 0x00145cc0;
|
||||||
// cdecl double (double x) // x >= 0 ? pow(x, 1/3) : -pow(-x, 1/3), with the exponent taken from the double 0.3333333333333333 at 0x00a3a7c8. Used by StripMineFraction [verified]
|
// cdecl double (double x) // x >= 0 ? pow(x, 1/3) : -pow(-x, 1/3), with the exponent taken from the double 0.3333333333333333 at 0x00a3a7c8. Used by StripMineFraction [verified]
|
||||||
constexpr uint32_t SignedCubeRoot = 0x004e5680;
|
constexpr uint32_t SignedCubeRoot = 0x004e5680;
|
||||||
// offset offset float ScOutMod -- the last of the five output multipliers ComputeTotalOutput applies, and the innermost in the x87 chain [verified]
|
// offset offset float ScOutMod -- the last of the five output multipliers ComputeTotalOutput applies, and the innermost in the x87 chain [verified]
|
||||||
|
|
@ -1943,6 +1961,8 @@ constexpr uint32_t ServerPlayer_off_ScOutMod = 0x0000012c;
|
||||||
constexpr uint32_t ServerPlayer_off_SetupOutputMult = 0x00000224;
|
constexpr uint32_t ServerPlayer_off_SetupOutputMult = 0x00000224;
|
||||||
// data PopTypeRow[3] -- .bss, filled by InitPopTypeTable at startup. Stride 0x30 [verified]
|
// data PopTypeRow[3] -- .bss, filled by InitPopTypeTable at startup. Stride 0x30 [verified]
|
||||||
constexpr uint32_t PopTypeTable_base = 0x007104e8;
|
constexpr uint32_t PopTypeTable_base = 0x007104e8;
|
||||||
|
// data SpeciesDef[7] -- .bss, filled from the data files. Stride 0x184 [verified]
|
||||||
|
constexpr uint32_t SpeciesDefTable_base = 0x00710a00;
|
||||||
// data float** -- pointer slot; storage 0x00af08f0, which unusually carries a value (0.1f) in the file image rather than being .bss [verified]
|
// data float** -- pointer slot; storage 0x00af08f0, which unusually carries a value (0.1f) in the file image rather than being .bss [verified]
|
||||||
constexpr uint32_t GlobalConst_slot_STATION_BONUS_IMPERIAL_OUTPUT = 0x006f08f4;
|
constexpr uint32_t GlobalConst_slot_STATION_BONUS_IMPERIAL_OUTPUT = 0x006f08f4;
|
||||||
// data int** -- pointer slot read by MoraleOutputMod; the threshold is an INT compared against the Morale int[7] entry [verified]
|
// data int** -- pointer slot read by MoraleOutputMod; the threshold is an INT compared against the Morale int[7] entry [verified]
|
||||||
|
|
@ -2043,30 +2063,6 @@ constexpr uint32_t TurnCommands_WriteBuildOrderList = 0x00422870;
|
||||||
constexpr uint32_t TurnCommands_WriteSystemRatesList = 0x0042e3d0;
|
constexpr uint32_t TurnCommands_WriteSystemRatesList = 0x0042e3d0;
|
||||||
// cdecl void __cdecl (Mars::IStream* s, std::list<T>* orders) -- writer for TurnCommands member +0xb8, the COLONIZE list. Per element two WriteInts (node+0x8 then node+0xc): {shipId, w}. Observed once, in zuul-turn17-orders2.sav: three ships 2512/2544/2624, each with w = 1 [verified]
|
// cdecl void __cdecl (Mars::IStream* s, std::list<T>* orders) -- writer for TurnCommands member +0xb8, the COLONIZE list. Per element two WriteInts (node+0x8 then node+0xc): {shipId, w}. Observed once, in zuul-turn17-orders2.sav: three ships 2512/2544/2624, each with w = 1 [verified]
|
||||||
constexpr uint32_t TurnCommands_WriteColonizeList = 0x00422960;
|
constexpr uint32_t TurnCommands_WriteColonizeList = 0x00422960;
|
||||||
// thiscall int (Game::SVScriptObject* this, int evt, void* arg) // the script-object event bus. Calls this->vft[0x10](evt, arg) -- the GENERIC handler every object sees -- then `cmp evt,0x20; ja done; jmp dword [evt*4 + SVScriptObject_EventSlotJumpTable]`, which dispatches to ONE event-specific vtable slot with the argument shape that event carries. Every hand-written `vft[0x10](id,0); vft[slot]()` pair in the two turn drivers is this same two-step done on the root object [verified]
|
|
||||||
constexpr uint32_t SVScriptObject_DispatchEvent = 0x003a60d0;
|
|
||||||
// data void* [33] // evt (0..0x20) -> the vtable slot SVScriptObject_DispatchEvent calls. Slot byte offsets in evt order: 0x14 0x18 0x1c 0x20 0x24 0x28 0x2c 0x30 0x34 0x38 0x3c 0x40 0x44 0x48 0x4c 0x50 0x54 0x58 0x5c 0x60 0x64 0x6c 0x70 0x74 0x68 0x7c 0x80 0x84 0x78 0x88 0x8c 0x90 0x94. Note 0x15->+0x6c, 0x16->+0x70, 0x17->+0x74, 0x18->+0x68 and 0x1c->+0x78 are NOT in slot order [verified]
|
|
||||||
constexpr uint32_t SVScriptObject_EventSlotJumpTable = 0x003a6480;
|
|
||||||
// thiscall int (Game::SVSOSots* this, int evt, void* arg) // Game::SVSOSots vftable 0x00A063C4 slot +0x10, the ONLY slot that class overrides. evt 3 or 0x1b re-runs the new-game seeder 0x005a7d70; evt 0x1a runs 0x005a37e0; then it fans the delivery out to every child in the pointer vector at this+0x1c..0x20 through SVScriptObject_DispatchEvent. The loop re-reads both bounds every iteration, so a callee may resize the child vector under it [verified]
|
|
||||||
constexpr uint32_t SVSOSots_HandleEvent = 0x001a7e40;
|
|
||||||
// thiscall int (Game::SVSOSlaversRefuel* this, int evt, void* arg) // generic handler; the class overrides no event-specific slot at all. Body is `if (evt == 0x14) SVSOSlaversRefuel_UpdateDifficultyTier(this)` and nothing else [verified]
|
|
||||||
constexpr uint32_t SVSOSlaversRefuel_HandleEvent = 0x0011a800;
|
|
||||||
// thiscall void (Game::SVSOSlaversRefuel* this) // writes CDiff at this+0x38. Builds a 3x3-dword table on the stack -- thresholds 1 / 50 / 100 -- and scans for the FIRST threshold GREATER than StrategyServer+0xc (the frame), then stores index-1 if it differs from the stored value. Frame <= 0 exits at index 0; frame >= 100 runs off the end and stores NOTHING, so the tier can never reach 2. Only on a change does it continue into the per-system pass at 0x005158d4 [verified]
|
|
||||||
constexpr uint32_t SVSOSlaversRefuel_UpdateDifficultyTier = 0x00115820;
|
|
||||||
// thiscall void (Game::SVSORefugees* this) // vtable slot +0x60, event 0x13, sent from BeginProcessTurn. `if (!this->ini(+0x14)) { this->ini = 1; obj = <instantiate "Mission" / "_Refugee_Trader" from the data files>; if (obj) this->dids(+0x18).push_back(obj->handle(+0xa0)->id(+4)); }`. The store to the latch is unconditional on the lookup's result [verified]
|
|
||||||
constexpr uint32_t SVSORefugees_OnTurnBegin = 0x00111260;
|
|
||||||
// thiscall void (Game::SVSORefugees* this) // vtable slot +0x34, event 8, sent from OnAllCombatDone_Tail phase 8. Walks StrategyServer+0x44 (Systems) and drains the object vector at this+0x28..0x2c, destroying what it resolves. It does NOT write dids -- that is SVSORefugees_OnTurnBegin [verified]
|
|
||||||
constexpr uint32_t SVSORefugees_OnCombatDone = 0x00111310;
|
|
||||||
// thiscall void (Game::SVSOSwarmQueen* this) // vtable slot +0x60, event 0x13. Runs SVSOSwarmQueen_RegisterHives, then prunes hives whose system's EggScio (+0x184) no longer equals this->scenarioTag (+0x4), then SVSOSwarmQueen_TickHives and 0x00505100 [verified]
|
|
||||||
constexpr uint32_t SVSOSwarmQueen_OnTurnBegin = 0x00129930;
|
|
||||||
// thiscall void (Game::SVSOSwarmQueen* this) // for every system in StrategyServer+0x44 whose EggScio (+0x184) equals this->scenarioTag (+0x4, which the ctor sets to 3 -- the SWARM's tag, not the queen's own EncID 10), and that no hive already references, appends a HiveInfo {vptr 0x009f1a68, sys, nextQ, queen=0} to the vector at this+0x10..0x14, stride 0x10. nextQ = frame + *(int*)[0x00ae0204] + RNG_NextInt (0x004271c0, already in addresses.json; INCLUSIVE of its bound) over (*(int*)[0x00ae0208] - *(int*)[0x00ae0204]) -- ONE strategic-generator draw per new hive, taken inside BeginProcessTurn and therefore OUTSIDE both turn drivers [verified]
|
|
||||||
constexpr uint32_t SVSOSwarmQueen_RegisterHives = 0x00127630;
|
|
||||||
// thiscall void (Game::SVSOSwarmQueen* this) // per hive with queen (+0xc) == 0: if any spawn gate fails, `inc [hive+8]` -- the target turn SLIPS FORWARD BY ONE, which is why NextQ reads 31 after turn 1 and 32 after turn 2. Otherwise, if nextQ <= frame, spawn a queen (0x0050dfc0 then 0x004fe810) and append to the Queens vector at this+0x20. Gates read config pointers at 0x00ae0210, 0x00ae0228 and 0x00ae0220 [verified]
|
|
||||||
constexpr uint32_t SVSOSwarmQueen_TickHives = 0x00127770;
|
|
||||||
// thiscall void (Game::SVSOSwarmQueen* this) // vtable slot +0x64, event 0x14, sent from OnAllCombatDone_Tail phase 20. The same hive prune as the turn-begin handler, then 0x00521150. It does NOT register hives [verified]
|
|
||||||
constexpr uint32_t SVSOSwarmQueen_OnTurnEnd = 0x001275d0;
|
|
||||||
// cdecl Game::SVSOSwarmQueen* () // operator new(0x40); vftable 0x009f49e4; and the two ids that settle who the queen works for: [+0x4] = 3 (the scenario tag it selects systems by) and [+0x8] = 10 (its own EncID) [verified]
|
|
||||||
constexpr uint32_t SVSOSwarmQueen_Ctor = 0x0011ae20;
|
|
||||||
// thiscall void (ServerPlayer* this, float dt) // RET 4. The per-player turn driver, called once per player from StrategyServer::ProcessTurn's player loop. THE dt ARGUMENT IS NEVER READ: the whole 1086-byte body contains zero [ebp+N] references (mechanical check over the full instruction decode), so a reimplementation may ignore it. Order: ComputeBudget -> Sav = SatAdd(Sav, net) -> record aid given -> ProcessSpecialProjects -> (ResT ? RollResearchAccident/ProcessResearch) -> research refund -> zero TRM/TRA/TRP -> RebAI decay -> timed-bonus sweep -> ResearchRollPending site -> EVENT_NO_RESEARCH -> PruneRaidTargets [verified]
|
// thiscall void (ServerPlayer* this, float dt) // RET 4. The per-player turn driver, called once per player from StrategyServer::ProcessTurn's player loop. THE dt ARGUMENT IS NEVER READ: the whole 1086-byte body contains zero [ebp+N] references (mechanical check over the full instruction decode), so a reimplementation may ignore it. Order: ComputeBudget -> Sav = SatAdd(Sav, net) -> record aid given -> ProcessSpecialProjects -> (ResT ? RollResearchAccident/ProcessResearch) -> research refund -> zero TRM/TRA/TRP -> RebAI decay -> timed-bonus sweep -> ResearchRollPending site -> EVENT_NO_RESEARCH -> PruneRaidTargets [verified]
|
||||||
constexpr uint32_t ServerPlayer_ProcessTurn = 0x00491340;
|
constexpr uint32_t ServerPlayer_ProcessTurn = 0x00491340;
|
||||||
// site site in ServerPlayer::ProcessTurn: `if (this->ResT(+0x294) != 0) { if (!RollResearchAccident(&budget)) TechTree::ProcessResearch(this->TechTree(+0xf4), rng, &budget.researchAlloc, &overBudget); }`. Argument order read off the push order at 0x00891496-0x008914a5: pushes are (edx=&overBudget), (ecx=&allocVector), (eax=rng), so left-to-right the args are (RNG*, vector*, int*). The RNG is `*(ServerPlayer+8 - 4 + 0x16c)`. `overBudget` is a FRESH STACK LOCAL at [ebp-0x14], NOT Budget+0x64 [verified]
|
// site site in ServerPlayer::ProcessTurn: `if (this->ResT(+0x294) != 0) { if (!RollResearchAccident(&budget)) TechTree::ProcessResearch(this->TechTree(+0xf4), rng, &budget.researchAlloc, &overBudget); }`. Argument order read off the push order at 0x00891496-0x008914a5: pushes are (edx=&overBudget), (ecx=&allocVector), (eax=rng), so left-to-right the args are (RNG*, vector*, int*). The RNG is `*(ServerPlayer+8 - 4 + 0x16c)`. `overBudget` is a FRESH STACK LOCAL at [ebp-0x14], NOT Budget+0x64 [verified]
|
||||||
|
|
@ -2165,6 +2161,8 @@ constexpr uint32_t StrategyServer_UpdateSensors = 0x0046a8d0;
|
||||||
constexpr uint32_t StrategyServer_StepAIRebellion = 0x00418530;
|
constexpr uint32_t StrategyServer_StepAIRebellion = 0x00418530;
|
||||||
// thiscall void (StrategyServer* this, std::vector<TeamRecord>* out) // the LAST phase of the turn: per system with combatants present (0x0078cb10), builds the pairwise 0x74-byte team records for fleet pairs whose relation (0x0080e050) is war, into StrategyServer+0x1e4 (S+4 frame). The turn's Status-back-to-playing sweep then walks the records it produced [mapped]
|
// thiscall void (StrategyServer* this, std::vector<TeamRecord>* out) // the LAST phase of the turn: per system with combatants present (0x0078cb10), builds the pairwise 0x74-byte team records for fleet pairs whose relation (0x0080e050) is war, into StrategyServer+0x1e4 (S+4 frame). The turn's Status-back-to-playing sweep then walks the records it produced [mapped]
|
||||||
constexpr uint32_t StrategyServer_DetectEncounters = 0x003d7f70;
|
constexpr uint32_t StrategyServer_DetectEncounters = 0x003d7f70;
|
||||||
|
// data const float = 0.5f. The ONLY consumer is the ResearchRollPending block in ServerPlayer::ProcessTurn: the roll fires when 0.5f < progress/Cost, strictly. Not a registered config key - it is an image literal [verified]
|
||||||
|
constexpr uint32_t g_flt_ResearchRollProgressThreshold = 0x0062c788;
|
||||||
// data const float = 0.04f, subtracted from RebOutMod each turn for a RebAI player [verified]
|
// data const float = 0.04f, subtracted from RebOutMod each turn for a RebAI player [verified]
|
||||||
constexpr uint32_t g_flt_RebOutModDecay = 0x00617870;
|
constexpr uint32_t g_flt_RebOutModDecay = 0x00617870;
|
||||||
// data const float = 1.0f, the lower clamp of RebOutMod [verified]
|
// data const float = 1.0f, the lower clamp of RebOutMod [verified]
|
||||||
|
|
@ -2205,6 +2203,10 @@ constexpr uint32_t TechDef_off_Name = 0x00000040;
|
||||||
constexpr uint32_t TechTree_SetResearched_flag_Refresh = 0x00000008;
|
constexpr uint32_t TechTree_SetResearched_flag_Refresh = 0x00000008;
|
||||||
// site site inside TechTree::ProcessResearch: the tail loop that collects the newly available nodes for EVENT_TECHS_UNLOCKED. Runs only when tree->owner != 0, after the per-node loop AND after the decay sweep. Collects every node n with n != NULL, n->def != NULL, p = tree->nodes[n->def->techId] != NULL, p->state (+0x14) == 2, and n->turnAvailable (+0x20) == the owner's ModCount. Posts once if the collected vector is non-empty. NOTE the asymmetry: the state test is on the SELF-RESOLVED node p, the turn test on the iterated node n [verified]
|
// site site inside TechTree::ProcessResearch: the tail loop that collects the newly available nodes for EVENT_TECHS_UNLOCKED. Runs only when tree->owner != 0, after the per-node loop AND after the decay sweep. Collects every node n with n != NULL, n->def != NULL, p = tree->nodes[n->def->techId] != NULL, p->state (+0x14) == 2, and n->turnAvailable (+0x20) == the owner's ModCount. Posts once if the collected vector is non-empty. NOTE the asymmetry: the state test is on the SELF-RESOLVED node p, the turn test on the iterated node n [verified]
|
||||||
constexpr uint32_t TechTree_ProcessResearch_TechsUnlockedCollector = 0x00187cc3;
|
constexpr uint32_t TechTree_ProcessResearch_TechsUnlockedCollector = 0x00187cc3;
|
||||||
|
// site site at the very head of ServerPlayer::OnTechResearched: RecordObservedTech is the FIRST statement, called unconditionally on every completion -- before the ResT/roll block and before the !silent event post. It de-duplicates by tech name, so the observed-tech vector grows by one 0x2c element per completion of a tech not already observed and by nothing otherwise [verified]
|
||||||
|
constexpr uint32_t ServerPlayer_OnTechResearched_RecordObservedTech = 0x00491790;
|
||||||
|
// site site in ServerPlayer::OnTechResearched, second statement: `if (this->ResT(+0x294) == def) { if (this->ResearchRollPending(+0x3b4)) RollResearchEvent(this); this->ResearchRollPending = 0; this->ResT = 0; }`. RollResearchEvent (0x0088df20) draws ONE NextFloat unconditionally and then enters ServerPlayer_OnResearchRollSucceeded (0x00889d60) only when roll < ResearchEventOdds -- the odds are 0 for every tech outside the plague and AI-rebellion families, so that branch is normally dead. CORRECTED BY LANE K 2026-09-08: that one word is the cost of REACHING the branch, not of a fired roll -- the plague path draws a SECOND word (NextInt) and posts EVENT_PLAGUE_OUTBREAK, the rebellion path cancels the research. A fired roll costs one or two words. This is the extra RNG a completion consumes, and clearing ResT means a second completion in the same pass consumes none [verified]
|
||||||
|
constexpr uint32_t ServerPlayer_OnTechResearched_ResearchRollBlock = 0x00491790;
|
||||||
// member Game::ServerSpyManager* StrategyServer::SpyManager, in lane T's S+4 frame (absolute StrategyServer+0x15c). StrategyServer ctor 0x007d78d0: `call 0x00832a30` (the ServerSpyManager ctor, identified by its store of vftable 0x00a3073c) then `mov [esi+0x15c],eax` at 0x007d7d8e. Corroborated independently by 0x007dcf90, which the RTTI inverse map shows is Game::StrategyServer vftable 0x00a26034 slot 14 at sub-object +4: it calls the same two ctors and stores at [esi+0x154] and [esi+0x158], exactly 4 lower than the base-frame 0x158/0x15c, as a +4 `this` requires. Sits immediately after StrategyServer_off_TradeManager (lane T, 0x154 in the same frame) [verified]
|
// member Game::ServerSpyManager* StrategyServer::SpyManager, in lane T's S+4 frame (absolute StrategyServer+0x15c). StrategyServer ctor 0x007d78d0: `call 0x00832a30` (the ServerSpyManager ctor, identified by its store of vftable 0x00a3073c) then `mov [esi+0x15c],eax` at 0x007d7d8e. Corroborated independently by 0x007dcf90, which the RTTI inverse map shows is Game::StrategyServer vftable 0x00a26034 slot 14 at sub-object +4: it calls the same two ctors and stores at [esi+0x154] and [esi+0x158], exactly 4 lower than the base-frame 0x158/0x15c, as a +4 `this` requires. Sits immediately after StrategyServer_off_TradeManager (lane T, 0x154 in the same frame) [verified]
|
||||||
constexpr uint32_t StrategyServer_off_SpyManager = 0x00000158;
|
constexpr uint32_t StrategyServer_off_SpyManager = 0x00000158;
|
||||||
// data void* Game::ServerSpyManager::vftable[18] // sub-object +0, COL 0x00a87ed4, bases Game::IServerSpyManager / Game::ISpyManager / Mars::IStreamable. Unlike the trade manager there is no *Impl: ServerSpyManager is itself concrete (no purecall slots) and has no derived class. A second vftable 0x00a30728 sits at sub-object +4 with 3 slots [verified]
|
// data void* Game::ServerSpyManager::vftable[18] // sub-object +0, COL 0x00a87ed4, bases Game::IServerSpyManager / Game::ISpyManager / Mars::IStreamable. Unlike the trade manager there is no *Impl: ServerSpyManager is itself concrete (no purecall slots) and has no derived class. A second vftable 0x00a30728 sits at sub-object +4 with 3 slots [verified]
|
||||||
|
|
@ -2253,6 +2255,8 @@ constexpr uint32_t SVSOSots_vftable = 0x006063c4;
|
||||||
constexpr uint32_t SVSOIndependentSystems_vftable = 0x00620314;
|
constexpr uint32_t SVSOIndependentSystems_vftable = 0x00620314;
|
||||||
// thiscall void (void*, Mars::IStream*) // `C2 04 00` -- a bare RET 4 shared as the inherited Read/Write for classes that serialize nothing [verified]
|
// thiscall void (void*, Mars::IStream*) // `C2 04 00` -- a bare RET 4 shared as the inherited Read/Write for classes that serialize nothing [verified]
|
||||||
constexpr uint32_t Streamable_NoOpStub = 0x001f8ac0;
|
constexpr uint32_t Streamable_NoOpStub = 0x001f8ac0;
|
||||||
|
// thiscall void (Game::SVSOCrowDefenders* this, Mars::IStream* s) // sys; ndsys count then a loop writing dsys; ndes count then a loop writing des; drad. NOTE: `dsys` is INSIDE the ndsys loop -- objects/layouts.json records it as a plain member, which is wrong, and no save can settle it because both counts are 0 everywhere [verified]
|
||||||
|
constexpr uint32_t SVSOCrowDefenders_Write = 0x000f8c90;
|
||||||
// thiscall void (Game::SVSOMonitor* this, Mars::IStream* s) // calls SVSODerelict::Write (0x004fc2b0) as its first act -- Monitor derives from Derelict, which is why its tag run starts NDsn/DsnID/Dwght + NAsg/Eflt/Esys before nt/scnm/spwt/rsmd/dsgn [verified]
|
// thiscall void (Game::SVSOMonitor* this, Mars::IStream* s) // calls SVSODerelict::Write (0x004fc2b0) as its first act -- Monitor derives from Derelict, which is why its tag run starts NDsn/DsnID/Dwght + NAsg/Eflt/Esys before nt/scnm/spwt/rsmd/dsgn [verified]
|
||||||
constexpr uint32_t SVSOMonitor_Write = 0x000fd810;
|
constexpr uint32_t SVSOMonitor_Write = 0x000fd810;
|
||||||
// thiscall void (Game::SVSODerelict* this, Mars::IStream* s) // NDsn count then a loop of (DsnID, Dwght); NAsg count then a loop of (Eflt, Esys). Two fields per iteration in each, confirmed by the 8-byte element strides [verified]
|
// thiscall void (Game::SVSODerelict* this, Mars::IStream* s) // NDsn count then a loop of (DsnID, Dwght); NAsg count then a loop of (Eflt, Esys). Two fields per iteration in each, confirmed by the 8-byte element strides [verified]
|
||||||
|
|
@ -2369,6 +2373,8 @@ constexpr uint32_t ServerNodeGraph_FindPathById = 0x002e23d0;
|
||||||
constexpr uint32_t StarFleet_HasFlagShips = 0x00303500;
|
constexpr uint32_t StarFleet_HasFlagShips = 0x00303500;
|
||||||
// cdecl int (StarFleet* fleet, int npid) // 234 B. Returns 3 exactly when the fleet's FRONT waypoint (the deque at fleet+0xc4, element +0x10) names this npid -- i.e. the fleet is currently riding this line; 0/1/2/4 otherwise. Node-line decay drops a rolled line when any 0x20000-flagged fleet returns 3 for it [verified]
|
// cdecl int (StarFleet* fleet, int npid) // 234 B. Returns 3 exactly when the fleet's FRONT waypoint (the deque at fleet+0xc4, element +0x10) names this npid -- i.e. the fleet is currently riding this line; 0/1/2/4 otherwise. Node-line decay drops a rolled line when any 0x20000-flagged fleet returns 3 for it [verified]
|
||||||
constexpr uint32_t StarFleet_PathRelation = 0x0038c360;
|
constexpr uint32_t StarFleet_PathRelation = 0x0038c360;
|
||||||
|
// note NAME CORRECTION, from StrategyServer::Write's own wire tags. At 0x0079fb2f `lea edx,[edi+0x08]; push "ModCount"` and at 0x0079fb40 `lea eax,[edi+0x0c]; push "Frame"`, with edi = S (the same edi that indexes the players vector at +0x54). So in the S frame **S+0x8 is ModCount and S+0xc is Frame**, i.e. in the stored (S+4) frame +0x4 is ModCount and +0x8 is Frame. `StrategyServer_off_ModCount = 0x8` therefore carries the WRONG NAME: that word is Frame, the turn number. The word it names is the one lane T recorded as StrategyServer_off_PhaseCounter = 0x4 and lane K called 'never named' -- it has a name, and it is ModCount. CONFIRMED FROM THE SAVES, which is an independent instrument: Frame reads 1/2/3 on turn1/2/3-state, 16 on zuul-turn16, 23 on zuul-turn23, while ModCount reads 0/12/24/241/412. And CONFIRMED LIVE: lane Z measured S+0x8 advancing 12, 14, 12 per turn on the early Human game (the saves say +12/turn) and 16, 21, 44 on the Zuul one (the saves say ~24/turn average). A modification counter is exactly what those numbers look like, and it explains why only 2 of the 12-44 increments come from the two turn drivers. Integrator: reconcile StrategyServer_off_ModCount / StrategyServer_off_PhaseCounter rather than adding a third name [verified]
|
||||||
|
constexpr uint32_t StrategyServer_wire_ModCount_vs_Frame = 0x0039fb2f;
|
||||||
// thiscall void (void* rawBase /* = S+4 */) // the StrategyServer base-class ctor, called from StrategyServer::StrategyServer 0x007d78d0 at 0x007d7905 as `lea ecx,[esi+0x4]`. It zero-initialises FOUR CONSECUTIVE std::vectors as three-word triples with the fourth word skipped: raw +0x40/+0x44/+0x48, +0x50/+0x54/+0x58, +0x60/+0x64/+0x68, +0x70/+0x74/+0x78, then `lea ecx,[esi+0x80]` for the entity hash. That is the campaign's `{_Myfirst,_Mylast,_Myend,_Alval}` = 0x10 allocator-last shape (method rule 5) enumerated four times in a row, and it independently pins StrategyServer_off_Players = 0x50 and _off_Fleets = 0x60 in the raw frame WITHOUT any frame arithmetic -- the ctor is entered with ecx = S+4, so the players triple is literally {S+0x54, S+0x58, S+0x5c}. This is the enumeration that closes the 0x60-vs-0x64 question the campaign paid for once [verified]
|
// thiscall void (void* rawBase /* = S+4 */) // the StrategyServer base-class ctor, called from StrategyServer::StrategyServer 0x007d78d0 at 0x007d7905 as `lea ecx,[esi+0x4]`. It zero-initialises FOUR CONSECUTIVE std::vectors as three-word triples with the fourth word skipped: raw +0x40/+0x44/+0x48, +0x50/+0x54/+0x58, +0x60/+0x64/+0x68, +0x70/+0x74/+0x78, then `lea ecx,[esi+0x80]` for the entity hash. That is the campaign's `{_Myfirst,_Mylast,_Myend,_Alval}` = 0x10 allocator-last shape (method rule 5) enumerated four times in a row, and it independently pins StrategyServer_off_Players = 0x50 and _off_Fleets = 0x60 in the raw frame WITHOUT any frame arithmetic -- the ctor is entered with ecx = S+4, so the players triple is literally {S+0x54, S+0x58, S+0x5c}. This is the enumeration that closes the 0x60-vs-0x64 question the campaign paid for once [verified]
|
||||||
constexpr uint32_t StrategyServer_ctor_VectorBlock = 0x0045b120;
|
constexpr uint32_t StrategyServer_ctor_VectorBlock = 0x0045b120;
|
||||||
// thiscall ServerPlayer* (StrategyServer* this /*S frame*/) // five sibling accessors at 0x00788de0, 0x00788e10, 0x00788e40, 0x00788e70, 0x00788ea0, one per NPC pseudo-player index word at S+0x1b8/0x1bc/0x1c0/0x1c4/0x1c8 (the five words the ctor sets to -1 at 0x007d79fe..0x007d7a16, and the save's NPCm/NPCo/NPCi/NPCv/NPCa). Each is `idx = this->+0x1b8; if (idx < 0) return 0; first = [this+0x54]; last = [this+0x58]; if (idx >= (last-first)>>2) return 0; return first[idx];` -- a bounds check against the players vector's size followed by an index off _Myfirst, which is a third independent confirmation that S+0x54/S+0x58 are _Myfirst/_Mylast. THE PLAYER VECTOR IS NOT THE LOBBY'S PLAYER LIST: it is #empires + one rebel-AI per distinct empire species + 4 NPC pseudo-players (Alien Menace, Peacekeeper Enforcer, Von Neumann, Independent Colony, all Species 4). Hence NumPlrs 8 on the Human saves (two species) and 7 on the Zuul ones (one species), against a lobby that says '2 Players' in both -- Summary.Players counts EMPIRE SLOTS and is also right [verified]
|
// thiscall ServerPlayer* (StrategyServer* this /*S frame*/) // five sibling accessors at 0x00788de0, 0x00788e10, 0x00788e40, 0x00788e70, 0x00788ea0, one per NPC pseudo-player index word at S+0x1b8/0x1bc/0x1c0/0x1c4/0x1c8 (the five words the ctor sets to -1 at 0x007d79fe..0x007d7a16, and the save's NPCm/NPCo/NPCi/NPCv/NPCa). Each is `idx = this->+0x1b8; if (idx < 0) return 0; first = [this+0x54]; last = [this+0x58]; if (idx >= (last-first)>>2) return 0; return first[idx];` -- a bounds check against the players vector's size followed by an index off _Myfirst, which is a third independent confirmation that S+0x54/S+0x58 are _Myfirst/_Mylast. THE PLAYER VECTOR IS NOT THE LOBBY'S PLAYER LIST: it is #empires + one rebel-AI per distinct empire species + 4 NPC pseudo-players (Alien Menace, Peacekeeper Enforcer, Von Neumann, Independent Colony, all Species 4). Hence NumPlrs 8 on the Human saves (two species) and 7 on the Zuul ones (one species), against a lobby that says '2 Players' in both -- Summary.Players counts EMPIRE SLOTS and is also right [verified]
|
||||||
|
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
# Watching the lab guests
|
|
||||||
|
|
||||||
The lab is five Windows guests now. This is how to see all of them at once.
|
|
||||||
|
|
||||||
## The live wall
|
|
||||||
|
|
||||||
<http://192.168.3.201:8140/>
|
|
||||||
|
|
||||||
Leave the tab open. Tiles refresh every 5 s; click a tile for that guest full size.
|
|
||||||
Each tile carries the VM id, name, status and the capture timestamp. A guest that is
|
|
||||||
stopped, paused or unreachable shows a labelled `NO SIGNAL` tile with the monitor's
|
|
||||||
own error text, never a broken image.
|
|
||||||
|
|
||||||
Served by `vmwatch.service` on **spicy** (`/opt/vmwatch/vmwatch.py`, systemd, enabled
|
|
||||||
at boot). Source of truth is `tools/vmwatch.py` in this repo; redeploy with:
|
|
||||||
|
|
||||||
tools/vmwatch-install.sh spicy # install or update
|
|
||||||
tools/vmwatch-install.sh spicy --uninstall
|
|
||||||
|
|
||||||
Nothing was installed on spicy beyond that file and its unit — the service is Python 3
|
|
||||||
standard library only (spicy has no ImageMagick, no netpbm, no Pillow).
|
|
||||||
|
|
||||||
## The one-shot contact sheet
|
|
||||||
|
|
||||||
tools/vmshot.py # all guests -> dumps/vmshot/sheet-<ts>.png + latest.png
|
|
||||||
tools/vmshot.py --one 140 # one guest, full size
|
|
||||||
tools/vmshot.py 144 145 --cols 2
|
|
||||||
tools/vmshot.py --ssh # bypass the service, capture over SSH
|
|
||||||
|
|
||||||
`vmshot` pulls frames from the vmwatch service when it is reachable (~0.5 s, no extra
|
|
||||||
load on the guests) and falls back to SSH + `qm monitor` when it is not (~4 s). Output
|
|
||||||
lands in `dumps/vmshot/`, which is gitignored.
|
|
||||||
|
|
||||||
## Why this mechanism
|
|
||||||
|
|
||||||
Both tools capture with QEMU `screendump`, reached over each guest's
|
|
||||||
`/var/run/qemu-server/<id>.qmp` socket — the same socket and command `qm monitor` uses.
|
|
||||||
|
|
||||||
* It needs **no guest agent, no guest network and nothing installed in the guest**.
|
|
||||||
* It does **not perturb the guest**. That is the point (method-rule 19: an instrument
|
|
||||||
that perturbs the thing it measures has already cost this campaign once). Reading
|
|
||||||
VM 140's screen is therefore *not* an experiment and does **not** take 140's
|
|
||||||
exclusivity lock.
|
|
||||||
* The campaign board already records that `qm monitor` screendump is more reliable
|
|
||||||
than the in-guest click helper's `shot`.
|
|
||||||
|
|
||||||
## Two things worth knowing
|
|
||||||
|
|
||||||
**Do not fork `qm` in a loop.** The first version of vmwatch shelled out to
|
|
||||||
`qm monitor` once per guest per 5 s tick. `qm` is a Perl program: that cost **~90 % of
|
|
||||||
a host core and a 728 MB cgroup peak**. Talking to the QMP socket directly — same
|
|
||||||
socket, same command, no fork — brought it to **0.33 CPU-seconds per 88 s and 23 MB
|
|
||||||
RSS**, roughly a 300× reduction. The fleet list comes from `/etc/pve/qemu-server/*.conf`
|
|
||||||
plus `query-status` for the same reason. `qm` remains the fallback path only.
|
|
||||||
|
|
||||||
**QEMU 11 on spicy dumps PNG natively** (`screendump <file> -f png`), so no PPM
|
|
||||||
conversion is needed. `vmwatch.ppm_to_png` exists as a fallback for an older QEMU and
|
|
||||||
was checked against QEMU's own encoder on a real 1024×768 framebuffer: pixel-identical.
|
|
||||||
|
|
||||||
Guests are discovered dynamically by matching `sots-re` in the guest name, so clones
|
|
||||||
added or destroyed later appear and vanish on their own — nothing to edit.
|
|
||||||
|
|
||||||
Both tools are strictly read-only. Neither starts, stops, resets nor reconfigures a VM.
|
|
||||||
|
|
@ -11,30 +11,12 @@ base = int(j["image_base"], 16)
|
||||||
# ghidra/addresses.d/<lane>.json ({"entries": [...]}) — its own file, no shared-line edits.
|
# ghidra/addresses.d/<lane>.json ({"entries": [...]}) — its own file, no shared-line edits.
|
||||||
# Fragments are merged here in sorted order; a duplicate name is an error, not a silent
|
# Fragments are merged here in sorted order; a duplicate name is an error, not a silent
|
||||||
# last-wins, because two lanes disagreeing about an address is exactly what we must not paper over.
|
# last-wins, because two lanes disagreeing about an address is exactly what we must not paper over.
|
||||||
# Two collisions are possible and BOTH are errors:
|
|
||||||
# same name, different address -> two lanes disagree about a fact.
|
|
||||||
# same address, different name -> two lanes fork the vocabulary. This one is worse, because
|
|
||||||
# nothing downstream notices: the header just grows a synonym and later readers cannot tell
|
|
||||||
# that `RNG_NextInt` and `RNG_NextIntInclusive` are one function. A lane caught exactly that
|
|
||||||
# by hand on the campaign's most-used RNG primitive; the check below is so nobody has to.
|
|
||||||
def _key(e):
|
|
||||||
return ("offset", e["offset"].lower()) if "offset" in e else ("addr", e["addr"].lower())
|
|
||||||
|
|
||||||
seen = {e["name"]: "addresses.json" for e in j["entries"]}
|
seen = {e["name"]: "addresses.json" for e in j["entries"]}
|
||||||
# Offsets are only meaningful per owning struct, so they are not globally unique — key addresses only.
|
|
||||||
by_addr = {_key(e)[1]: (e["name"], "addresses.json") for e in j["entries"] if "addr" in e}
|
|
||||||
for frag_path in sorted(glob.glob(os.path.join(root, "ghidra", "addresses.d", "*.json"))):
|
for frag_path in sorted(glob.glob(os.path.join(root, "ghidra", "addresses.d", "*.json"))):
|
||||||
frag_name = os.path.basename(frag_path)
|
frag_name = os.path.basename(frag_path)
|
||||||
for e in json.load(open(frag_path))["entries"]:
|
for e in json.load(open(frag_path))["entries"]:
|
||||||
if e["name"] in seen:
|
if e["name"] in seen:
|
||||||
sys.exit(f"duplicate address entry {e['name']!r}: in {seen[e['name']]} and {frag_name}")
|
sys.exit(f"duplicate address entry {e['name']!r}: in {seen[e['name']]} and {frag_name}")
|
||||||
if "addr" in e:
|
|
||||||
a = e["addr"].lower()
|
|
||||||
if a in by_addr:
|
|
||||||
other, where = by_addr[a]
|
|
||||||
sys.exit(f"address {e['addr']} named twice: {other!r} in {where}, "
|
|
||||||
f"{e['name']!r} in {frag_name} — pick one name and record the agreement")
|
|
||||||
by_addr[a] = (e["name"], frag_name)
|
|
||||||
seen[e["name"]] = frag_name
|
seen[e["name"]] = frag_name
|
||||||
j["entries"].append(e)
|
j["entries"].append(e)
|
||||||
try: rev = subprocess.check_output(["git","-C",root,"rev-parse","--short","HEAD"]).decode().strip()
|
try: rev = subprocess.check_output(["git","-C",root,"rev-parse","--short","HEAD"]).decode().strip()
|
||||||
|
|
|
||||||
444
tools/vmshot.py
444
tools/vmshot.py
|
|
@ -1,444 +0,0 @@
|
||||||
#!/usr/bin/env -S uv run --quiet --with pillow python3
|
|
||||||
"""vmshot -- one-shot screenshot contact sheet for the SOTS lab Windows guests.
|
|
||||||
|
|
||||||
Why this exists
|
|
||||||
---------------
|
|
||||||
The lab went from one Windows guest (VM 140) to five (140, 141, 144, 145, 146).
|
|
||||||
There was no way to see what they were all doing without issuing a QEMU
|
|
||||||
``screendump`` per guest by hand. This grabs every guest in parallel and lays
|
|
||||||
them out on a single labelled contact sheet.
|
|
||||||
|
|
||||||
Mechanism
|
|
||||||
---------
|
|
||||||
``qm monitor <id> <<< "screendump <file> -f png"`` on the Proxmox host. This
|
|
||||||
reads the guest framebuffer straight out of QEMU:
|
|
||||||
|
|
||||||
* no guest agent, no guest network, nothing installed inside the guest;
|
|
||||||
* it does not perturb the guest at all -- relevant to method-rule 19, an
|
|
||||||
instrument that perturbs the thing it measures. Reading VM 140's screen is
|
|
||||||
therefore *not* an experiment and does not take VM 140's exclusivity lock;
|
|
||||||
* the campaign board already records that ``qm monitor`` screendump is more
|
|
||||||
reliable than the in-guest click helper's ``shot``.
|
|
||||||
|
|
||||||
spicy runs pve-manager 9.2 / QEMU 11, whose ``screendump`` takes ``-f png``
|
|
||||||
natively, so no PPM conversion and no netpbm/ImageMagick is needed on the host
|
|
||||||
(none is installed there, and this tool installs nothing). The PNGs are written
|
|
||||||
to a per-run temp dir on the host, streamed back inside a base64 tar in the same
|
|
||||||
SSH round trip, and the temp dir is removed before the SSH call returns -- so
|
|
||||||
nothing is left behind on spicy even if this script is killed.
|
|
||||||
|
|
||||||
Relationship to vmwatch
|
|
||||||
-----------------------
|
|
||||||
``tools/vmwatch.py`` is the live version of this: an always-on service on spicy
|
|
||||||
serving an auto-refreshing wall at http://192.168.3.201:8140/. When that
|
|
||||||
service is reachable this script pulls its already-captured frames over HTTP
|
|
||||||
instead of opening its own SSH session -- same frames, no extra load on the
|
|
||||||
guests, and it works from anywhere on the LAN without SSH. If the service is
|
|
||||||
down it falls back to the SSH + ``qm monitor`` path described below, so the CLI
|
|
||||||
never depends on the service being up.
|
|
||||||
|
|
||||||
Usage
|
|
||||||
-----
|
|
||||||
tools/vmshot.py # contact sheet of every sots-* guest
|
|
||||||
tools/vmshot.py --open # ... and open it in the default viewer
|
|
||||||
tools/vmshot.py 140 141 # only these ids
|
|
||||||
tools/vmshot.py --one 140 # full-size single guest, no sheet
|
|
||||||
tools/vmshot.py --cols 3 --width 900
|
|
||||||
|
|
||||||
Output lands in ``~/sots-re/dumps/vmshot/`` (``dumps/`` is gitignored).
|
|
||||||
|
|
||||||
A guest that is stopped, paused, or whose screendump fails gets a labelled
|
|
||||||
placeholder tile with the host-side error rather than aborting the sheet.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import base64
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
import sys
|
|
||||||
import tarfile
|
|
||||||
import time
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
|
|
||||||
HOST = "spicy"
|
|
||||||
SERVICE = os.environ.get("VMWATCH_URL", "http://192.168.3.201:8140")
|
|
||||||
OUT_DIR = Path.home() / "sots-re" / "dumps" / "vmshot"
|
|
||||||
FLEET_PATTERN = re.compile(r"sots", re.I)
|
|
||||||
FONT_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
|
|
||||||
FONT_BOLD = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
|
|
||||||
|
|
||||||
# Tile chrome
|
|
||||||
LABEL_H = 34
|
|
||||||
PAD = 10
|
|
||||||
BG = (24, 26, 30)
|
|
||||||
LABEL_BG = (38, 42, 50)
|
|
||||||
LABEL_BG_BAD = (74, 34, 34)
|
|
||||||
FG = (232, 234, 238)
|
|
||||||
FG_DIM = (150, 156, 166)
|
|
||||||
BORDER = (60, 66, 78)
|
|
||||||
PLACEHOLDER_BG = (44, 30, 30)
|
|
||||||
|
|
||||||
|
|
||||||
def ssh(cmd: str, timeout: int = 90) -> subprocess.CompletedProcess:
|
|
||||||
return subprocess.run(
|
|
||||||
["ssh", "-o", "BatchMode=yes", HOST, cmd],
|
|
||||||
capture_output=True,
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def service_state(timeout: float = 3.0) -> list[dict] | None:
|
|
||||||
"""Fleet state from a reachable vmwatch service, or None if it is not up."""
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(f"{SERVICE}/api/state", timeout=timeout) as r:
|
|
||||||
return json.load(r)
|
|
||||||
except (urllib.error.URLError, OSError, ValueError, json.JSONDecodeError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def service_grab(ids: list[int], state: list[dict]) -> dict[int, dict]:
|
|
||||||
"""Pull already-captured frames from the vmwatch service. No guest load."""
|
|
||||||
by_id = {g["id"]: g for g in state}
|
|
||||||
out: dict[int, dict] = {}
|
|
||||||
for vid in ids:
|
|
||||||
g = by_id.get(vid)
|
|
||||||
if g is None:
|
|
||||||
out[vid] = {"png": None, "status": "absent", "log": "not known to vmwatch"}
|
|
||||||
continue
|
|
||||||
png = None
|
|
||||||
if g.get("ok"):
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(
|
|
||||||
f"{SERVICE}/shot/{vid}.png?t={g.get('ts', '')}", timeout=10
|
|
||||||
) as r:
|
|
||||||
png = r.read()
|
|
||||||
except (urllib.error.URLError, OSError):
|
|
||||||
png = None
|
|
||||||
out[vid] = {
|
|
||||||
"png": png,
|
|
||||||
"status": g.get("status", "?"),
|
|
||||||
"log": g.get("error", "") if not png else "",
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def discover_fleet() -> list[tuple[int, str, str]]:
|
|
||||||
"""Return [(vmid, name, status)] for every VM whose name matches sots-*."""
|
|
||||||
r = ssh("qm list", timeout=30)
|
|
||||||
if r.returncode != 0:
|
|
||||||
sys.exit(f"vmshot: `qm list` on {HOST} failed: {r.stderr.decode().strip()}")
|
|
||||||
fleet = []
|
|
||||||
for line in r.stdout.decode().splitlines()[1:]:
|
|
||||||
parts = line.split()
|
|
||||||
if len(parts) < 3 or not parts[0].isdigit():
|
|
||||||
continue
|
|
||||||
vmid, name, status = int(parts[0]), parts[1], parts[2]
|
|
||||||
if FLEET_PATTERN.search(name):
|
|
||||||
fleet.append((vmid, name, status))
|
|
||||||
return sorted(fleet)
|
|
||||||
|
|
||||||
|
|
||||||
# Remote script: dump every requested guest in parallel, tar the results back,
|
|
||||||
# always clean up. Per-guest stderr is kept so a failure explains itself.
|
|
||||||
REMOTE = r"""
|
|
||||||
set -u
|
|
||||||
D=$(mktemp -d /tmp/vmshot.XXXXXX)
|
|
||||||
trap 'rm -rf "$D"' EXIT INT TERM
|
|
||||||
for id in %(ids)s; do
|
|
||||||
(
|
|
||||||
st=$(qm status "$id" 2>&1 | awk '{print $2}')
|
|
||||||
echo "$st" > "$D/$id.status"
|
|
||||||
timeout %(t)s qm monitor "$id" <<< "screendump $D/$id.png -f png" \
|
|
||||||
> "$D/$id.log" 2>&1
|
|
||||||
# qm monitor exits 0 even when the monitor command errors; the monitor
|
|
||||||
# echoes the failure into the log, so treat "no file" as the real test.
|
|
||||||
[ -s "$D/$id.png" ] || echo "no framebuffer written" >> "$D/$id.log"
|
|
||||||
) &
|
|
||||||
done
|
|
||||||
wait
|
|
||||||
tar -C "$D" -cf - . | base64 -w0
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def grab(ids: list[int], per_vm_timeout: int = 20) -> dict[int, dict]:
|
|
||||||
"""Screendump each id on the host; return {id: {'png': bytes|None, 'status', 'log'}}."""
|
|
||||||
remote = REMOTE % {"ids": " ".join(str(i) for i in ids), "t": per_vm_timeout}
|
|
||||||
r = ssh(f"bash -s <<'VMSHOT_EOF'\n{remote}\nVMSHOT_EOF", timeout=per_vm_timeout + 60)
|
|
||||||
if r.returncode != 0 and not r.stdout.strip():
|
|
||||||
sys.exit(f"vmshot: remote grab failed: {r.stderr.decode().strip()[:500]}")
|
|
||||||
|
|
||||||
out: dict[int, dict] = {i: {"png": None, "status": "?", "log": ""} for i in ids}
|
|
||||||
try:
|
|
||||||
blob = base64.b64decode(r.stdout.strip())
|
|
||||||
with tarfile.open(fileobj=io.BytesIO(blob), mode="r:") as tf:
|
|
||||||
for m in tf.getmembers():
|
|
||||||
name = Path(m.name).name
|
|
||||||
if not m.isfile():
|
|
||||||
continue
|
|
||||||
stem, _, ext = name.rpartition(".")
|
|
||||||
if not stem.isdigit():
|
|
||||||
continue
|
|
||||||
vid = int(stem)
|
|
||||||
if vid not in out:
|
|
||||||
continue
|
|
||||||
data = tf.extractfile(m).read()
|
|
||||||
if ext == "png":
|
|
||||||
out[vid]["png"] = data
|
|
||||||
elif ext == "status":
|
|
||||||
out[vid]["status"] = data.decode(errors="replace").strip() or "?"
|
|
||||||
elif ext == "log":
|
|
||||||
out[vid]["log"] = data.decode(errors="replace").strip()
|
|
||||||
except Exception as e: # noqa: BLE001 - a mangled tar must not lose the whole sheet
|
|
||||||
for v in out.values():
|
|
||||||
v["log"] = v["log"] or f"could not unpack remote payload: {e}"
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
|
|
||||||
try:
|
|
||||||
return ImageFont.truetype(FONT_BOLD if bold else FONT_PATH, size)
|
|
||||||
except OSError:
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
def _clean_monitor_log(log: str) -> str:
|
|
||||||
"""Strip the monitor banner/prompt noise, keep the actual complaint."""
|
|
||||||
keep = []
|
|
||||||
for ln in log.splitlines():
|
|
||||||
ln = ln.replace("\x1b", "").strip()
|
|
||||||
ln = re.sub(r"^(QEMU \d[\w.]* monitor.*|qm> ?)", "", ln).strip()
|
|
||||||
if not ln or ln.startswith("Entering QEMU Monitor") or ln.startswith("Type 'help'"):
|
|
||||||
continue
|
|
||||||
keep.append(ln)
|
|
||||||
return "; ".join(keep)[:180]
|
|
||||||
|
|
||||||
|
|
||||||
def make_tile(vmid: int, name: str, info: dict, width: int, stamp: str) -> Image.Image:
|
|
||||||
png = info.get("png")
|
|
||||||
status = info.get("status", "?")
|
|
||||||
err = _clean_monitor_log(info.get("log", ""))
|
|
||||||
|
|
||||||
if png:
|
|
||||||
shot = Image.open(io.BytesIO(png)).convert("RGB")
|
|
||||||
native = f"{shot.width}x{shot.height}"
|
|
||||||
h = max(1, round(shot.height * width / shot.width))
|
|
||||||
shot = shot.resize((width, h), Image.LANCZOS)
|
|
||||||
detail = f"{status} {native} {stamp}"
|
|
||||||
bad = False
|
|
||||||
else:
|
|
||||||
h = round(width * 3 / 4)
|
|
||||||
shot = Image.new("RGB", (width, h), PLACEHOLDER_BG)
|
|
||||||
d = ImageDraw.Draw(shot)
|
|
||||||
msg = err or f"no screendump ({status})"
|
|
||||||
d.text(
|
|
||||||
(width // 2, h // 2 - 12),
|
|
||||||
"NO SIGNAL",
|
|
||||||
font=_font(max(18, width // 22), bold=True),
|
|
||||||
fill=(210, 120, 120),
|
|
||||||
anchor="mm",
|
|
||||||
)
|
|
||||||
f = _font(max(10, width // 60))
|
|
||||||
# crude wrap
|
|
||||||
line, lines = "", []
|
|
||||||
for word in msg.split():
|
|
||||||
if len(line) + len(word) + 1 > 52:
|
|
||||||
lines.append(line)
|
|
||||||
line = word
|
|
||||||
else:
|
|
||||||
line = f"{line} {word}".strip()
|
|
||||||
lines.append(line)
|
|
||||||
for i, ln in enumerate(lines[:4]):
|
|
||||||
d.text(
|
|
||||||
(width // 2, h // 2 + 18 + i * 15),
|
|
||||||
ln,
|
|
||||||
font=f,
|
|
||||||
fill=(190, 150, 150),
|
|
||||||
anchor="mm",
|
|
||||||
)
|
|
||||||
detail = f"{status} {stamp}"
|
|
||||||
bad = True
|
|
||||||
|
|
||||||
tile = Image.new("RGB", (width, h + LABEL_H), LABEL_BG_BAD if bad else LABEL_BG)
|
|
||||||
tile.paste(shot, (0, LABEL_H))
|
|
||||||
d = ImageDraw.Draw(tile)
|
|
||||||
f_id, f_name, f_detail = _font(16, bold=True), _font(14), _font(11)
|
|
||||||
y = LABEL_H // 2
|
|
||||||
|
|
||||||
d.text((8, y), str(vmid), font=f_id, fill=FG, anchor="lm")
|
|
||||||
name_x = 8 + round(d.textlength(str(vmid), font=f_id)) + 10
|
|
||||||
|
|
||||||
# A narrow tile must not let the two labels overlap: drop the timestamp
|
|
||||||
# first, then ellipsise the guest name to whatever room is left.
|
|
||||||
for candidate in (detail, detail.rsplit(" ", 1)[0], status):
|
|
||||||
detail_w = d.textlength(candidate, font=f_detail)
|
|
||||||
if name_x + d.textlength(name, font=f_name) + 12 + detail_w + 8 <= width:
|
|
||||||
detail = candidate
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
detail = status
|
|
||||||
detail_w = d.textlength(detail, font=f_detail)
|
|
||||||
|
|
||||||
room = width - 8 - detail_w - 12 - name_x
|
|
||||||
shown = name
|
|
||||||
while shown and d.textlength(shown + "…", font=f_name) > room:
|
|
||||||
shown = shown[:-1]
|
|
||||||
if shown != name:
|
|
||||||
shown = (shown + "…") if shown else ""
|
|
||||||
if room > 0 and shown:
|
|
||||||
d.text((name_x, y), shown, font=f_name, fill=FG, anchor="lm")
|
|
||||||
d.text((width - 8, y), detail, font=f_detail, fill=FG_DIM, anchor="rm")
|
|
||||||
d.rectangle([0, 0, width - 1, h + LABEL_H - 1], outline=BORDER)
|
|
||||||
return tile
|
|
||||||
|
|
||||||
|
|
||||||
def contact_sheet(fleet, shots, tile_w: int, cols: int, stamp: str) -> Image.Image:
|
|
||||||
tiles = [make_tile(vid, name, shots.get(vid, {}), tile_w, stamp) for vid, name, _ in fleet]
|
|
||||||
rows = (len(tiles) + cols - 1) // cols
|
|
||||||
row_h = [
|
|
||||||
max((t.height for t in tiles[r * cols : (r + 1) * cols]), default=0) for r in range(rows)
|
|
||||||
]
|
|
||||||
header = 40
|
|
||||||
W = PAD + cols * (tile_w + PAD)
|
|
||||||
H = header + PAD + sum(h + PAD for h in row_h)
|
|
||||||
sheet = Image.new("RGB", (W, H), BG)
|
|
||||||
d = ImageDraw.Draw(sheet)
|
|
||||||
live = sum(1 for v in shots.values() if v.get("png"))
|
|
||||||
d.text((PAD, header // 2), "SOTS lab guests", font=_font(18, bold=True), fill=FG, anchor="lm")
|
|
||||||
d.text(
|
|
||||||
(W - PAD, header // 2),
|
|
||||||
f"{live}/{len(fleet)} framebuffers captured {stamp} host={HOST}",
|
|
||||||
font=_font(12),
|
|
||||||
fill=FG_DIM,
|
|
||||||
anchor="rm",
|
|
||||||
)
|
|
||||||
y = header + PAD
|
|
||||||
for r in range(rows):
|
|
||||||
x = PAD
|
|
||||||
for t in tiles[r * cols : (r + 1) * cols]:
|
|
||||||
sheet.paste(t, (x, y))
|
|
||||||
x += tile_w + PAD
|
|
||||||
y += row_h[r] + PAD
|
|
||||||
return sheet
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
ap = argparse.ArgumentParser(description="Contact sheet of the SOTS lab VM screens.")
|
|
||||||
ap.add_argument("ids", nargs="*", type=int, help="VM ids (default: every sots-* guest)")
|
|
||||||
ap.add_argument("--one", type=int, metavar="ID", help="save one guest full-size, no sheet")
|
|
||||||
ap.add_argument("--cols", type=int, default=3)
|
|
||||||
ap.add_argument("--width", type=int, default=760, help="tile width in px")
|
|
||||||
ap.add_argument("--out", type=Path, help="output png path")
|
|
||||||
ap.add_argument("--timeout", type=int, default=20, help="per-guest screendump timeout (s)")
|
|
||||||
ap.add_argument("--keep-raw", action="store_true", help="also save each guest's raw png")
|
|
||||||
ap.add_argument("--json", action="store_true", help="print a machine-readable result line")
|
|
||||||
ap.add_argument("--open", action="store_true", help="xdg-open the result")
|
|
||||||
ap.add_argument(
|
|
||||||
"--ssh",
|
|
||||||
action="store_true",
|
|
||||||
help="always capture over SSH, even if the vmwatch service is reachable",
|
|
||||||
)
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
t0 = time.time()
|
|
||||||
|
|
||||||
state = None if args.ssh else service_state()
|
|
||||||
if state is not None:
|
|
||||||
names = {g["id"]: g["name"] for g in state}
|
|
||||||
all_ids = [g["id"] for g in state]
|
|
||||||
source = SERVICE
|
|
||||||
else:
|
|
||||||
discovered = discover_fleet()
|
|
||||||
names = {v: n for v, n, _ in discovered}
|
|
||||||
all_ids = [v for v, _, _ in discovered]
|
|
||||||
source = f"ssh {HOST}"
|
|
||||||
|
|
||||||
if args.one is not None:
|
|
||||||
wanted = [args.one]
|
|
||||||
elif args.ids:
|
|
||||||
wanted = args.ids
|
|
||||||
else:
|
|
||||||
wanted = all_ids
|
|
||||||
if not wanted:
|
|
||||||
sys.exit(f"vmshot: no sots-* guests found via {source}")
|
|
||||||
fleet = [(v, names.get(v, f"vm{v}"), "") for v in wanted]
|
|
||||||
|
|
||||||
shots = service_grab(wanted, state) if state is not None else grab(wanted, args.timeout)
|
|
||||||
now = datetime.now()
|
|
||||||
stamp = now.strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
tag = now.strftime("%Y%m%d-%H%M%S")
|
|
||||||
|
|
||||||
if args.keep_raw or args.one is not None:
|
|
||||||
for vid, info in shots.items():
|
|
||||||
if info.get("png"):
|
|
||||||
p = OUT_DIR / f"vm{vid}-{tag}.png"
|
|
||||||
p.write_bytes(info["png"])
|
|
||||||
|
|
||||||
if args.one is not None:
|
|
||||||
info = shots.get(args.one, {})
|
|
||||||
if not info.get("png"):
|
|
||||||
print(
|
|
||||||
f"vmshot: no framebuffer from {args.one}: "
|
|
||||||
f"{_clean_monitor_log(info.get('log', '')) or info.get('status', '?')}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
return 1
|
|
||||||
out = args.out or (OUT_DIR / f"vm{args.one}-{tag}.png")
|
|
||||||
out.write_bytes(info["png"])
|
|
||||||
else:
|
|
||||||
sheet = contact_sheet(fleet, shots, args.width, args.cols, stamp)
|
|
||||||
out = args.out or (OUT_DIR / f"sheet-{tag}.png")
|
|
||||||
sheet.save(out)
|
|
||||||
if args.out is None: # only the default location keeps a `latest` alias
|
|
||||||
(OUT_DIR / "latest.png").write_bytes(out.read_bytes())
|
|
||||||
|
|
||||||
live = sum(1 for v in shots.values() if v.get("png"))
|
|
||||||
if args.json:
|
|
||||||
print(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"out": str(out),
|
|
||||||
"captured": live,
|
|
||||||
"requested": len(wanted),
|
|
||||||
"source": source,
|
|
||||||
"seconds": round(time.time() - t0, 1),
|
|
||||||
"guests": {
|
|
||||||
str(v): {
|
|
||||||
"name": names.get(v, ""),
|
|
||||||
"status": i.get("status"),
|
|
||||||
"ok": bool(i.get("png")),
|
|
||||||
"error": _clean_monitor_log(i.get("log", "")) or None,
|
|
||||||
}
|
|
||||||
for v, i in sorted(shots.items())
|
|
||||||
},
|
|
||||||
},
|
|
||||||
indent=2,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
for v, i in sorted(shots.items()):
|
|
||||||
mark = "ok " if i.get("png") else "FAIL"
|
|
||||||
note = "" if i.get("png") else " " + (_clean_monitor_log(i.get("log", "")) or "?")
|
|
||||||
print(f" {mark} {v:<5} {names.get(v, ''):<20} {i.get('status', '?')}{note}")
|
|
||||||
print(
|
|
||||||
f"vmshot: {live}/{len(wanted)} captured in {time.time() - t0:.1f}s "
|
|
||||||
f"via {source} -> {out}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if args.open:
|
|
||||||
subprocess.run(["xdg-open", str(out)], check=False)
|
|
||||||
return 0 if live else 2
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Install / update the vmwatch fishtank service on the Proxmox host.
|
|
||||||
#
|
|
||||||
# What this puts on spicy (and nothing else -- no packages, no pip):
|
|
||||||
# /opt/vmwatch/vmwatch.py the service (Python 3 stdlib only)
|
|
||||||
# /etc/systemd/system/vmwatch.service
|
|
||||||
#
|
|
||||||
# Usage: tools/vmwatch-install.sh [host] (default host: spicy)
|
|
||||||
# tools/vmwatch-install.sh spicy --uninstall
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
HOST="${1:-spicy}"
|
|
||||||
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/vmwatch.py"
|
|
||||||
PORT="${VMWATCH_PORT:-8140}"
|
|
||||||
INTERVAL="${VMWATCH_INTERVAL:-5}"
|
|
||||||
|
|
||||||
if [[ "${2:-}" == "--uninstall" ]]; then
|
|
||||||
ssh "$HOST" 'systemctl disable --now vmwatch.service 2>/dev/null || true
|
|
||||||
rm -f /etc/systemd/system/vmwatch.service
|
|
||||||
rm -rf /opt/vmwatch
|
|
||||||
systemctl daemon-reload
|
|
||||||
echo "vmwatch removed from $(hostname)"'
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "installing vmwatch on $HOST (port $PORT, interval ${INTERVAL}s)"
|
|
||||||
ssh "$HOST" "mkdir -p /opt/vmwatch"
|
|
||||||
scp -q "$SRC" "$HOST:/opt/vmwatch/vmwatch.py"
|
|
||||||
ssh "$HOST" "chmod 0755 /opt/vmwatch/vmwatch.py"
|
|
||||||
|
|
||||||
ssh "$HOST" "cat > /etc/systemd/system/vmwatch.service" <<UNIT
|
|
||||||
[Unit]
|
|
||||||
Description=vmwatch - live screen wall for the SOTS lab VMs
|
|
||||||
Documentation=file:///opt/vmwatch/vmwatch.py
|
|
||||||
After=network-online.target pve-guests.service
|
|
||||||
Wants=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
ExecStart=/usr/bin/python3 /opt/vmwatch/vmwatch.py --port ${PORT} --interval ${INTERVAL}
|
|
||||||
Restart=always
|
|
||||||
RestartSec=3
|
|
||||||
# read-only instrument: it only runs \`qm list\` and \`qm monitor ... screendump\`
|
|
||||||
NoNewPrivileges=yes
|
|
||||||
ProtectHome=yes
|
|
||||||
PrivateTmp=no
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
UNIT
|
|
||||||
|
|
||||||
# `enable --now` will not restart an already-running unit, so restart explicitly
|
|
||||||
ssh "$HOST" "systemctl daemon-reload \
|
|
||||||
&& systemctl enable vmwatch.service >/dev/null \
|
|
||||||
&& systemctl restart vmwatch.service && sleep 2 && systemctl is-active vmwatch.service"
|
|
||||||
IP=$(ssh "$HOST" "hostname -I | tr ' ' '\n' | grep -E '^192\.168\.' | head -1")
|
|
||||||
echo
|
|
||||||
echo "http://${IP}:${PORT}/"
|
|
||||||
637
tools/vmwatch.py
637
tools/vmwatch.py
|
|
@ -1,637 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""vmwatch -- a fishtank for the SOTS lab Windows guests.
|
|
||||||
|
|
||||||
A small always-on HTTP service that serves an auto-refreshing page of live
|
|
||||||
guest screens. Leave the tab open and watch the lab.
|
|
||||||
|
|
||||||
http://192.168.3.201:8140/
|
|
||||||
|
|
||||||
Where it runs
|
|
||||||
-------------
|
|
||||||
On the Proxmox host **spicy** itself, because it needs `qm`. Installed as
|
|
||||||
``/opt/vmwatch/vmwatch.py`` with a systemd unit ``vmwatch.service``. The
|
|
||||||
canonical copy lives here in ``~/sots-re/tools/`` -- edit here, then reinstall
|
|
||||||
with ``tools/vmwatch-install.sh``.
|
|
||||||
|
|
||||||
Dependencies: **Python 3 standard library only.** Nothing is installed on
|
|
||||||
spicy beyond this file and its unit -- no pip, no ImageMagick, no netpbm (none
|
|
||||||
of which are present there).
|
|
||||||
|
|
||||||
Mechanism
|
|
||||||
---------
|
|
||||||
A QMP ``screendump`` sent to each guest's ``/var/run/qemu-server/<id>.qmp``
|
|
||||||
unix socket -- the same socket, and the same command, that
|
|
||||||
``qm monitor <id> <<< "screendump <file> -f png"`` uses, minus the Perl. That
|
|
||||||
detail is not cosmetic: forking ``qm`` once per guest per poll interval cost
|
|
||||||
about 90%% of a core on the host, which is not acceptable on a box that is also
|
|
||||||
running the guests. ``qm monitor`` remains the fallback when the socket is
|
|
||||||
absent. Either way this reads the guest framebuffer straight out of QEMU:
|
|
||||||
|
|
||||||
* no guest agent, no guest network, nothing running inside the guest;
|
|
||||||
* it does **not** perturb the guest -- relevant to method-rule 19, an
|
|
||||||
instrument that perturbs the thing it measures has already burned this
|
|
||||||
campaign once. Reading VM 140's screen is therefore not an experiment and
|
|
||||||
does not take VM 140's exclusivity lock;
|
|
||||||
* the campaign board already records that ``qm monitor`` screendump is more
|
|
||||||
reliable than the in-guest click helper's ``shot``.
|
|
||||||
|
|
||||||
spicy runs pve-manager 9.2 / QEMU 11, whose ``screendump`` accepts ``-f png``
|
|
||||||
natively. On an older QEMU without ``-f``, this falls back to a PPM dump and a
|
|
||||||
hand-rolled PPM->PNG encoder (zlib + a stored-filter IDAT), so no external
|
|
||||||
image tooling is needed either way. Temp files are read into memory and
|
|
||||||
unlinked immediately; nothing accumulates in /tmp.
|
|
||||||
|
|
||||||
Politeness
|
|
||||||
----------
|
|
||||||
A single background poller grabs every guest on a fixed interval and serves
|
|
||||||
every browser from that one in-memory copy, so N open tabs still cost one
|
|
||||||
screendump per guest per interval. The poller goes idle when no client has
|
|
||||||
asked for anything in IDLE_AFTER seconds, and wakes on the next request.
|
|
||||||
|
|
||||||
Routes
|
|
||||||
------
|
|
||||||
GET / the wall
|
|
||||||
GET /one/<id> one guest, full size
|
|
||||||
GET /api/state JSON: per-guest id, name, status, ok, error, age
|
|
||||||
GET /shot/<id>.png the latest cached framebuffer for that guest
|
|
||||||
GET /healthz plaintext ok
|
|
||||||
|
|
||||||
Read-only: this service never starts, stops, resets or reconfigures anything.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import html
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import socket
|
|
||||||
import struct
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import zlib
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from http import HTTPStatus
|
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
||||||
|
|
||||||
FLEET_PATTERN = re.compile(r"sots-re", re.I)
|
|
||||||
DEFAULT_PORT = 8140
|
|
||||||
DEFAULT_INTERVAL = 5.0 # seconds between framebuffer grabs
|
|
||||||
FLEET_TTL = 20.0 # seconds between `qm list` refreshes
|
|
||||||
IDLE_AFTER = 120.0 # stop polling if nobody has looked for this long
|
|
||||||
DUMP_TIMEOUT = 15 # per-guest screendump timeout, seconds
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# host-side capture
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
CONF_DIR = "/etc/pve/qemu-server"
|
|
||||||
QMP_DIR = "/var/run/qemu-server"
|
|
||||||
|
|
||||||
|
|
||||||
def _run(cmd: list[str], timeout: int, stdin: str | None = None) -> subprocess.CompletedProcess:
|
|
||||||
return subprocess.run(
|
|
||||||
cmd, input=stdin, capture_output=True, text=True, timeout=timeout, check=False
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class QmpError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class Qmp:
|
|
||||||
"""Minimal QMP client over the per-VM unix socket PVE already exposes.
|
|
||||||
|
|
||||||
This is the same socket `qm monitor` uses, opened the same way (connect,
|
|
||||||
negotiate, one command, close). Going straight to it instead of shelling
|
|
||||||
out to `qm` matters: `qm` is a Perl program and forking one per guest per
|
|
||||||
poll interval cost ~90%% of a core on the host. Falls back to `qm monitor`
|
|
||||||
if the socket is missing.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, vmid: int, timeout: float = 10.0):
|
|
||||||
self.path = os.path.join(QMP_DIR, f"{vmid}.qmp")
|
|
||||||
self.timeout = timeout
|
|
||||||
self.sock: socket.socket | None = None
|
|
||||||
self.buf = b""
|
|
||||||
|
|
||||||
def __enter__(self) -> Qmp:
|
|
||||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
||||||
s.settimeout(self.timeout)
|
|
||||||
s.connect(self.path)
|
|
||||||
self.sock = s
|
|
||||||
self._read_json() # greeting
|
|
||||||
self.execute("qmp_capabilities")
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, *exc) -> None:
|
|
||||||
if self.sock:
|
|
||||||
try:
|
|
||||||
self.sock.close()
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _read_json(self) -> dict:
|
|
||||||
while True:
|
|
||||||
nl = self.buf.find(b"\n")
|
|
||||||
if nl >= 0:
|
|
||||||
line, self.buf = self.buf[:nl], self.buf[nl + 1 :]
|
|
||||||
if line.strip():
|
|
||||||
msg = json.loads(line)
|
|
||||||
if "event" in msg: # async event: not our reply
|
|
||||||
continue
|
|
||||||
return msg
|
|
||||||
continue
|
|
||||||
chunk = self.sock.recv(65536) # type: ignore[union-attr]
|
|
||||||
if not chunk:
|
|
||||||
raise QmpError("monitor closed the connection")
|
|
||||||
self.buf += chunk
|
|
||||||
|
|
||||||
def execute(self, cmd: str, **args) -> dict:
|
|
||||||
payload = {"execute": cmd}
|
|
||||||
if args:
|
|
||||||
payload["arguments"] = args
|
|
||||||
self.sock.sendall(json.dumps(payload).encode() + b"\n") # type: ignore[union-attr]
|
|
||||||
reply = self._read_json()
|
|
||||||
if "error" in reply:
|
|
||||||
raise QmpError(reply["error"].get("desc", json.dumps(reply["error"]))[:200])
|
|
||||||
return reply.get("return", {})
|
|
||||||
|
|
||||||
|
|
||||||
def _conf_name(vmid: int) -> str | None:
|
|
||||||
"""Guest name from its PVE config, ignoring the [snapshot] sections."""
|
|
||||||
try:
|
|
||||||
with open(os.path.join(CONF_DIR, f"{vmid}.conf"), encoding="utf-8") as fh:
|
|
||||||
for line in fh:
|
|
||||||
if line.startswith("["): # snapshot section begins
|
|
||||||
break
|
|
||||||
if line.startswith("name:"):
|
|
||||||
return line.split(":", 1)[1].strip()
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def list_fleet() -> list[dict]:
|
|
||||||
"""[{id, name, status}] for every VM whose name matches sots-re.
|
|
||||||
|
|
||||||
Read from /etc/pve/qemu-server + the QMP sockets rather than `qm list`,
|
|
||||||
which is another Perl fork. Falls back to `qm list` if that is not
|
|
||||||
readable (e.g. running somewhere that is not a PVE node).
|
|
||||||
"""
|
|
||||||
fleet = []
|
|
||||||
try:
|
|
||||||
entries = os.listdir(CONF_DIR)
|
|
||||||
except OSError:
|
|
||||||
entries = []
|
|
||||||
for entry in entries:
|
|
||||||
stem, _, ext = entry.rpartition(".")
|
|
||||||
if ext != "conf" or not stem.isdigit():
|
|
||||||
continue
|
|
||||||
vmid = int(stem)
|
|
||||||
name = _conf_name(vmid)
|
|
||||||
if not name or not FLEET_PATTERN.search(name):
|
|
||||||
continue
|
|
||||||
status = "stopped"
|
|
||||||
if os.path.exists(os.path.join(QMP_DIR, f"{vmid}.qmp")):
|
|
||||||
try:
|
|
||||||
with Qmp(vmid, timeout=5) as q:
|
|
||||||
status = q.execute("query-status").get("status", "running")
|
|
||||||
except (OSError, QmpError, ValueError):
|
|
||||||
status = "unreachable"
|
|
||||||
fleet.append({"id": vmid, "name": name, "status": status})
|
|
||||||
if fleet:
|
|
||||||
return sorted(fleet, key=lambda g: g["id"])
|
|
||||||
|
|
||||||
try:
|
|
||||||
r = _run(["qm", "list"], timeout=20)
|
|
||||||
except (subprocess.TimeoutExpired, OSError):
|
|
||||||
return []
|
|
||||||
for line in r.stdout.splitlines()[1:]:
|
|
||||||
parts = line.split()
|
|
||||||
if len(parts) < 3 or not parts[0].isdigit():
|
|
||||||
continue
|
|
||||||
if FLEET_PATTERN.search(parts[1]):
|
|
||||||
fleet.append({"id": int(parts[0]), "name": parts[1], "status": parts[2]})
|
|
||||||
return sorted(fleet, key=lambda g: g["id"])
|
|
||||||
|
|
||||||
|
|
||||||
def ppm_to_png(data: bytes) -> bytes:
|
|
||||||
"""Minimal binary-P6 PPM -> PNG. Only used if QEMU lacks screendump -f png."""
|
|
||||||
if not data.startswith(b"P6"):
|
|
||||||
raise ValueError("not a binary P6 PPM")
|
|
||||||
# header: P6, then width height maxval, whitespace separated, # comments allowed
|
|
||||||
fields, pos = [], 2
|
|
||||||
while len(fields) < 3:
|
|
||||||
while pos < len(data) and data[pos : pos + 1].isspace():
|
|
||||||
pos += 1
|
|
||||||
if data[pos : pos + 1] == b"#":
|
|
||||||
while pos < len(data) and data[pos] != 0x0A:
|
|
||||||
pos += 1
|
|
||||||
continue
|
|
||||||
start = pos
|
|
||||||
while pos < len(data) and not data[pos : pos + 1].isspace():
|
|
||||||
pos += 1
|
|
||||||
fields.append(int(data[start:pos]))
|
|
||||||
pos += 1 # single whitespace byte after maxval
|
|
||||||
w, h, maxval = fields
|
|
||||||
if maxval != 255:
|
|
||||||
raise ValueError(f"unsupported PPM maxval {maxval}")
|
|
||||||
px = data[pos : pos + w * h * 3]
|
|
||||||
stride = w * 3
|
|
||||||
raw = bytearray()
|
|
||||||
for y in range(h):
|
|
||||||
raw.append(0) # filter type 0 (None)
|
|
||||||
raw += px[y * stride : (y + 1) * stride]
|
|
||||||
|
|
||||||
def chunk(tag: bytes, payload: bytes) -> bytes:
|
|
||||||
return (
|
|
||||||
struct.pack(">I", len(payload))
|
|
||||||
+ tag
|
|
||||||
+ payload
|
|
||||||
+ struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF)
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
b"\x89PNG\r\n\x1a\n"
|
|
||||||
+ chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
|
|
||||||
+ chunk(b"IDAT", zlib.compress(bytes(raw), 6))
|
|
||||||
+ chunk(b"IEND", b"")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def screendump(vmid: int, png_native: bool = True) -> tuple[bytes | None, str]:
|
|
||||||
"""Grab one guest framebuffer. Returns (png_bytes, error_message); never raises."""
|
|
||||||
suffix = ".png" if png_native else ".ppm"
|
|
||||||
fd, path = tempfile.mkstemp(prefix=f"vmwatch-{vmid}-", suffix=suffix, dir="/tmp")
|
|
||||||
os.close(fd)
|
|
||||||
os.unlink(path) # QEMU creates it itself; we only wanted a unique name
|
|
||||||
err = ""
|
|
||||||
try:
|
|
||||||
if os.path.exists(os.path.join(QMP_DIR, f"{vmid}.qmp")):
|
|
||||||
args = {"filename": path}
|
|
||||||
if png_native:
|
|
||||||
args["format"] = "png"
|
|
||||||
with Qmp(vmid, timeout=DUMP_TIMEOUT) as q:
|
|
||||||
q.execute("screendump", **args)
|
|
||||||
else:
|
|
||||||
# not running, or not a PVE node: fall back to the CLI monitor
|
|
||||||
cmd = f"screendump {path}" + (" -f png" if png_native else "")
|
|
||||||
r = _run(["qm", "monitor", str(vmid)], timeout=DUMP_TIMEOUT, stdin=cmd + "\n")
|
|
||||||
# `qm monitor` exits 0 even when the monitor command itself errors;
|
|
||||||
# it echoes the failure to stdout, so "no file" is the real test.
|
|
||||||
err = _clean_monitor_text(r.stdout + r.stderr)
|
|
||||||
except FileNotFoundError:
|
|
||||||
_unlink(path)
|
|
||||||
return None, "guest is not running (no monitor socket)"
|
|
||||||
except (socket.timeout, subprocess.TimeoutExpired):
|
|
||||||
_unlink(path)
|
|
||||||
return None, "screendump timed out"
|
|
||||||
except (QmpError, OSError, ValueError) as e:
|
|
||||||
_unlink(path)
|
|
||||||
return None, str(e) or f"{type(e).__name__}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(path, "rb") as fh:
|
|
||||||
blob = fh.read()
|
|
||||||
except OSError:
|
|
||||||
blob = b""
|
|
||||||
finally:
|
|
||||||
_unlink(path)
|
|
||||||
|
|
||||||
if not blob:
|
|
||||||
return None, err or "no framebuffer written"
|
|
||||||
if png_native and blob.startswith(b"\x89PNG"):
|
|
||||||
return blob, ""
|
|
||||||
try:
|
|
||||||
return ppm_to_png(blob), ""
|
|
||||||
except Exception as e: # noqa: BLE001
|
|
||||||
return None, f"could not decode framebuffer: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
def _unlink(path: str) -> None:
|
|
||||||
try:
|
|
||||||
os.unlink(path)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _clean_monitor_text(text: str) -> str:
|
|
||||||
keep = []
|
|
||||||
for ln in text.replace("\x1b", "").splitlines():
|
|
||||||
ln = re.sub(r"^(QEMU \d[\w.]* monitor.*|qm> ?)", "", ln.strip()).strip()
|
|
||||||
if not ln or ln.startswith(("Entering QEMU Monitor", "Type 'help'", "screendump ")):
|
|
||||||
continue
|
|
||||||
keep.append(ln)
|
|
||||||
return "; ".join(keep)[:200]
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# poller
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class Tank:
|
|
||||||
def __init__(self, interval: float):
|
|
||||||
self.interval = interval
|
|
||||||
self.lock = threading.Lock()
|
|
||||||
self.shots: dict[int, dict] = {} # id -> {png, error, ts}
|
|
||||||
self.fleet: list[dict] = []
|
|
||||||
self.fleet_ts = 0.0
|
|
||||||
self.last_client = time.time()
|
|
||||||
self.png_native = True
|
|
||||||
self.cycles = 0
|
|
||||||
self.started = time.time()
|
|
||||||
self.wake = threading.Event()
|
|
||||||
|
|
||||||
def touch(self) -> None:
|
|
||||||
idle = time.time() - self.last_client > IDLE_AFTER
|
|
||||||
self.last_client = time.time()
|
|
||||||
if idle:
|
|
||||||
self.wake.set() # first look after a nap: refresh right away
|
|
||||||
|
|
||||||
def get_fleet(self) -> list[dict]:
|
|
||||||
with self.lock:
|
|
||||||
fresh = time.time() - self.fleet_ts < FLEET_TTL and self.fleet
|
|
||||||
if fresh:
|
|
||||||
return list(self.fleet)
|
|
||||||
fleet = list_fleet()
|
|
||||||
with self.lock:
|
|
||||||
if fleet or not self.fleet:
|
|
||||||
self.fleet, self.fleet_ts = fleet, time.time()
|
|
||||||
return list(self.fleet)
|
|
||||||
|
|
||||||
def cycle(self) -> None:
|
|
||||||
fleet = self.get_fleet()
|
|
||||||
if not fleet:
|
|
||||||
return
|
|
||||||
with ThreadPoolExecutor(max_workers=max(1, len(fleet))) as pool:
|
|
||||||
results = list(pool.map(lambda g: (g["id"], screendump(g["id"], self.png_native)), fleet))
|
|
||||||
now = time.time()
|
|
||||||
with self.lock:
|
|
||||||
for vmid, (png, err) in results:
|
|
||||||
prev = self.shots.get(vmid, {})
|
|
||||||
self.shots[vmid] = {
|
|
||||||
"png": png if png else prev.get("png"),
|
|
||||||
"fresh": bool(png),
|
|
||||||
"error": err,
|
|
||||||
"ts": now if png else prev.get("ts", 0.0),
|
|
||||||
}
|
|
||||||
live = {g["id"] for g in fleet}
|
|
||||||
for gone in set(self.shots) - live:
|
|
||||||
del self.shots[gone]
|
|
||||||
self.cycles += 1
|
|
||||||
# one-time downgrade if this QEMU has no `-f png`
|
|
||||||
if self.png_native and all(not p for _, (p, _) in results):
|
|
||||||
errs = " ".join(e for _, (_, e) in results)
|
|
||||||
if "format" in errs.lower() or "invalid" in errs.lower() or "-f" in errs:
|
|
||||||
self.png_native = False
|
|
||||||
print("vmwatch: QEMU lacks screendump -f png; falling back to PPM", flush=True)
|
|
||||||
|
|
||||||
def loop(self) -> None:
|
|
||||||
while True:
|
|
||||||
if time.time() - self.last_client <= IDLE_AFTER:
|
|
||||||
try:
|
|
||||||
self.cycle()
|
|
||||||
except Exception as e: # noqa: BLE001 - the poller must never die
|
|
||||||
print(f"vmwatch: cycle error: {e}", file=sys.stderr, flush=True)
|
|
||||||
self.wake.wait(self.interval)
|
|
||||||
else:
|
|
||||||
self.wake.wait(2.0)
|
|
||||||
self.wake.clear()
|
|
||||||
|
|
||||||
def state(self) -> list[dict]:
|
|
||||||
now = time.time()
|
|
||||||
out = []
|
|
||||||
with self.lock:
|
|
||||||
shots = dict(self.shots)
|
|
||||||
for g in self.get_fleet():
|
|
||||||
s = shots.get(g["id"], {})
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
**g,
|
|
||||||
"ok": bool(s.get("png")),
|
|
||||||
"fresh": bool(s.get("fresh")),
|
|
||||||
"error": s.get("error") or "",
|
|
||||||
"age": round(now - s["ts"], 1) if s.get("ts") else None,
|
|
||||||
"ts": time.strftime("%H:%M:%S", time.localtime(s["ts"])) if s.get("ts") else "",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
def png(self, vmid: int) -> bytes | None:
|
|
||||||
with self.lock:
|
|
||||||
return (self.shots.get(vmid) or {}).get("png")
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# page
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
PAGE = """<!doctype html>
|
|
||||||
<html lang="en"><head><meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>%(title)s</title>
|
|
||||||
<link rel="icon" href="data:image/svg+xml,%%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%%3E%%3Crect width='16' height='16' rx='2' fill='%%232a2f39'/%%3E%%3Crect x='2' y='3' width='12' height='8' rx='1' fill='%%2368c07a'/%%3E%%3Crect x='6' y='12' width='4' height='1.5' fill='%%2398a0ad'/%%3E%%3C/svg%%3E">
|
|
||||||
<style>
|
|
||||||
:root{--bg:#16181c;--tile:#22262e;--tile2:#2a2f39;--line:#3a4150;--fg:#e8eaee;
|
|
||||||
--dim:#98a0ad;--bad:#4a2222;--badfg:#e08a8a;--ok:#68c07a}
|
|
||||||
*{box-sizing:border-box}
|
|
||||||
body{margin:0;background:var(--bg);color:var(--fg);
|
|
||||||
font:13px/1.4 "Segoe UI",system-ui,-apple-system,sans-serif}
|
|
||||||
header{display:flex;align-items:center;gap:12px;padding:8px 14px;
|
|
||||||
background:#1c1f25;border-bottom:1px solid var(--line);
|
|
||||||
position:sticky;top:0;z-index:5}
|
|
||||||
header h1{font-size:14px;font-weight:600;margin:0;letter-spacing:.2px}
|
|
||||||
header .sp{flex:1}
|
|
||||||
header .meta{color:var(--dim);font-size:11px;font-variant-numeric:tabular-nums}
|
|
||||||
.dot{width:7px;height:7px;border-radius:50%%;background:var(--ok);display:inline-block;
|
|
||||||
margin-right:5px;transition:opacity .3s}
|
|
||||||
.dot.blink{opacity:.25}
|
|
||||||
main{display:grid;gap:10px;padding:10px;
|
|
||||||
grid-template-columns:repeat(auto-fill,minmax(%(minw)spx,1fr))}
|
|
||||||
main.one{grid-template-columns:1fr;padding:10px}
|
|
||||||
.tile{background:var(--tile);border:1px solid var(--line);border-radius:5px;
|
|
||||||
overflow:hidden;text-decoration:none;color:inherit;display:block}
|
|
||||||
.tile.bad{background:var(--bad);border-color:#6a3030}
|
|
||||||
.bar{display:flex;align-items:center;gap:8px;padding:5px 9px;background:var(--tile2);
|
|
||||||
border-bottom:1px solid var(--line)}
|
|
||||||
.tile.bad .bar{background:#3a1e1e;border-bottom-color:#6a3030}
|
|
||||||
.id{font-weight:700;font-size:13px;font-variant-numeric:tabular-nums}
|
|
||||||
.nm{color:var(--fg);font-size:12px}
|
|
||||||
.st{margin-left:auto;color:var(--dim);font-size:11px;font-variant-numeric:tabular-nums;
|
|
||||||
white-space:nowrap}
|
|
||||||
.shot{display:block;width:100%%;aspect-ratio:4/3;object-fit:contain;background:#000}
|
|
||||||
.ph{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;
|
|
||||||
aspect-ratio:4/3;background:#2c1e1e;color:var(--badfg);text-align:center;padding:14px}
|
|
||||||
.ph b{font-size:15px;letter-spacing:1.5px}
|
|
||||||
.ph span{font-size:11px;color:#b89696;max-width:34em;word-break:break-word}
|
|
||||||
a.back{color:var(--dim);text-decoration:none;font-size:12px}
|
|
||||||
a.back:hover{color:var(--fg)}
|
|
||||||
</style></head><body>
|
|
||||||
<header>
|
|
||||||
<span class="dot" id="dot"></span>
|
|
||||||
<h1>%(title)s</h1>
|
|
||||||
%(nav)s
|
|
||||||
<span class="sp"></span>
|
|
||||||
<span class="meta" id="meta">connecting...</span>
|
|
||||||
</header>
|
|
||||||
<main id="wall" class="%(cls)s"></main>
|
|
||||||
<script>
|
|
||||||
const ONLY = %(only)s, INTERVAL = %(interval)s;
|
|
||||||
const wall = document.getElementById('wall'), meta = document.getElementById('meta'),
|
|
||||||
dot = document.getElementById('dot');
|
|
||||||
const esc = s => String(s).replace(/[&<>"]/g, c =>
|
|
||||||
({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
|
||||||
|
|
||||||
function render(guests){
|
|
||||||
const want = guests.map(g => String(g.id)).join(',');
|
|
||||||
if (wall.dataset.keys !== want){ wall.innerHTML = ''; wall.dataset.keys = want;
|
|
||||||
for (const g of guests){
|
|
||||||
const el = document.createElement(ONLY ? 'div' : 'a');
|
|
||||||
el.className = 'tile'; el.id = 'g' + g.id;
|
|
||||||
if (!ONLY) el.href = '/one/' + g.id;
|
|
||||||
el.innerHTML = '<div class="bar"><span class="id"></span><span class="nm"></span>'
|
|
||||||
+ '<span class="st"></span></div><div class="body"></div>';
|
|
||||||
wall.appendChild(el);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const g of guests){
|
|
||||||
const el = document.getElementById('g' + g.id); if (!el) continue;
|
|
||||||
el.classList.toggle('bad', !g.ok);
|
|
||||||
el.querySelector('.id').textContent = g.id;
|
|
||||||
el.querySelector('.nm').textContent = g.name;
|
|
||||||
el.querySelector('.st').textContent =
|
|
||||||
g.status + (g.ok ? ' ' + g.ts + (g.fresh ? '' : ' (stale)') : ' no signal');
|
|
||||||
const body = el.querySelector('.body');
|
|
||||||
if (g.ok){
|
|
||||||
let img = body.querySelector('img');
|
|
||||||
if (!img){ body.innerHTML = ''; img = document.createElement('img');
|
|
||||||
img.className = 'shot'; img.alt = 'VM ' + g.id;
|
|
||||||
// an undecodable frame must degrade to the placeholder, not a broken icon
|
|
||||||
img.onerror = () => { el.classList.add('bad'); body.innerHTML =
|
|
||||||
'<div class="ph"><b>NO SIGNAL</b><span>frame did not decode</span></div>'; };
|
|
||||||
body.appendChild(img); }
|
|
||||||
// cache-buster keyed to capture time: no refetch unless the frame changed
|
|
||||||
const next = '/shot/' + g.id + '.png?t=' + encodeURIComponent(g.ts);
|
|
||||||
if (img.getAttribute('src') !== next) img.src = next;
|
|
||||||
} else {
|
|
||||||
body.innerHTML = '<div class="ph"><b>NO SIGNAL</b><span>'
|
|
||||||
+ esc(g.error || g.status) + '</span></div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function tick(){
|
|
||||||
try{
|
|
||||||
const r = await fetch('/api/state', {cache:'no-store'});
|
|
||||||
let guests = await r.json();
|
|
||||||
if (ONLY) guests = guests.filter(g => g.id == ONLY);
|
|
||||||
render(guests);
|
|
||||||
const live = guests.filter(g => g.ok).length;
|
|
||||||
meta.textContent = live + '/' + guests.length + ' framebuffers · '
|
|
||||||
+ new Date().toLocaleTimeString() + ' · every ' + INTERVAL + 's · spicy';
|
|
||||||
dot.style.background = 'var(--ok)';
|
|
||||||
} catch(e){
|
|
||||||
meta.textContent = 'lost contact with vmwatch — retrying';
|
|
||||||
dot.style.background = '#c05a5a';
|
|
||||||
}
|
|
||||||
dot.classList.add('blink'); setTimeout(() => dot.classList.remove('blink'), 300);
|
|
||||||
}
|
|
||||||
tick(); setInterval(tick, INTERVAL * 1000);
|
|
||||||
</script></body></html>
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
|
||||||
server_version = "vmwatch"
|
|
||||||
tank: Tank = None # type: ignore[assignment]
|
|
||||||
|
|
||||||
def log_message(self, fmt, *a): # quieter than the default one-line-per-image
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _send(self, code, ctype, body: bytes, cache: str = "no-store"):
|
|
||||||
self.send_response(code)
|
|
||||||
self.send_header("Content-Type", ctype)
|
|
||||||
self.send_header("Content-Length", str(len(body)))
|
|
||||||
self.send_header("Cache-Control", cache)
|
|
||||||
self.end_headers()
|
|
||||||
try:
|
|
||||||
self.wfile.write(body)
|
|
||||||
except (BrokenPipeError, ConnectionResetError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _page(self, only: int | None):
|
|
||||||
title = f"SOTS guest {only}" if only else "SOTS lab guests"
|
|
||||||
nav = '<a class="back" href="/">← all guests</a>' if only else ""
|
|
||||||
body = PAGE % {
|
|
||||||
"title": html.escape(title),
|
|
||||||
"nav": nav,
|
|
||||||
"cls": "one" if only else "",
|
|
||||||
"only": only if only else "null",
|
|
||||||
"interval": self.tank.interval,
|
|
||||||
"minw": 900 if only else 460,
|
|
||||||
}
|
|
||||||
self._send(HTTPStatus.OK, "text/html; charset=utf-8", body.encode())
|
|
||||||
|
|
||||||
def do_GET(self): # noqa: N802
|
|
||||||
path = self.path.split("?", 1)[0]
|
|
||||||
self.tank.touch()
|
|
||||||
|
|
||||||
if path == "/":
|
|
||||||
return self._page(None)
|
|
||||||
if m := re.fullmatch(r"/one/(\d+)/?", path):
|
|
||||||
return self._page(int(m.group(1)))
|
|
||||||
if path == "/api/state":
|
|
||||||
return self._send(
|
|
||||||
HTTPStatus.OK, "application/json", json.dumps(self.tank.state()).encode()
|
|
||||||
)
|
|
||||||
if m := re.fullmatch(r"/shot/(\d+)\.png", path):
|
|
||||||
png = self.tank.png(int(m.group(1)))
|
|
||||||
if png:
|
|
||||||
# immutable: the URL carries the capture timestamp
|
|
||||||
return self._send(HTTPStatus.OK, "image/png", png, "max-age=30")
|
|
||||||
return self._send(HTTPStatus.NOT_FOUND, "text/plain", b"no framebuffer\n")
|
|
||||||
if path == "/healthz":
|
|
||||||
t = self.tank
|
|
||||||
up = int(time.time() - t.started)
|
|
||||||
return self._send(
|
|
||||||
HTTPStatus.OK,
|
|
||||||
"text/plain",
|
|
||||||
f"ok guests={len(t.get_fleet())} cycles={t.cycles} uptime={up}s\n".encode(),
|
|
||||||
)
|
|
||||||
return self._send(HTTPStatus.NOT_FOUND, "text/plain", b"not found\n")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
||||||
ap.add_argument("--port", type=int, default=DEFAULT_PORT)
|
|
||||||
ap.add_argument("--bind", default="0.0.0.0")
|
|
||||||
ap.add_argument("--interval", type=float, default=DEFAULT_INTERVAL)
|
|
||||||
args = ap.parse_args()
|
|
||||||
|
|
||||||
tank = Tank(args.interval)
|
|
||||||
Handler.tank = tank
|
|
||||||
threading.Thread(target=tank.loop, daemon=True, name="poller").start()
|
|
||||||
srv = ThreadingHTTPServer((args.bind, args.port), Handler)
|
|
||||||
srv.daemon_threads = True
|
|
||||||
print(
|
|
||||||
f"vmwatch: http://{args.bind}:{args.port}/ interval={args.interval}s "
|
|
||||||
f"guests={[g['id'] for g in tank.get_fleet()]}",
|
|
||||||
flush=True,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
srv.serve_forever()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
pass
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
Loading…
Add table
Reference in a new issue