sots-engine/docs/EV-events.md
alex e30d43dd1b P11: post the no-research event into the save's turn bucket
The engine has modelled the event log since lane E, but the standalone never wrote
any of it into the save it produces. This wires the two together for the one event
on the reference pair the standalone can compute, and corrects the condition.

What a turn actually posts, measured over all eleven saves: two events on
turn1 -> turn2 and three on turn2 -> turn3, and only two players in the whole
corpus ever hold an event at all. Order is readable off the ids -- the build pass
posts before the research pass. See docs/EV-events.md section 1.

The condition had two of the original's three tests. The missing one is "no tech
finished on this turn or later", and it is what keeps the event off the turn a tech
lands; zuul-turn23 exercises it. The third input, whether the player holds a target
at the moment of the check, is not on the wire -- three of the four real players
acquire one during the turn, which is AI research selection -- so the operator's
--ai-player roster stands in for it as a stated hypothesis and the phase stays
blocked without it.

No prose in the engine: the record's text is resolved through a caller-supplied
lookup over the operator's own installed string table, and a run without a data
root posts nothing rather than writing a record it cannot fill.

Measured (CT111 host build, real data root), closed and regressed never netted:
  turn1 -> turn2  209 -> 127  closed 82 (+4 this lane), regressed 0
  turn2 -> turn3  108 ->  69  closed 39 (+3 this lane), regressed 0
The posted record agrees with the oracle on all eight of its fields. Controls: with
the roster withheld the phase over-fires and regresses 16 leaves on pair 1; with the
string table withheld it posts nothing and regresses none.

Gates run separately: tools/clean_room_check.sh OK; host ctest 50/50; CT111 shim
cross-build OK (exports 66 names identical to binkw32.dll, staged in
/srv/re-lab/shim/dist-ev); CT111 host ctest 50/50.
2026-09-08 15:39:42 -04:00

11 KiB

EV — posting events into the save's turn bucket (P11)

Roadmap item 5. The engine already models the event log (game/events: turn-bucketed storage, per-bucket dedup, the FLT_MAX position sentinel, the act == 0 && no subject && no position -> 2 rule). What was missing is that the standalone never wrote any of it into the save it produces. This lane wires that up for the one event a turn on the reference pair actually posts that the standalone can compute, and reports the rest as blocked with the blocker named.

Nothing here carries the game's displayed text. The engine holds EVENTSUM_* / EVENTMSG_* keys only; the text is resolved at run time through a lookup the caller supplies, which the standalone builds from the operator's own installed string table (--data).


1. What a turn actually posts (measured, whole corpus)

Not derived — read out of the eleven saves in verify/results/saves with the state-checksum tree. Only two players in any corpus save ever hold an event at all: the human (PlyrIdx 0) and the one AI empire that owns colonies (PlyrIdx 1). Every other player's EvNxID is 0 on every save, including the two AI shadow empires that do pick research targets.

Reference pair turn1-state.sav -> turn2-state.sav — the whole turn posts two events:

player bucket id image act loc
0 (human) EvTurn=2 1 EVENT_NO_RESEARCH 1 0
1 (AI) EvTurn=2 1 EVENT_SHIPS_BUILT 0 288

Pair 2 turn2-state.sav -> turn3-state.sav — three:

player bucket id image act loc
0 EvTurn=3 2 EVENT_NO_RESEARCH 1 0
1 EvTurn=3 2 EVENT_SHIPS_BUILT 0 288
1 EvTurn=3 3 EVENT_RESEARCH_OVERBUDGET 1 0

Order matters and is visible in the ids: within player 1's turn-3 bucket the construction event is id 2 and the research event is id 3, so the build pass posts before the research pass. Ids are per player (EvNxID lives on the player's own storage), so no cross-player order is observable and none is assumed.

The bucket's EvTurn is the post-increment turn: a turn run from a save at turn N posts into bucket N+1. In the standalone that is game.sim.frame after H00.

