Skip to content

Give an MSX turn a BIOS-clock deadline

Keep optional turn timing, buffered input and BIOS text output separate, using the maintained Z80 countdown.

Native caller after an automatic third turn: one expiry, timing enabled.
Two player actions, then one automatic turn. Recorded msx assembly caller in Emu198x 0.44.2.

Space spends one turn. Wait and nothing changes. T enables a deadline; after 240 BIOS ticks (about four seconds on the named NTSC configuration), a turn arrives automatically. T disables or starts a fresh duration; Escape returns to BASIC after release. The three hexadecimal bytes show turn, expiry count and enabled flag.

Build, load and reserve

Run make -C msx/patterns/assembly/framework/turn-deadline ASM=/path/to/asm198x. Enter the generated build/load.bas into the recorded stock MSX1 BIOS/BASIC. CLEAR 200,49151 reserves the upper area before READ/POKE writes the actual emitted bytes. RUN defines USR at $C000; allow the loader to finish, then SCREEN 0:WIDTH 40:CLS:A=USR(0) launches it. The verifier requires exact loaded-byte equality before entry. This is a keyboard loader, not cassette transfer.

This qualification uses a 64 KiB MSX1 with its normal BIOS/BASIC slots and default NTSC VDP timing. The 361-byte image occupies $C000–$C168, including 22 data bytes: four game/input/reserved bytes, ten deadline bytes and eight display-buffer bytes. Keep that area out of BASIC and other owners. The native arithmetic fixture is 519 bytes. The post-return observer additionally borrows $D000–$D3C0 for actual VPEEK text cells and a completion marker. It is diagnostic only, inside the CLEAR reservation; no game routine needs it. No slot, mapper, interrupt vector or private stack is installed.

Take the shared helper

demo.asm

demo.asm
; SPDX-License-Identifier: MIT
    org $C000
DURATION equ 240
    include "caller.inc"
    include "clock.inc"
    include "input.inc"
    include "../../../../../cross-platform/patterns/framework/elapsed-deadline/z80.inc"
    include "dispatch.inc"
TURN: db 0
EXPIRIES: db 0
KEY: db 0
UNUSED: db 0
DEADLINE: defs 10,0
DISPLAY: db "00 00 00"
code_end:

z80.inc

z80.inc
; DEADLINE owns 10 bytes. HL=low 16 clock bits. AF/DE/HL clobbered.
; Poll at least once per 65536 clock ticks. BC/IX/IY preserved.
deadline_init:
    ld (DEADLINE+8),hl
    ld hl,0
    ld (DEADLINE+2),hl
    ld (DEADLINE+6),hl
    xor a
    ld (DEADLINE+4),a
    ld (DEADLINE+5),a
    ret
deadline_start:
    ld hl,(DEADLINE)
    ld (DEADLINE+2),hl
    ld a,h
    or l
    jr z,deadline_off
    ld a,1
deadline_off:
    ld (DEADLINE+4),a
    xor a
    ld (DEADLINE+5),a
    ret
deadline_poll:
    ld de,(DEADLINE+8)
    ld (DEADLINE+8),hl
    or a
    sbc hl,de
    ld (DEADLINE+6),hl
    xor a
    ld (DEADLINE+5),a
    ld a,(DEADLINE+4)
    or a
    ret z
    ld hl,(DEADLINE+2)
    ld de,(DEADLINE+6)
    or a
    sbc hl,de
    jr c,deadline_expired
    ld a,h
    or l
    jr z,deadline_expired
    ld (DEADLINE+2),hl
    ret
deadline_expired:
    ld hl,0
    ld (DEADLINE+2),hl
    xor a
    ld (DEADLINE+4),a
    inc a
    ld (DEADLINE+5),a
    ret

Include cross-platform/patterns/framework/elapsed-deadline/z80.inc once and supply DEADLINE: ten writable bytes. Little-endian words: duration +0, remaining +2, active +4, one-poll expiry +5, elapsed +6, previous +8.

Set duration to unsigned 0..65535; pass a clock sample in HL and call deadline_init once. It records the baseline, preserves duration and starts disabled. deadline_start copies duration into remaining and enables when nonzero, clearing the event without taking a new sample. Poll immediately before starting if the exact baseline matters. deadline_poll takes the next clock in HL, including while disabled. It updates the baseline, clears the old event and subtracts elapsed time from an active countdown. Expiry clamps remaining to zero, disables active and sets +5. Consume the event before the next poll clears it.

