Skip to content
RetrogamingDeep Dive Published Updated 8 min readViews unavailable

NES MMC3 IRQ Counter: PPU A12 Edges, Reloads, and Mapper Variants

Trace MMC3 IRQs from filtered PPU A12 transitions through latch reloads, counter decrements, enable and acknowledge writes, and board-specific timing differences.

The MMC3 mapper’s interrupt is often described as a scanline counter, but the chip does not receive a scanline number. It observes activity on the PPU address line A12 and clocks its IRQ counter on a qualified low-to-high transition. The familiar “one interrupt per scanline” behavior emerges from how the PPU fetches nametable, background-pattern, and sprite-pattern data while rendering. A mapper that counts host scanlines instead of observing the PPU bus can fail when software changes pattern-table selection or accesses VRAM outside ordinary rendering.

The counter is part of cartridge hardware. It has a reload latch, a current counter, a reload request, an enable state, and an IRQ output. Writes to the mapper’s register windows also control PRG/CHR bank layout and memory protection. These components must remain distinct: changing a graphics-bank mode changes which PPU addresses are fetched, and therefore can change the A12 waveform that clocks the IRQ circuit.

Why a mapper sees the PPU address bus

The PPU alternates among nametable, attribute, background-pattern, and sprite-pattern fetches. A12 is the high bit of the PPU address, so the pattern-table configuration and fetch phase determine when it rises. With one pattern table selected for background tiles and the other for sprites, the line can remain low during one group of fetches, rise during another, and fall again at a predictable point. The MMC3’s edge detector turns that bus activity into a counter clock.

This gives game software a useful raster signal without requiring the CPU to poll every pixel. An IRQ handler can change scroll state, bank selection, or graphics registers near a chosen region boundary. But the hardware event is tied to the filtered edge, not to a logical frame counter. If the emulator changes PPU fetch order, disables background rendering, performs a CPU PPUDATA read, or changes CHR inversion, the mapper may see a different sequence of A12 levels.

The low-time qualification prevents rapid A12 toggles from producing multiple clocks within one nominal scanline. The implementation should track how long A12 has remained low in the relevant clock domain and accept a rising edge only after the required interval. References and emulator implementations express that threshold in different related cycle units, so use the board/model timing definition consistently. Do not use an arbitrary debounce delay measured in host microseconds.

An A12 signal model needs both address transitions and elapsed time. Sampling only one address per rendered pixel is insufficient because the PPU performs external accesses during fetch windows and may expose address transitions while pixels are not advancing. A cycle-accurate PPU can feed every address-bus change to the mapper; a batched implementation must still emit all mapper-visible edges in order.

Latch and current counter are separate registers

Software writes a reload value to the IRQ latch, then writes a reload request. The current counter does not necessarily change immediately when the latch is written. On a later qualified A12 clock, the mapper either loads the latch or decrements the current counter according to its reload state. When the counter reaches the terminal condition, an enabled mapper asserts its IRQ output. This makes the same latch value produce different behavior depending on the current counter and pending reload request.

Mapper register writes also enable or disable the IRQ output and acknowledge a pending IRQ. A disable write can clear the external assertion while leaving the counter state to be reloaded by a later event. An enable write makes future terminal events eligible. Do not model enable as a counter reset unless the selected board behavior says so. Preserve the IRQ line state separately from the counter so CPU polling and interrupt entry reflect the mapper’s state at each cycle.

Several MMC3 revisions and licensed clones differ at edge cases, including reload semantics and the zero-latch terminal behavior. Cartridge headers can identify board or submapper details for known variants, but older iNES metadata may not distinguish them. An emulator should use the best available board identity, document its fallback, and avoid hard-coding one chip revision’s behavior into every mapper-4 cartridge.

The scanline count is not always “one less than the programmed value.” Software can request an initial reload, clock the counter during setup, or use a value of zero. The interaction of current counter, latch, reload-pending, and IRQ-enable state determines the event. This explains why a title’s initialization sequence matters: a test that begins after a game’s startup toggles may not reproduce a cold boot.

Mapper bank modes interact with interrupt timing

MMC3 bank selection controls PRG and CHR regions. One control bit selects PRG bank layout, another inverts the ordering of CHR bank registers, and several data registers supply bank numbers with different granularities. Pattern-table inversion changes which physical addresses the PPU fetches in each region. Since the IRQ detector observes A12, a bank-mode write can affect both the rendered tiles and the counter’s edge pattern.

PRG RAM has separate enable and write-protect controls on common boards. These are not IRQ features, but they share the mapper register space and are often a source of state corruption when a generic register handler masks or aliases writes incorrectly. Keep address decoding for bank select, bank data, IRQ latch/reload, IRQ disable/acknowledge, and RAM control separate. Test writes at each documented mirror and ensure unrelated registers do not change.

The PPU can also read the cartridge during palette-related or CPU-driven accesses. If the emulator reports only rendering fetches to the mapper, a game that manipulates PPUADDR can fail to clock or suppress the IRQ as expected. Conversely, forwarding internal palette RAM accesses that do not reach cartridge CHR can create phantom A12 edges. The mapper should receive the actual external address-bus events from the PPU bus layer.

A traceable counter model

This test fixture keeps the latch, counter, reload request, and output enable separate. It deliberately abstracts the exact mapper revision and A12 filter; the hardware adapter must call qualified_rising_edge only when the PPU bus has met the selected low-time rule. This makes the counter arithmetic testable without pretending that every PPU mode produces one edge per line.

from dataclasses import dataclass


@dataclass
class Mmc3Irq:
    latch: int = 0
    counter: int = 0
    reload_pending: bool = False
    enabled: bool = False
    asserted: bool = False

    def write_latch(self, value):
        self.latch = value & 0xFF

    def request_reload(self):
        self.reload_pending = True

    def enable(self):
        self.enabled = True

    def disable_and_acknowledge(self):
        self.enabled = False
        self.asserted = False

    def qualified_rising_edge(self, zero_reload_asserts=True):
        if self.counter == 0 or self.reload_pending:
            self.counter = self.latch
            self.reload_pending = False
            terminal = self.counter == 0 and zero_reload_asserts
        else:
            self.counter = (self.counter - 1) & 0xFF
            terminal = self.counter == 0
        if terminal and self.enabled:
            self.asserted = True


irq = Mmc3Irq(latch=2, counter=0, enabled=True)
irq.qualified_rising_edge()
irq.qualified_rising_edge()
assert irq.counter == 1 and not irq.asserted
irq.qualified_rising_edge()
assert irq.counter == 0 and irq.asserted

The zero_reload_asserts parameter represents a board/revision policy, not a runtime option exposed to games. A production core should select the appropriate hardware profile and validate it against known test ROMs and real mapper traces. The fixture’s purpose is to prevent accidental conflation of the latch write, reload request, counter clock, and CPU-visible IRQ.

Build a PPU-to-mapper test, not only a register test

First unit-test each mapper write: latch value, reload request, enable, disable/acknowledge, RAM protect, PRG mode, and CHR inversion. Then drive the counter with a synthetic A12 waveform and verify that short low pulses are rejected while qualifying pulses clock exactly once. Check a latch of zero, a reload request while the counter is nonzero, disable just before terminal count, and re-enable after an acknowledged assertion.

At integration level, make the PPU fetch background and sprite patterns from the same A12 half, then from opposite halves. Toggle rendering off and on around the expected fetch windows. Include CPU reads and writes through PPUDATA, palette accesses, pre-render and vblank transitions, and a change to the pattern-table inversion bit. Record PPU dot, address, A12 low-duration, mapper counter before and after, pending reload, IRQ enable, and output line for each event.

Run established MMC3 IRQ test ROMs and several commercial titles with raster status bars. A test should distinguish the first edge after startup from later scanline clocks and specify whether it targets a particular mapper clone. Compare CPU IRQ entry time as well as the screen result; a bar that happens to look right at 60 Hz can still be one PPU fetch group late.

Acceptance criteria

A correct MMC3 IRQ implementation is driven by mapper-visible PPU A12 transitions, filters short low periods in emulated time, separates latch and counter state, applies the board’s reload and zero-counter rules, and derives the CPU line from enable and acknowledgement state. Its trace makes every interrupt reproducible from PPU bus activity and register writes. This is much more robust than a mapper callback scheduled once per host scanline.

Related:

Sources:

Comments