Skip to content

A Saturating 16-bit Score on Amiga

A coin worth 300 points should still be worth 300 when the low byte fills up.

A coin worth 300 points should still be worth 300 when the low byte fills up. This binary score uses two bytes and keeps the carry between them. It also makes the top of the range deliberate: the score stops at its cap instead of wrapping back to zero.

Amiga shows the working result FF FF FF FF 01 00 00 00.
The completed native caller shows hexadecimal state bytes. The field order below explains the result; the full verification packet retains every call.

Include, initialise and call

Bind SCORE_LO, SCORE_HI, ADD_LO, ADD_HI, CLIPPED as 5 contiguous private bytes in that order. Include the helper once. Call score_counter_init at the deliberate reset boundary: it clears all named bytes, including inputs. Set your starting values afterwards. Call score_counter_step when this service is needed.

Initialise at the start of a new session. Set both award bytes before each call; an award of 300 is low byte 44, high byte 1. Keep the score in binary for arithmetic and format a separate display copy. Call once for each scoring event, not once per overlapping frame unless that is the scoring rule.

SCORE_LO and SCORE_HI hold an unsigned little-endian score; ADD_LO and ADD_HI hold an unsigned award. The current score must be 0 through SCORE_CAP. Step adds the award with a wide intermediate, clamps to SCORE_CAP and sets CLIPPED to 1 only when the sum exceeds the cap. An exact hit on the cap leaves CLIPPED zero. The award bytes survive.

Reusable initialisation and service

component.inc
; SPDX-License-Identifier: MIT
; A Saturating 16-bit Score. Bind these contiguous private bytes: SCORE_LO, SCORE_HI, ADD_LO, ADD_HI, CLIPPED.
; init clears the whole block; step uses current inputs. No I/O, waits or IRQ owner.
; D0-D3/A0-A1/CCR may change; D4-D7/A2-A6 survive.
score_counter_init:
    clr.b SCORE_LO
    clr.b SCORE_HI
    clr.b ADD_LO
    clr.b ADD_HI
    clr.b CLIPPED
    rts
score_counter_step:
    clr.b CLIPPED
    moveq #0,d0
    move.b SCORE_HI,d0
    lsl.w #8,d0
    move.b SCORE_LO,d0
    moveq #0,d1
    move.b ADD_HI,d1
    lsl.w #8,d1
    move.b ADD_LO,d1
    add.l d1,d0
    cmp.l #SCORE_CAP,d0
    bls.s score_store
    move.l #SCORE_CAP,d0
    move.b #1,CLIPPED
score_store:
    move.b d0,SCORE_LO
    lsr.w #8,d0
    move.b d0,SCORE_HI
    rts
component_end:

State and ownership

The two SCORE bytes persist; the two ADD bytes are inputs; CLIPPED is a replaced overflow/cap pulse. Byte order is explicit on every CPU, including the big-endian 68000.

D0–D3/A0–A1/CCR may change; D4–D7/A2–A6 survive. A direct BSR/JSR uses four return-address stack bytes. The program is a 68000 implementation with no division instruction requirement.

This implementation has fixed symbol bindings and one mutation owner. Independent users need distinct state blocks and scoped/renamed bindings; including another copy with the same global labels is not sufficient. Sequential queries may reuse a scratch block only after the previous result has been consumed. Do not let an interrupt and the main loop mutate the same block without a separately designed ownership policy.

The standalone boot program takes over Copper/bitplane/interrupt/DMA resources, explicitly disarms all eight sprites, and does not restore Workbench. Those are diagnostic presentation choices; the helper itself owns no chipset registers.

A small caller you can adapt

The standalone program includes the helper and a separate client. Its read-only table exercises 96 valid inputs, then this client initialises once and retains state between calls. Replace the supplied input changes with your own game values; the display and packet machinery are diagnostics rather than dependencies of the component.

Buildable caller, bindings and include order

demo.asm
    section code,code_c
CUSTOM = 0
BROKEN = 0
SCORE_CAP = 65535
QUEUE_CAP = 4
REPEAT_DELAY = 4
REPEAT_RATE = 2
    include "caller.inc"
    include "exercise.inc"
component_start:
    include "component.inc"
    include "cases.inc"
    include "display.inc"
    include "../turn-deadline/font.inc"
code_end:
STATE:
SCORE_LO: dc.b 0
SCORE_HI: dc.b 0
ADD_LO: dc.b 0
ADD_HI: dc.b 0
CLIPPED: dc.b 0
    even
ready: dc.w 0
preview: dcb.b 8,0
OUTPUT: dcb.b 1300,0
    even