The helper clobbers AF/DE/HL, preserves BC/IX/IY, uses the caller’s return stack and performs no I/O or interrupt manipulation. One labelled context has one owner; this is not a re-entrant or implicit multi-timer interface.

Unsigned subtraction wraps modulo 65536: 65526 → 10 is twenty ticks. Poll before a full cycle passes (roughly 18 minutes 12 seconds at 60 Hz). An extra missed cycle looks identical. Zero duration means off. Reinitialise after a global clock reset; it is not a wrap. Late observation gives one event without catch-up; restarting at observation time introduces drift. Units, ranges and wrap connect directly to Maths for Games.

Machine adapters and policy

clock.inc

clock.inc
; JIFFY is owned by the normal BIOS VBlank ISR. HL out, no flags changed.
; One Z80 instruction loads the word; maskable IRQs occur between instructions.
clock_read:
    ld hl,($FC9E)
    ret

input.inc

input.inc
; Mainline, normal BIOS mapping, sole consumer of buffered characters.
; CHSNS says Z=empty; CHGET is called only after availability was established.
; Return A: 0 none/ignored, 1 Space, 2 T, 3 Escape. BIOS registers clobbered.
read_command:
    call $009C
    jr z,input_none
    call $009F
    cp 32
    jr z,input_action
    cp 'T'
    jr z,input_toggle
    cp 't'
    jr z,input_toggle
    cp 27
    jr z,input_quit
input_none:
    xor a
    ret
input_action:
    ld a,1
    ret
input_toggle:
    ld a,2
    ret
input_quit:
    ld a,3
    ret

dispatch.inc

dispatch.inc
; Fixed labelled game state. AF/DE/HL clobbered; BC/IX/IY preserved.
dispatch:
    ld a,(DEADLINE+5)
    or a
    jr z,dispatch_key
    xor a
    ld (KEY),a
    ld hl,EXPIRIES
    inc (hl)
    ld hl,TURN
    inc (hl)
    jp deadline_start
dispatch_key:
    ld a,(KEY)
    cp 2
    jr nz,dispatch_action
    ld a,(DEADLINE+4)
    or a
    jp z,deadline_start
    xor a
    ld (DEADLINE+4),a
    ld hl,0
    ld (DEADLINE+2),hl
    ret
dispatch_action:
    cp 1
    ret nz
    ld hl,TURN
    inc (hl)
    ld a,(DEADLINE+4)
    or a
    ret z
    jp deadline_start

caller.inc

caller.inc
; BASIC USR entry, normal BIOS slots and firmware IRQs retained.
start:
    push af
    push bc
    push de
    push hl
    push ix
    push iy
    xor a
    ld (TURN),a
    ld (EXPIRIES),a
    ld hl,DURATION
    ld (DEADLINE),hl
    call clock_read
    call deadline_init
main:
    call clock_read
    call deadline_poll
    call read_command
    ld (KEY),a
    call dispatch
    call show_state
    ld a,(KEY)
    cp 3
    jr nz,main
quit_release:
    ld a,7
    call $0141
    bit 2,a
    jr z,quit_release
    call $0156
    pop iy
    pop ix
    pop hl
    pop de
    pop bc
    pop af
    ret
show_state:
    ld a,(TURN)
    ld de,DISPLAY
    call show_byte
    ld a,(EXPIRIES)
    ld de,DISPLAY+3
    call show_byte
    ld a,(DEADLINE+4)
    ld de,DISPLAY+6
    call show_byte
    ld hl,DISPLAY
    ld de,0
    ld bc,8
    call $005C
    ret
show_byte:
    push af
    rrca
    rrca
    rrca
    rrca
    and 15
    call hex_digit
    ld (de),a
    inc de
    pop af
    and 15
    call hex_digit
    ld (de),a
    ret
hex_digit:
    cp 10
    jr c,hex_number
    add a,7
hex_number:
    add a,48
    ret

clock.inc reads BIOS JIFFY at $FC9E–$FC9F into HL. One Z80 LD instruction reads the word; maskable IRQs occur between instructions. Other registers/flags and interrupt state are unchanged. Keep the normal VBlank interrupt service running; it supplies the clock and keyboard scan. The default NTSC configuration counts about sixty ticks per second; PAL timing needs a different seconds conversion. Never reset TIME/JIFFY behind an active context.

