sots-engine/docs/SV-script-objects.md
alex 2d93404c67 SV: the script-object event bus, and what it writes in a turn
`SvSctOb` is not written by direct calls. Every update goes through an event bus: a
driver notifies the root object with an integer id, the root fans the delivery out to
every child, and each delivery is two steps -- a generic handler that takes the id, then
one event-specific vtable slot that does not. The id -> slot map is a 33-entry jump table
in the image, so which class reacts to which event is recovered and exhaustive rather than
inferred from what the saves happen to show. Five of the 33 rows are not in slot order,
including two the tail sends.

Three handlers write the eight leaves that diverged:

  * the slavers' difficulty tier, on the tail's end-of-turn delivery -- a three-record
    stack table scanned against the frame, boundaries 1/50/100, stored only on a change,
    and at frame 100 and above the scan runs off the end and stores nothing, so the tier
    can never reach 2;
  * the refugees' one-shot latch, on the turn-begin delivery, with a design instantiation
    behind the same latch that nothing here can do;
  * the swarm queen's hives, also at turn begin, registered on the systems carrying the
    SWARM's scenario tag (the queen's constructor stores 3 for that and 10 for its own
    encounter id) and then ticked -- and the tick is the whole explanation of a target
    turn that reads 31 after one turn and 32 after the next. It is not re-rolled; it slips
    forward by one every turn the spawn gates stay shut.

New host phase H03 for the turn-begin delivery, run right after the frame counter where
the original sends it, and tail phase T20 implemented. Rules are pure in game/sim.

Measured on CT111, closed and regressed stated separately:

  default                 turn1->turn2  209 -> 126 (was 128)  closed 83, regressed 0
                          turn2->turn3  108 ->  67 (was  69)  closed 41, regressed 0
  --commit-blocked=H03    turn1->turn2  209 -> 124            closed 87, regressed 2
                          turn2->turn3  108 ->  67            closed 41, regressed 0

Registering a hive closes the four leaves that say which systems have hives and that they
have no queens, and opens two carrying a target turn known to be wrong: the original draws
it from the strategic generator inside the turn-begin step, outside both turn drivers, and
neither the two data-file constants nor the generator's position there is settled. That
trade is a flag, not a default.

The prediction in docs/SV-script-objects.md was committed before the build, and P5 was
wrong: it called the second pair a null control, and the second pair is where the slip
rule is tested EXACTLY -- two hives, two target turns, both landing on the oracle with no
draw and no fitting.

Gates run separately: clean-room OK, host ctest 51/51, CT111 shim cross-build clean.
2026-09-08 16:46:25 -04:00

264 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# SV — what updates `SvSctOb` during a turn
Lane SV. Written **before** the build (rule 2). The falsification section is §5.
The eight leaves of `/Sim/SvSctOb` that diverge on the reference pair belong to three of the
twelve script objects the save carries. This lane asked what writes them, found the mechanism,
and implements the part of it the standalone can reach.
---
## 1. The mechanism: one event bus, two steps per delivery
`StrategyServer+0x1b4` holds the root script object (a `Game::SVSOSots`). Every site that
notifies it does the **same two-step**: a generic handler taking the event id, then one
event-specific virtual slot with no id:
```
script->vft[0x10](evt, arg); // generic: every object sees every event
script->vft[<slot(evt)>](...); // specific: one slot per event id
```
The root's generic handler fans the same delivery out to **every child** script object through
a shared dispatcher, which repeats both steps per child and carries a **33-entry jump table**
mapping `evt` (0..0x20) to the specific slot. So `evt -> slot` is a real, exhaustive encoding
in the image, not a guess:
| 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 |
The table is what proves the hand-written pairs in the two turn drivers are event deliveries and
not ad-hoc calls: the tail's `vft[0x10](8,0); vft[0x34]()` is exactly `evt 8`, and
`vft[0x10](0x14,0); vft[0x64]()` is exactly `evt 0x14`.
## 2. Where a turn delivers events
Every site that reads `StrategyServer+0x1b4` and dispatches, with the id it sends:
| driver | event | when |
|---|---|---|
| `BeginProcessTurn` | **0x13** | first thing in the turn, right after the frame counter |
| `StrategyServer::ProcessTurn` | 6, then 0x1c | the spine |
| `StrategyServer::MoveFleet` | 0xe | per fleet move |
| `ApplyEncounterResult` | 7 | per encounter, in the tail |
| `OnAllCombatDone_Tail` | **8**, then **0x14**, **0x15**, then **0x1c** | tail phases 8, 20 and one site lane K's phase map does not list |
| `BuildTurnEvents` | 0x1a, ?, 0x1b | after the tail |
| `SynchronizePlayer`, `LoadGame`, `ResumePlaying`, and eight others | 1..5, 0xd, 0x17, 0x18, 0x2 | not a turn |
**Correction to lane K.** `combat-done-tail.md` lists three script-hook sites in the tail
(phases 8 and 20). There is a **fourth**, after the maintenance/research recompute, and it sends
**event 0x1c** — the same id `ProcessTurn` sends. Lane K's tier-4 note attributes 0x1c to
`ProcessTurn` alone.
Of the twelve classes our saves carry, only these override a slot a turn delivers:
| class | evt 0x13 (turn begin) | evt 8 | evt 0x14 | evt 0x1c | evt 6 | evt 7 |
|---|---|---|---|---|---|---|
| VonNeumann (1) | yes | yes | — | yes | yes | yes |
| Swarm (3) | yes | — | — | — | — | yes |
| Derelict (4) / Monitor (5) / CrowRuins (17) | — | — | — | — | — | yes |
| SlaversRefuel (9) | — | — | **yes** (via its generic handler) | — | — | yes |
| SwarmQueen (10) | **yes** | — | **yes** | — | — | — |
| Refugees (20) | **yes** | yes | — | — | yes | yes |
| Traps / CrowDefenders / IndependentSystems | yes | yes | yes | — | yes | — |
| GrandMenaceTrigger | yes | — | — | — | — | — |
`evt 0x15` is overridden by **nobody** — the tail's second phase-20 pair is dead in every class
our saves hold.
## 3. The three writers behind the eight leaves
`EncObj[3]` is EncID 9 (SlaversRefuel), `EncObj[5]` is EncID 10 (SwarmQueen), `EncObj[6]` is
EncID 20 (Refugees).
### 3a. `CDiff` (1 leaf) — SlaversRefuel, event 0x14, the tail
The class overrides only the **generic** handler, which does nothing unless `evt == 0x14`. The
body builds a **three-record table on the stack** and walks it against the server's frame
counter:
| threshold | payload |
|---|---|
| 1 | (1, 1) |
| 50 | (2, 3) |
| 100 | (2, 5) |
It scans for the first record whose threshold is **greater** than the frame, and writes
`index - 1` into `CDiff` — but only if that differs from what is there. Three consequences,
all from the instruction stream and none of them guessable from the data:
* frame ≤ 0 → the first record already exceeds it, index 0, `jle` exit: **no write**;
* frame in 1..49 → `CDiff = 0`; frame in 50..99 → `CDiff = 1`;
* **frame ≥ 100 → the scan runs off the end and there is no write at all**, so `CDiff` can never
reach 2 through this path. That looks like an off-by-one in the original and is recorded as
what the code does, not as what it presumably meant.
Only when `CDiff` changes does the function continue into a per-system pass. That pass writes
nothing this object serialises (`NAsg`, `NTD`, `NAD` are unchanged across the pair), so it is
**not** modelled and is labelled below.
### 3b. `ini` + `didc`/`did` (3 leaves) — Refugees, event 0x13, turn begin
```
if (!ini) {
ini = true;
obj = <instantiate "_Refugee_Trader" from the "Mission" section of the data files>;
if (obj) dids.push_back(obj.handleId);
}
```
`ini` is a one-shot latch and it is the whole gate. The push-back is **not** modelled: the id it
appends is `1712`, and the same turn's save also gains design `1712`, ship `1728` and fleet
`1744` — three consecutive handle allocations, `NMnx` 106 → 109. That is the refugee-trader
convoy being created from the data files, and nothing in the standalone allocates handles or
instantiates a design template. So this lane commits the latch and names the rest.
### 3c. `Hives` (4 leaves) — SwarmQueen, event 0x13, turn begin
The constructor is decisive about the two ids this class carries: it stores **3** in the
`SVScriptObject` scenario tag and **10** in its own encounter id. So the queen operates on the
**Swarm's** systems, not on its own.
```
for each system with system.EggScio == this.scenarioTag (== 3):
if no hive already references it:
hive.system = system
hive.queen = 0
hive.nextQ = frame + LO + rand(HI - LO) <-- ONE MT DRAW PER NEW HIVE
hives.push_back(hive)
prune hives whose system.EggScio != 3
for each hive with queen == 0:
if <spawn gates fail>: ++hive.nextQ
elif hive.nextQ <= frame: <spawn a queen>
```
`EggScio` is confirmed as the system's owning-scenario tag by the data and not only by the code:
in `turn1-state.sav` exactly the two systems with `EggScio == 3` (336, 400) are the two the
Swarm has infested and the two that get hives; `EggScio == 4` are the two systems the Derelict
has fleets on; `EggScio == 5` is the Monitor's one system.
`++nextQ` is the whole explanation of a number that looked impossible: `NextQ` reads 31/29 after
turn 1 and 32/30 after turn 2. It is not re-rolled — it **slips forward by one every turn the
spawn gates fail**, so a hive's queen date walks away from it until the gates open.
## 4. What is predicted
Implemented in `src/game/sim/scriptobjects.{h,cpp}` and driven from three phases in `src/app`.
**P1 — `CDiff`.** `/Sim/SvSctOb/EncObj[3]/CDiff` closes, `-1 -> 0`, on both reference pairs.
This is the only one of the eight that is completely free of anything the standalone lacks.
**P2 — `ini`.** `/Sim/SvSctOb/EncObj[6]/ini` closes, `False -> True`, on both pairs.
`didc` and `did` do **not** close and do **not** regress: `didc` stays 0 against the oracle's 1
and `did` stays absent.
**P3 — `Hives`.** `/Sim/SvSctOb/EncObj[5]/Hives/.` and `.[0]` (the count, 2) close. `.[1]` and
`.[2]` do **not**, because `NextQ` needs a draw this lane cannot place. Two of four.
**P4 — regressions: zero.** Nothing here writes a leaf that currently agrees.
**P5 — the pair total.** 128 → 124, closed 4, regressed 0, on `turn1 -> turn2`. Pair 2 is
`turn2 -> turn3`, where the latch and the tier are already set and the hives already exist, so
**pair 2 moves by 0** — and that asymmetry is itself the check that these are one-shot rules and
not per-turn ones.
**P6 — an RNG claim, not measured here.** Lane Z measured a turn at 18–22 generator words, *all*
inside `ProcessTurn`, residual outside the two drivers **exactly zero** — on turns 6 and 64,
where the hives already existed. On the reference pair the hives are **created**, and creation
draws once per hive from the strategic generator inside `BeginProcessTurn`, which is **outside
both turn drivers and before either of them**. So the reference turn should cost lane Z's
`ProcessTurn` total **plus at least two words**, and lane Z's "residual is exactly zero" is a
statement about the turns it measured, not about a turn.
## 5. How this could be wrong, and the symptom of each way
1. **`CDiff` reads a different counter than the frame.** The handler reads the server's `+0xc`,
which `BeginProcessTurn` increments while logging "Begin processing turn N", so it is the
frame. If it were instead the modification counter, the tier for the reference pair would
still be 0 (both are small), so **this corpus cannot separate them** — the 1..49 window
swallows the difference. Symptom elsewhere: a save at frame ~50 with a very different
ModCount would put `CDiff` on the wrong side of the boundary.
2. **The stack table is read with the wrong stride.** If the records were 2 dwords rather than 3,
the thresholds would be 1/1/50 and the reference pair would come out `CDiff = 1`, not 0. The
symptom is immediate and visible in the very leaf we are trying to close.
3. **`ini` is set somewhere else as well.** If some other handler also latches it, committing it
at turn begin is right by accident. Symptom: none on this corpus. Stated as a risk.
4. **`ini` should not be set without the design.** If the original's latch were written only
*after* a successful instantiation, then a standalone that cannot instantiate should leave it
false, and closing it here is closing a leaf with the wrong reason. Read from the instruction
stream: the store to the latch is the **second instruction of the guarded block**, before the
lookup and unconditional on its result. So the latch is not conditional on the design.
5. **The hive set is keyed by something other than `EggScio`.** Symptom: the wrong count, or
hives on systems 448/480/64. The count leaf would then regress rather than close.
6. **`NextQ` might be reachable after all.** If the two creation draws are the first draws of the
turn and the standalone's generator is loaded from the save, a future lane that models the
two config constants could reproduce them exactly. This lane does not claim they are
unreachable, only that it has not placed them.
7. **The per-system pass after a `CDiff` change might write something.** It runs on the
reference pair (the tier changes on turn 1). If it wrote a leaf, a regression would appear
somewhere outside `SvSctOb` — which is exactly what P4 would catch.
## 5a. What actually happened
Measured on CT111, `tools/standalone_report.py`. Closed and regressed are stated separately
and never netted, and the two configurations are stated separately too.
**Default (hive registration off).**
| pair | before | after | closed | regressed |
|---|---|---|---|---|
| turn1 -> turn2 | 209 -> 128 | 209 -> **126** | 83 (was 81) | **0** |
| turn2 -> turn3 | 108 -> 69 | 108 -> **67** | 41 (was 39) | **0** |
**With `--commit-blocked=H03` (hive registration on).**
| pair | before | after | closed | regressed |
|---|---|---|---|---|
| turn1 -> turn2 | 209 -> 128 | 209 -> **124** | 87 | **2** |
| turn2 -> turn3 | 108 -> 69 | 108 -> **67** | 41 | **0** |
Scoring the predictions.
* **P1 held.** `CDiff` closed, `-1 -> 0`, on pair 1; pair 2 already carried 0 and the store
is conditional, so it correctly did nothing there.
* **P2 held**, including its negative half: `didc` and `did` neither closed nor regressed.
* **P3 held**, and only under the flag: the count and the list-shape leaves close, the two
`NextQ` leaves do not — and they show up as **regressions**, because the tool's baseline
had those two positions on the "agrees" side while our tree had no hive there at all. That
is the honest reading of a knowingly-wrong value and it is why the registration is opt-in.
* **P4 held** in the default configuration and **failed as stated** under the flag: 2
regressions, both named above, both the same field.
* **P5 was WRONG, and wrong in the direction that matters.** It said pair 2 would move by 0,
because the latch and the tier are already set there and the hives already exist. Pair 2
moved by **2**: `Hives/.[1]/NextQ` and `.[2]/NextQ` closed, 31 -> 32 and 29 -> 30. The
prediction forgot that the hives being present is exactly what lets the **slip** run, and
the slip is a per-turn rule, not a one-shot. So the second pair is not the null control
P5 called it — it is the only **exact** test of the rule this lane recovered, and it
passes: two hives, two independent target turns, both landing on the oracle's value with
no fitting and no draw. A rule that reproduces two numbers it was not built from is worth
more than the pair-1 leaves it was aimed at.
* **P6 is untested here.** It is a claim about the generator, not about leaves, and this lane
did not instrument it. It is recorded so the next lane to touch the RNG ledger can falsify
it cheaply: 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 our saves hold overrides it. The tail's second phase-20 pair is a no-op
on every workload we can build from this corpus.
* The queen **spawn** arm: no hive in any corpus save has a queen, so only the `++NextQ` slip
arm has ever run. Workload needed: a swarm game run past the spawn gates.
* `CDiff` tiers 1 and 2: needs a save at frame ≥ 50. The Zuul saves reach turn 23.
* The Refugees `dids` push: needs design instantiation from the data files, not a different save.
* Two script objects with a factory entry and no occurrence, and the whole alliance/contact
family of events, remain unexercised — the corpus has no alliance and no two-empire contact.