Skip to content

Fractional Position Updates on Amiga

A quarter-pixel step should eventually move the sprite a whole pixel.

A quarter-pixel step should eventually move the sprite a whole pixel. Keep the fraction between updates and four small steps add up; throw it away each time and the sprite never gets there.

Amiga shows the working diagnostic row 00 65 40 00 00 00 00 00.
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 POS_LO, POS_HI, VEL_LO, VEL_HI as 4 contiguous private bytes in that order. Include the helper once. Call fractional_position_init at the intended reset boundary: it clears every named byte, including input fields. Set the starting values afterwards, then call fractional_position_step for each intended service.

Initialise once, then set both position bytes and both velocity bytes. Call once per simulation update. Use POS_HI as the integer coordinate only where that coordinate range suits the renderer; retain POS_LO in the game state. The normal client adds 0.25 four times to position 100 and reaches exactly 101.

POS_LO/POS_HI hold an unsigned 8.8 position: the low byte counts 1/256 units and the high byte is the integer part. VEL_LO/VEL_HI hold a signed two’s-complement 8.8 displacement. Step adds the whole two-byte displacement to the whole position, preserving both velocity bytes. Position wraps modulo 65,536 raw units by design; this helper does not clamp a world boundary.

Reusable initialisation and service

component.inc
; SPDX-License-Identifier: MIT
; Fractional Position Updates. Contiguous private bytes: POS_LO, POS_HI, VEL_LO, VEL_HI.
; init clears all fields; set inputs afterwards. No I/O, waits or IRQ ownership.
; D0-D3/A0-A1/CCR change; D4-D7/A2-A6 survive.
fractional_position_init:
    clr.b POS_LO
    clr.b POS_HI
    clr.b VEL_LO
    clr.b VEL_HI
    rts
fractional_position_step:
    moveq #0,d0
    move.b POS_HI,d0
    lsl.w #8,d0
    move.b POS_LO,d0
    moveq #0,d1
    move.b VEL_HI,d1
    lsl.w #8,d1
    move.b VEL_LO,d1
    add.w d1,d0
    move.b d0,POS_LO
    lsr.w #8,d0
    move.b d0,POS_HI
    rts
component_end:

State, registers and ownership

Four persistent/input bytes. A displacement of −0.5 is FF80 in hexadecimal, stored low byte 80 then high byte FF. One raw unit is 1/256 of your chosen world unit; these are units per service call, not per second.

D0–D3/A0–A1/CCR may change; D4–D7/A2–A6 survive. A direct BSR/JSR needs four return-address stack bytes.

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 bootable caller takes over Copper/bitplane/interrupt/DMA resources and explicitly disarms all eight sprites. It does not restore Workbench. These are caller presentation choices, not dependencies of the reusable helper.

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 fractional_position_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
    section code,code_c
CUSTOM = 0
BROKEN = 0
COYOTE_STEPS = 4
BUFFER_STEPS = 4
TEXT_CAP = 8
PROPERTY_COUNT = 16
STREAM_BYTES = 13
CRC_POLY = 7
    include "caller.inc"
    include "exercise.inc"
component_start:
    include "component.inc"
    include "data.inc"
    include "cases.inc"
    even
    include "display.inc"
    include "../../framework/turn-deadline/font.inc"
code_end:
STATE:
POS_LO: dc.b 0
POS_HI: dc.b 0
VEL_LO: dc.b 0
VEL_HI: dc.b 0
    even
ready: dc.w 0
preview: dcb.b 8,0
OUTPUT: dcb.b 1300,0
    even
signature: dc.b "198XPLAYTOOLS00",0
    even
copperlist:
    dc.w $08e,$2c81,$090,$2cc1,$092,$0038,$094,$00d0
    dc.w $100,$1200,$102,0,$104,0,$108,0,$10a,0
    dc.w $180,$012,$182,$fff
copbpl:
    dc.w $0e0,0,$0e2,0
    dc.w $ffff,$fffe
plane: dcb.b 10240,0

A separate client retaining state between calls

exercise.inc, excerpt
preview_rules:
    ifne CUSTOM
    move.b #0,POS_LO
    move.b #100,POS_HI
    move.b #128,VEL_LO
    move.b #0,VEL_HI
    ifne BROKEN
    move.b #0,POS_LO
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    ifne BROKEN
    move.b #0,POS_LO
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    ifne BROKEN
    move.b #0,POS_LO
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    ifne BROKEN
    move.b #0,POS_LO
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    else
    move.b #0,POS_LO
    move.b #100,POS_HI
    move.b #64,VEL_LO
    move.b #0,VEL_HI
    ifne BROKEN
    move.b #0,POS_LO
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    ifne BROKEN
    move.b #0,POS_LO
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    ifne BROKEN
    move.b #0,POS_LO
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    ifne BROKEN
    move.b #0,POS_LO
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    endif
    rts

