Skip to content

vram_write()/vram_read() with size 0 transfer 65536 bytes — undocumented #6

Description

@killerdevildog

vram_write() and vram_read() test their length counter after the transfer body, so a size of 0 underflows to $ffff and the routine transfers 64 KB. The header documents no minimum length.

The loop

_vram_write (neslib.sinc L769-L780) and _vram_read (neslib.sinc L731-L742) share this tail:

@2:

	lda <TEMP
	bne @3
	dec <TEMP+1

@3:

	dec <TEMP
	lda <TEMP
	ora <TEMP+1
	bne @1

Entering with TEMP/TEMP+1 both 0, one byte has already been transferred before the counter is looked at. lda <TEMP is 0, so dec <TEMP+1 takes the high byte to $ff and dec <TEMP takes the low byte to $ff. ora is non-zero, so the loop runs another 65535 times.

Effect

  • vram_write(src, 0) — 65536 stores to $2007. The source pointer wraps the address space, so this sprays 64 KB of arbitrary bytes into VRAM.
  • vram_read(dst, 0) — 65536 loads from $2007 stored through dst, which wraps and overwrites all of RAM including the 6502 stack at $0100-$01ff. The routine's own return address is destroyed, so it never returns.

Measurements

I linked a minimal ROM against the library (cc65 2.19, nes.cfg from the cnrom sample) and ran the resulting binary, counting accesses to $2007:

vram_write(src, size=5)   ->     5 PPU_DATA writes   (sanity check)
vram_write(src, size=0)   -> 65536 PPU_DATA writes
vram_read(dst,  size=0)   -> never returns; clobbers $0000..$7fff,
                             including the stack at $0100-$01ff

Sizes 1, 2, 5, 255, 256, 257, 960, 1024 and 4096 all transfer correctly. Only 0 is affected.

Request

The comment above these functions covers the rendering requirement but says nothing about length. A computed length can legitimately be 0 at a call site — a variable-length string that happens to be empty, for instance — so it would help to either document that size must be >= 1, or reject it. Documenting it is fine by me; what makes this one awkward is that the failure is silent and lands nowhere near its cause.

If a guard is preferred instead, popping the argument first keeps the cc65 stack balanced:

 	jsr popax
 	sta <TEMP+2
 	stx <TEMP+3
 
+	lda <TEMP
+	ora <TEMP+1
+	beq @4
+
 	ldy #0
 
 @1:
@@
 	ora <TEMP+1
 	bne @1
 
+@4:
+
 	rts

The same applies to _vram_read, placed before the priming lda PPU_DATA. With it applied both zero cases become 0 transfers and the sizes listed above are unchanged.

This looks inherited from the original rather than introduced here — nesdoug's fork carries the identical loop.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions