Skip to content

Format a 16-bit Decimal Number on NES

A binary score is good for adding points.

A binary score is good for adding points. People would rather read 12345 than its two storage bytes. This formatter turns any unsigned 16-bit value into five decimal characters, ready for a text routine to place or translate into tile numbers.

NES shows the working result FF FF 36 35 35 33 35 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 VALUE_LO, VALUE_HI, DIGIT0, DIGIT1, DIGIT2, DIGIT3, DIGIT4, WORK_LO, WORK_HI as 9 contiguous private bytes in that order. Include the helper once. Call decimal_format_init at the deliberate reset boundary: it clears all named bytes, including inputs. Set your starting values afterwards. Call decimal_format_step when this service is needed.

Bind all nine contiguous bytes, set the two value bytes and call step. Render the five output bytes afterwards. ASCII is a logical output contract: the C64 demo uses a prepared hex font and NES normally needs tile-number mapping, so these five bytes do not automatically become screen characters on either machine.

VALUE_LO/VALUE_HI contain an unsigned little-endian integer 0–65,535 and survive the call. Step writes exactly five ASCII bytes DIGIT0–DIGIT4 with leading zeroes, no terminator, and finishes with WORK_LO/WORK_HI zero. The work bytes belong to the helper, not to a second caller running concurrently.

Reusable initialisation and service

6502.inc
; SPDX-License-Identifier: MIT
; Format a 16-bit Decimal Number. Bind these contiguous private bytes: VALUE_LO, VALUE_HI, DIGIT0, DIGIT1, DIGIT2, DIGIT3, DIGIT4, WORK_LO, WORK_HI.
; init clears the whole block; step uses current inputs. No I/O, waits or IRQ owner.
; A/X/flags may change; Y and decimal mode survive. Binary arithmetic requires D=0.
decimal_format_init:
    lda #0
    sta VALUE_LO
    sta VALUE_HI
    sta DIGIT0
    sta DIGIT1
    sta DIGIT2
    sta DIGIT3
    sta DIGIT4
    sta WORK_LO
    sta WORK_HI
    rts
decimal_format_step:
    lda VALUE_LO
    sta WORK_LO
    lda VALUE_HI
    sta WORK_HI
    lda #48
    sta DIGIT0
format_0:
    lda WORK_HI
    cmp #39
    bcc format_next_0
    bne format_sub_0
    lda WORK_LO
    cmp #16
    bcc format_next_0
format_sub_0:
    lda WORK_LO
    sec
    sbc #16
    sta WORK_LO
    lda WORK_HI
    sbc #39
    sta WORK_HI
    inc DIGIT0
    jmp format_0
format_next_0:
    lda #48
    sta DIGIT1
format_1:
    lda WORK_HI
    cmp #3
    bcc format_next_1
    bne format_sub_1
    lda WORK_LO
    cmp #232
    bcc format_next_1
format_sub_1:
    lda WORK_LO
    sec
    sbc #232
    sta WORK_LO
    lda WORK_HI
    sbc #3
    sta WORK_HI
    inc DIGIT1
    jmp format_1
format_next_1:
    lda #48
    sta DIGIT2
format_2:
    lda WORK_HI
    cmp #0
    bcc format_next_2
    bne format_sub_2
    lda WORK_LO
    cmp #100
    bcc format_next_2
format_sub_2:
    lda WORK_LO
    sec
    sbc #100
    sta WORK_LO
    lda WORK_HI
    sbc #0
    sta WORK_HI
    inc DIGIT2
    jmp format_2
format_next_2:
    lda #48
    sta DIGIT3
format_3:
    lda WORK_HI
    cmp #0
    bcc format_next_3
    bne format_sub_3
    lda WORK_LO
    cmp #10
    bcc format_next_3
format_sub_3:
    lda WORK_LO
    sec
    sbc #10
    sta WORK_LO
    lda WORK_HI
    sbc #0
    sta WORK_HI
    inc DIGIT3
    jmp format_3
format_next_3:
    lda #48
    sta DIGIT4
format_4:
    lda WORK_HI
    cmp #0
    bcc format_next_4
    bne format_sub_4
    lda WORK_LO
    cmp #1
    bcc format_next_4
format_sub_4:
    lda WORK_LO
    sec
    sbc #1
    sta WORK_LO
    lda WORK_HI
    sbc #0
    sta WORK_HI
    inc DIGIT4
    jmp format_4
format_next_4:
    rts
component_end:

State and ownership

Two input bytes, five output characters and two scratch bytes. For example 65,535 produces ASCII bytes 36 35 35 33 35 in hexadecimal. Read DIGIT0–DIGIT4 as a counted five-byte span; there is no trailing zero to scan for.

