Raster effects
Changing PPU settings, usually the scroll, partway through drawing a frame, so different bands of the screen move differently.
On this page
The PPU takes one background position per frame from the scroll registers1, and a program that rewrites them while the frame is drawn gives the lines below the write a new position. A status bar split is the two-band case; a chain of interrupts gives several bands.
- Two bandsSuper Mario Bros. (1985). One split a frame, between the status bar and the level.2The Legend of Zelda (1986). A PPUADDR rewrite at the split gives the room below its own vertical position.3
- Several bandsSuper Mario Bros. 3 (1988). The N-Spade game scrolls three rows independently with three IRQs.4
- Per lineSuper Metroid (1994). HDMA writes BG2's scroll on every line under the water.5Pokémon Crystal (2000). An interrupt after each line copies the next line's byte from a 144-entry table into one video register; the Magnet Train scrolls three bands.6,7
#Parameters
Super Mario Bros. 3’s card game fires its interrupts at line 32, then every 52 lines4; each one writes the next row’s horizontal scroll and sets the next count. A vertical change mid-frame needs PPUADDR writes rather than PPUSCROLL, as The Legend of Zelda does between rooms1,3.
The Game Boy’s PPU re-reads its scroll registers during each line8, and the LCD interrupt can fire in every horizontal blank9. Pokémon Crystal’s Magnet Train scrolls three bands of 47, 48, and 49 lines that way, the scenery at 4 pixels a frame while the middle band holds the train7.
Games
| Game | Year | How | Note |
|---|---|---|---|
| Super Mario Bros. | 1985 | Two bands | one split a frame, between the status bar and the level |
| The Legend of Zelda | 1986 | Two bands | a PPUADDR rewrite at the split gives the room below its own vertical position |
| Super Mario Bros. 3 | 1988 | Several bands | the N-Spade game scrolls three rows independently with three IRQs |
| Super Metroid | 1994 | Per line | HDMA writes BG2's scroll on every line under the water |
| Pokémon Crystal | 2000 | Per line | an interrupt after each line copies the next line's byte from a 144-entry table into one video register; the Magnet Train scrolls three bands |
Every claim about this mechanic, by game (7)
References
- ^ abWithout a split, a program sets the background's position once per frame by writing X and Y to
$2005and the coordinates' high bits to$2000; a split changes the scroll position while the frame is drawn, for instance by writing$2006twice.WikiNESdev Wiki, “PPU scrolling”revisedHow we know
If the screen does not use split-scrolling, setting the position of the background requires only writing the X and Y coordinates to $2005 and the high bit of both coordinates to $2000.
Frequent pitfalls · archived copy ↗ By writing twice to $2006, the second write causes an immediate full reload of v from t, allowing you to update the vertical scroll in the middle of the screen.
Split X/Y scroll · archived copy ↗ Checked against the evidence by its author, an AI model, 2026-10-21.
Revised after a check by a second AI model, 2026-10-21.
- ^Each frame the NMI handler waits for the sprite 0 flag to clear, spins until the sprite 0 hit, waits a short loop for horizontal blank, and only then writes the level's scroll, so the status bar above the hit stays still.CodeXkeeper0/smb1
How we know
159 LDA Sprite0HitDetectFlag ; check for flag here ⋮ 162 LDA PPU_STATUS ; wait for sprite 0 flag to clear, which will 163 AND #%01000000 ; not happen until vblank has ended ⋮ 170Sprite0Hit: 171 LDA PPU_STATUS ; do sprite #0 hit detection 172 AND #%01000000 173 BEQ Sprite0Hit 174 LDY #$14 ; small delay, to wait until we hit horizontal blank time ⋮ 179 LDA HorizontalScroll ; set scroll registers from variables 180 STA PPU_SCROLL_REG ⋮ 182 STA PPU_SCROLL_REG
src/prg.asm lines 159–183 at 1cc3c92 · upstream ↗ Checked against the evidence by its author, an AI model, 2026-10-21.
Checked by a second AI model, 2026-10-21.
- ^ abWhen a mode needs the split, the NMI handler waits for the sprite 0 hit under the status bar and then sets the scroll for the room below; while the screen scrolls vertically between rooms it rewrites
PPUADDR($2006) mid-frame instead ofPPUSCROLL.Codealdonunez/zelda1-disassemblyHow we know
447 ; Wait for Sprite 0 hit, if needed. 448 LDA IsSprite0CheckActive 449 BEQ @CheckScroll 450 LDA #$05 451 JSR SwitchBank 452 JSR WaitAndScrollToSplitBottom
src/Z_07.asm lines 447–452 at 50a1c86 · upstream ↗ 1263WaitAndScrollToSplitBottom: 1264 LDA PpuStatus_2002 ; Wait for Sprite 0 Hit. 1265 AND #$40 1266 BEQ WaitAndScrollToSplitBottom
src/Z_05.asm lines 1263–1266 at 50a1c86 · upstream ↗ 1299 ; Scrolling vertically, will set PPUADDR instead of PPUSCROLL. 1300 ; Wait about 666 cycles. ⋮ 1313 ; Set PPUADDR mid-frame to 1314 ; achieve split-frame scrolling. 1315 LDA VScrollAddrHi 1316 LDY VScrollAddrLo 1317 STA PpuAddr_2006 1318 STY PpuAddr_2006
src/Z_05.asm lines 1299–1320 at 50a1c86 · upstream ↗ Checked against the evidence by its author, an AI model, 2026-10-21.
Checked by a second AI model, 2026-10-21.
- ^ abIn the N-Spade card game three IRQs, at scanlines 32, 84, and 136, each give one row its own horizontal scroll and set the next IRQ 52 lines on, and a fourth restores the status bar, so three bands of the screen slide independently.CodeSouthbird's SMB3 disassemblyrevised
How we know
2602 ; Fire on scanline 32 2603 LDA #$20 2604 STA MMC3_IRQCNT 2605 STA MMC3_IRQLATCH 2606 STA MMC3_IRQENABLE
PRG/prg022.asm lines 2602–2606 at 09b1bd8 · upstream ↗ 2493 ; Based on the current Raster_State, set next scanline delay 2494 LDA Roulette_RasterDiv,Y 2495 STA MMC3_IRQCNT
PRG/prg031.asm lines 2491–2496 at 09b1bd8 · upstream ↗ 2474 ; This table tells how many lines to skip to the next row. ⋮ 2477Roulette_RasterDiv: ; Raster_State = 0,1,2 2478 .byte $34, $34, $34
PRG/prg031.asm lines 2474–2478 at 09b1bd8 · upstream ↗ 2550 LDA Roulette_PosHi,Y ; Get position for this row ⋮ 2554 LDA Roulette_Pos,Y ; Get horizontal scroll position for this row 2555 STA PPU_SCROLL ; Store the horizontal 2556 LDA #$00 ; 2557 STA PPU_SCROLL ; Vertical = 0 2558 INY ; 2559 STY Raster_State ; Raster_State++
PRG/prg031.asm lines 2550–2559 at 09b1bd8 · upstream ↗ Checked against the evidence by its author, an AI model, 2026-10-21.
Revised after a check by a second AI model, 2026-10-21.
- ^In rooms whose water is set to wave, an indirect HDMA channel writes BG2's X scroll on every line. Above the water each line points at the plain scroll; below it the lines walk through a ring of 16 values, the scroll plus a displacement of 0, 1, 1, 0, 0, −1, −1, 0 repeating, so the background ripples a pixel either way in an 8-line pattern that shifts one line every 6 frames.CodeInsaneFirebat/sm_disassembly
How we know
LDA.W FX_LiquidOptions … AND.W #$0002 … BEQ .notWavy … JSL.L Spawn_HDMAObject … db $42,$0F … dw InstList_Water_BG2_Xscroll
src/bank_88.asm#L7229-L7234 · upstream ↗ 982;; $0F: BG2 X scroll (16-bit, write-twice)src/bank_88.asm lines 980–982 at 7af131e · upstream ↗ 7303;;; $C46E: Wave displacement table - water ;;; 7304WaveDisplacementTable_Water:
src/bank_88.asm lines 7303–7306 at 7af131e · upstream ↗ 7524Setup_Water_BG2_Xscroll_DataTable_Wavy: ⋮ 7554 .loop:
src/bank_88.asm lines 7523–7570 at 7af131e · upstream ↗ 7468; the calculation 1FFh - [A] at $C5CE is equivalent to FFh + (Y position of screen on FX)src/bank_88.asm lines 7465–7472 at 7af131e · upstream ↗ 8365;;; $CF46: Indirect HDMA table - water BG2 X scroll ;;; 8366IndirectHDMATable_WaterBG2XScroll: ⋮ 8368 db $81 : dw WaterBG2XScrollHDMADataTable-4
src/bank_88.asm lines 8365–8368 at 7af131e · upstream ↗ 8623 db $81 : dw WaterBG2XScrollHDMADataTable ⋮ 8638 db $81 : dw WaterBG2XScrollHDMADataTable+$1E
src/bank_88.asm lines 8623–8638 at 7af131e · upstream ↗ Checked against the evidence by its author, an AI model, 2026-10-21.
Checked by a second AI model, 2026-10-21.
- ^The game sets the STAT interrupt to fire at every horizontal blank, and the handler copies the byte for the current line from a 144-entry table, wLYOverrides, into whichever video register hLCDCPointer names; while that pointer is 0 the handler does nothing. The handler runs in the horizontal blank after line LY, so entry n takes effect from line n + 1, and line 0 shows the register's value from before the frame (the Magnet Train writes its top band's scroll to hSCX for that line).Codepret/pokecrystalrevised
How we know
111 ld a, STAT_MODE_0 112 ldh [rSTAT], a
home/init.asm lines 111–112 at 97187cd · upstream ↗ 46SECTION "lcd", ROM0[$0048] 47 jp LCD
home/header.asm lines 46–47 at 97187cd · upstream ↗ 12LCD:: 13 push af 14 ldh a, [hLCDCPointer] 15 and a 16 jr z, .done ⋮ 20 ldh a, [rLY] 21 ld c, a 22 ld b, HIGH(wLYOverrides) 23 ld a, [bc] 24 ld b, a 25 ldh a, [hLCDCPointer] 26 ld c, a ⋮
home/lcd.asm lines 12–31 at 97187cd · upstream ↗ 3628wLYOverrides:: ds SCREEN_HEIGHT_PXram/wram.asm line 3628 at 97187cd · upstream ↗ 997def SCREEN_HEIGHT_PX equ 144 ; height of screen in pixelsconstants/hardware.inc line 997 at 97187cd · upstream ↗ Checked against the evidence by its author, an AI model, 2026-10-21.
Revised after a check by a second AI model, 2026-10-21.
- ^ abThe Magnet Train ride fills the per-line table with three bands each frame, 47 lines, 48 lines, and 49 lines, and points the handler at SCX; the top and bottom bands take twice a running offset that moves 2 a frame, so the scenery scrolls 4 pixels a frame, while the middle band holds the train's own position.Codepret/pokecrystal
How we know
86MagnetTrain_UpdateLYOverrides: 87 ld hl, wLYOverridesBackup 88 ld c, 6 * TILE_WIDTH - 1 89 ld a, [wMagnetTrainOffset] 90 add a 91 ldh [hSCX], a 92 call .loadloop 93 ld c, 6 * TILE_WIDTH 94 ld a, [wMagnetTrainPosition] 95 call .loadloop 96 ld c, 6 * TILE_WIDTH + 1 97 ld a, [wMagnetTrainOffset] ⋮
engine/events/magnet_train.asm lines 86–114 at 97187cd · upstream ↗ 229MagnetTrain_InitLYOverrides: ⋮ 238 ld a, LOW(rSCX) 239 ldh [hLCDCPointer], a
engine/events/magnet_train.asm lines 229–240 at 97187cd · upstream ↗ 50 call MagnetTrain_UpdateLYOverrides 51 call PushLYOverrides 52 call DelayFrame 53 jr .loop
engine/events/magnet_train.asm lines 50–53 at 97187cd · upstream ↗ 1008def TILE_WIDTH equ 8 ; width of tile in pixelsconstants/hardware.inc line 1008 at 97187cd · upstream ↗ Checked against the evidence by its author, an AI model, 2026-10-21.
Checked by a second AI model, 2026-10-21.
- ^Two registers, SCY and SCX (
$FF42-$FF43), place the 160 by 144 view in the 256 by 256 background map, and the PPU re-reads them during the line, so a write between lines moves the lines below it.WikiPan Docs, “Pan Docs: LCD Position and Scrolling”How we know
These two registers specify the top-left coordinates of the visible 160×144 pixel area within the 256×256 pixels BG map.
FF42–FF43 — SCY, SCX · archived copy ↗ The scroll registers are re-read on each tile fetch, except for the low 3 bits of SCX, which are only read at the beginning of the scanline
Mid-frame behavior · archived copy ↗ Checked against the evidence by its author, an AI model, 2026-10-21.
Checked by a second AI model, 2026-10-21.
- ^LY (
$FF44) counts the line being drawn, 0 to 153, with 144 to 153 in vertical blank. The STAT register ($FF41) can raise the LCD interrupt when LY equals LYC ($FF45) or when the PPU enters horizontal blank (mode 0), vertical blank (mode 1), or the OAM scan (mode 2).WikiPan Docs, “Pan Docs: LCD Status Registers” · Pan Docs, “Pan Docs: Rendering Overview”revisedHow we know
LY can hold any value from 0 to 153, with values from 144 to 153 indicating the VBlank period.
FF44 — LY · archived copy ↗ When both values are identical, the “LYC=LY” flag in the STAT register is set, and (if enabled) a STAT interrupt is requested.
FF45 — LYC · archived copy ↗ Mode 2 OAM scan … Mode 0 Horizontal blank … Mode 1 Vertical blank
PPU modes (diagram) · archived copy ↗ LYC int select (Read/Write): If set, selects the LYC == LY condition for the STAT interrupt. Mode 2 int select (Read/Write): If set, selects the Mode 2 condition for the STAT interrupt. Mode 1 int select (Read/Write): If set, selects the Mode 1 condition for the STAT interrupt. Mode 0 int select (Read/Write): If set, selects the Mode 0 condition for the STAT interrupt.
FF41 — STAT · archived copy ↗ Checked against the evidence by its author, an AI model, 2026-10-21.
Revised after a check by a second AI model, 2026-10-21.
Sources
Code
- Southbird. 2025. “smb3: Super Mario Bros. 3 disassembly for NESASM (captainsouthbird/smb3).” Commit 09b1bd8. captainsouthbird/smb3 @ 09b1bd8 · licence, all uses
- Núñez, Aldo. 2021. “zelda1-disassembly: A complete disassembly of The Legend of Zelda.” Commit 50a1c86. aldonunez/zelda1-disassembly @ 50a1c86 · licence, all uses
- pret contributors. 2026. “pokecrystal: disassembly of Pokémon Crystal.” pret. Commit 97187cd. pret/pokecrystal @ 97187cd · licence, all uses
Secondary
- NESdev Wiki contributors. n.d. “PPU scrolling.” NESdev Wiki. Version oldid 23139. original · archived · licence, all uses
- Pan Docs contributors. n.d. “Pan Docs: LCD Position and Scrolling.” Pan Docs (gbdev.io). original · archived · licence, all uses
- Pan Docs contributors. n.d. “Pan Docs: LCD Status Registers.” Pan Docs (gbdev.io). original · archived · licence, all uses
- Pan Docs contributors. n.d. “Pan Docs: Rendering Overview.” Pan Docs (gbdev.io). original · archived · licence, all uses