The title menu lets the player pick one of four control schemes
(KEYBOARD / INTERFACE 2 / KEMPSTON / CURSOR). Each scheme reads
hardware ports differently but writes the same bit pattern into
$E45F (the per-frame input flag byte) for the rest of the game
to consume.
D8F0 LD HL,($E461)
D8F3 JP (HL) ; jump to the currently-selected handler
($E461) holds the address of the active scheme's handler. Set
by the title-menu selection code; default at boot = $D8F4
(keyboard).
All four handlers fill the bits of $E45F identically:
| Bit | Meaning | Read by |
|---|---|---|
| 0 | FIRE pressed (latched via $E460) |
$DE44 BIT 0 → laser fire |
| 1 | LEFT/RIGHT key (= L on keyboard) | $D9CB BIT 1 → horizontal scroll |
| 2 | (LEFT row in $D8F4; "facing flipped" flag in $F0F9) |
internal |
| 3 | DOWN | $D991 AND $08 → altitude++ |
| 4 | UP | $D964 AND $10 → altitude-- |
| 5 | (RIGHT toggle) | $D949 BIT 5 → facing flip |
Plus $E460 bit 0 = latched fire-released flag (so the player
can't hold fire to spam shots — must release between).
$F741: $FB71 $F177 $F14E $F0F9 $D8F4
(5) (4) (3) (2) (1)
The menu indexes this table in REVERSE. The selector at $F67C
starts B=5 and scans the $F7FE key bits from key 1 upward with
SRL A / DJNZ, so key 1 leaves B=5 → $F741[4] = $D8F4, key 2
gives B=4 → $F741[3] = $F0F9, etc. Verified by emu-peek:
holding key 1 on the title installs $E461=$D8F4; key 2 installs
$E461=$F0F9.
- Option 1 (
$D8F4) — KEYBOARD - Option 2 (
$F0F9) — joystick-style key row 6/7/8/9/0 - Option 3 (
$F14E) — KEMPSTON - Option 4 (
$F177) — INTERFACE 2 - Option 5 (
$FB71) — SINCLAIR
While the title loop runs, $F660 re-defaults ($E461) to $FB71
on every pass — so poking $E461 before the menu has no effect;
the selection is whatever digit starts the game.
D8F4 LD HL,$E45F
D8F7 LD A,$BF; IN A,($FE); CPL; AND $03; LD (HL),A
; read half-row $BFFE = ENTER/L/K/J/H
; keep bits 0..1 → fire (ENTER) + L
D8FF RES 0,(HL)
D901 BIT 0,A
D903 JR Z,$D914 ; fire NOT pressed → clear latch
... (fire-press latch via $E460 bit 0)
D918 LD A,$FE; IN A,($FE); CPL; AND $1F ; read SHIFT/Z/X/C/V ($FEFE)
D91F JR Z,$D923
D921 SET 2,(HL) ; bit 2 = LEFT row pressed
D923 LD A,$FD; IN A,($FE); CPL; AND $1F ; read A/S/D/F/G ($FDFE)
D92A JR Z,$D92E
D92C SET 3,(HL) ; bit 3 = DOWN row pressed
D92E LD A,$FE; IN A,($FE); CPL; AND $1F ; re-read SHIFT row
D935 JR Z,$D939
D937 SET 5,(HL) ; bit 5 = RIGHT toggle (paired with bit 2)
D939 LD A,$FB; IN A,($FE); CPL; AND $1F ; read Q/W/E/R/T ($FBFE)
D940 JR Z,$D944
D942 SET 4,(HL) ; bit 4 = UP row pressed
D944 ; if bit 2 set (LEFT row), flip $E586 facing
D944 BIT 2,(HL); INC HL
D947 JR Z,$D959
D949 BIT 5,(HL); JR Z,$D95B
D94D LD A,($E586); XOR $01; LD ($E586),A ; toggle facing
D955 RES 5,(HL); JR $D95B
D959 SET 5,(HL) ; arm RIGHT toggle
So KEYBOARD scheme (option 1):
ENTER= FIRE (with release-debounce via$E460) — half-row$BFFEbit 0, not key 0 as an earlier revision of this doc claimedL= horizontal scroll —$BFFEbit 1, not key 9- Any of SHIFT/Z/X/C/V = LEFT
- Any of A/S/D/F/G = DOWN
- Any of Q/W/E/R/T = UP
- Toggle facing via
$E586when LEFT+RIGHT bits clash
F14E XOR A
F150 IN A,($1F) ; Kempston joystick port
F152 LD B,$00
F154 BIT 0,A; SET 3,B ; right → UP bit 3?
F158 BIT 1,A; SET 4,B ; left → UP bit 4?
F15C BIT 2,A; SET 2,B ; down → bit 2
F164 BIT 3,A; SET 1,B ; up → bit 1
F16A BIT 4,A; SET 0,B ; fire → bit 0
F172 LD A,B; CPL
F174 JP $F101 ; reuse $F0FF's fall-through to write to $E45F
Reads the Kempston port $1F (interface ROMs map this), translates
to $E45F bits, then jumps into the CURSOR-handler's tail at
$F101 for the latch-store.
Reads half-rows $F7FE (keys 1-5) and $EFFE (keys 0,9,8,7,6),
maps the bits onto B, falls into $F101 like KEMPSTON.
Reads half-row $EFFE once, then an RRA chain peels the bits
(active-low; bit 0 = key 0 ... bit 4 = key 6):
F0FD LD A,$EF; IN A,($FE); LD B,A ; B = raw read for later
F107 RRA; JR NC,latch ; key 0 → FIRE (edge-latched via $E460 bit 0)
F116 RRA; JR C,+2; SET 4,C ; key 9 → UP ($E45F bit 4)
F11B RRA; JR C,+2; SET 3,C ; key 8 → DOWN ($E45F bit 3)
F120 RRA; JR NC,horiz ; key 7 pressed → horizontal
F123 RRA; JR NC,horiz ; key 6 pressed → horizontal
F126 JR $F146 ; neither → store C, done
horiz:
F128 LD E,$00; SET 1,C ; bit 1 = horizontal scroll
F12C BIT 3,B; JR NZ,+2; LD E,$01 ; E = 1 if the press was key 7
F132 LD A,($E586); AND $01; XOR E ; compare wanted direction vs facing
F138 JR Z,$F146 ; same → just scroll
F13A SET 2,C ; differs → flag + flip facing
F13C LD A,($E586); AND $01; XOR $01; LD ($E586),A
F146 LD A,C; LD ($E45F),A; POP HL; JP $D959
So option 2 is 6=LEFT, 7=RIGHT, 8=DOWN, 9=UP, 0=FIRE — the
classic Sinclair Interface-2 port 1 arrangement, not the
Protek cursor layout (5/6/7/8) an earlier revision of this doc
assumed. Left/right presses flip $E586 facing when needed, then
scroll ($E45F bit 1). Empirically verified: holding key 8 sets
$E45F=$08 and dives ($E584 0→$51 in 60 frames); key 7 sets
$E45F=$02.
$F101 is the shared tail entry used by KEMPSTON/INTERFACE 2 —
they build the raw byte in their own way and jump in.
(Not yet disassembled.)
Our Sdl2InputPump already maps host keyboard keys into the
GameInput struct (Up/Down/Left/Right/Horizontal/Fire); the
cassette's control-scheme dispatch is mostly bypassed since we
don't have to emulate a Spectrum keyboard.
The interesting bits are:
($E460)fire-release debounce — we approximate with a_fireCooldowncounter inWorldthat prevents spam.- The
$E586facing flip on LEFT-vs-RIGHT key combo — relevant for the original's "press direction key to flip facing" UX. Our port has explicitLeft/RightGameInput flags, so the flip is direct (no SHIFT-row+key-row combo needed).
The cassette's keyboard scheme treats SHIFT as part of the
LEFT key-group (any of SHIFT/Z/X/C/V = LEFT, per $D918
"IN A,($FE)" read of the bottom row of the keyboard matrix).
The port hijacks SHIFT for a different role:
- Hold L-Shift or R-Shift to enter precision mode.
- In precision mode, each direction key (Up/Down/Horizontal)
fires one step per press-edge instead of accelerating
while held. Step sizes:
- Up / Down: 1 pixel of altitude per edge (the smallest
altitude unit
($E584)supports). - Horizontal (L / Left / Right): 1 pixel of scroll per edge — port-only sub-byte composition (see below).
- Up / Down: 1 pixel of altitude per edge (the smallest
altitude unit
- To step again, RELEASE the direction key, then RE-PRESS it (while still holding SHIFT). Continuous hold = exactly one step then frozen.
- Releasing SHIFT immediately reverts to the cassette's
acceleration ramp (
SpeedShiftreset to 1, no mid-ramp carry-over).
Verified in HeadlessTestRunner:
| Inputs (50 frames) | Final altitude |
|---|---|
| A held, no Shift | 80 (acceleration ramp) |
| A held + Shift held | 1 (single edge, then frozen) |
| A pulsed 3-on/2-off + Shift held | = number of press-edges |
GameInput.Shift— held-state flag.Sdl2InputPumpmaps bothSDLK_LSHIFT(0x400000E1) andSDLK_RSHIFT(0x400000E5) into the flag.HeadlessTestRunneracceptsSHIFTin--keys=schedules for reproducible tests.Worldkeeps three private_prevUp / _prevDown / _prevHorizontalbools updated at the end of eachTickPlaying; the Shift-precision branch readsinput.X && !_prevXas the edge condition.
Nothing in $D8F4..$DDA9 implements an "edge-only" mode — every
control scheme writes the held key state directly into $E45F,
and $D95D always runs the SpeedShift ramp. Adding edge
detection at the cassette layer would have required spare RAM
for the per-key previous-state cache (which the original game
doesn't allocate). This is purely a port quality-of-life
feature; the diff-vs-emu harness is unaffected because the
emulator never receives a SHIFT keystroke.
The cassette's horizontal scroll routines $DA23 (bitmap
LEFT/ship RIGHT) and $DA62 (bitmap RIGHT/ship LEFT) use
LDIR/LDDR over 31 bytes per scanline — each call shifts the
entire bitmap by exactly 1 byte = 8 pixels. Verified by
disasm: both routines have a single entry path from $D9C8,
no caller invokes them more than once per frame, and there's
no other scroll mechanism in the ROM. So the cassette CAN'T
scroll horizontally at sub-byte precision.
This is fine in the original game because all walls + tile art are byte-aligned (8 px grid), so the player and obstacles share the same granularity. But the Shift precision modifier in the port aims for literal 1-pixel control, so we add an extension:
World.SubPixelScroll(0..7) tracks the pixel offset within the current byte. Total world-pixel X =ScrollOffsetX * 8 + SubPixelScroll.- Shift+Horizontal edge advances
SubPixelScrollby 1; on overflow it wraps and bumpsScrollOffsetX, which triggers a freshPaintLevelAtOffset(byte-aligned, cassette-faithful). LoadLevelresets bothScrollOffsetXandSubPixelScrollto 0.DrawPlayingapplies a post-shift over the entire playfield bitmap (y=0..127) after the level + every entity has been drawn —ApplyPlayfieldSubPixelShift(fb, SubPixelScroll). Each scanline is bit-rotated left bysubPxpixels in one pass (out[col] = (in[col] << subPx) | (in[col+1] >> (8-subPx))). Player is drawn AFTER this shift so it stays at fixed screen X=128.
Why post-shift instead of compose-during-paint? An earlier
attempt did the sub-byte composition inside PaintLevelAtOffset
only, which shifted the LEVEL by 1 px but left workers / ships /
bullets / decor entities at byte-aligned positions. Result: as
the user pressed Shift+L the level shifted but workers stayed
put, looking like "the miner moves away from the ship". Doing
the shift on the composite playfield bitmap fixes that — the
cave + all entities shift together. The player is the only thing
exempted (drawn after the shift), preserving the "ship stays at
fixed screen X" invariant.
Non-Shift L still uses the cassette's byte-aligned 8 px/frame
path. SubPixelScroll stays 0 in normal play, so the post-shift
is a no-op — diff-vs-emu unaffected (verified 0% at f100/f300).