input.inc calls CHSNS ($009C) and calls CHGET ($009F) only after a character is available. It returns A=0 for none/ignored, 1 for Space, 2 for T/t and 3 for Escape. Treat BIOS/main registers as clobbered. This must be the sole mainline consumer of that buffer; availability followed by a blocking reader is safe only while another owner cannot drain it in between. Delivered characters can include typeahead and repeat. This is not physical press detection or simultaneous-key scanning. The native taps span ten fields so the firmware scanner can observe them; a very short press may be missed.

dispatch.inc handles expiry first and discards that iteration’s command, including Escape/T. Otherwise Space advances once and restarts only an already active deadline. Untimed play stays untimed. T discards remaining on disable and starts a full duration on enable. Byte turn/expiry counters wrap. Replace their effects with your own rules; pause preservation, queued commands, carried lateness and multiple contexts are deliberate extensions with different costs.

Presentation formats the three status bytes into DISPLAY, then uses BIOS LDIRVM ($005C) to copy eight cells to VRAM address zero in SCREEN 0, width 40. Other modes/table bases need a different adapter. Treat main registers/flags as clobbered; the whole USR session saves/restores AF/BC/DE/HL/IX/IY on BASIC’s existing balanced stack. Escape release is checked with SNSMAT ($0141), row 7 bit 2; KILBUF ($0156) clears remaining firmware repeats before RET. BIOS and normal IRQ workspace remain firmware-owned.

Customise and diagnose

customise.asm

customise.asm
; SPDX-License-Identifier: MIT
    org $C000
DURATION equ 120
    include "caller.inc"
    include "clock.inc"
    include "input.inc"
    include "../../../../../cross-platform/patterns/framework/elapsed-deadline/z80.inc"
    include "dispatch.inc"
TURN: db 0
EXPIRIES: db 0
KEY: db 0
UNUSED: db 0
DEADLINE: defs 10,0
DISPLAY: db "00 00 00"
code_end:

Deliberately broken: reset-baseline.inc

reset-baseline.inc
; JIFFY is owned by the normal BIOS VBlank ISR. HL out, no flags changed.
; One Z80 instruction loads the word; maskable IRQs occur between instructions.
clock_read:
    ld hl,($FC9E)
    ld (DEADLINE+8),hl
    ret

customise.asm changes DURATION from 240 to 120: roughly four seconds becomes two in this configuration, with identical code/state size.

mistakes/reset-baseline.asm deliberately chooses a clock adapter that overwrites previous before polling. The clock advances, but elapsed always becomes zero, remaining stays at 240 and the automatic turn never arrives. The faulty screen stays at 01 00 01 rather than reaching 03 01 01. Restore clock.inc and rebuild; the separate fresh repaired run verifies the fix. The faulty adapter adds three bytes. Keep it out of working builds.

Native faulty caller with one turn, zero expiries and timing enabled.
The clock advances, but the deliberately reset baseline prevents the deadline from counting down.

Cost and qualification

The shared helper emits 97 bytes plus its ten-byte context. The clock adapter emits four bytes. The complete caller is 361 bytes including its 22 data bytes. Calls use the existing BASIC stack plus saved registers, BIOS and IRQ needs; no exhaustive peak-stack or cycle count is claimed. Drawing, BIOS work and your own game determine polling latency. This is continuous feedback, not frame pacing.

Emu198x 0.44.2, Asm198x 0.0.72 and the hashed stock BIOS/BASIC. Six independent loaded callers cover normal, half duration, wrong baseline, repair, twelve synthetic arithmetic pairs/three policy cases and a wrong-equality control. Loaded bytes must match assembly before USR. The wrong equality mutation completes but fails the unsigned oracle. Supplied samples do not claim an observed 18-minute rollover.

After quit, BASIC computes 2+3. A guest VPEEK observer copies the actual name table and writes its completion marker before the host accepts that return check. Captures, source/tool/firmware hashes, loaded bytes and state samples are retained. Run scripts/verify-msx-assembly-deadlines.py with released tools and your own firmware; run python3 scripts/check-msx-assembly-deadlines.py and python3 -m unittest discover -s scripts -p test_msx_assembly_deadlines.py for retained checks. Ten deliberately corrupted records must fail. This is configuration-specific execution, not original hardware, every BIOS or whole-emulator qualification.

Primary references: MSX BIOS source listing, fixed entries CHSNS/CHGET, LDIRVM, SNSMAT and KILBUF; TIMI/JIFFY service and BASIC TIME workspace. The adapters and policy are this implementation, not an assertion that a manufacturer supplied this exact game loop. See the existing MSX command-loop entry for the independent untimed BIOS route.

Maintained source · Native execution record · Untimed command loop · Choosing progression and clocks