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:
parent
c997c6ebce
commit
c224ff2214
3 changed files with 121 additions and 7 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
12
ghidra/addresses.d/lane-ev.json
Normal file
12
ghidra/addresses.d/lane-ev.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue