Skip to content

Bounded Signed Acceleration on C64

Gravity should keep adding speed until the chosen terminal velocity, not turn a fast fall into an upward launch when an integer overflows.

Gravity should keep adding speed until the chosen terminal velocity, not turn a fast fall into an upward launch when an integer overflows. This helper adds signed acceleration and clamps the result to an explicit symmetric speed limit.

C64 shows the working diagnostic row 00 FE 80 FF 00 02 00 01.
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 VEL_LO, VEL_HI, ACC_LO, ACC_HI, LIMIT_LO, LIMIT_HI, CLIPPED, VALID as 8 contiguous private bytes in that order. Include the helper once. Call bounded_acceleration_init at the intended reset boundary: it clears every named byte, including input fields. Set the starting values afterwards, then call bounded_acceleration_step for each intended service.

Initialise once, set the starting velocity, acceleration and limit, then service before the position update if that is your chosen integrator order. Treat that order as part of the game’s behaviour. The normal four-call client accelerates by −0.5 per call to a limit of 2.0 and finishes at −2.0.

VEL_LO/VEL_HI and ACC_LO/ACC_HI are signed two’s-complement 8.8 words, stored low byte first. LIMIT_LO/LIMIT_HI are a nonnegative raw magnitude from 0 to 32,767. Step clears CLIPPED and VALID, rejects a limit with its high bit set, and otherwise adds velocity and acceleration in a logically wider signed range before clamping to −LIMIT…+LIMIT. VALID becomes 1; CLIPPED becomes 1 only when the wide sum was outside that interval. An invalid limit preserves velocity and leaves both flags zero. Acceleration and limit bytes survive.

Reusable initialisation and service

6502.inc
; SPDX-License-Identifier: MIT
; Bounded Signed Acceleration. Contiguous private bytes: VEL_LO, VEL_HI, ACC_LO, ACC_HI, LIMIT_LO, LIMIT_HI, CLIPPED, VALID.
; 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_acceleration_init:
    lda #0
    sta VEL_LO
    sta VEL_HI
    sta ACC_LO
    sta ACC_HI
    sta LIMIT_LO
    sta LIMIT_HI
    sta CLIPPED
    sta VALID
    rts
bounded_acceleration_step:
    lda #0
    sta CLIPPED
    sta VALID
    lda LIMIT_HI
    bpl accel_valid
    rts
accel_valid:
    inc VALID
    lda VEL_LO
    clc
    adc ACC_LO
    sta VEL_LO
    lda VEL_HI
    adc ACC_HI
    sta VEL_HI
    bvc accel_normal
    lda ACC_HI
    bmi accel_negative_cap
    jmp accel_positive_cap
accel_normal:
    lda VEL_HI
    bmi accel_negative
    cmp LIMIT_HI
    bcc accel_done
    bne accel_positive_cap
    lda VEL_LO
    cmp LIMIT_LO
    bcc accel_done
    beq accel_done
accel_positive_cap:
    lda LIMIT_LO
    sta VEL_LO
    lda LIMIT_HI
    sta VEL_HI
    lda #1
    sta CLIPPED
    rts
accel_negative:
    lda VEL_LO
    eor #255
    clc
    adc #1
    pha
    lda VEL_HI
    eor #255
    adc #0
    tax
    pla
    cpx LIMIT_HI
    bcc accel_done
    bne accel_negative_cap
    cmp LIMIT_LO
    bcc accel_done
    beq accel_done
accel_negative_cap:
    lda LIMIT_LO
    eor #255
    clc
    adc #1
    sta VEL_LO
    lda LIMIT_HI
    eor #255
    adc #0
    sta VEL_HI
    lda #1
    sta CLIPPED
accel_done:
    rts
component_end:

State, registers and ownership

