Skip to content

Decode Bounded RLE Data on C64

Repeated tiles or pixels can be stored as a count followed by a value.

Repeated tiles or pixels can be stored as a count followed by a value. Decoding is small, but trusting a damaged run count can overwrite the next object in RAM. This version checks both ends and tells the caller exactly how much output was produced.

C64 shows the working diagnostic row 06 00 00 06 08 41 41 41.
A native completed caller shows its state as hexadecimal bytes. The field order below explains the row; the full packet retains every call.

Include, initialise and call

Reserve WRITTEN, ERROR, INPUT_START, INPUT_END, OUTPUT_LIMIT, BUF0, BUF1, BUF2, BUF3, BUF4, BUF5, BUF6, BUF7 as 13 contiguous private bytes in that order. Include the helper once. Call bounded_rle_init at the intended reset boundary: it clears every named byte, including input fields. Set the starting values afterwards, then call bounded_rle_step for each intended service.

Initialise once, set the selected encoded span and available output length, then service. Check ERROR before accepting an asset as complete. On a decoding error the buffer contains a valid decoded prefix of length WRITTEN; it is not rolled back. Bytes after that prefix are preserved. A consumer that wants an all-or-nothing asset should decode into staging memory and commit only on success.

INPUT_START and INPUT_END are byte offsets into caller-owned rle_stream, with INPUT_START ≤ INPUT_END ≤ STREAM_BYTES. OUTPUT_LIMIT is 0–8 for the reserved eight-byte output buffer. Step replaces WRITTEN and ERROR, preserves the input parameters and decodes count/value pairs. ERROR 0 means the selected input span finished; 1 means invalid input/output bounds; 2 means a truncated pair; 3 means a zero run; 4 means output capacity was reached before the selected stream finished. WRITTEN counts only bytes actually stored.

Reusable initialisation and service

6502.inc
; SPDX-License-Identifier: MIT
; Decode Bounded RLE Data. Contiguous private bytes: WRITTEN, ERROR, INPUT_START, INPUT_END, OUTPUT_LIMIT, BUF0, BUF1, BUF2, BUF3, BUF4, BUF5, BUF6, BUF7.
; init clears all fields; set inputs afterwards. No I/O, waits or IRQ ownership.
; A/X/flags change; Y and decimal mode survive. D=0 for binary arithmetic.
bounded_rle_init:
    lda #0
    sta WRITTEN
    sta ERROR
    sta INPUT_START
    sta INPUT_END
    sta OUTPUT_LIMIT
    sta BUF0
    sta BUF1
    sta BUF2
    sta BUF3
    sta BUF4
    sta BUF5
    sta BUF6
    sta BUF7
    rts
bounded_rle_step:
    tya
    pha
    lda #0
    sta WRITTEN
    sta ERROR
    lda INPUT_END
    cmp #STREAM_BYTES+1
    bcs rle_bounds
    lda INPUT_START
    cmp INPUT_END
    bcc rle_check_output
    beq rle_check_output
rle_bounds:
    lda #1
    jmp rle_error
rle_check_output:
    lda OUTPUT_LIMIT
    cmp #9
    bcs rle_bounds
    ldx INPUT_START
rle_pair:
    cpx INPUT_END
    beq rle_done
    txa
    clc
    adc #1
    cmp INPUT_END
    bcs rle_truncated
    lda rle_stream,x
    beq rle_zero
    tay
    inx
    txa
    pha
    lda rle_stream,x
    ldx WRITTEN
rle_copy:
    cpx OUTPUT_LIMIT
    bcs rle_full
    sta BUF0,x
    inx
    dey
    bne rle_copy
    stx WRITTEN
    pla
    tax
    inx
    jmp rle_pair
rle_full:
    stx WRITTEN
    pla
    lda #4
    jmp rle_error
rle_truncated:
    lda #2
    jmp rle_error
rle_zero:
    lda #3
rle_error:
    sta ERROR
rle_done:
    pla
    tay
    rts
component_end:

Caller-owned immutable lookup/encoded data

data.inc
data_start:
rle_stream:
    !byte 3,65,2,66,1,67,0,88,5,68,1,69,2
data_end:

State, registers and ownership

Five control/input/output bytes followed by BUF0–BUF7. The sample has thirteen immutable input bytes, including malformed examples. Count zero is rejected; there is no terminator convention or implicit end beyond INPUT_END. A zero-length selected span succeeds with zero output. The routine can stop partway through a run when capacity is exhausted.

A/X/flags may change; Y and decimal mode survive. Clear decimal mode before binary arithmetic; the caller uses CLD. A direct call needs two return-address stack bytes. Saving Y and the input offset adds up to two temporary stack bytes: allow four bytes for the helper call, excluding its caller and interrupts.

