The Tile Fetcher
The BG/window tile fetcher is a 3-bit counter, a drain-detect pipeline, and a combinational VRAM address generator. It runs continuously through Mode 3, producing one tile (two bitplane bytes) every 8 dots, in parallel with pixel shifting.
- A 3-bit ripple counter steps through six states (0–5), then holds at 5 until the NYXU reset pulse restarts it. One tile fetch = 3 VRAM accesses × 2 dots.
- The full tile cadence is 8 dots: 6 counting plus 2 while the drain cascade (NYKA → PORY → RENE/RYFA → SEKO) empties and retriggers NYXU.
- NYXU fires 22 times per scanline at SCX=0, 23 at SCX&7 ∈ {1..7}.
- LCDC.3/4/6 reach the VRAM address bus through level-sensitive paths — no clocked stage — and TILE_SEL is sampled twice per fetch.
The fetch counter
The counter advances through six states (0–5) to drive one tile fetch. It then holds at 5 until the NYXU reset pulse restarts it for the next tile, window handoff, or Mode 3 startup. (The downstream drain-detect pipeline that closes each tile is tabulated in the next section.)
| Gate | Role | Type | Clock / Trigger | Notes |
|---|---|---|---|---|
| LEBO | Fetch-counter gated clock | nand2 | Inputs: MOCE, ALET | When MOCE=0 (terminal count), LEBO freezes at 1; the counter stops |
| LAXU | Fetch counter bit 0 | dffr | LEBO | Async reset by NYXU |
| MESU | Fetch counter bit 1 | dffr | LAXU (~Q ripple) | Async reset by NYXU |
| NYVA | Fetch counter bit 2 | dffr | MESU (~Q ripple) | Async reset by NYXU |
| MOCE | Self-stop condition | nand3 | Inputs: LAXU, NYVA, NYXU | Drives LEBO low at count 5 when NYXU=1 |
| NYXU | BG fetch counter reset | nor3 | Inputs: MOSU, TEVO, AVAP | Async reset; also forces MOCE=1, re-enabling LEBO |
LEBO (nand2) = NAND(MOCE, ALET) — gated clock, stops when MOCE=0
├── LAXU (dffr) [clk=LEBO] — bit 0
│ ├── MESU (dffr) [clk=LAXU.~Q] — bit 1 (ripple)
│ │ └── NYVA (dffr) [clk=MESU.~Q] — bit 2 (ripple)
│ └── data to LYZU
MOCE (nand3) = NAND(LAXU, NYVA, NYXU) — self-stop at count 5 when NYXU=1
Reset and restart. NYXU = NOR3(MOSU, TEVO, AVAP) combines three triggers: window activation (MOSU), tile boundary (TEVO), and OAM-scan completion (AVAP). A single ~0.5-dot NYXU pulse asynchronously resets the counter DFFs and forces MOCE=1, re-enabling LEBO — clear and clock-restart in one operation. At count 5 (LAXU=1, MESU=0, NYVA=1), MOCE freezes LEBO and the counter holds. The six states match the 6 dots of one tile fetch (3 VRAM accesses × 2 dots).
NYXU firings per scanline under normal BG rendering — 22 at SCX=0, 23 at SCX&7 ∈ {1..7}:
| Source | Count | Fires at | Role |
|---|---|---|---|
| AVAP | 1 | Mode 2→3 transition | Starts Mode 3, before any tile fetch |
| TAVE → TEVO | 1 | +5.996 dots from AVAP | Closes tile 0 (the startup fetch) |
| SEKO → TEVO | 20 | 8-dot boundaries from +13.997 dots | Closes tiles 1–20 (the 20 visible BG tiles) |
| SEKO → TEVO (tail) | 0 or 1 | +173.997 dots, fixed | Closes tile 21 at SCX&7 ∈ {1..7} only — suppressed at SCX=0 (the tail drain) |
The 167-pixel SACU count per Mode 3 is decoupled from the TEVO firings. SACU runs at CLKPIPE cadence, not fetch cadence.
The 8-dot tile cycle
The counter counts 0→5 over 6 dots, then holds at 5 for 2 dots while the cascade (NYKA → PORY → RENE/RYFA) drains. When SEKO detects the drained state, TEVO fires and resets the counter for the next tile. The counter is a position within the current tile fetch, not an absolute Mode 3 position. Fetching and pixel shifting run in parallel. The per-tile TEVO reset is normal fetcher operation, not a stall.
| Gate | Role | Type | Clock / Trigger | Notes |
|---|---|---|---|---|
| LYRY | "Fetch complete" combinational | not_x1 | Input: MOCE | High when the counter sits at terminal with no reset — drives NYKA's data input |
| NYKA | Fetch-complete capture | dffr | ALET | Data: LYRY |
| PORY | NYKA pipeline stage | dffr | MYVO | Half-dot-later capture of NYKA |
| LYZU | Fetch counter bit 0 sample | dffr | ALET | Data: LAXU |
| PYGO | PORY pipeline stage | dffr | ALET | Feeds POKY |
| RENE | Drain-detect stage 2 | dffr | ALET | Data: RYFA |
| RYFA | Drain-detect stage 1 | dffr | SEGU | Data: PANY |
| TEVO | Tile-boundary / restart trigger | or3 | Inputs: SEKO, SUZU, TAVE | Drives NYXU per tile, per window trigger, per startup |
| SEKO | Tile-boundary drain detector | nor2 | Inputs: RENE, RYFA | Fires when the pipeline drains (both 0) |
| SUZU | Window-activation trigger | not | Input: TUXY | See Window control |
| TAVE | Startup / window-restart trigger | not | Input: SUVU | SUVU = NAND4(NYKA, PORY, ROMO, mode3) |
The 2-dot hold, stage by stage:
- The counter reaches 5. MOCE→0 freezes LEBO, and LYRY→1 starts propagating down the NYKA/PORY/PYGO half-dot pipeline. POKY sets, and RYFA still holds 1.
- Hold-dot 1: RENE captures RYFA=1 — SEKO stays 0.
- Hold-dot 2: PANY falls, RYFA captures 0, and RENE follows. Once RYFA and RENE are both 0, SEKO rises, TEVO fires, and NYXU resets the counter. LYRY's fall then propagates back down, returning SEKO to 0.
The cascade does not drain during a sprite fetch. When FEPO=1 freezes SEGU at 1 (the pixel clock), RYFA is frozen at its pre-freeze 1, and RENE keeps re-capturing 1. SEKO therefore never rises, so neither TEVO nor NYXU fires during the sprite window. The counter holds at 5 by the ordinary MOCE self-stop. The reset that would normally come 2 dots later does not arrive until the freeze lifts (sprite pipeline).
PANY takes wxy_match (NUKO) as an input. Any NUKO=1 landing inside PANY's
natural 1-dot tile-boundary window perturbs the drain pulse, slipping the
cascade by one dot. The slip delays the BG-shifter parallel-load
unconditionally; that branch is not gated by LCDC.5 or in_window.
The slip also delays the window tile-X increment, but only when
in_window=1. NUKO=1 inside the PANY window arises from a mid-Mode-3 WX
rewrite, or from the natural PX==WX sweep when REJO is armed with WX≡7
(mod 8). The sweep case includes the armed-but-disabled state (REJO=1,
LCDC.5=0), where the BG still slips even though no window renders. Full
mechanism and measurements: Window edge cases.
Outside Mode 3 the MOSU/TEVO/AVAP cascade is idle, so NYXU=1 and the counter is not held in reset. The counter instead freezes via the self-stop at whatever value was last clocked (typically 5), and holds there until the next AVAP. Reset domains:
- LAXU/MESU/NYVA — reset on every NYXU pulse;
- PYGO/RENE/RYFA/LYZU — the mode3 reset net (held at 0 outside Mode 3);
- NYKA/PORY — NAFY (VID_RST plus window-related events).
The fetch cycle and VRAM addressing
Each tile fetch takes 6 dots:
| Counter | What happens | VRAM address |
|---|---|---|
| 0 | Tilemap fetch: address set | Tilemap address |
| 1 | Tilemap data arrives | — |
| 2 | Tile data low: address set | Tile-data address (low byte) |
| 3 | Tile data low arrives | — |
| 4 | Tile data high: address set | Tile-data address (high byte) |
| 5 | Tile data high arrives; MOCE stops the clock | — |
The set-address/data-arrives pattern assumes the standard async-SRAM model. VRAM is external, so its access time is not determined by the on-die netlist. The address is driven combinationally from the fetch counter through the VRAM address adder. The full carry chain takes up to 66.2 ge, though in practice the high bits settle from the previous line.
LCDC.3/4/6 sampling during the fetch
Three LCDC bits feed the fetcher's VRAM address generation. Each path is purely combinational, gated by tri-state drivers that the fetch counter enables at specific stages:
| LCDC bit | Cell | Wire name | Drives | Path |
|---|---|---|---|---|
| 3 (BG_MAP) | XAFO | LCDC_BGMAPp | ~ma10 during BG tilemap reads | XAFO → AMUV (not_if0, enable BAFY) |
| 4 (TILE_SEL) | WEXU | LCDC_BGTILEp | ~ma12 during tile-data reads | WEXU → VUZA (nor2 with tile-index bit 7 PYJU) → VURY (not_if1, enable bp_cy) |
| 6 (WIN_MAP) | WOKY | LCDC_WINMAPp | ~ma10 during window tilemap reads | WOKY → VEVY (not_if0, enable WUKO) |
The enables derive combinationally from the fetch counter:
- BAFY / WUKO (BG vs window tilemap arm) — mutually exclusive, both fire at counter=0.
bp_cy(the NETA AND2 output) — active across counters 2–5, measured 4.000 dots continuous per fetch. TILE_SEL is therefore read twice per fetch, once per bitplane.- PYJU (dffr_cc_q) — captures VRAM data bit 7 at the tile-index stage and feeds VUZA, implementing the signed/unsigned tile-index split.
The sampling cadence:
| Counter | Stage | LCDC bits sampled |
|---|---|---|
| 0 | Tilemap address | LCDC.3 or LCDC.6 (BAFY/WUKO arbitration) |
| 1 | Tilemap data | — (tile index captured) |
| 2 | Tile-data low address | LCDC.4 |
| 3 | — | — |
| 4 | Tile-data high address | LCDC.4 (second sample) |
| 5 | — | — |
There is no clocked stage between an LCDC latch and the VRAM address bus. The address bit at any instant is whatever the latch holds, gated by the tri-state enable. TILE_SEL is sampled twice per fetch, so a mid-fetch change mixes bitplane data from two different tile patterns. BG_MAP and WIN_MAP sample once per fetch, at counter=0.
Mid-fetch writes. A CUPA write landing mid-cycle takes effect by stage.
A write before the sampling stage takes effect normally. A write during the
sampling stage leaves the fetched byte as the one the address held when the
access window opened. A write after the sampling stage takes effect on the
next fetch. For BG_MAP and WIN_MAP this produces tile-aligned 8-pixel
divergence. For TILE_SEL, a write landing between counter=2 and counter=4
produces a hybrid fetch. The low bitplane comes from the old tile-data
block, and the high bitplane from the new (the Mealybug
m3_lcdc_tile_sel_win_change reference image). Model the hybrid fetch by
reading each bitplane once, at its own sample stage.
All values here come from the Mealybug m3_lcdc_tile_sel_win_change ROM
(dmg-sim measurement; 1,019 writes per direction, all ps-identical). The
LCDC-write strobe WARU rises at sub-dot 0.507 with a 1.493-dot pulse — the
CUPA width. ff40_d4 settles within ~1.4 ge after WARU↑ on a 0→1 write,
but ~32 ge on a 1→0 write. That gap is a measured rise/fall asymmetry
of the drlatch cell. ~ma12 follows within a gate delay once bp_cy
enables VURY.
LCDC.5 transients and the fetcher arbitration
The window-enable consumer chain enters the tilemap-select arbitration
through PYNU only. That chain — PYNU → NOCU → PORE → in_window →
AXAD/XEZE → BAFY/WUKO — is purely combinational (~3 ge). A mid-Mode-3
LCDC.5 1→0 write therefore switches the fetcher back to BG-tilemap
arbitration on the dot the write lands. Every subsequent counter=0 stage
then drives ~ma10 from XAFO.
Measured across a 65-dot LCDC.5↓ gap: all 8 counter=0 stages in the gap
drive the BG tilemap region, with VEVY tri-stated (high-Z) and AMUV active
(dmg-sim measurement, Mealybug m3_lcdc_win_en_change_multiple ROM). That
matches the hardware reference image, which shows BG content in the gap.
Sample in_window live at counter=0 of every fetch. The netlist has no
fetcher-side window latch beyond the cycle-local tile-index capture. A
latched fetcher-side window-mode flag that ignores PYNU↓ therefore renders
window content where hardware shows BG.
SCX/SCY sampling during the fetch
SCX and SCY both feed the VRAM address adder, but they enter at different fetch stages. A mid-Mode-3 scroll write therefore splits differently for X and Y:
| Scroll input | Drives | Stage(s) sampled | Mid-fetch write effect |
|---|---|---|---|
| SCX, map column | tilemap column bits | counter=0 only | tile-aligned 8-px shift (like BG_MAP); no hybrid |
SCX, fine scroll (SCX & 7) | startup pixel-clock suppression | startup fine scroll only | separate mechanism; never a tile-data address |
| SCY, map row | tilemap row bits | counter=0 only | tile-aligned 8-px shift (like BG_MAP) |
| SCY, fine-Y | tile-data row offset | counter=2 and counter=4 | hybrid Y-fetch (like TILE_SEL): low/high bitplanes from different fine-Y rows |
Like the LCDC fetch-address bits, the scroll address bits are level-sensitive, not edge-captured. Each stage drives the adder from whatever the SCX/SCY latch holds at that stage's address dot (counter=0, then +2, then +4). Which SCY each of the three stages sees therefore depends on where any mid-fetch write commits. A write landing in the counter=2 → counter=4 gap leaves the map row (counter=0) and the low-bitplane fine-Y (counter=2) on the old SCY. The high-bitplane fine-Y (counter=4) takes the new SCY. The two bitplanes therefore split vertically — the Y-direction analogue of the LCDC.4 hybrid fetch.
All values here come from the Mealybug m3_scy_change ROM (dmg-sim,
TIMING=default), observed on the external VRAM address bus ma[12:0] — the
adder output. The ROM sweeps SCY as a triangle 0,1,2,3,4,3,2,1×3, at 24 FF42
writes per line, one BEDY↑ every 8 dots. Within each 8-dot tile the three
address stages land 2 dots apart. Counter=0 is the tilemap read, with map
row = ma[9:5]. Counter=2 and counter=4 are the tile-data low and high
reads, with fine-Y = ma[3:1] and bitplane = ma[0]. On a scanline where
the sweep crosses a tile boundary the map row reads out directly. For
example, at LY=44 the counter=0 read of the tile whose ma settles to
$18c3 gives map_row=6. That value only occurs at SCY=4, so the tilemap
adder used SCY=4. That tile's counter=2 read (fineY=0) also used SCY=4,
while its counter=4 read (fineY=7) used SCY=3. The cause: the SCY write
(4→3) committed at BEDY↑+0.08 dots, landing between the two tile-data reads.
The counter=2 read came 1.54 dots before the value settled, and the
counter=4 read 0.44 dots after. The map row and the low bitplane always
agree; only the high bitplane catches the intervening write.