signature: dc.b "198XGAMEPLAY000",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 #250,SCORE_LO
    move.b #0,SCORE_HI
    move.b #44,ADD_LO
    move.b #1,ADD_HI
    ifne BROKEN
    move.b #0,ADD_HI
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    move.b #255,ADD_LO
    move.b #255,ADD_HI
    ifne BROKEN
    move.b #0,ADD_HI
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    else
    move.b #250,SCORE_LO
    move.b #0,SCORE_HI
    move.b #44,ADD_LO
    move.b #1,ADD_HI
    ifne BROKEN
    move.b #0,ADD_HI
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    move.b #255,ADD_LO
    move.b #255,ADD_HI
    ifne BROKEN
    move.b #0,ADD_HI
    bsr checked_step
    else
    bsr checked_step
    endif
    bsr emit_state
    endif
    rts

The eight display fields follow SCORE_LO, SCORE_HI, ADD_LO, ADD_HI, CLIPPED; unused display fields are zero. Each field is a hexadecimal byte. The working row is FF FF FF FF 01 00 00 00. The output is a completed diagnostic state, not a recording of movement, input or sound hardware.

Make it your own

The custom build sets SCORE_CAP to 999 instead of 65,535. It uses the same two-byte representation and enforces the smaller cap. The native case table keeps every starting score inside the chosen cap.

Maintained custom configuration or caller

custom.asm
    section code,code_c
CUSTOM = 1
BROKEN = 0
SCORE_CAP = 999
QUEUE_CAP = 2
REPEAT_DELAY = 6
REPEAT_RATE = 3
    include "caller.inc"
    include "exercise.inc"
component_start:
    include "component.inc"
    include "cases.inc"
    include "display.inc"
    include "../turn-deadline/font.inc"
code_end:
STATE:
SCORE_LO: dc.b 0
SCORE_HI: dc.b 0
ADD_LO: dc.b 0
ADD_HI: dc.b 0
CLIPPED: dc.b 0
    even
ready: dc.w 0
preview: dcb.b 8,0
OUTPUT: dcb.b 1300,0
    even
signature: dc.b "198XGAMEPLAY000",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 observed custom row is E7 03 FF FF 01 00 00 00. The retained records qualify these named configurations; a new input domain or larger layout needs its own bounds and fresh execution checks.

Common mistake and repair

The wrong client clears ADD_HI before awarding points. A 300-point award becomes 44; a 65,535-point award becomes 255. Supply both bytes together. Merely changing a display width cannot restore points that the arithmetic never received.

Deliberately wrong client selection

wrong.asm
    section code,code_c
CUSTOM = 0
BROKEN = 1
SCORE_CAP = 65535
QUEUE_CAP = 4
REPEAT_DELAY = 4
REPEAT_RATE = 2
    include "caller.inc"
    include "exercise.inc"
component_start:
    include "component.inc"
    include "cases.inc"
    include "display.inc"
    include "../turn-deadline/font.inc"
code_end:
STATE:
SCORE_LO: dc.b 0
SCORE_HI: dc.b 0
ADD_LO: dc.b 0
ADD_HI: dc.b 0
CLIPPED: dc.b 0
    even
ready: dc.w 0
preview: dcb.b 8,0
OUTPUT: dcb.b 1300,0
    even
signature: dc.b "198XGAMEPLAY000",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 result 25 02 FF 00 00 00 00 00.
The wrong client completes and records 25 02 FF 00 00 00 00 00. Compare its state with the working row and the explained failure.

The wrong branch is in the displayed client above, selected by BROKEN. repaired.asm selects the original client again. Its executable matches demo exactly; Spectrum tape filenames can differ while loaded CODE is identical. The bad caller is a labelled experiment, not an alternative recommended implementation.

Cost and verification

The measured helper occupies 102 code bytes plus 5 named state bytes in this layout. Return addresses, alignment, tables and display diagnostics are additional. The sizes cover init and step together, not the whole standalone program.

The addition and cap comparison use bounded paths. On 6502 the carry joins two byte additions; the Z80 uses a word addition and carry; the 68000 forms a long intermediate. Rendering and decimal conversion are separate costs.

A BCD score can simplify decimal display on some CPUs, but the NES CPU does not implement decimal arithmetic. Wider binary counters or milestone rewards need their own carry, cap and event policies. Saturation deliberately loses later awards; games that need lifetime totals should choose a wider representation.

Four native runs use A500 OCS PAL with Kickstart 1.3 and an OFS boot disk; Asm198x 0.0.72 and Emu198x 0.44.2, with Build198x 0.2.9 for disk mastering. Every run checks 96 table results, dirty initialisation and every state in a separate sequential trace against an independent integer model. This is boundary/varied-input qualification, not exhaustive coverage of every byte combination. Source/tool/firmware hashes, native startup and completion, preserved-register sentinels, immutable loaded code, actual helper extent, capture bytes and pixels, and exact repair are checked. Deliberately changed observations must fail. Firmware is hashed, never distributed.

Related reusable technique · Maths for Games · The Craft.

Source and native evidence.