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)`.
|
Every research-path site computes the turn as `*(int*)(*(char**)(player + 8) + 8)`.
|
||||||
`ServerPlayer+8` is the `StrategyServer` *second* base; `0x0080e320` is
|
`ServerPlayer+8` is the `StrategyServer` *second* base; `0x0080e320` is
|
||||||
`__thiscall void* ServerPlayer::GetServer()` = `[this+8] ? [this+8]-4 : 0`, so the field 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(primary base)+0x0c`. This matches the B4 finding that `StrategyServer` has
|
||||||
`StrategyServer` has two bases four bytes apart.
|
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)
|
if (player->ResT (+0x294) == NULL)
|
||||||
ListAvailableTechs(&out, turn, INT_MAX, 1) ; 0x00584e50
|
TechTree::CollectResearchedTechs(&out, turn, INT_MAX, sort=1) ; 0x00584e50
|
||||||
if (out.empty() && TechTree::0x0057da90() != 0)
|
if (out.empty() && TechTree::FindFirstAvailableTech() != NULL) ; 0x0057da90
|
||||||
PostEvent(EVENTSUM_NO_RESEARCH, EVENTMSG_NO_RESEARCH, NULL, NULL, turn,
|
PostEvent(EVENTSUM_NO_RESEARCH, EVENTMSG_NO_RESEARCH, NULL, NULL, turn,
|
||||||
"EVENT_NO_RESEARCH" (0x00a3332c), 1)
|
"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
|
Both strings are `"No Research Project Assigned."` — matches `turn3-state.sav` player 0
|
||||||
exactly (`EvAct 1`, `EvLoc 0`, `EvPos` FLT_MAX³).
|
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
|
Also in this window (0x008915ec–0x00891624): `RollResearchEvent` (0x0088df20) is called when
|
||||||
`ResT != NULL && ResErrRoll(+0x3b4) != 0 && (const at 0x00a2c788) < progressRatio`, then
|
`ResT != NULL && ResErrRoll(+0x3b4) != 0 && (const at 0x00a2c788) < progressRatio`, then
|
||||||
`ResErrRoll` is cleared — the same draw the B2/B3 lanes measured. `RollResearchAccident`
|
`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
|
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.
|
coverage, not in confidence about the layout.
|
||||||
* The 50-turn prune has never been observed running (our saves are at turn ≤ 3).
|
* 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}
|
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))
|
out_sav = os.path.join(workdir, "post-" + os.path.basename(src))
|
||||||
metric = os.path.join(workdir, "metric-" + os.path.basename(src) + ".json")
|
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)
|
proc = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
row = {
|
row = {
|
||||||
"input": os.path.basename(src),
|
"input": os.path.basename(src),
|
||||||
"oracle": os.path.basename(oracle),
|
"oracle": os.path.basename(oracle),
|
||||||
"note": note,
|
"note": note,
|
||||||
|
"engineArgs": list(engine_args),
|
||||||
"exit": proc.returncode,
|
"exit": proc.returncode,
|
||||||
"stdout": proc.stdout.strip().splitlines()[-30:],
|
"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("--no-write", action="store_true", help="render only, touch nothing")
|
||||||
ap.add_argument("--keep-saves", action="store_true",
|
ap.add_argument("--keep-saves", action="store_true",
|
||||||
help="copy each post-turn save into verify/results/standalone/")
|
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)
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
binary = find_binary(args.binary)
|
binary = find_binary(args.binary)
|
||||||
|
|
@ -172,7 +179,8 @@ def main(argv=None):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
for src, oracle, note in pairs:
|
for src, oracle, note in pairs:
|
||||||
row, remaining = run_pair(binary, src, oracle, note, tmp,
|
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)
|
rows.append(row)
|
||||||
if row["input"] == os.path.basename(pairs[0][0]):
|
if row["input"] == os.path.basename(pairs[0][0]):
|
||||||
all_remaining = remaining
|
all_remaining = remaining
|
||||||
|
|
@ -183,6 +191,7 @@ def main(argv=None):
|
||||||
"schema": "sots-standalone-status/1",
|
"schema": "sots-standalone-status/1",
|
||||||
"generated": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
"generated": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||||
"binary": binary,
|
"binary": binary,
|
||||||
|
"engineArgs": list(args.engine_args),
|
||||||
"reference": {
|
"reference": {
|
||||||
"input": ref.get("input"),
|
"input": ref.get("input"),
|
||||||
"oracle": ref.get("oracle"),
|
"oracle": ref.get("oracle"),
|
||||||
|
|
@ -207,6 +216,8 @@ def main(argv=None):
|
||||||
w("# standalone vs the oracle")
|
w("# standalone vs the oracle")
|
||||||
w("")
|
w("")
|
||||||
w(f"generated {status['generated']} binary {binary}")
|
w(f"generated {status['generated']} binary {binary}")
|
||||||
|
if args.engine_args:
|
||||||
|
w("engine args: " + " ".join(args.engine_args))
|
||||||
w("")
|
w("")
|
||||||
ph = status["phases"] or {}
|
ph = status["phases"] or {}
|
||||||
tp = status["tailPhases"] or {}
|
tp = status["tailPhases"] or {}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue