diff --git a/.changeset/address-or-symbol.md b/.changeset/address-or-symbol.md new file mode 100644 index 0000000..8590d17 --- /dev/null +++ b/.changeset/address-or-symbol.md @@ -0,0 +1,12 @@ +--- +'@gba-kit/gba-emulator': patch +--- + +Accept a symbol wherever an address is accepted, and normalize a code address + +- A numeric code address now has bit 0 cleared. A Thumb function pointer carries it + set, so `watchExecution(ptr)` counted 0 where the same function by name counted 420. +- `watchMemory` takes a symbol name, and throws on an unknown one instead of coercing + it to address 0 and watching nothing. +- A symbol watch defaults to the object's whole extent rather than `st_size`, which is + null for a linker-placed global — `watchSymbol('gLayers')` watched 1 byte of 112. diff --git a/.changeset/observe-execution.md b/.changeset/observe-execution.md new file mode 100644 index 0000000..98bdacd --- /dev/null +++ b/.changeset/observe-execution.md @@ -0,0 +1,22 @@ +--- +'@gba-kit/arm-emulator': minor +'@gba-kit/gba-emulator': minor +'@gba-kit/gba-node': minor +--- + +Observe execution instead of sampling it + +- `ArmCpu.addExecWatchpoint(address, cb)` — new: fires from the instruction step and + returns a disposer. Composable, and independent of `setDebugHooks`. +- `wait({ execution })` replaces `wait({ pc })`, which compared the PC once per frame + and so reported code that ran constantly as never reached. Takes an address or a + symbol name. +- `watchExecution(target, options?)` — new: the execution counterpart to + `watchMemory`, reporting `count`, `hits` (with the caller's `lr` and source + location), `dropped` and `stop()`. +- `watchMemory` reports `dropped`, so a capped `hits` array is not read as the whole + story. + +`wait()` also throws on an unrecognised condition, which previously returned immediately. + +**Breaking:** `wait({ pc })` is now `wait({ execution })`. diff --git a/docs/scripting.md b/docs/scripting.md index a361097..fcb5bf3 100644 --- a/docs/scripting.md +++ b/docs/scripting.md @@ -89,16 +89,14 @@ await wait({ }); ``` -**Wait for the program counter to reach an address:** +**Wait for an instruction to execute:** ```javascript -await wait({ - pc: 0x08001234, - timeout: 600, -}); +await wait({ execution: 0x08001234, timeout: 600 }); +await wait({ execution: 'UpdatePlayer' }); // a symbol, when debug info is loaded ``` -Throws an error if the condition isn't met within the timeout. +Throws if the instruction doesn't execute within the timeout. ### `press(buttons, options?)` — Button Input @@ -228,9 +226,7 @@ read16(0x03002923); // throws: not 2-byte aligned; the hardware would read 0x030 read32(0x01000000); // throws: nothing is mapped there ``` -The hardware would answer both: it rounds `read16(0x03002923)` down to `0x03002922`, and reads undecoded space as `0`. Either way the number is indistinguishable from the one you asked for, so these refuse instead. Use `readBytes` to read at any alignment. - -A RAM mirror is not an error — `0x02F00000` reads the same byte as `0x02000000`, and neither throws. +A RAM mirror is not an error — `0x02F00000` reads the same byte as `0x02000000`. Use `readBytes` to read at any alignment. ### `readBytes(address, size)` — Unaligned Memory Reads @@ -274,13 +270,13 @@ readVariable('gLayers[4].width'); // throws: "gLayers" has 4 element(s) in dimension 0, so index 4 is past the end ``` -Element 4 of a 4-element array is a real address — whatever the linker placed next — so without the bound it reads as plausible data and writes as corruption. A dimension the DWARF leaves unstated (`extern T x[][4]`) is not checked. +A dimension the DWARF leaves unstated (`extern T x[][4]`) is not checked. Throws if debug info isn't loaded, the path can't be resolved, the field is wider than 4 bytes, or the target is read-only. ### `symbolExtent(name)` — How Big Is That Object -Returns `{ size, source }` — a named object's byte extent and where it came from — or `null` when nothing states it. A global defined in C is sized by the assembler (`'st_size'`); one placed by the linker (`gFoo = 0x03000000;`) has no size of its own, so its extent comes from the type of a C `extern` declaration (`'dwarf'`). With neither, there is no extent. +Returns `{ size, source }` — a named object's byte extent, and whether it came from `'st_size'` or the DWARF type — or `null` when nothing states it. This is the bound the write guards apply. A write starting inside a known extent and running past its end is refused: @@ -290,11 +286,11 @@ writeBytes(gLayersAddr + 110, 4, 0); // (112 bytes, from dwarf), into "gLevelStatePtr". ``` -Only a span that _crosses_ a boundary is catchable this way. An address computed past an array's end lands wholly inside its neighbour, which is indistinguishable from a deliberate write there — use a subscripted `writeVariable` path instead. +Only a span that _crosses_ a boundary is caught. An address computed past an array's end lands wholly inside its neighbour — use a subscripted `writeVariable` path, which is bounds-checked. ### `readMember(base, member)` / `writeMember(base, member, value)` — Struct Members at a Runtime Address -The same read and write, addressed by a base plus a `MemberLocation` from `structMember()` / `variableMember()` rather than by name. Use these when the instance has no symbol of its own — one reached through a pointer, an array element, or anything placed at run time, none of which a `readVariable` path can express. +The same read and write, addressed by a base plus a `MemberLocation` from `structMember()` / `variableMember()` rather than by name. Use these when the instance has no symbol of its own — one reached through a pointer, an array element, or anything placed at run time. ```javascript const f = di.structMember('PlayerState', 'invincible'); @@ -379,8 +375,11 @@ searchMemory({ value: 3, region: 'both' }); // Both (default) Registers a write watchpoint over a memory range. Every time a write **commits** to the range, a hit is appended to the returned handle's `hits` array, recording **which code performed the write** — a CPU instruction, or a DMA channel. +`address` is a raw address, or a symbol name when debug info is loaded — a symbol watches the whole object. + ```javascript const w = watchMemory({ address: 0x03005220 }); // watch 1 byte +const g = watchMemory({ address: 'gPlayerState' }); // watch all of it await press('right', { hold: 30 }); // make the value change w.stop(); // remove the watchpoint for (const h of w.hits) { @@ -396,7 +395,23 @@ Each hit has: `pc`, `instructionAddress`, `address`, `value`, `size`, `thumb`, a - `length` — watch a multi-byte range (default 1). - `filter(hit)` — record only matching hits, so you can watch a wide region without the `hits` array exploding. -- `maxHits` — cap recorded hits (keeps the first N). +- `maxHits` — cap recorded hits (keeps the first N); the handle's `dropped` counts the rest. + +### `watchExecution(target, options?)` — Execution Watchpoint (find _whether_ code runs) + +The execution counterpart to `watchMemory`. `target` is an address, or a symbol name when debug info is loaded. + +```javascript +const w = watchExecution('UpdatePlayer'); +await wait({ frames: 60 }); +w.stop(); +console.log(w.count); // exact number of executions; 0 means it did not run +for (const h of w.hits) console.log(h.callerLocation); // who called it +``` + +The handle carries `hits` (recorded, subject to `maxHits`), `count` (every execution seen, always exact), `dropped`, and `stop()`. A numeric `target` may carry the Thumb bit; it is cleared. Each hit has `address`, `lr` — the caller's return address — `thumb`, and `callerLocation` when debug info covers the caller. + +Counted from the CPU's instruction step, so `count === 0` means the code did not run. ```javascript watchMemory({ diff --git a/packages/arm-emulator/src/__tests__/exec-watchpoint.spec.ts b/packages/arm-emulator/src/__tests__/exec-watchpoint.spec.ts new file mode 100644 index 0000000..a90e3b1 --- /dev/null +++ b/packages/arm-emulator/src/__tests__/exec-watchpoint.spec.ts @@ -0,0 +1,134 @@ +/** + * Execution watchpoints — the CPU-side primitive behind `watchExecution` and + * `wait({ pc })`. + * + * They exist because sampling the PC between frames is not an observation of + * execution: it sees only whatever the CPU is doing at the sample instant, so code + * that runs constantly in between reads as never reached. These pin that the + * watchpoint counts every pass, composes with `setDebugHooks` rather than replacing + * it, and stops when disposed. + */ +import { describe, expect, it } from 'vitest'; + +import { ArmCpu } from '../arm-cpu.js'; +import { GbaMemory } from '../memory.js'; +import { PC } from '../types.js'; + +const BASE = 0x02000000; + +/** A CPU in Thumb state running `nop; nop; b -4` — a three-instruction loop at BASE. */ +function loopingCpu(): ArmCpu { + const mem = new GbaMemory(); + // 0x46c0 = nop (mov r8, r8). 0xe7fc = `b` with offset11 = -4: the branch sits at + // BASE+4 and lands on BASE+8 + (-4 * 2) = BASE, so the three instructions loop. + const code = [0x46c0, 0x46c0, 0xe7fc]; + const bytes = new Uint8Array(code.length * 2); + code.forEach((instr, i) => { + bytes[i * 2] = instr & 0xff; + bytes[i * 2 + 1] = (instr >>> 8) & 0xff; + }); + mem.loadBytes(BASE, bytes); + const cpu = new ArmCpu(mem); + cpu.registers[PC] = BASE; + cpu.setT(true); + return cpu; +} + +describe('ArmCpu execution watchpoints', () => { + it('fires once per execution of the watched instruction', () => { + const cpu = loopingCpu(); + let hits = 0; + cpu.addExecWatchpoint(BASE, () => hits++); + for (let i = 0; i < 30; i++) { + cpu.step(); + } + // 30 steps over a 3-instruction loop passes BASE ten times. + expect(hits).toBe(10); + }); + + it('does not fire for an address that never executes', () => { + const cpu = loopingCpu(); + let hits = 0; + cpu.addExecWatchpoint(BASE + 0x100, () => hits++); + for (let i = 0; i < 30; i++) { + cpu.step(); + } + // A zero here has to mean "did not run", so the positive control above is what + // makes this assertion worth anything. + expect(hits).toBe(0); + }); + + it('stops firing once disposed', () => { + const cpu = loopingCpu(); + let hits = 0; + const dispose = cpu.addExecWatchpoint(BASE, () => hits++); + for (let i = 0; i < 15; i++) { + cpu.step(); + } + const atDispose = hits; + expect(atDispose).toBeGreaterThan(0); + dispose(); + for (let i = 0; i < 15; i++) { + cpu.step(); + } + expect(hits).toBe(atDispose); + }); + + it('supports several watchpoints on one address, disposed independently', () => { + const cpu = loopingCpu(); + let a = 0; + let b = 0; + const disposeA = cpu.addExecWatchpoint(BASE, () => a++); + cpu.addExecWatchpoint(BASE, () => b++); + for (let i = 0; i < 15; i++) { + cpu.step(); + } + expect(a).toBe(b); + expect(a).toBeGreaterThan(0); + disposeA(); + const frozen = a; + for (let i = 0; i < 15; i++) { + cpu.step(); + } + expect(a).toBe(frozen); + expect(b).toBeGreaterThan(frozen); + }); + + it('tolerates a watchpoint disposing itself while firing', () => { + // A one-shot wait is exactly this shape, so the dispatch must not skip or + // double-fire the remaining callbacks. + const cpu = loopingCpu(); + let once = 0; + let other = 0; + const dispose = cpu.addExecWatchpoint(BASE, () => { + once++; + dispose(); + }); + cpu.addExecWatchpoint(BASE, () => other++); + for (let i = 0; i < 30; i++) { + cpu.step(); + } + expect(once).toBe(1); + expect(other).toBe(10); + }); + + it('composes with setDebugHooks instead of replacing them', () => { + // A single-slot hooks object is owned by whoever set it last; an analysis tool + // must be able to watch a PC without evicting a debugger's hooks. + const cpu = loopingCpu(); + let watch = 0; + let hook = 0; + cpu.addExecWatchpoint(BASE, () => watch++); + cpu.setDebugHooks({ + onInstructionPre: () => { + hook++; + return 'continue'; + }, + }); + for (let i = 0; i < 30; i++) { + cpu.step(); + } + expect(watch).toBe(10); + expect(hook).toBe(30); + }); +}); diff --git a/packages/arm-emulator/src/arm-cpu.ts b/packages/arm-emulator/src/arm-cpu.ts index 0421f6b..7a70fc2 100644 --- a/packages/arm-emulator/src/arm-cpu.ts +++ b/packages/arm-emulator/src/arm-cpu.ts @@ -201,6 +201,15 @@ export class ArmCpu { /** Optional debug hooks */ #hooks?: DebugHooks; + /** + * Execution watchpoints by instruction address. Composable and independent of + * {@link setDebugHooks}, which is a single slot one owner replaces wholesale — an + * analysis tool must be able to watch a PC without evicting a debugger's hooks. + */ + readonly #execWatch = new Map void)[]>(); + /** Fast path: skip the lookup entirely on the common no-watchpoint case. */ + #execWatchActive = false; + /** Platform-specific SWI handler */ #swiHandler?: SwiHandler; @@ -426,6 +435,50 @@ export class ArmCpu { this.#hooks = hooks; } + /** + * Call `onExecute` every time the instruction at `address` is about to run, and + * return a disposer. Several watchpoints may share an address, and registering one + * does not disturb {@link setDebugHooks}. + * + * This is the only way to observe execution soundly. Sampling the PC between frames + * sees whatever the CPU happens to be doing at a frame boundary — on a game that + * idles in a BIOS wait loop, that is one address out of the thousands executed, so + * every other address reads as "never reached". + */ + addExecWatchpoint(address: number, onExecute: (address: number) => void): () => void { + const key = address >>> 0; + const list = this.#execWatch.get(key) ?? []; + list.push(onExecute); + this.#execWatch.set(key, list); + this.#execWatchActive = true; + return () => { + const current = this.#execWatch.get(key); + if (!current) { + return; + } + const i = current.indexOf(onExecute); + if (i >= 0) { + current.splice(i, 1); + } + if (current.length === 0) { + this.#execWatch.delete(key); + } + this.#execWatchActive = this.#execWatch.size > 0; + }; + } + + /** Fire any execution watchpoints registered at `address`. */ + #fireExecWatch(address: number): void { + const list = this.#execWatch.get(address); + if (!list) { + return; + } + // Copy: a callback may dispose itself (a one-shot wait is exactly that). + for (const cb of list.slice()) { + cb(address); + } + } + /** Register a stub for an external function call */ registerStub(symbolName: string): number { const addr = this.#nextStub; @@ -617,6 +670,10 @@ export class ArmCpu { const instrAddr = pc & ~1; const instr = this.memory.read16(instrAddr); + if (this.#execWatchActive) { + this.#fireExecWatch(instrAddr); + } + if (this.#hooks?.onInstructionPre) { const action = this.#hooks.onInstructionPre(instrAddr, instr); if (action === 'break') { @@ -1234,6 +1291,10 @@ export class ArmCpu { const instrAddr = pc & ~3; const instr = this.memory.read32(instrAddr); + if (this.#execWatchActive) { + this.#fireExecWatch(instrAddr); + } + if (this.#hooks?.onInstructionPre) { const action = this.#hooks.onInstructionPre(instrAddr, instr); if (action === 'break') { diff --git a/packages/gba-emulator/src/__tests__/watchpoint.spec.ts b/packages/gba-emulator/src/__tests__/watchpoint.spec.ts index 7448656..a642a49 100644 --- a/packages/gba-emulator/src/__tests__/watchpoint.spec.ts +++ b/packages/gba-emulator/src/__tests__/watchpoint.spec.ts @@ -1,9 +1,15 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { Gba } from '../gba.js'; import { ScriptingEngine, type ScriptingHost } from '../scripting.js'; import { GbaSystemBus, type WatchpointWrite } from '../system-bus.js'; +const here = dirname(fileURLToPath(import.meta.url)); +const AGBCC_ELF = join(here, '..', '..', '..', 'debug-info', 'test-projects', 'agbcc-min', 'build', 'min.elf'); + const stubHost: ScriptingHost = { writeScreenshot: async () => {}, writeMemorySnapshot: async () => {}, @@ -218,3 +224,196 @@ describe('ScriptingEngine watchMemory', () => { expect(busCount).toBe(1); // foreign watchpoint untouched }); }); + +describe('ScriptingEngine watchMemory maxHits', () => { + it('reports the writes it dropped, so a full array is not read as the whole story', () => { + const gba = new Gba(); + const engine = new ScriptingEngine(gba, stubHost); + const w = engine.watchMemory({ address: 0x03000000, length: 4, maxHits: 2 }); + for (let i = 0; i < 10; i++) { + gba.bus.write8(0x03000000, i); + } + w.stop(); + expect(w.hits).toHaveLength(2); + expect(w.dropped).toBe(8); + }); + + it('reports zero drops when nothing was capped', () => { + const gba = new Gba(); + const engine = new ScriptingEngine(gba, stubHost); + const w = engine.watchMemory({ address: 0x03000000, length: 4 }); + for (let i = 0; i < 10; i++) { + gba.bus.write8(0x03000000, i); + } + w.stop(); + expect(w.hits).toHaveLength(10); + expect(w.dropped).toBe(0); + }); +}); + +// ─── Execution watchpoints (scripting engine) ──────────────────────── + +describe('ScriptingEngine watchExecution', () => { + const BASE = 0x02000000; + + /** A Gba whose CPU loops over three Thumb instructions at BASE. */ + function loopingGba(): Gba { + const gba = new Gba(); + // nop; nop; b -4 (see arm-emulator/src/__tests__/exec-watchpoint.spec.ts). + [0x46c0, 0x46c0, 0xe7fc].forEach((instr, i) => { + gba.bus.write16(BASE + i * 2, instr); + }); + gba.armCpu.registers[15] = BASE; + gba.armCpu.setT(true); + return gba; + } + + function step(gba: Gba, n: number): void { + for (let i = 0; i < n; i++) { + gba.armCpu.step(); + } + } + + it('counts every execution, and zero means it did not run', () => { + const gba = loopingGba(); + const engine = new ScriptingEngine(gba, stubHost); + const ran = engine.watchExecution(BASE); + const never = engine.watchExecution(BASE + 0x100); + step(gba, 30); + ran.stop(); + never.stop(); + expect(ran.count).toBe(10); + expect(ran.hits).toHaveLength(10); + expect(ran.dropped).toBe(0); + // The zero is only meaningful because the count above is not zero. + expect(never.count).toBe(0); + }); + + it('keeps the count exact under maxHits and says what it dropped', () => { + const gba = loopingGba(); + const engine = new ScriptingEngine(gba, stubHost); + const w = engine.watchExecution(BASE, { maxHits: 3 }); + step(gba, 30); + w.stop(); + expect(w.hits).toHaveLength(3); + expect(w.count).toBe(10); // the cap bounds memory, not the finding + expect(w.dropped).toBe(7); + expect(w.hits.length + w.dropped).toBe(w.count); + }); + + it('records the caller’s return address', () => { + const gba = loopingGba(); + gba.armCpu.registers[14] = 0x08001234; + const engine = new ScriptingEngine(gba, stubHost); + const w = engine.watchExecution(BASE); + step(gba, 3); + w.stop(); + expect(w.hits[0]).toMatchObject({ address: BASE, lr: 0x08001234, thumb: true }); + }); + + it('stops recording after stop()', () => { + const gba = loopingGba(); + const engine = new ScriptingEngine(gba, stubHost); + const w = engine.watchExecution(BASE); + step(gba, 15); + const atStop = w.count; + w.stop(); + step(gba, 15); + expect(atStop).toBeGreaterThan(0); + expect(w.count).toBe(atStop); + }); + + it('needs debug info to accept a symbol name', () => { + const engine = new ScriptingEngine(loopingGba(), stubHost); + expect(() => engine.watchExecution('SomeFunction')).toThrow(/requires debug info/); + }); +}); + +describe('addressing code and data by number or name', () => { + const BASE = 0x02000000; + + it('clears the Thumb bit on a numeric code address', () => { + // A Thumb function POINTER carries bit 0 set — read32 of a callback table returns + // exactly that. Left set, the watchpoint address is odd and never matches, so the + // function reads as never executed. + const gba = new Gba(); + [0x46c0, 0x46c0, 0xe7fc].forEach((instr, i) => gba.bus.write16(BASE + i * 2, instr)); + gba.armCpu.registers[15] = BASE; + gba.armCpu.setT(true); + const engine = new ScriptingEngine(gba, stubHost); + + const even = engine.watchExecution(BASE); + const asPointer = engine.watchExecution(BASE | 1); + for (let i = 0; i < 30; i++) { + gba.armCpu.step(); + } + even.stop(); + asPointer.stop(); + expect(even.count).toBe(10); + expect(asPointer.count).toBe(10); // the same instruction, addressed as a pointer + }); + + it('watchMemory takes a symbol and watches the whole object', () => { + const gba = new Gba(); + const engine = new ScriptingEngine(gba, stubHost); + engine.loadDebugInfo(new Uint8Array(readFileSync(AGBCC_ELF))); + const probe = engine.symbolToAddress('g_probe')!; + const extent = engine.symbolExtent('g_probe')!; + + expect(extent.size).toBeGreaterThan(1); // otherwise this proves nothing + + const w = engine.watchMemory({ address: 'g_probe' }); // no explicit length + gba.bus.write8(probe, 1); // first byte + gba.bus.write8(probe + extent.size - 1, 2); // last byte — only caught if the + gba.bus.write8(probe + extent.size, 3); // default length is the whole object + w.stop(); + expect(w.hits.map((h) => h.address)).toEqual([probe, probe + extent.size - 1]); + }); + + it('refuses an unknown symbol rather than watching nothing', () => { + const engine = new ScriptingEngine(new Gba(), stubHost); + engine.loadDebugInfo(new Uint8Array(readFileSync(AGBCC_ELF))); + expect(() => engine.watchMemory({ address: 'no_such_global' })).toThrow(/unknown symbol/); + }); + + it('needs debug info before it can take a name', () => { + const engine = new ScriptingEngine(new Gba(), stubHost); + expect(() => engine.watchMemory({ address: 'g_probe' })).toThrow(/requires debug info/); + }); +}); + +describe('wait({ execution })', () => { + const BASE = 0x02000000; + + function loopingEngine(): { gba: Gba; engine: ScriptingEngine } { + const gba = new Gba(); + [0x46c0, 0x46c0, 0xe7fc].forEach((instr, i) => gba.bus.write16(BASE + i * 2, instr)); + gba.armCpu.registers[15] = BASE; + gba.armCpu.setT(true); + const engine = new ScriptingEngine(gba, stubHost); + return { gba, engine }; + } + + it('resolves when the instruction executes', async () => { + const { engine } = loopingEngine(); + await expect(engine.wait({ execution: BASE, timeout: 5 })).resolves.toBeUndefined(); + }); + + it('times out on an instruction that never executes, naming it', async () => { + const { engine } = loopingEngine(); + await expect(engine.wait({ execution: BASE + 0x100, timeout: 2 })).rejects.toThrow( + /wait\(\{ execution \}\) timed out after 2 frames waiting for 0x2000100 to execute/, + ); + }); +}); + +describe('wait() with an unrecognised condition', () => { + it('throws instead of silently waiting for nothing', async () => { + const engine = new ScriptingEngine(new Gba(), stubHost); + // Scripts are untyped JS at run time, so a stale or misspelled key arrives here. + await expect(engine.wait({ pc: 0x08000000 } as never)).rejects.toThrow(/unknown condition.*expected one of/s); + await expect(engine.wait({} as never)).rejects.toThrow(/unknown condition/); + // Positive control: a valid condition is unaffected. + await expect(engine.wait({ frames: 1 })).resolves.toBeUndefined(); + }); +}); diff --git a/packages/gba-emulator/src/scripting.ts b/packages/gba-emulator/src/scripting.ts index 860b676..b7da7b2 100644 --- a/packages/gba-emulator/src/scripting.ts +++ b/packages/gba-emulator/src/scripting.ts @@ -108,8 +108,13 @@ interface WaitMemory { timeout?: number; } -interface WaitPC { - pc: number; +interface WaitExecution { + /** + * Wait until this instruction executes — an address, or a symbol name when debug + * info is loaded. The counterpart to {@link ScriptingEngine.watchExecution}, which + * records the same event rather than waiting for it. + */ + execution: number | string; timeout?: number; } @@ -124,7 +129,7 @@ interface WaitPixel { timeout?: number; } -type WaitCondition = WaitFrames | WaitMemory | WaitPC | WaitPixel; +type WaitCondition = WaitFrames | WaitMemory | WaitExecution | WaitPixel; // ─── Memory Snapshot Types ─────────────────────────────────────────── @@ -200,6 +205,17 @@ export interface WatchHit { location?: SourceLocation; } +/** One execution of a watched instruction, recorded by `watchExecution`. */ +export interface ExecHit { + /** The watched instruction address. */ + address: number; + /** Link register at entry — the caller's return address, when the watch is a function entry. */ + lr: number; + thumb: boolean; + /** The caller's C `file:line`, when debug info covers it. */ + callerLocation?: SourceLocation; +} + // ─── Scripting Engine ──────────────────────────────────────────────── export class ScriptingEngine { @@ -337,15 +353,30 @@ export class ScriptingEngine { throw new Error(`wait({ memory }) timed out after ${timeout} frames at ${probe.label}`); } - if ('pc' in condition) { - const targetPC = condition.pc; - for (let i = 0; i < timeout; i++) { - this.#runFrame(); - if (this.#gba.armCpu.registers[15] === targetPC) { - return; + if ('execution' in condition) { + const target = condition.execution; + // Watched at the CPU's own instruction step, not sampled between frames. A + // sample sees only what the CPU happens to be doing at a frame boundary — for a + // game that idles in a BIOS wait loop that is a single address, so everything + // else reads as never reached however often it actually runs. + const address = this.#resolveCodeAddress(target, 'wait({ execution })'); + let reached = false; + const dispose = this.#gba.armCpu.addExecWatchpoint(address, () => { + reached = true; + }); + try { + for (let i = 0; i < timeout; i++) { + this.#runFrame(); + if (reached) { + return; + } } + } finally { + dispose(); } - throw new Error(`wait({ pc }) timed out after ${timeout} frames waiting for PC=0x${condition.pc.toString(16)}`); + const label = + typeof target === 'string' ? `"${target}" (0x${address.toString(16)})` : `0x${address.toString(16)}`; + throw new Error(`wait({ execution }) timed out after ${timeout} frames waiting for ${label} to execute`); } if ('pixel' in condition) { @@ -362,6 +393,13 @@ export class ScriptingEngine { `wait({ pixel }) timed out after ${timeout} frames at (${x}, ${y}) waiting for rgb(${r}, ${g}, ${b})`, ); } + + // Scripts run as untyped JS, so an unknown key reaches here at run time. Falling + // out of the function would wait for nothing and continue as if the condition had + // been met. + throw new Error( + `wait: unknown condition ${JSON.stringify(Object.keys(condition))} — expected one of frames, memory, execution, pixel`, + ); } // ─── Input ─────────────────────────────────────────────────────── @@ -526,7 +564,11 @@ export class ScriptingEngine { * w.stop(); */ watchMemory(options: { - address: number; + /** + * A raw address, or — when debug info is loaded — a symbol name, in which case + * `length` defaults to the whole object rather than one byte. + */ + address: number | string; length?: number; /** * Keep a hit only when this returns true — watch a wide region but record only @@ -540,17 +582,26 @@ export class ScriptingEngine { maxHits?: number; }): { hits: WatchHit[]; + /** + * Writes that matched but were not recorded because `maxHits` was reached. A cap + * that reports nothing leaves `hits.length === maxHits` meaning either "that is + * all of them" or "that is the first few", which are different findings. + */ + dropped: number; stop: () => void; } { - const length = options.length ?? 1; + const target = this.#resolveDataLocation(options.address, 'watchMemory'); + const length = options.length ?? target.length; const filter = options.filter; const maxHits = options.maxHits; const hits: WatchHit[] = []; + const handle = { hits, dropped: 0, stop: () => {} }; const busDispose = this.#gba.bus.addWriteWatchpoint( - options.address, + target.address, length, ({ address, value, size, dmaChannel, dmaOrigin }) => { if (maxHits !== undefined && hits.length >= maxHits) { + handle.dropped++; return; } // DMA: the captured trigger instruction; CPU: the live PC + CPSR. @@ -583,20 +634,20 @@ export class ScriptingEngine { hits.push(hit); }, ); - const stop = (): void => { + handle.stop = (): void => { if (this.#watchDisposers.delete(busDispose)) { busDispose(); } }; this.#watchDisposers.add(busDispose); - return { hits, stop }; + return handle; } /** - * Watch a named global by symbol (requires debug info). Resolves the symbol to - * its address, then behaves like `watchMemory`. The watch length defaults to the - * symbol's own size (st_size) so a multi-byte global is watched in full; pass - * `length` to override. Throws if no debug info is loaded or the symbol is unknown. + * Watch a named global by symbol (requires debug info) — `watchMemory` with a + * symbol name, kept for readability at the call site. The length defaults to the + * whole object, so a multi-byte global is watched in full; pass `length` to + * override. Throws if no debug info is loaded or the symbol is unknown. * * @example * const w = watchSymbol('gPlayerState'); // covers the whole global @@ -605,16 +656,130 @@ export class ScriptingEngine { watchSymbol( name: string, options?: { length?: number; filter?: (hit: WatchHit) => boolean; maxHits?: number }, - ): { hits: WatchHit[]; stop: () => void } { + ): { hits: WatchHit[]; dropped: number; stop: () => void } { if (!this.#debugInfo) { throw new Error('watchSymbol requires debug info; call loadDebugInfo(elfBytes) first'); } - const address = this.#debugInfo.symbolToAddress(name); + return this.watchMemory({ address: name, ...options }); + } + + /** + * Record every execution of the instruction at `target` — a raw address, or a + * symbol name when debug info is loaded. The execution counterpart to + * {@link watchMemory}, and the way to answer "does this code ever run". + * + * Each hit carries the caller's return address, so a body that runs from several + * places says which. Counting is exact: the watchpoint fires from the CPU's own + * instruction step, not from a sample. + * + * @example + * const w = watchExecution('UpdatePlayer'); + * await wait({ frames: 60 }); + * w.stop(); + * console.log(w.hits.length); // 0 means it really did not run + */ + watchExecution( + target: number | string, + options?: { + /** Keep a hit only when this returns true. A throw is treated as `false`. */ + filter?: (hit: ExecHit) => boolean; + /** Cap recorded hits; `dropped` counts the rest, and `count` stays exact. */ + maxHits?: number; + }, + ): { + hits: ExecHit[]; + /** Every execution seen, whether recorded or not — unaffected by `maxHits`. */ + count: number; + /** Executions that matched but were not recorded because `maxHits` was reached. */ + dropped: number; + stop: () => void; + } { + const address = this.#resolveCodeAddress(target, 'watchExecution'); + const maxHits = options?.maxHits; + const filter = options?.filter; + const hits: ExecHit[] = []; + const handle = { hits, count: 0, dropped: 0, stop: () => {} }; + const cpu = this.#gba.armCpu; + const dispose = cpu.addExecWatchpoint(address, () => { + handle.count++; + // Straight off the CPU, not via the optional `cpuCpsr` hook: that is wired by + // the runtime, so an engine constructed directly would report ARM for + // everything. + const hit: ExecHit = { address, lr: cpu.registers[14]! >>> 0, thumb: (cpu.cpsr & 0x20) !== 0 }; + const loc = this.#debugInfo?.pcToSource(hit.lr & ~1); + if (loc) { + hit.callerLocation = loc; + } + if (filter) { + let keep = false; + try { + keep = filter(hit); + } catch { + keep = false; // a throwing filter must not abort emulation + } + if (!keep) { + return; + } + } + if (maxHits !== undefined && hits.length >= maxHits) { + handle.dropped++; + return; + } + hits.push(hit); + }); + handle.stop = (): void => { + if (this.#watchDisposers.delete(dispose)) { + dispose(); + } + }; + this.#watchDisposers.add(dispose); + return handle; + } + + /** + * An address for code: a number, or a symbol resolved through debug info. + * + * Bit 0 is cleared either way. On ARM it is a state marker, never part of an + * instruction address — a Thumb function POINTER carries it set, which is exactly + * what `read32` returns from a callback table. Clearing it recovers the address the + * value denotes; leaving it set on the numeric arm alone made the same function + * count 420 executions when named and 0 when passed as the pointer that reaches it. + */ + #resolveCodeAddress(target: number | string, api: string): number { + if (typeof target === 'number') { + return (target & ~1) >>> 0; + } + if (!this.#debugInfo) { + throw new Error(`${api}: resolving "${target}" requires debug info; call loadDebugInfo(elfBytes) first`); + } + const address = this.#debugInfo.symbolToAddress(target); + if (address === null) { + throw new Error(`${api}: unknown symbol "${target}"`); + } + return (address & ~1) >>> 0; + } + + /** + * Address + default watch length for a data location named by number or symbol. + * + * A symbol names an OBJECT, so the whole of it is watched unless a length is given. + * The extent comes from {@link DebugInfo.symbolExtent} rather than `st_size` alone, + * because a linker-placed global has no `st_size` — in a decomp that is every data + * global, and defaulting to 1 byte silently watched the first byte of a 112-byte + * array. + */ + #resolveDataLocation(target: number | string, api: string): { address: number; length: number } { + if (typeof target === 'number') { + return { address: target >>> 0, length: 1 }; + } + if (!this.#debugInfo) { + throw new Error(`${api}: resolving "${target}" requires debug info; call loadDebugInfo(elfBytes) first`); + } + const address = this.#debugInfo.symbolToAddress(target); if (address === null) { - throw new Error(`watchSymbol: unknown symbol "${name}"`); + throw new Error(`${api}: unknown symbol "${target}"`); } - const length = options?.length ?? this.#debugInfo.symbolSize(name) ?? 1; - return this.watchMemory({ address, length, filter: options?.filter, maxHits: options?.maxHits }); + return { address, length: this.#debugInfo.symbolExtent(target)?.size ?? 1 }; } /** Remove the data watchpoints created via this engine's `watchMemory`. */ diff --git a/packages/gba-node/src/headless-runtime.ts b/packages/gba-node/src/headless-runtime.ts index dcd65c2..68d67bb 100644 --- a/packages/gba-node/src/headless-runtime.ts +++ b/packages/gba-node/src/headless-runtime.ts @@ -149,6 +149,8 @@ export class HeadlessRuntime { filterMemory: (addresses: number[], options: Parameters[1]) => engine.filterMemory(addresses, options), watchMemory: (options: Parameters[0]) => engine.watchMemory(options), + watchExecution: (target: number | string, options?: Parameters[1]) => + engine.watchExecution(target, options), watchSymbol: (name: string, options?: Parameters[1]) => engine.watchSymbol(name, options), clearWatchpoints: () => engine.clearWatchpoints(),