Eight bytes: two for velocity, two for acceleration, two for a magnitude limit, and two replaced status flags. The symmetric limit cannot be 32,768 because the positive signed endpoint cannot represent it. Initial velocity may be anywhere in the signed 16-bit range; a valid call clamps the resulting sum.

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. This helper can also use one temporary stack byte, so allow three bytes for its 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_acceleration_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
VEL_LO=$80
VEL_HI=$81
ACC_LO=$82
ACC_HI=$83
LIMIT_LO=$84
LIMIT_HI=$85
CLIPPED=$86
VALID=$87
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 #128
    sta ACC_LO
    lda #255
    sta ACC_HI
    lda #0
    sta LIMIT_LO
    lda #1
    sta LIMIT_HI
!if BROKEN {
    lda #0
    sta ACC_HI
    jsr checked_step
} else {
    jsr checked_step
}
    jsr emit_state
!if BROKEN {
    lda #0
    sta ACC_HI
    jsr checked_step
} else {
    jsr checked_step
}
    jsr emit_state
!if BROKEN {
    lda #0
    sta ACC_HI
    jsr checked_step
} else {
    jsr checked_step
}
    jsr emit_state
!if BROKEN {
    lda #0
    sta ACC_HI
    jsr checked_step
} else {
    jsr checked_step
}
    jsr emit_state
} else {
    lda #128
    sta ACC_LO
    lda #255
    sta ACC_HI
    lda #0
    sta LIMIT_LO
    lda #2
    sta LIMIT_HI
!if BROKEN {
    lda #0
    sta ACC_HI
    jsr checked_step
} else {
    jsr checked_step
}
    jsr emit_state
!if BROKEN {
    lda #0
    sta ACC_HI
    jsr checked_step
} else {
    jsr checked_step
}
    jsr emit_state
!if BROKEN {
    lda #0
    sta ACC_HI
    jsr checked_step
} else {
    jsr checked_step
}
    jsr emit_state
!if BROKEN {
    lda #0
    sta ACC_HI
    jsr checked_step
} else {
    jsr checked_step
}
    jsr emit_state
}
    rts

The display fields are VEL_LO, VEL_HI, ACC_LO, ACC_HI, LIMIT_LO, LIMIT_HI, CLIPPED, VALID. Each is one hexadecimal byte. The normal row is 00 FE 80 FF 00 02 00 01. 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 custom client lowers the limit from 2.0 to 1.0. Continued acceleration now saturates at −1.0 and sets CLIPPED on the later calls. A zero limit is also a valid request to hold velocity at zero.

Maintained custom configuration or caller

custom.asm
VEL_LO=$80
VEL_HI=$81
ACC_LO=$82
ACC_HI=$83
LIMIT_LO=$84
LIMIT_HI=$85
CLIPPED=$86
VALID=$87
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 00 FF 80 FF 00 01 01 01. 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 clears ACC_HI. The intended FF80 displacement (−0.5) becomes 0080 (+0.5), so the object accelerates the wrong way. Supply the signed high byte rather than zero-extending a negative value.

Select the deliberately wrong client

wrong.asm
VEL_LO=$80
VEL_HI=$81
ACC_LO=$82
ACC_HI=$83
LIMIT_LO=$84
LIMIT_HI=$85
CLIPPED=$86
VALID=$87
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 00 02 80 00 00 02 00 01.
The wrong client completes with 00 02 80 00 00 02 00 01. 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 135 code bytes, 0 immutable data bytes and 8 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.

The 6502 examines signed overflow after the high-byte ADC; the Z80 uses ADC HL,DE because ADD HL,DE does not provide the same signed-overflow result; the 68000 sign-extends words into a long intermediate. Comparisons and clamping take bounded paths. No floating-point unit is used.

This models acceleration over fixed simulation updates. A variable time step needs explicit scaling, wider intermediates and a rounding policy. Drag, collision response and different upward/downward limits are separate rules. If parameters are prevalidated, an unchecked internal variant may save work, but it is a different contract and is not the qualified implementation here.

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.