Across the whole corpus the only other images seen are EVENT_SHIPS_BUILT, EVENT_RESEARCH_COMPLETE, EVENT_TECHS_UNLOCKED, EVENT_RESEARCH_OVERBUDGET, EVENT_FLEET_ARRIVED, EVENT_FLEET_EXPLORED, EVENT_FLEET_MULTIPOINT_NONODE, EVENT_LABACCIDENT_SMALL, EVENT_COLONY_NEWSETTLERS, EVENT_ENEMY_INCOMING_Human and EVENT_NO_RESEARCH. None of the tail's event phases (T18, T21, T32) fires on either reference pair — measured, not assumed; on other corpus saves they clearly do.

2. The no-research condition, corrected

The standalone's condition was no research target && at least one available tech. That is two thirds of the original's and it over-fires by three players on the reference pair. The original's is three tests, all of which now have a name in ghidra/addresses.json:

  1. ResT == NULL — no research target;
  2. TechTree::CollectResearchedTechs(out, turn, INT_MAX, sort=1) came back empty, i.e. no tech finished on this turn or later;
  3. TechTree::FindFirstAvailableTech() != NULL, i.e. at least one node is in state 2.

Test 2 was missing here and it is not decoration. zuul-turn23-fleet23.sav exercises it: the human posts EVENT_NO_RESEARCH on turns 2..15, posts EVENT_RESEARCH_COMPLETE on turn 22 and no no-research event that turn, then posts EVENT_NO_RESEARCH again on turn 23. That is exactly test 2 suppressing the event on the turn a tech landed.

Tests 2 and 3 are computable from the wire: a TechState row's St is the node state (0 hidden, 1 parent researched, 2 available, 3 available and selected, 4 researched) and TAcq is the turn it was researched.

Test 1 is not fully computable. ResTNm gives the target the save was written with, but three of the four real players on the reference pair acquire a target during the turn, and that is AI research selection (game/ai). The stand-in is the operator-supplied AI roster, --ai-player N, which the standalone already carries for T31:

HYPOTHESIS (workload: the whole 11-save corpus). An AI-controlled player that begins its turn with no research target acquires one before the no-research check, so it never posts EVENT_NO_RESEARCH. Support: across the corpus no AI player posts that event on any of roughly ninety player-turns, while the human posts it on every turn it has no target. Falsified by a single AI player-turn carrying the event.

So P11 stays blocked, commits only under --commit-blocked=P11, and even then posts nothing for a player the operator named as AI and nothing at all without a text table.

3. Prediction (written before the build; rule 2)

Baseline moved under this lane: main is now eafbc5f (lane T2). Re-measured with this lane's own binary before any change: pair 1 209 -> 131 (closed 78, regressed 0), pair 2 108 -> 72 (closed 36, regressed 0). The brief's 209 -> 157 / 108 -> 86 predate T2. The numbers below are against the re-measured baseline, so the lane's own contribution is pair 1 131 -> 127 and pair 2 72 -> 69.

Run A — --data <root> --ai-player 1 --ai-player 2 --ai-player 3 --commit-blocked=P11.

P11 posts exactly one event, for player 0, into bucket EvTurn = frame: EvEID 1 (pair 1) / 2 (pair 2), EvDsc and EvMsg from EVENTSUM_NO_RESEARCH / EVENTMSG_NO_RESEARCH, EvImg = "EVENT_NO_RESEARCH", EvLoc = 0, EvPos = {FLT_MAX, FLT_MAX, FLT_MAX}, EvAct = 1, EvCID = 0; EvNxID 0 -> 2 (pair 1), 2 -> 3 (pair 2).

  • pair 1: closed 4, regressed 0 — Events/EvNxID, the empty-collection leaf Events/Events/., the new count leaf Events/Events/.[0], the new bucket Events/Events/.[EvTurn=2].
  • pair 2: closed 3, regressed 0 — Events/EvNxID, Events/Events/.[0] (1 -> 2), the new bucket Events/Events/.[EvTurn=3].

Player 1's four (pair 1) / three (pair 2) event leaves stay: they need EVENT_SHIPS_BUILT, which needs a ship the turn builds, which needs an AI-generated build order — the same blocker B6 and T36 already name.

