Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/address-or-symbol.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions .changeset/observe-execution.md
Original file line number Diff line number Diff line change
@@ -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 })`.
43 changes: 29 additions & 14 deletions docs/scripting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:

Expand All @@ -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');
Expand Down Expand Up @@ -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) {
Expand All @@ -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({
Expand Down
134 changes: 134 additions & 0 deletions packages/arm-emulator/src/__tests__/exec-watchpoint.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
61 changes: 61 additions & 0 deletions packages/arm-emulator/src/arm-cpu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, ((address: number) => void)[]>();
/** Fast path: skip the lookup entirely on the common no-watchpoint case. */
#execWatchActive = false;

/** Platform-specific SWI handler */
#swiHandler?: SwiHandler;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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') {
Expand Down
Loading
Loading