Execution hooks
The deepest client-side surface. Attach a callback to a location in the game's own code; when the CPU reaches it, your script runs — able to read and write the full machine: RAM, the CPU registers, the stack, PC, and the ROM image itself. All of these are client-side.
This is an advanced, console-specific surface. What's described here is implemented for the NES.
Physical coordinates: prg / bank / cpu
A raw CPU address like $8B3A is not a stable name for a place in the code. NES cartridges bank-switch the $8000–$FFFF window, so the same address is one routine while one bank is mapped and something else while another is. A hook is therefore anchored to a physical coordinate — a location in the cartridge image — built with one of three constructors:
prg(offset: u32): u32 // a flat offset into the PRG-ROM image
bank(n: u32, addr: u32): u32 // bank n, address within the 16 KB window
cpu(addr: u32): u32 // a raw CPU address, no translationEach returns an opaque coordinate you hand to on_exec or write_rom.
prgis the primary form: an offset means the same byte of the cartridge forever, whatever is currently mapped.bank(n, addr)is sugar forprg(n * 0x4000 + (addr & 0x3fff))— for authors who think in bank + window address.cpuis the escape hatch for addresses that mean the same thing regardless of banking (reset vectors, a fixed last bank, mapper registers).
Finding the offset to hook is your own research — a debugger, a disassembly, your own notes. The constructors take bare numbers, so a plain ROM is enough to author against.
on_exec / clear_exec
on_exec(target: u32, cb): u32
clear_exec(handle: u32)on_exec registers cb to fire whenever execution reaches target (a coordinate from prg/bank/cpu). It returns a handle; clear_exec removes the hook by that handle, so you can arm a hook only after a level loads and drop it later.
The callback's shape is:
function (pc: u32) { ... }pc is the CPU address that was about to execute. The callback fires before that instruction runs (pre-step): it observes the inputs to the trapped code, and — crucially — a write to PC from inside it takes effect instead of that instruction (see registers).
client {
event frame(n: i32) {
if n == 0 {
on_exec(prg(0x1AB3A), function (pc: u32) {
print("reached the hooked routine at ", pc)
})
}
}
}A hook callback runs mid-frame, re-entering your script. Like every callback that isn't event frame, it may touch any state the host can see and send on the wire; a draw_* from inside it is not painted (drawing is tied to event frame).
Reading and writing registers: read_reg / write_reg
read_reg(reg: u32): u32
write_reg(reg: u32, value: u32)reg is one of the injected constants:
| Constant | Register | Width |
|---|---|---|
REG_A | accumulator | 8-bit |
REG_X | X index | 8-bit |
REG_Y | Y index | 8-bit |
REG_SP | stack pointer | 8-bit |
REG_P | status flags | 8-bit |
REG_PC | program counter | 16-bit |
Because the hook fires pre-step, write_reg(REG_PC, addr) redirects execution — the CPU resumes at addr instead of running the trapped instruction. There is no dedicated "call a subroutine" builtin: a call is a return address staged on the stack (write_u8 into $0100 + SP, then adjust REG_SP) followed by a write_reg(REG_PC, …). When the native routine runs its own rts, execution returns to the address you staged.
on_exec(bank(5, 0x8B3A), function (pc: u32) {
if read_u8(PENDING_ACTION) != 0 {
write_reg(REG_PC, ROUTINE_ENTRY) // run the game's own routine
write_u8(PENDING_ACTION, 0)
}
})write_rom
write_rom(coord: u32, byte: u32)Patches the host's copy of the ROM image at a prg/bank coordinate. Real cartridge ROM is read-only; the emulator's image is not, so this is how you turn a branch into a NOP, swap an opcode, or change a constant the code reads.
write_rom(prg(0x1C2A0), 0xEA) // 0xEA = NOPA CPU-address write_u8 into $8000+ goes to the mapper, not the cartridge, which is why ROM patching takes its own coordinate-based call rather than a plain memory write.
Scope
Execution hooks are implemented for the NES. prg and cpu coordinates match today; PRG_RAM is reserved in the coordinate space but not yet matched. Read/write traps (the equivalent of on_exec for data accesses) and painting a deferred draw_* from a hook are not yet available.