Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Engage, Release, and HALT

A transfer has three boundaries: the engage after an FF46 store, the release back to the scan when the last byte commits, and the pause across HALT. Each interacts with the Mode-2 scan differently — engage is edge-immediate, release waits for the scan cadence, and HALT freezes the byte counter without surrendering the bus. This page also covers the machinery behind those boundaries: the arm chain that raises dma_run, and the byte counter whose decode drops it again.

At a glance

  • FF46-store → dma_run↑ is a fixed engine constant: 1.495 M-cycles ≈ 6 dots, M-cycle-phase quantised by MATU's clock.
  • Engage is immediate — the lock takes the bus and suppresses captures on the same master-clock edge as dma_run↑.
  • A transfer ends on a counter decode, not a timer: the byte counter reaches 159, and MYTE clears the run-request latch.
  • Release waits for the scan cadence — the first post-DMA capture fires within 2 dots, and only if BESU is still set (before dot 80).
  • The dot-80 boundary is sharp: DMA ending after AVAP defers OAM visibility one full line.
  • HALT pauses DMA structurally — dma_phi is pinned, the byte counter freezes, but dma_run keeps owning the buses for the whole span.

DMA-start during Mode 2: engage cadence

A dma_run↑ inside Mode 2 freezes the scan partway through the line. Two things fix where it cuts off: the FF46-store → dma_run↑ latency, and the engage cascade's alignment to the scan cadence.

FF46-store → dma_run↑ latency. The arm chain:

LAVY → LYXE → LUPA → LUVY → LENE → LOKY/LARA → MATU

LAVY detects the store and sets LYXE, the FF46 store latch. LUPA, the store-ended term, rises when the store's strobe ends. LUVY and LENE form the start synchroniser, which carries the request across two capture edges half an M-cycle apart. LENE sets the LOKY/LARA run-request latch, and MATU registers that latch as dma_run.

GateRoleTypeLogic
LAVYFF46 store detectand2AND2(ppu_wr, ff46)
LORUFF46 source-page latch enable (~ena side)not_x1NOT(LAVY)
PYSUFF46 source-page latch enable (ena side)not_x1NOT(LORU)
LYXEFF46 store latchnor_latchset by LAVY, reset by LOKO
LUPAStore-ended termnor2NOR2(LAVY, LYXE.q_n)
LUVYStart synchroniser stage 1dffrD = LUPA, clocked by dma_phi
LENEStart synchroniser stage 2dffrD = LUVY, clocked by dma_phi_n
LOKOStart-pulse decodenand2NAND2(LENE.q_n, ppu_hard_reset_n)
LAPAByte-counter and MYTE reset drivernot_x1NOT(LOKO)
LOKYRun-request latch outputnand2NAND2(LARA, LENE.q_n)
LARARun-request latch feedbacknand3NAND3(LOKY, MYTE.q_n, ppu_hard_reset_n)
MATUdma_run flip-flopdffrD = LOKY, clocked by dma_phi

dma_phi = NOT(data_phase) is high through T1–T2 of each M-cycle, and dma_phi_n is its complement. ppu_hard_reset_n is the system reset, not the LCD-off reset ppu_reset_n.

LORU and PYSU drive the two complementary enables of the eight dlatch_ee cells that hold dma_a8–dma_a15. Those latches are transparent while LAVY is high, so the byte the store puts on the CPU data bus becomes the source page.

LUPA needs LAVY = 0 with LYXE still set, so the request cannot be taken while the store's strobe is still high. LENE's set state is the start pulse, and that pulse does three things at once. Through LOKO it clears LYXE, so one store arms exactly one transfer. Through LOKO and LAPA it holds the byte counter and MYTE in reset, so every transfer starts at byte 0. Through LOKY it sets the run-request latch.

Because MATU is clocked by dma_phi, dma_run↑ is M-cycle-phase quantised. Measured LAVY↑ → dma_run↑ is a fixed engine constant of 1,459,029 ps = 1.495 M-cycles ≈ 6 dots, identical across all 1,154 DMAs in the capture. Every dma_run↑ in the capture lands at dma_phi = 1. A one-M-cycle shift of the FF46 store shifts dma_run↑ by exactly one M-cycle.

