lane EV: what a turn actually posts, and two corrections to events.md

Measured over all eleven saves with state_checksum, not derived:

  * the reference pair posts TWO events (turn1->turn2) and THREE (turn2->turn3),
    and only two players in the whole corpus ever hold an event -- the human and
    the one AI empire that owns colonies. The dormant shadow empires pick research
    targets every turn and still post nothing.
  * order within a player is readable off the ids: the build pass posts before the
    research pass. Ids are per player, so no cross-player order is observable.
  * the event type is the EvImg string and it is composed at run time --
    EVENT_ENEMY_INCOMING_Human carries a species suffix, so the type space is not
    an enumeration.

Two corrections in place (rule 11):

  * the turn PostEvent is handed is the FRAME, not ModCount. turn2-state.sav has
    Frame 2 and ModCount 12, and every event it carries is in bucket EvTurn=2. It
    is the post-increment turn: a turn run from a save at turn N posts into N+1.
  * the EVENT_NO_RESEARCH gate: 0x00584e50 is TechTree::CollectResearchedTechs,
    not a ListAvailableTechs, and the middle test is "nothing was researched on
    this turn or later" -- not "no affordable tech". zuul-turn23 exercises it: the
    human posts RESEARCH_COMPLETE on turn 22 with no no-research event that turn,
    then NO_RESEARCH again on turn 23.

ghidra/addresses.d/lane-ev.json records the gate at 0x0089162a with the argument
order re-read from the instruction stream. Validated with tools/gen_addresses.py to
a scratch path (1121 entries, no duplicate); the shared header is NOT regenerated.

tools/standalone_report.py gains --engine-arg (repeatable), so a lane can feed the
standalone the operator inputs a save does not carry -- the data root, the AI
roster, which blocked phases may commit -- instead of hard-coding them. Every run
records what it was given, in the report header and in status.json.
This commit is contained in:
alex 2026-09-08 15:40:07 -04:00
parent c997c6ebce
commit c224ff2214
3 changed files with 121 additions and 7 deletions

View file

