Control-flow offset-path diff: Frida engine op-tracer (recon-gated tick/0x41b940 hook) + VM ITraceSink offsets + diff_optrace.py first- divergence report. Deterministic opening (SC0000 --boot). Ready to execute in a fresh context; prereq = game running at the opening. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
187 lines
12 KiB
Markdown
187 lines
12 KiB
Markdown
# Differential Oracle (control-flow) — Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Pinpoint mis-modeled opcodes by diffing the real engine's offset-execution path against our VM's, on the deterministic opening — output "agreed N steps, diverge at offset X = op 0xYY".
|
||
|
||
**Architecture:** A Frida engine op-tracer emits per-op `(codebase, offset)` to `build/engine-optrace.jsonl` (recon-gated capture). Our VM emits the SC0000 offset sequence to `build/vm-optrace.json`. `diff_optrace.py` identifies SC0000's codebase in the engine trace, aligns the two offset sequences, and reports the first divergence. The diff/parse logic is pure (unit-tested); Frida + VM emitter are exercised live.
|
||
|
||
**Tech Stack:** Python 3.11 (Frida 17.x for the tracer, `tomllib`/json for the diff), `Age.Cli` (.NET 8) for the VM trace, ghidra-mcp only if we need to re-confirm a handler addr.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-07-09-differential-oracle-design.md`.
|
||
|
||
## Global Constraints
|
||
|
||
- Frida scripts: `py -3.11 -u -X utf8 tools/frida/<name>.py`; **read-only** (Interceptor + memory reads; no patching). Model on `dump_engine.py`/`probe_frame_cadence.py` (attach + `send()`/`on(message)`; frida-subdir tools compute `REPO = parents[2]`, no `paths.py`).
|
||
- **Known-safe vs risky hooks:** `vm_operand_fetch@0x41b940` is proven-safe (plain-JS, `probe_frame_cadence.py`). `adv_interpreter_tick@0x410fb0` crashed with a CModule hook — only ever plain-JS here, and Task 1 gates on it.
|
||
- Engine ctx offsets (now typed): `cur_ctx_index@0x53d14`, `frame_pc@0x53d2c`, `frame_codebase@0x53d28`; per-context stride `0x78`. `offset = (pc − codebase)/4`.
|
||
- The opening is `SC0000 --boot` (deterministic). `build/` is disposable/gitignored.
|
||
- Control-flow only (offset path); effects-diff is out of scope (phase 2).
|
||
|
||
---
|
||
|
||
## Task 1: Engine op-tracer + recon gate
|
||
|
||
**Files:**
|
||
- Create: `tools/frida/trace_engine_ops.py`
|
||
- Reference: `tools/frida/probe_frame_cadence.py` (the proven 0x41b940 hook + attach boilerplate)
|
||
|
||
- [ ] **Step 1: Write the tracer with a selectable hook + batched flush**
|
||
|
||
`trace_engine_ops.py`: attach to the running game; a `--hook tick|operand` flag (default `tick`). JS `Interceptor.attach`:
|
||
- `tick` mode → `adv_interpreter_tick` `0x410fb0`; `operand` mode → `vm_operand_fetch` `0x41b940`.
|
||
- onEnter: `ctx` = `this.context.ecx` (thiscall `ecx=ctx`); `curCtx = ctx.add(0x53d14).readU32()`; `pc = ctx.add(0x53d2c + curCtx*0x78).readU32()` (read via a computed pointer — recompute the address each call); `codebase = ctx.add(0x53d28 + curCtx*0x78).readU32()`; push `{cb: codebase, off: (pc-codebase)>>2}` to a JS buffer. In `operand` mode, skip if `pc === lastPc` (dedupe consecutive same-pc).
|
||
- Flush the buffer to Python via `send(batch)` every ~2000 entries; Python appends each `{codebase, offset}` to `build/engine-optrace.jsonl`.
|
||
Model attach/`on(message)` exactly on `probe_frame_cadence.py`.
|
||
|
||
- [ ] **Step 2: RECON — capture the opening safely (THE GATE)**
|
||
|
||
Launch the game to the opening (or attach at the title and let the opening replay). Run:
|
||
`py -3.11 -u -X utf8 tools/frida/trace_engine_ops.py --hook tick`
|
||
Let the opening auto-advance to the first wait-for-input, then stop. Inspect `build/engine-optrace.jsonl`.
|
||
- **GATE — tick hook stable + trace captured:** proceed. Record entry count + number of distinct codebases.
|
||
- **GATE — tick hook destabilizes the game** (crash/hang): switch to `--hook operand` (proven-safe) and re-capture. Record which hook worked.
|
||
- **GATE — neither yields a usable trace:** STOP, document in `engine-re.md`, end the slice.
|
||
Expected: a jsonl with thousands of `(codebase, offset)` entries across a handful of codebases (boot scripts + SC0000).
|
||
|
||
- [ ] **Step 3: Sanity-identify the SC0000 codebase**
|
||
|
||
Quick check (inline Python): group entries by `codebase`; for each, does its offset-0/low-offset region's static opcode (from `build/disasm/SC0000.asm` via `sys4load`) match SC0000's opening? Report the codebase whose executed offsets are consistent with SC0000 (largest plausible run). This de-risks Task 3's codebase identification.
|
||
Expected: one codebase identifiable as SC0000.
|
||
|
||
- [ ] **Step 4: Commit the tracer**
|
||
|
||
```bash
|
||
git add tools/frida/trace_engine_ops.py
|
||
git commit -m "re(frida): engine op-path tracer (tick/operand hook, per-script offsets)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: VM offset-trace emitter
|
||
|
||
**Files:**
|
||
- Modify (if needed): `Age.Cli` trace command (a `--trace-json <path>` that dumps executed SC0000 offsets), or reuse an existing offset-trace artifact.
|
||
- Test: `engine/Age.Engine.Tests/` (a small test for the JSON emitter if one is added).
|
||
|
||
- [ ] **Step 1: Check for an existing full offset trace**
|
||
|
||
Inspect how `Age.Cli trace --trace` / the `vm0-trace.json` selftest artifact are produced (`grep` `Age.Cli`, `ITraceSink`, `vm0-trace`). If a full ordered-offset JSON for a booted scene already exists or is trivially emittable, use it and skip to Step 3.
|
||
Expected: a decision — reuse vs add a small emitter.
|
||
|
||
- [ ] **Step 2: Add a minimal `--trace-json` emitter (if needed)**
|
||
|
||
Add a `JsonOffsetTraceSink : ITraceSink` (or a post-run collector on the existing sink) that records each Step's script-relative offset in order, and a `--trace-json <path>` option on the trace command writing `{"scene":"SC0000","offsets":[...]}`. Keep it observe-only (parity preserved — non-JSON runs unaffected). Add one xUnit test: running a tiny synthetic scene yields the expected offset list. `dotnet test` green.
|
||
|
||
- [ ] **Step 3: Produce the VM trace for SC0000 (booted, to match the engine)**
|
||
|
||
Run: `dotnet run --project engine/Age.Cli -- trace SC0000.BIN --boot --trace-json build/vm-optrace.json` (exact invocation per the CLI). Confirm the offset count is on the order of the engine trace's SC0000 run.
|
||
Expected: `build/vm-optrace.json` with the ordered SC0000 offsets.
|
||
|
||
- [ ] **Step 4: Commit (if a C# change was made)**
|
||
|
||
```bash
|
||
git add engine/
|
||
git commit -m "feat(engine): --trace-json emitter for the differential oracle"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: `diff_optrace.py` — align + first-divergence
|
||
|
||
**Files:**
|
||
- Create: `tools/diff_optrace.py`
|
||
- Create: `tools/test_diff_optrace.py`
|
||
|
||
**Interfaces:**
|
||
- `first_divergence(a: list[int], b: list[int]) -> dict` → `{"agreed": n, "index": i, "a": a[i]|None, "b": b[i]|None}` or `{"agreed": len, "index": None}` if one is a prefix of the other / equal.
|
||
- `pick_scene_codebase(entries: list[dict], vm_offsets: list[int]) -> int|None` → the codebase whose offset sequence shares the longest common prefix with `vm_offsets` (identifies SC0000's instance).
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
```python
|
||
# tools/test_diff_optrace.py (plain runner)
|
||
import sys
|
||
from diff_optrace import first_divergence, pick_scene_codebase
|
||
FAILS=[]
|
||
def check(c,m): (FAILS.append(m) or print("FAIL:",m)) if not c else print("ok:",m)
|
||
|
||
def test_equal_no_divergence():
|
||
r = first_divergence([0,1,2,3],[0,1,2,3])
|
||
check(r["index"] is None and r["agreed"]==4, "equal traces -> no divergence")
|
||
|
||
def test_first_divergence_point():
|
||
r = first_divergence([0,1,2,9],[0,1,2,3])
|
||
check(r["index"]==3 and r["a"]==9 and r["b"]==3, "divergence at first differing offset")
|
||
|
||
def test_prefix_shorter_vm():
|
||
r = first_divergence([0,1,2,3],[0,1]) # vm ends early
|
||
check(r["index"]==2 and r["b"] is None and r["agreed"]==2, "shorter VM trace flagged at end")
|
||
|
||
def test_pick_codebase_by_longest_common_prefix():
|
||
entries=[{"codebase":100,"offset":0},{"codebase":100,"offset":5}, # cb100: [0,5,...]
|
||
{"codebase":200,"offset":0},{"codebase":200,"offset":1},{"codebase":200,"offset":2}]
|
||
check(pick_scene_codebase(entries,[0,1,2])==200, "codebase matching VM prefix chosen")
|
||
|
||
def main():
|
||
test_equal_no_divergence(); test_first_divergence_point(); test_prefix_shorter_vm()
|
||
test_pick_codebase_by_longest_common_prefix()
|
||
print("FAILURES:",len(FAILS)); return 1 if FAILS else 0
|
||
if __name__=="__main__": sys.exit(main())
|
||
```
|
||
Run → FAIL (`ModuleNotFoundError`).
|
||
|
||
- [ ] **Step 2: Implement `diff_optrace.py`**
|
||
|
||
Pure functions `first_divergence` + `pick_scene_codebase` (per the tests), plus a CLI: load `build/engine-optrace.jsonl` (list of `{codebase, offset}` in order) and `build/vm-optrace.json` (`{"offsets":[...]}`); `cb = pick_scene_codebase(entries, vm_offsets)`; extract the engine offset sequence for `cb`; `d = first_divergence(engine_seq, vm_offsets)`; if diverged, print the offset, the **opcode at that offset** (decode `build/disasm/SC0000.asm` or call `sys4load`), and ±3 ops of context on each side. Run tests → PASS.
|
||
|
||
- [ ] **Step 3: Run the oracle end-to-end**
|
||
|
||
Run: `py -3.11 -X utf8 tools/diff_optrace.py SC0000`
|
||
Expected: either "no divergence over N steps" or "agreed N, diverge at offset X = op 0xYY" with context. **Interpret the result:** if it lands on the known coroutine yield (`0x140`/`0x7b`, `~0x450–0x50f`), that confirms the tool pinpoints a real known gap (the self-test). Record the finding.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add tools/diff_optrace.py tools/test_diff_optrace.py
|
||
git commit -m "re: diff_optrace.py — engine-vs-VM offset-path divergence oracle"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Docs + memory + close
|
||
|
||
**Files:**
|
||
- Modify: `docs/engine-re.md` (the oracle: mechanism, capture method that worked, the first divergence found)
|
||
- Modify: `docs/tools-reference.md` (`trace_engine_ops.py`, `diff_optrace.py`, `--trace-json`)
|
||
- Modify: `~/.claude/…/memory/himegari-port-status.md` (milestone; lever #3 done; phase-2 effects-diff next)
|
||
|
||
- [ ] **Step 1: Update engine-re.md**
|
||
|
||
Record: the differential-oracle mechanism (offset-path diff), which capture hook worked (tick vs 0x41b940), the SC0000 codebase identification, and the first divergence the oracle reported (offset + op + interpretation). Note it as the repeatable way to localize a mis-modeled op.
|
||
|
||
- [ ] **Step 2: Update tools-reference.md**
|
||
|
||
Rows for `trace_engine_ops.py` (Runtime capture / Frida) and `diff_optrace.py` (Native engine RE or a new "Validation" group); note the `--trace-json` VM emitter.
|
||
|
||
- [ ] **Step 3: Update the status memory**
|
||
|
||
Record: differential oracle DONE — capture method, first divergence found, tool names; lever #3 of 3 complete; phase-2 (effects-diff: global/gfx writes) is the next extension.
|
||
|
||
- [ ] **Step 4: Commit docs**
|
||
|
||
```bash
|
||
git add docs/engine-re.md docs/tools-reference.md
|
||
git commit -m "re: record differential oracle outcome + first divergence"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
**Spec coverage:** engine tracer + recon gate → Task 1; per-script codebase tagging → Task 1 Step 3 + Task 3 `pick_scene_codebase`; VM offset trace → Task 2; diff + first-divergence + opcode context → Task 3; coroutine-gap self-test → Task 3 Step 3; docs/memory → Task 4. Covered. (The spec's loader-hook codebase-tagging is simplified to post-hoc `pick_scene_codebase` matching — noted; a loader hook can be added later if identification is ambiguous.)
|
||
|
||
**Placeholder scan:** Task 1/Task 2 describe the Frida/C# bodies to the key calls (hook target, ctx reads, `--trace-json`) rather than full source, and flag "model on `probe_frame_cadence.py`" / "reuse if exists" — intentional (live-API bring-up like the earlier Frida/Ghidra tasks). Task 3 (the pure, deterministic core) is complete code + tests. No hidden TODOs.
|
||
|
||
**Type consistency:** engine trace = list of `{codebase:int, offset:int}`; VM trace = `{"offsets":[int]}`; `pick_scene_codebase(entries, vm_offsets)->int`; `first_divergence(list[int],list[int])->{agreed,index,a,b}` — consumed consistently by the Task 3 CLI. Matches the tests.
|