Run B — the control, no --data, no --ai-player, no --commit-blocked. closed 0, regressed 0; the phase reports the condition and posts nothing.

Falsification

# if the model is wrong this way symptom
F1 the AI-picks-a-target hypothesis is wrong with the roster supplied the event is missing for a player the oracle has one for; without it, four posts and roughly fifteen regressed leaves on pair 1
F2 the bucket turn is the input turn, not the incremented frame the bucket lands at EvTurn=1; closed 1, regressed 2
F3 the position sentinel is written as +inf rather than FLT_MAX bucket appears, EvPos/.[0..2] diverge; closed 3, regressed 3
F4 the act == 0 -> 2 rule is applied where it should not be (act here is 1) EvAct diverges; regressed 1
F5 the writer emits a trailing node on a record the engine synthesised rather than parsed the post-turn save's coverage drops below the input's, visible in the report's coverage line
F6 the "researched this turn" gate is wrong no visible effect on either reference pair (nothing is researched on either turn); it would show as a spurious post on the turn a corpus save completes a tech

Run B is the control for F1: it is the same code with the input withheld, and its over-fire count is a direct measurement of how much of the phase the AI roster is carrying.


4. Measured

tools/standalone_report.py --binary <this build> --engine-arg=..., all four runs against the same binary. closed and regressed are never netted.

run pair 1 (209 baseline) closed regressed pair 2 (108 baseline) closed regressed
before this lane 131 78 0 72 36 0
A: --data + roster + --commit-blocked=P11 127 82 0 69 39 0
B: nothing supplied (default) 131 78 0 72 36 0
F1 control: --data, no roster 139 86 16 69 39 0
F2 control: roster, no --data 131 78 0 72 36 0

The lane closes 4 leaves on pair 1 and 3 on pair 2, regressed 0. They are exactly the four and three paths the prediction named, and no other leaf moved. The prediction held.

Two things the run said that the prediction did not:

  • The oracle's record matches ours field for field. The bucket exists in both saves after run A, so the checksum descends into it, and nothing under it is reported — EvEID, EvDsc, EvMsg, EvImg, EvLoc, all three EvPos words, EvAct and EvCID all agree. The text came out of the operator's own string table through the key lookup, so F3 and F4 are not merely untriggered, they are actively disconfirmed.
  • F1's regression is 16, not the ~15 estimated, and it lands on pair 1 only. Pair 2's input save already carries a research target for all three AI players, so ResTNm alone suppresses them there and the roster buys nothing. The AI roster is load-bearing exactly on the turn after which the AI first picks a target — which is the reference pair, and which is why the phase cannot be committed without it.

5. What is still not posted, and what blocks it

event player pairs blocker
EVENT_SHIPS_BUILT 1 both a ship the turn builds. Both reference pairs carry no build order anywhere — three empty system queues, no ship-borne queue, and the only command block is the human's empty one — so the order behind that ship is generated by the AI during the turn. Same blocker B6 and T36 already name: AI order generation, not this phase.
EVENT_RESEARCH_OVERBUDGET 1 pair 2 the research allocation, which needs ComputeBudget's per-system money on the turn path (roadmap item 1). game/events already holds the posting rule and game/sim the over-budget decision; only the points are missing.
tail event phases T18 / T21 / T32 — neither measured negative: no EVENT_ENEMY_INCOMING_*, EVENT_FLEET_EXPLORED or EVENT_FLEET_ARRIVED appears in either oracle save, so these phases post nothing on the reference pair. Other corpus saves do carry all three, so they are live phases with an unexercised workload here, not dead ones.

Two tail functions that sound as though they should be in this table are not, and lane K's reading is why: the one named for generating a turn's events is 122 bytes whose copy loop is provably dead, and the one named for building them is gated on a descriptor set only on the load/rejoin path and references no event identifier at all. No tail phase manufactures the turn's events. Every event a turn posts is posted by the subsystem that caused it — the research pass, the build pass, movement, combat — which is what section 1 measures.