The chain accounts for that constant. LUVY captures on dma_phi↑, LENE half an M-cycle later on dma_phi↓, and MATU half an M-cycle after that on the next dma_phi↑. Those three edges span one M-cycle of the 1.495 M-cycle total. The remaining ≈0.5 M-cycle is the wait from LAVY↑ to LUVY's capture edge.

For implementors

ppu_reset_n reaches none of the DMA engine's cells. LUVY, LENE, and MATU take ppu_hard_reset_n on their asynchronous resets, and LOKO and LARA take it as a logic input. Clearing LCDC.7 mid-transfer therefore does not stop DMA.

Engage cascade. Opposite polarity to the release chain (next section), and — unlike release — with no XOCE wait:

StageΔ from matu.q↑Source
BOGE↓+2,741 psNOT(dma_run)
mode2↓+3,688 ps totalAND2(BOGE, oam_parsing) → 0
oam_addr_parse_n↑+4,507 psAPAR — parse driver tri-states; DMA takes the bus
AJEPalready high — no edgeNAND2(mode2, XOCE); the quantised engage always lands at XOCE=0
ODL held lowwhile AJEP = 1BODE

The +4,507 ps is constant across every mode2=1 engage in the stream (dmg-sim measurement, gambatte late_sp00x_2). The lock takes the bus and suppresses captures on the same master-clock edge as matu.q↑ — engage is immediate, unlike release.

The last captured entry. The scan captures one byte-pair per XOCE↑ with mode2=1; entry 0 is captured at BESU↑ + 0.49 dots. The scan tick at dot 2k captures sprite k, so with dma_run↑ at Mode-2 dot D, sprites 0..⌊D/2⌋ are captured this line and ⌊D/2⌋+1..39 are blocked, holding the pre-engage Stage-1 pair. A 4-dot shift of the FF46 store moves the boundary by ±2 sprites — the structure the late_sp* partner ROMs bracket.

Sprite 0 is always captured — the early-engage sub-case is unreachable. The engage instant is phase-locked twice over (dmg-sim measurement):

  • dma_run↑ sits at a fixed phase of the CPU clock grid (+973,143 ps past clk_1mhz↑; single population, n=1,154);
  • the line boundary CATU↑ sits at its own fixed phase (+610,111 ps, n=5,468), and the PPU grid's phase against the CPU clock is itself pinned by the M-cycle-quantised LCD-on write.

The only reachable engage positions are therefore Mode-2 dots 1.488 + 4k, so no program can place a lock inside the first ~0.5 dots of Mode 2. The entry-0 capture (BESU↑ + 0.49 dots) always completes a full dot before the earliest possible lock.

What ends a transfer: the byte counter and its decode

A transfer ends on a counter decode, not on a timer. The byte counter is a ripple chain of eight dffr cells, separate from MATU. The chain is NAKY (dma_a0) → PYRO → NEFY → MUTY → NYKO → PYLO → NUTO → MUGU (dma_a7). Each stage feeds its own q_n back to its D input, so it toggles on every clock edge. Each stage also clocks the next from that same q_n, so the chain counts up.

GateRoleTypeLogic
METAByte-counter clockand2AND2(dma_phi, LOKY)
NAKY, PYRO, NEFY, MUTY, NYKO, PYLO, NUTO, MUGUDMA byte counter bits 0–7 (dma_a0–dma_a7)dffrripple chain, dma_a0 first
NAVOByte-159 decodenand6NAND6(dma_a0, dma_a1, dma_a2, dma_a3, dma_a4, dma_a7)
NOLOLast-byte termnot_x1NOT(NAVO)
MYTELast-byte flip-flopdffrD = NOLO, clocked by dma_phi_n, reset by LAPA

META clocks NAKY, so the counter advances one step per M-cycle once LAPA releases the reset. NAVO tests six of the eight counter bits; dma_a5 and dma_a6 are not wired to it. The counter starts at zero, so the first value matching NAVO's decode is $9F = 159. That is the last of the 160 bytes.