@ -216,8 +216,16 @@ this is the single event API for the whole simulation, not a research-specific h
Every research-path site computes the turn as `*(int*)(*(char**)(player + 8) + 8)`.
`ServerPlayer+8` is the `StrategyServer` *second* base; `0x0080e320` is
`__thiscall void* ServerPlayer::GetServer()` = `[this+8] ? [this+8]-4 : 0`, so the field is
`StrategyServer(primary base)+0x0c` — i.e. `ModCount`. This matches the B4 finding that
`StrategyServer` has two bases four bytes apart.
`StrategyServer(primary base)+0x0c`. This matches the B4 finding that `StrategyServer` has
two bases four bytes apart.
**CORRECTION (lane EV 2026-09-08): that field is the FRAME (the turn counter), not
`ModCount`.** The two are different words and the saves separate them cleanly:
`turn2-state.sav` has `Summary/Turn = 2`, `Sim/Frame = 2` and `Sim/ModCount = 12`, and every
event the turn posted landed in bucket `EvTurn = 2`. If the argument were `ModCount` the
buckets would be 12 and 24. The value is the **post-increment** turn — a turn run from a save
at turn *N* posts into bucket *N+1* — because the frame is bumped in `BeginProcessTurn`,
before either turn driver runs.
---
@ -368,8 +376,8 @@ Posted at **0x0089168c** in `ServerPlayer::ProcessTurn`. Condition (0x0089162a
```
if (player->ResT (+0x294) == NULL)
ListAvailableTechs(&out, turn, INT_MAX, 1) ; 0x00584e50
if (out.empty() && TechTree::0x0057da90() != 0)
TechTree::CollectResearchedTechs(&out, turn, INT_MAX, sort=1) ; 0x00584e50
if (out.empty() && TechTree::FindFirstAvailableTech() != NULL) ; 0x0057da90
PostEvent(EVENTSUM_NO_RESEARCH, EVENTMSG_NO_RESEARCH, NULL, NULL, turn,
"EVENT_NO_RESEARCH" (0x00a3332c), 1)
```
@ -377,6 +385,31 @@ if (player->ResT (+0x294) == NULL)
Both strings are `"No Research Project Assigned."` — matches `turn3-state.sav` player 0
exactly (`EvAct 1`, `EvLoc 0`, `EvPos` FLT_MAX³).
**CORRECTION (lane EV 2026-09-08) to this section as first written.** 0x00584e50 was named
here as a `ListAvailableTechs` and the middle test read as "no affordable tech". It is
`TechTree::CollectResearchedTechs` (lane T read the whole body; the call site's argument order
was re-read here byte for byte from 0x00891600), and the test is about what was **researched**,
not about what is available:
1. `ResT == NULL` — the player holds no research target;
2. **no tech has `state == 4` (researched) with `turnResearched (+0x24) >= turn`**, i.e.
nothing finished on this turn or later. The turn is passed as the collector's `minTurn`
and the range runs to `INT_MAX`;
3. at least one node is in `state == 2` (available) — that is the availability half, and it is
what excludes the four monster factions, whose entire tree is state 4.
Test 2 is not decoration and the corpus exercises it: in `zuul-turn23-fleet23.sav` the human
posts `EVENT_RESEARCH_COMPLETE` on turn 22 and **no** no-research event that turn, then
`EVENT_NO_RESEARCH` again on turn 23. `sots-engine`'s P11 implements all three tests; before
this lane it implemented tests 1 and 3 only, and over-fired.
Test 1 is the one a save cannot answer. `ResTNm` on the wire is the target the file was
**written** with; on the reference pair all four real players start the turn with none and
three of them acquire one *during* it, which is AI research selection. Across all eleven
corpus saves no AI player ever posts `EVENT_NO_RESEARCH` (~90 player-turns) while the human
posts it on every turn it lacks a target — stated as a hypothesis, with the operator's
`--ai-player` roster standing in for the missing input.
Also in this window (0x008915ec–0x00891624): `RollResearchEvent` (0x0088df20) is called when
`ResT != NULL && ResErrRoll(+0x3b4) != 0 && (const at 0x00a2c788) < progressRatio`, then
`ResErrRoll` is cleared — the same draw the B2/B3 lanes measured. `RollResearchAccident`
@ -399,3 +432,61 @@ Also in this window (0x008915ec–0x00891624): `RollResearchEvent` (0x0088df20)
order is identical in both directions and is confirmed by the save, so this is a gap in
coverage, not in confidence about the layout.
* The 50-turn prune has never been observed running (our saves are at turn ≤ 3).
---
## 5. What a turn actually posts (lane EV 2026-09-08, measured over all 11 saves)
Read out of the corpus with `verify/state-checksum/state_checksum.py`, not derived. Every
`EvImg` value, bucket and id below is a file fact.
**Only two players in the whole corpus ever hold an event.** `PlyrIdx 0` (the human) and
`PlyrIdx 1` (the one AI empire that owns colonies). `PlyrIdx 2..3` are the dormant shadow
empires — `Sav = 0`, no colonies — and although they *do* pick research targets every turn,
their `EvNxID` is 0 on every save. `PlyrIdx 4..7` are the monster factions, whose whole tech
tree is state 4 and whose `EvNxID` is likewise 0 throughout. So "who posts" is not a property
of the event API; it is a property of who does anything.
### The reference pair
| pair | player | bucket | id | image | EvAct | EvLoc |
|---|---|---|---|---|---|---|
| turn1 -> turn2 | 0 | `EvTurn=2` | 1 | `EVENT_NO_RESEARCH` | 1 | 0 |
| turn1 -> turn2 | 1 | `EvTurn=2` | 1 | `EVENT_SHIPS_BUILT` | 0 | 288 |
| turn2 -> turn3 | 0 | `EvTurn=3` | 2 | `EVENT_NO_RESEARCH` | 1 | 0 |
| turn2 -> turn3 | 1 | `EvTurn=3` | 2 | `EVENT_SHIPS_BUILT` | 0 | 288 |
| turn2 -> turn3 | 1 | `EvTurn=3` | 3 | `EVENT_RESEARCH_OVERBUDGET` | 1 | 0 |
**Order within a player is readable off the ids**: on turn 3 player 1's construction event is
id 2 and its research event id 3, so **the build pass posts before the research pass**. Ids
are per player (`EvNxID` is on the player's own storage), so no cross-player order is
observable from a save and none should be assumed.
`EVENT_SHIPS_BUILT` carries `EvAct = 0` **as stored**, because it has a subject (`EvLoc` 288,
a real position) — the `act == 0 && !obj && !pos -> 2` rule does not fire. The only stored
`EvAct = 2` in the corpus is `EVENT_LABACCIDENT_SMALL`.
### Every image the corpus contains
`EVENT_NO_RESEARCH`, `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`. The last is worth noting: the **event type is the `EvImg`
string and it is composed at run time** — `_Human` is a species suffix, so the type space is
not a fixed enumeration and never was.
### Text keys used, from `Locale/EN/Strings.csv`
`EVENTSUM_SHIPS_BUILT` = `Ships Constructed At %s`; `EVENTMSG_SHIPS_BUILT_1SHIP` =
`1 ship built in system %s`; `EVENTMSG_SHIPS_BUILT_NSHIPS` = `%s ships built in system %s`.
So the construction event picks between **two** message keys on the ship count, and the count
is substituted as a `%s`. Both `EVENTSUM_NO_RESEARCH` and `EVENTMSG_NO_RESEARCH` are the same
string and take no parameter.
### Wired into `sots-engine`
`src/app/event_phase.{h,cpp}` bridges the save's event subtree to `game::events` and P11 posts
through it. On the reference pair, with the data root and the AI roster supplied and
`--commit-blocked=P11`: **4 leaves closed on turn1->turn2 and 3 on turn2->turn3, 0 regressed**,
and the posted record agrees with the oracle on every one of its eight fields. Engine-side
write-up: `sots-engine docs/EV-events.md`.

View file

@ -0,0 +1,12 @@
{
"entries": [
{
"name": "ServerPlayer_ProcessTurn_NoResearchGate",
"addr": "0x0089162a",
"convention": "none",
"prototype": "the three-test gate that guards EVENT_NO_RESEARCH; the post itself is ServerPlayer_ProcessTurn_PostEventNoResearch 0x0089168c. Read byte for byte (ReVa read-memory 0x00891600+160, decoded by hand): cmp [esi+0x294],ebx / jne past the whole block -> test 1, ResT == NULL. Then a 12-byte stack vector is zeroed at [ebp-0x28] and TechTree::CollectResearchedTechs 0x00584e50 is called as ecx = this->TechTree(+0xf4), push 1 (sort), push 0x7fffffff (maxTurn), push [[esi+8]+8] (the SERVER TURN -- the same word the post below hands PostEvent as its turn argument), push &out; then cmp [ebp-0x28],[ebp-0x24] / jne past -> test 2, THE LIST CAME BACK EMPTY, i.e. NO TECH WAS RESEARCHED ON THIS TURN OR LATER. Then ecx = this->TechTree again, call TechTree::FindFirstAvailableTech 0x0057da90, test eax,eax / setne al / cmp al,bl / je past -> test 3, at least one node is in state 2. CORRECTION to the note on ServerPlayer_ProcessTurn_PostEventNoResearch, which reads 0x00584e50 as a ListAvailableTechs and the gate as 'no tech is available': it is TechTree::CollectResearchedTechs (lane T's reading, confirmed here from the call site's argument order) and the gate is about what was RESEARCHED, not about what is available -- test 3 is the availability half. The turn argument is passed as CollectResearchedTechs' minTurn, so the range is [turn, INT_MAX] over node->turnResearched(+0x24)",
"status": "mapped",
"source": "findings/subsystems/events.md section 3.5 (lane EV 2026-09-08); the corrected gate is what sots-engine P11 implements, and it is confirmed behaviourally on the corpus -- zuul-turn23-fleet23.sav posts EVENT_RESEARCH_COMPLETE on turn 22 and NO EVENT_NO_RESEARCH that turn, then EVENT_NO_RESEARCH again on turn 23, which is exactly test 2 firing"
}
]
}

View file

@ -75,15 +75,16 @@ def leaf_paths(a, b, limit=200000):
return {e.path: e for e in entries}
def run_pair(binary, src, oracle, note, workdir, keep_saves):
def run_pair(binary, src, oracle, note, workdir, keep_saves, engine_args=()):
out_sav = os.path.join(workdir, "post-" + os.path.basename(src))
metric = os.path.join(workdir, "metric-" + os.path.basename(src) + ".json")
cmd = [binary, src, "--out", out_sav, "--metric", metric, "--roundtrip"]
cmd = [binary, src, "--out", out_sav, "--metric", metric, "--roundtrip"] + list(engine_args)
proc = subprocess.run(cmd, capture_output=True, text=True)
row = {
"input": os.path.basename(src),
"oracle": os.path.basename(oracle),
"note": note,
"engineArgs": list(engine_args),
"exit": proc.returncode,
"stdout": proc.stdout.strip().splitlines()[-30:],
}
@ -148,6 +149,12 @@ def main(argv=None):
ap.add_argument("--no-write", action="store_true", help="render only, touch nothing")
ap.add_argument("--keep-saves", action="store_true",
help="copy each post-turn save into verify/results/standalone/")
# Some phases are fed by operator inputs the save does not carry -- the game's data root
# (--data), the AI roster (--ai-player N), and which blocked phases may commit
# (--commit-blocked=IDS). Those are properties of the recorded game, not of the tool, so
# they are passed through rather than hard-coded, and every run records what it was given.
ap.add_argument("--engine-arg", dest="engine_args", action="append", default=[],
metavar="ARG", help="extra argument for sots_turn; repeatable")
args = ap.parse_args(argv)
binary = find_binary(args.binary)
@ -172,7 +179,8 @@ def main(argv=None):
with tempfile.TemporaryDirectory() as tmp:
for src, oracle, note in pairs:
row, remaining = run_pair(binary, src, oracle, note, tmp,
args.keep_saves and not args.no_write)
args.keep_saves and not args.no_write,
args.engine_args)
rows.append(row)
if row["input"] == os.path.basename(pairs[0][0]):
all_remaining = remaining
@ -183,6 +191,7 @@ def main(argv=None):
"schema": "sots-standalone-status/1",
"generated": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"binary": binary,
"engineArgs": list(args.engine_args),
"reference": {
"input": ref.get("input"),
"oracle": ref.get("oracle"),
@ -207,6 +216,8 @@ def main(argv=None):
w("# standalone vs the oracle")
w("")
w(f"generated {status['generated']} binary {binary}")
if args.engine_args:
w("engine args: " + " ".join(args.engine_args))
w("")
ph = status["phases"] or {}
tp = status["tailPhases"] or {}