Skip to content

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:

munos
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 translation

Each returns an opaque coordinate you hand to on_exec or write_rom.

  • prg is the primary form: an offset means the same byte of the cartridge forever, whatever is currently mapped.
  • bank(n, addr) is sugar for prg(n * 0x4000 + (addr & 0x3fff)) — for authors who think in bank + window address.
  • cpu is 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

munos
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:

munos
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).

munos
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

munos
read_reg(reg: u32): u32
write_reg(reg: u32, value: u32)

reg is one of the injected constants:

ConstantRegisterWidth
REG_Aaccumulator8-bit
REG_XX index8-bit
REG_YY index8-bit
REG_SPstack pointer8-bit
REG_Pstatus flags8-bit
REG_PCprogram counter16-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.

munos
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

munos
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.

munos
write_rom(prg(0x1C2A0), 0xEA)   // 0xEA = NOP

A 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.

Part of the MultiNostalgia project.