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

14 KiB
Raw Blame History

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.