A/X/flags may change; Y and decimal mode survive. Clear decimal mode before binary arithmetic; the standalone caller uses CLD. A direct call pushes a two-byte return address.

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 cartridge owns its background, CHR font and RAM layout. Its diagnostic packet is bounded below the end of the NES’s 2 KiB internal RAM; the component owns no PPU or audio 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
.segment "HEADER"
.byte "NES",$1a,2,1,0,0,0,0,0,0,0,0,0,0
VALUE_LO=$80
VALUE_HI=$81
DIGIT0=$82
DIGIT1=$83
DIGIT2=$84
DIGIT3=$85
DIGIT4=$86
WORK_LO=$87
WORK_HI=$88
STATE=$80
input_ptr=$70
cases_left=$72
ptr=$74
saved_y=$76
OUTPUT=$0200
preview=OUTPUT+1400
ready=OUTPUT+1408
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"
code_end:
.segment "VECTORS"
.word nmi,reset,irq
.segment "CHARS"
.include "../../input/controller-reading/edges-font.inc"
.res 8192-272,0

A separate client retaining state between calls

exercise.inc, excerpt
preview_rules:
.if CUSTOM
    lda #0
    sta VALUE_LO
    lda #16
    sta VALUE_HI
.if BROKEN
    lda #0
    sta VALUE_HI
    jsr checked_step
.else
    jsr checked_step
.endif
    jsr emit_state
.else
    lda #255
    sta VALUE_LO
    lda #255
    sta VALUE_HI
.if BROKEN
    lda #0
    sta VALUE_HI
    jsr checked_step
.else
    jsr checked_step
.endif
    jsr emit_state
.endif
    rts

The eight display fields follow VALUE_LO, VALUE_HI, DIGIT0, DIGIT1, DIGIT2, DIGIT3, DIGIT4, WORK_LO; the full packet also retains WORK_HI. Each field is a hexadecimal byte. The working row is FF FF 36 35 35 33 35 00. The output is a completed diagnostic state, not a recording of movement, input or sound hardware.

Make it your own

The custom caller formats 4,096 instead of 65,535, producing 04096. To hide leading zeroes, choose a presentation policy after formatting while keeping at least the final zero for a value of zero.

Maintained custom configuration or caller

custom.asm
.segment "HEADER"
.byte "NES",$1a,2,1,0,0,0,0,0,0,0,0,0,0
VALUE_LO=$80
VALUE_HI=$81
DIGIT0=$82
DIGIT1=$83
DIGIT2=$84
DIGIT3=$85
DIGIT4=$86
WORK_LO=$87
WORK_HI=$88
STATE=$80
input_ptr=$70
cases_left=$72
ptr=$74
saved_y=$76
OUTPUT=$0200
preview=OUTPUT+1400
ready=OUTPUT+1408
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"
code_end:
.segment "VECTORS"
.word nmi,reset,irq
.segment "CHARS"
.include "../../input/controller-reading/edges-font.inc"
.res 8192-272,0

The observed custom row is 00 10 30 34 30 39 36 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 VALUE_HI before formatting. The number 65,535 becomes 255, displayed as 00255. Copy both bytes from the score or counter; a correct formatter cannot recover a lost high byte.

Deliberately wrong client selection

wrong.asm
.segment "HEADER"
.byte "NES",$1a,2,1,0,0,0,0,0,0,0,0,0,0
VALUE_LO=$80
VALUE_HI=$81
DIGIT0=$82
DIGIT1=$83
DIGIT2=$84
DIGIT3=$85
DIGIT4=$86
WORK_LO=$87
WORK_HI=$88
STATE=$80
input_ptr=$70
cases_left=$72
ptr=$74
saved_y=$76
OUTPUT=$0200
preview=OUTPUT+1400
ready=OUTPUT+1408
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"
code_end:
.segment "VECTORS"
.word nmi,reset,irq
.segment "CHARS"
.include "../../input/controller-reading/edges-font.inc"
.res 8192-272,0
NES shows the deliberate fault result FF 00 30 30 32 35 35 00.
The wrong client completes and records FF 00 30 30 32 35 35 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 210 code bytes plus 9 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.

Five digit stages subtract 10,000, 1,000, 100, 10 and 1. Each stage needs at most nine successful subtractions, so 45 is a safe upper bound for successful subtractions, plus five terminating comparisons. There is no hardware division or screen write. Convert when the displayed value changes rather than every frame if that saves useful work.

Packed BCD can trade conversion work for more expensive score updates, and lookup tables trade ROM space for conversion speed. A divide-based implementation suits some CPUs better. Signs, decimal points and values beyond 65,535 need a different contract and output width.

Four native runs use NTSC NES with a mapper-0 cartridge, CHR-ROM diagnostic font, NMI and DMC disabled; Asm198x 0.0.72 and Emu198x 0.44.2. 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.