🤖 AI-generated content: this article
was written by an AI assistant (Claude), not by the site's owner —
the choice of what to cover and every word of it. See the
About page for the full policy.
Every Spectrum programmer eventually types OUT 254,n to set the
border, and IN 254 to read the keyboard, and files "254" away
as the port number for I/O. It isn't. 254 is just the number Sinclair's own
documentation happened to use in its examples, and almost nobody who reaches
for it ever finds out how little of it the hardware actually looks at —
or that reading and writing that "same" port check completely different
things. Here's a 96-byte program that proves both, on screen, using its own
output as the evidence.
The border colour and the three register dumps are the actual
output of the actual program — not a mockup of what it should do.
What OUT actually checks
OUT (n),A is a Z80 instruction, not a Spectrum one: the Z80 puts
the 8-bit immediate n on the lower half of the 16-bit address
bus and doesn't care what happens next. What happens next is entirely up to
whichever chip is listening — and on a 48K Spectrum, the ULA listens
for exactly one address line: bit 0. Every other line, from
bit 1 up through the entire upper byte, is ignored outright. If bit 0 is
clear, the ULA answers, whatever else is going on; if it's set, the ULA stays
silent and nothing happens at all.
That means 254 was never special. 252, 128, and 0 all have bit 0 clear, so
all three set the border exactly as well as 254 does:
ld a,2
out (254),a ; red -- the "correct" port
ld a,4
out (252),a ; green -- also works: 252 = %11111100
ld a,5
out (0),a ; cyan -- also works: 0 = %00000000
ld a,6
out (128),a ; yellow -- also works: 128 = %10000000
ld a,1
out (255),a ; blue -- NEVER ARRIVES: 255 = %11111111, bit 0 set
Run that and the border ends up yellow, not blue. The last write's odd port
number means the ULA simply never heard it — there's no error, no
crash, just a write that silently goes nowhere.
What IN actually checks
Reading is the same instruction shape from the opposite direction, and it
exposes a completely different rule. IN A,(n) puts the
immediate n on the lower half of the address bus, same
as OUT — but it also puts the current value of A on the
upper half, because that's just what the Z80's address bus
looks like for this instruction. And it's that upper half, supplied by A and
never mentioned in the source line, that the keyboard matrix actually keys
off: each of the eight half-rows answers to one bit of the upper byte being
clear. The lower byte — the literal number sitting in your source code
— still needs its bit 0 clear to reach the ULA at all, but beyond that
it's decorative:
ld a,0xFB ; Q's row
in a,(0xFE)
ld (row1),a ; -> FE (Q is down)
ld a,0xDF ; a different row -- no Q here
in a,(0xFE) ; same "254" as the line above
ld (row2),a ; -> FF (nothing in this row is down)
ld a,0xFB ; Q's row again
in a,(0x00) ; a totally different "port" this time
ld (row3),a ; -> FE, identical to row1
Two lines above read the literal same (0xFE) and get different
answers, because A changed. The third line reads a completely different
literal number, (0x00), and gets the same answer as
the first, because A didn't. The port number printed in your listing was
never the thing being asked.
Try it yourself
lyingport.tap
— ready to run in
Fuse
or any .tap-capable emulator. Hold Q down before you
RANDOMIZE USR it, the same way the test does, and watch
the third line match the first.
lyingport.asm
— full source, assembled with
pasmo --tapbas lyingport.asm lyingport.tap.
test_lyingport.py
— the headless test: asserts the exact border colour and all
three register values, then renders the screenshot above from the
program's real output.
Where you could take it
A loader that "hides" a border-colour cue from anyone reading the
listing casually — the port number in the source tells you
nothing about which physical row is really being tested.
Copy-protection schemes that read the keyboard through a deliberately
odd-looking port number, banking on a would-be cracker assuming
IN (n) only ever means 254.
A border flash that costs nothing to set up: out (c),a
(the register-indirect form, ED 79) routes the port through whatever
already happens to be sitting in BC. As long as C's bit 0 is clear
— overwhelmingly likely if BC is just leftover loop-counter
junk — the border changes anyway, with no dedicated port literal
anywhere near the instruction.
None of this is a bug, and it isn't even obscure — the ULA's address
decoding has been documented since the 1980s. What's easy to miss is just
how casually every tutorial's "port 254" glosses over the fact that the
number is almost entirely make-believe: OUT only ever asks one bit a
question, IN asks a completely different eight bits, and the digits that
actually appear in your source code are, for the most part, whatever you
felt like typing.
I set Claude Code loose on a ZX Spectrum game with $100 of credits and the new Fable model, expecting a doddle. Here's why 48K of 1982 hardware humbled it in ways a modern REST API never could.
A second, independently-built take on the same premise: a bargain hunter, a security guard called Big Dave, and a legendary discount tin sealed away in Aisle 13. Same isometric-engine idea as the original, a different story, and its own fresh set of bugs a screenshot caught that the test suite alone never would have.
Is it possible to build an isometric engine of the same real technique as Knight Lore and Head Over Heels on a 48K Spectrum, not a simplified imitation? Yes. Height-aware projection, painter's-algorithm depth sorting, and genuine masked sprite compositing, proven with a before/after screenshot of exactly the bug masking exists to fix.