The display fields are POS_LO, POS_HI, VEL_LO, VEL_HI; remaining display fields are zero. Each is one hexadecimal byte. The normal row is 00 65 40 00 00 00 00 00. 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 uses 0.5 per update instead of 0.25. Four calls reach 102. The representation and helper code are unchanged.

Maintained custom configuration or caller

custom.asm
    section code,code_c
CUSTOM = 1
BROKEN = 0
COYOTE_STEPS = 2
BUFFER_STEPS = 2
TEXT_CAP = 4
PROPERTY_COUNT = 16
STREAM_BYTES = 13
CRC_POLY = 49
    include "caller.inc"
    include "exercise.inc"
component_start:
    include "component.inc"
    include "data.inc"
    include "cases.inc"
    even
    include "display.inc"
    include "../../framework/turn-deadline/font.inc"
code_end:
STATE:
POS_LO: dc.b 0
POS_HI: dc.b 0
VEL_LO: dc.b 0
VEL_HI: dc.b 0
    even
ready: dc.w 0
preview: dcb.b 8,0
OUTPUT: dcb.b 1300,0
    even
signature: dc.b "198XPLAYTOOLS00",0
    even
copperlist:
    dc.w $08e,$2c81,$090,$2cc1,$092,$0038,$094,$00d0
    dc.w $100,$1200,$102,0,$104,0,$108,0,$10a,0
    dc.w $180,$012,$182,$fff
copbpl:
    dc.w $0e0,0,$0e2,0
    dc.w $ffff,$fffe
plane: dcb.b 10240,0

The custom row is 00 66 80 00 00 00 00 00. 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 POS_LO before every call. The quarter-step is always restarted from a whole unit, so four calls leave position 100.25 instead of 101. Retain the fractional byte between calls; discarding it only for drawing is harmless, discarding it from state is not.

Select the deliberately wrong client

wrong.asm
    section code,code_c
CUSTOM = 0
BROKEN = 1
COYOTE_STEPS = 4
BUFFER_STEPS = 4
TEXT_CAP = 8
PROPERTY_COUNT = 16
STREAM_BYTES = 13
CRC_POLY = 7
    include "caller.inc"
    include "exercise.inc"
component_start:
    include "component.inc"
    include "data.inc"
    include "cases.inc"
    even
    include "display.inc"
    include "../../framework/turn-deadline/font.inc"
code_end:
STATE:
POS_LO: dc.b 0
POS_HI: dc.b 0
VEL_LO: dc.b 0
VEL_HI: dc.b 0
    even
ready: dc.w 0
preview: dcb.b 8,0
OUTPUT: dcb.b 1300,0
    even
signature: dc.b "198XPLAYTOOLS00",0
    even
copperlist:
    dc.w $08e,$2c81,$090,$2cc1,$092,$0038,$094,$00d0
    dc.w $100,$1200,$102,0,$104,0,$108,0,$10a,0
    dc.w $180,$012,$182,$fff
copbpl:
    dc.w $0e0,0,$0e2,0
    dc.w $ffff,$fffe
plane: dcb.b 10240,0
Amiga shows the deliberate fault row 40 64 40 00 00 00 00 00.
The wrong client completes with 40 64 40 00 00 00 00 00. 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 68 code bytes, 0 immutable data bytes and 4 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.

One two-byte addition, with no rendering or wait. The 6502 carries from the low byte; Z80 adds a word; 68000 packs the byte binding into a word. Integer movement is smaller when fractional motion is unnecessary, but cannot accumulate these sub-unit steps.

An 8.8 unsigned position covers 0–255.996 units. C64 and Amiga displays can extend beyond 255 horizontal pixels, so do not mistake this for a complete screen-coordinate format. Wider worlds need a wider integer part. The Amiga Flock lessons already use 16.16 positions; that is a useful wider alternative rather than a reason to squeeze those games into this byte-oriented interface. Clamp or wrap at the game’s actual bounds as a separate policy.

Four native runs use A500 OCS PAL with Kickstart 1.3 and an OFS cold-boot disk; Asm198x 0.0.72 and Emu198x 0.44.2, with Build198x 0.2.9 for disk mastering. 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.