The implementation binds fixed symbols and has one mutation owner. Independent users need separate state blocks and scoped/renamed labels, including routine labels; sharing the same bindings means sharing the state. Do not let an interrupt and the main loop mutate the same block without an explicit ownership policy.

The standalone PRG owns screen/font/colour RAM, disables interrupt sources and does not restore BASIC. Its private scratch uses $70–$76 and the state binding starts at $80; its diagnostic output starts at $C000.

A caller you can adapt

The buildable program supplies bindings and include order. Its separate client retains state between calls. checked_step calls the public bounded_rle_step entry and checks register sentinels; emit_state records diagnostics. Your game can call the public entry directly, remove the diagnostics and consume the documented outputs.

Bindings, buildable caller and include order

demo.asm
WRITTEN=$80
ERROR=$81
INPUT_START=$82
INPUT_END=$83
OUTPUT_LIMIT=$84
BUF0=$85
BUF1=$86
BUF2=$87
BUF3=$88
BUF4=$89
BUF5=$8a
BUF6=$8b
BUF7=$8c
STATE=$80
input_ptr=$70
cases_left=$72
ptr=$74
saved_y=$76
OUTPUT=$c000
preview=OUTPUT+1400
ready=OUTPUT+1408
CUSTOM=0
BROKEN=0
COYOTE_STEPS=4
BUFFER_STEPS=4
TEXT_CAP=8
PROPERTY_COUNT=16
STREAM_BYTES=13
CRC_POLY=7
!source "caller.inc"
!source "exercise.inc"
component_start:
!source "component.inc"
!source "data.inc"
!source "cases.inc"
hex_font:
    !byte 0,0,0,0,0,0,0,0,0,56,40,40,40,56,0,0,0,16,48,16,16,56,0,0,0,56,8,56,32,56,0,0,0,56,8,56,8,56,0,0,0,40,40,56,8,8,0,0,0,56,32,56,8,56,0,0,0,56,32,56,40,56,0,0,0,56,8,8,8,8,0,0,0,56,40,56,40,56,0,0,0,56,40,56,8,56,0,0,0,16,40,56,40,40,0,0,0,48,40,48,40,48,0,0,0,56,32,32,32,56,0,0,0,48,40,40,40,48,0,0,0,56,32,48,32,56,0,0,0,56,32,48,32,32,0,0
code_end:

A separate client retaining state between calls

exercise.inc, excerpt
preview_rules:
!if CUSTOM {
    lda #0
    sta INPUT_START
    lda #6
    sta INPUT_END
    lda #5
    sta OUTPUT_LIMIT
!if BROKEN {
    jsr checked_step
    lda OUTPUT_LIMIT
    sta WRITTEN
} else {
    jsr checked_step
}
    jsr emit_state
} else {
    lda #0
    sta INPUT_START
    lda #6
    sta INPUT_END
    lda #8
    sta OUTPUT_LIMIT
!if BROKEN {
    jsr checked_step
    lda OUTPUT_LIMIT
    sta WRITTEN
} else {
    jsr checked_step
}
    jsr emit_state
}
    rts

The display fields are WRITTEN, ERROR, INPUT_START, INPUT_END, OUTPUT_LIMIT, BUF0, BUF1, BUF2; later state bytes are retained in the full packet. Each is one hexadecimal byte. The normal row is 06 00 00 06 08 41 41 41. This is a completed state observation, not a recording of an actual character jumping, a camera scrolling or an asset being displayed.

Make it your own

The normal caller decodes the first six input bytes into AAABBC, six output bytes. The custom caller offers only five output slots: AAABB is produced and ERROR 4 reports the unfinished selected stream. Treating capacity as the amount written would hide the normal caller’s two unused slots.

Maintained custom configuration or caller

custom.asm
WRITTEN=$80
ERROR=$81
INPUT_START=$82
INPUT_END=$83
OUTPUT_LIMIT=$84
BUF0=$85
BUF1=$86
BUF2=$87
BUF3=$88
BUF4=$89
BUF5=$8a
BUF6=$8b
BUF7=$8c
STATE=$80
input_ptr=$70
cases_left=$72
ptr=$74
saved_y=$76
OUTPUT=$c000
preview=OUTPUT+1400
ready=OUTPUT+1408
CUSTOM=1
BROKEN=0
COYOTE_STEPS=2
BUFFER_STEPS=2
TEXT_CAP=4
PROPERTY_COUNT=16
STREAM_BYTES=13
CRC_POLY=49
!source "caller.inc"
!source "exercise.inc"
component_start:
!source "component.inc"
!source "data.inc"
!source "cases.inc"
hex_font:
    !byte 0,0,0,0,0,0,0,0,0,56,40,40,40,56,0,0,0,16,48,16,16,56,0,0,0,56,8,56,32,56,0,0,0,56,8,56,8,56,0,0,0,40,40,56,8,8,0,0,0,56,32,56,8,56,0,0,0,56,32,56,40,56,0,0,0,56,8,8,8,8,0,0,0,56,40,56,40,56,0,0,0,56,40,56,8,56,0,0,0,16,40,56,40,40,0,0,0,48,40,48,40,48,0,0,0,56,32,32,32,56,0,0,0,48,40,40,40,48,0,0,0,56,32,48,32,56,0,0,0,56,32,48,32,32,0,0