MYTE captures the last-byte term on dma_phi_n. MYTE.q_n = 0 forces LARA high, which breaks the run-request latch, and MATU drops dma_run on the following dma_phi↑. LAPA clears MYTE again at the next transfer's start pulse.

DMA-end during Mode 2: release cadence

A matu.q↓ (DMA end) inside Mode 2 re-enables the scan capture — but not on the same edge; the next ODL pulse waits on the scan cadence:

StageΔ from matu.q↓Source
BOGE↑+1,941 psNOT(dma_run)
mode2↑+3,723 ps totalAND2(BOGE, oam_parsing) — requires BESU.q=1
next XOCE↑ edge0–2 dotsfree-running scan cadence
AJEP↓+105 ps after that XOCE↑NAND2(mode2, XOCE)
ODL↑+2,547 ps after AJEP↓BODE

Every stage is a few nanoseconds; the only multi-dot wait is for the next XOCE edge. Upper bound: the first post-DMA capture fires within 2 dots of DMA end.

The interlock that matters is BESU: the release requires BESU.q = 1 at the matu.q↓ edge. BESU's set (CATU, at the scanline boundary) is independent of DMA, and its reset (AVAP) fires at scan completion — the line's dot 80 — regardless of DMA, because the scan counter free-runs through the overlap. So:

  • DMA ends before dot 80: the chain re-enables at the next XOCE↑, and the remaining sprites of this line's scan capture from the freshly-written OAM. Earlier entries hold the pre-DMA Stage-1 pair.
  • DMA ends after dot 80 (post-AVAP): BESU is already clear, mode2 stays 0, and no captures occur for the rest of this line — new OAM contents first become visible at the next line's Mode 2.

The boundary is sharp at dot 80, with at most one XOCE period (2 dots) of resolution. Both regimes are directly captured in the DMA re-trigger stream (dmg-sim measurement, gambatte late_sp00x_2): a release with BESU.q=1 resumes captures within ~1 dot; a release ~280 dots after AVAP produces zero captures for the remainder of the line.

For implementors

This boundary is exactly what the gambatte late_sp* partner-test pairs bracket. Variants whose DMA ends ≤ dot 80 extend Mode 3 via the newly-captured sprites. The 4-dot-later partners miss the window and defer visibility one full line, landing the test's STAT sample in a different mode. Advance the scan counter through DMA, and switch the PPU's OAM view atomically at DMA end; both partners then fall out.

DMA pause during HALT

The DMA byte counter is clocked by META = AND2(dma_phi, LOKY), and data_phase is held LOW throughout HALT (HALT and EI). The consequence is structural: while halted, dma_phi is held HIGH, META is stuck at LOKY's level, and the byte counter sees no clock edges. MATU takes dma_phi directly, so it has no clock edges either. Any DMA in flight at HALT entry pauses on the byte being fetched and resumes on the first dma_phi↑ after HALT exit (~+0.5 dots after the wake CLK9↑). The same holds for any state that pins data_phase low (reset, STOP).

dma_run does not fall during the pause, because both its set and reset transitions require dma_phi clocks. So all dma_run-gated arbitration (OAM bus and source bus alike) continues to assert "DMA owns the bus" for the entire HALT span, even though no bytes move.

Measured: a 17 ms HALT contributes zero bytes

End to end (dmg-sim measurement, gambatte oamdmasrc80_halt_lycirq_read8000 ROM): a VRAM-source DMA advances 13 bytes, and HALT entry freezes dma_a at 0x0D. The counter stays frozen across a ~17 ms HALT — ≈17,900 M-cycles with zero advance. On wake the counter resumes at one byte per M-cycle. The post-HALT read of the source page lands at byte index 0x1F = source[$801F] = $81, matching the hardware-verified out81 expectation. The HALT span contributes exactly zero to the byte index — which is why the _lycirq (17 ms HALT) and _m2irq (~1 ms HALT) variants share the same expected value.