Code status: maintained routine, executed on a ROM-booted PAL C64 emulator. The check observes initial fine scroll, eight steps, all 1,000 screen cells and all 1,000 colour nibbles. It does not prove tear-free presentation or a frame budget.
Audit execution record · Scope and remaining checks
A pixel at a time, then a whole column
The VIC-II’s fine offset moves the display within a character cell. Once those eight positions run out, the character map must move too. Otherwise the world jumps back instead of continuing to scroll.
Include scroll.inc once, call init_scroll, then call scroll_left once per chosen update. It applies the initial offset immediately, uses 38-column mode to hide the side edges, and preserves the other $D016 bits. Prepare 25 bytes in next_column and 25 colours in next_colour before each coarse step; those become column 39.
Fine steps and complete screen/colour shift
; =============================================================================
; SMOOTH SCROLLING - C64 (horizontal, leftward)
; Fine-scroll 7->0 with $D016; on wrap, coarse-shift the map one column
; CPU: Light per frame; heavy on the 1-in-8 coarse step
; =============================================================================
VIC_CTRL2 = $D016 ; bits 0-2 = X fine scroll (0-7); bit 3 = 40/38 columns
SCREEN = $0400
scroll_x = $FB ; a free zero-page byte: our shadow of the fine offset
init_scroll:
lda VIC_CTRL2
and #%11110111 ; bit 3 = 0 -> 38-column mode (hides the edge characters)
sta VIC_CTRL2
lda #7
sta scroll_x
jmp set_fine
; Call once a frame (after raster sync) to scroll the world left by one pixel
scroll_left:
dec scroll_x
bpl set_fine ; still 0..7? just update the fine offset
lda #7 ; passed 0: a whole character has gone by
sta scroll_x
jsr coarse_left ; shift the map one column left, fill the new right edge
set_fine:
lda VIC_CTRL2
and #%11111000 ; clear the low 3 bits
ora scroll_x ; set the new fine-scroll offset
sta VIC_CTRL2
rts
; Caller owns screen $0400-$07e7, colour $d800-$dbe7, ZP $fb,
; and next_column/next_colour (25 bytes each). Colour values use low four bits.
; Prepare the next right-hand column before the wrap step. No map streamer here.
; A, X, flags change; Y survives. Decimal mode must be clear. IRQ/NMI code
; must not change the owned state. This copies 1950 bytes on the coarse step:
; raster synchronisation alone does NOT make it tear-free or fit a frame budget.
; Use a smaller playfield or a separately budgeted buffer scheme when needed.
coarse_left:
ldx #0
copy_columns:
!for row,0,24 {
lda SCREEN+row*40+1,x
sta SCREEN+row*40,x
lda $d800+row*40+1,x
sta $d800+row*40,x
}
inx
cpx #39
beq fill_column
jmp copy_columns
fill_column:
!for row,0,24 {
lda next_column+row
sta SCREEN+row*40+39
lda next_colour+row
sta $d800+row*40+39
}
rts
next_column: !fill 25,32
next_colour: !fill 25,1Integration contract
This version owns zero-page $FB, screen RAM $0400–$07E7, colour RAM $D800–$DBE7, and the two 25-byte column buffers. Your VIC bank and $D018 must display that screen. Use text mode with matching colour semantics. Clear decimal mode before calling. A, X and flags change; Y survives. Interrupts must preserve the routine’s working registers and must not change its state while it runs.
On the coarse step it copies 975 character bytes and 975 colours, then fills 25 edge cells of each. That is substantial work. Calling it after a raster wait alone does not make those writes fit safely before the beam reaches them. Budget the whole update, reduce the playfield, or use a separately designed buffering scheme. The routine supplies a correct memory shift, not an entire map streamer or a promise of smooth presentation under every workload.
Related: Raster splits, VIC-II.
Explore the cross-platform scrolling family for camera coordinates, seams and implementations on other machines.