code_end:

The custom row is 05 04 00 06 05 41 41 41. These records qualify the named builds and inputs; another layout, format or input domain needs explicit bounds and fresh execution evidence.

Common mistake and repair

The wrong client replaces WRITTEN with OUTPUT_LIMIT after decoding. The normal six-byte asset is now advertised as eight bytes, including two bytes the decoder never produced. Publish the returned length, and check the returned error; capacity is not output length.

Select the deliberately wrong client

wrong.asm
WRITTEN=$80
ERROR=$81
INPUT_START=$82
INPUT_END=$83
OUTPUT_LIMIT=$84
BUF0=$85
BUF1=$86
BUF2=$87
BUF3=$88
BUF4=$89
BUF5=$8a
BUF6=$8b
BUF7=$8c
STATE=$80
input_ptr=$70
cases_left=$72
ptr=$74
saved_y=$76
OUTPUT=$c000
preview=OUTPUT+1400
ready=OUTPUT+1408
CUSTOM=0
BROKEN=1
COYOTE_STEPS=4
BUFFER_STEPS=4
TEXT_CAP=8
PROPERTY_COUNT=16
STREAM_BYTES=13
CRC_POLY=7
!source "caller.inc"
!source "exercise.inc"
component_start:
!source "component.inc"
!source "data.inc"
!source "cases.inc"
hex_font:
    !byte 0,0,0,0,0,0,0,0,0,56,40,40,40,56,0,0,0,16,48,16,16,56,0,0,0,56,8,56,32,56,0,0,0,56,8,56,8,56,0,0,0,40,40,56,8,8,0,0,0,56,32,56,8,56,0,0,0,56,32,56,40,56,0,0,0,56,8,8,8,8,0,0,0,56,40,56,40,56,0,0,0,56,40,56,8,56,0,0,0,16,40,56,40,40,0,0,0,48,40,48,40,48,0,0,0,56,32,32,32,56,0,0,0,48,40,40,40,48,0,0,0,56,32,48,32,56,0,0,0,56,32,48,32,32,0,0
code_end:
C64 shows the deliberate fault row 08 00 00 06 08 41 41 41.
The wrong client completes with 08 00 00 06 08 41 41 41. Its code and the explanation above identify the cause; the capture observes the result.

The BROKEN branch is visible in the client. repaired.asm restores the normal selection; its executable matches demo exactly. Spectrum tape filenames may differ while loaded CODE remains identical. The fault is a labelled experiment, not a suggested alternative.

Cost, limits and verification

The measured layout has 128 code bytes, 13 immutable data bytes and 13 private state bytes. Alignment, stack usage, diagnostic tables and display machinery are additional. The extents come from the loaded program’s symbols, not from counting source lines.

Work is bounded by the selected input span and output capacity. Each pair consumes two input bytes; each produced byte advances the output count. The qualified output has at most eight stores, with no allocation or hidden display upload. Compression trades encoded storage against decode work and may make alternating data larger.

This is a local classic count/value codec example, not a new project-wide asset format. Longer assets need an appropriately sized output span and possibly wider offsets, plus independently checked bounds. Packed literals, streaming continuation and precompressed build-tool assets are separate extensions. Do not turn zero runs into an unchecked infinite loop or infer success from a full buffer.

Four native runs use PAL C64 with stock BASIC/KERNAL PRG startup and RUN; Asm198x 0.0.72 and Emu198x 0.44.2. Each checks 64 boundary/varied/error cases, dirty initialisation and every state of a separate sequential client against an independent integer model. This is not exhaustive coverage of every possible byte combination. Source/tool/firmware hashes, immutable loaded code/data, native startup/completion, preserved-register sentinels, helper/data sizes, screenshot bytes/pixels and exact repair are checked. Deliberately altered observations must fail. Firmware is hashed, never distributed.

Related technique · Maths for Games · The Craft.

Source and native evidence.