diff --git a/findings/subsystems/events.md b/findings/subsystems/events.md index acb6a0a..e9a889c 100644 --- a/findings/subsystems/events.md +++ b/findings/subsystems/events.md @@ -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`. diff --git a/ghidra/addresses.d/lane-ev.json b/ghidra/addresses.d/lane-ev.json new file mode 100644 index 0000000..347aefb --- /dev/null +++ b/ghidra/addresses.d/lane-ev.json @@ -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" + } + ] +} diff --git a/tools/standalone_report.py b/tools/standalone_report.py index e20b036..09d7bd7 100644 --- a/tools/standalone_report.py +++ b/tools/standalone_report.py @@ -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 {}