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.
- 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_phiis pinned, the byte counter freezes, butdma_runkeeps 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.
| Gate | Role | Type | Logic |
|---|---|---|---|
| LAVY | FF46 store detect | and2 | AND2(ppu_wr, ff46) |
| LORU | FF46 source-page latch enable (~ena side) | not_x1 | NOT(LAVY) |
| PYSU | FF46 source-page latch enable (ena side) | not_x1 | NOT(LORU) |
| LYXE | FF46 store latch | nor_latch | set by LAVY, reset by LOKO |
| LUPA | Store-ended term | nor2 | NOR2(LAVY, LYXE.q_n) |
| LUVY | Start synchroniser stage 1 | dffr | D = LUPA, clocked by dma_phi |
| LENE | Start synchroniser stage 2 | dffr | D = LUVY, clocked by dma_phi_n |
| LOKO | Start-pulse decode | nand2 | NAND2(LENE.q_n, ppu_hard_reset_n) |
| LAPA | Byte-counter and MYTE reset driver | not_x1 | NOT(LOKO) |
| LOKY | Run-request latch output | nand2 | NAND2(LARA, LENE.q_n) |
| LARA | Run-request latch feedback | nand3 | NAND3(LOKY, MYTE.q_n, ppu_hard_reset_n) |
| MATU | dma_run flip-flop | dffr | D = 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.
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 ps | NOT(dma_run) |
| mode2↓ | +3,688 ps total | AND2(BOGE, oam_parsing) → 0 |
oam_addr_parse_n↑ | +4,507 ps | APAR — parse driver tri-states; DMA takes the bus |
| AJEP | already high — no edge | NAND2(mode2, XOCE); the quantised engage always lands at XOCE=0 |
| ODL held low | while AJEP = 1 | BODE |
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 pastclk_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.
| Gate | Role | Type | Logic |
|---|---|---|---|
| META | Byte-counter clock | and2 | AND2(dma_phi, LOKY) |
| NAKY, PYRO, NEFY, MUTY, NYKO, PYLO, NUTO, MUGU | DMA byte counter bits 0–7 (dma_a0–dma_a7) | dffr | ripple chain, dma_a0 first |
| NAVO | Byte-159 decode | nand6 | NAND6(dma_a0, dma_a1, dma_a2, dma_a3, dma_a4, dma_a7) |
| NOLO | Last-byte term | not_x1 | NOT(NAVO) |
| MYTE | Last-byte flip-flop | dffr | D = 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 ps | NOT(dma_run) |
| mode2↑ | +3,723 ps total | AND2(BOGE, oam_parsing) — requires BESU.q=1 |
| next XOCE↑ edge | 0–2 dots | free-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.
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.
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.