← Back to all articles

The port that lies about its own number

22 August 2026

Z80Deep diveULAMachine code

🤖 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.

ZX Spectrum screen with a yellow border, printing: THE PORT THAT LIES ABOUT ITS OWN NUMBER. BORDER VIA OUT (N),A: N=254 252 0 128 -- ALL EVEN, ALL REACH THE BORDER. N=255 IS ODD -- IGNORED. FINAL BORDER: YELLOW. (THE BLUE ONE NEVER ARRIVED). Q HELD DOWN THE WHOLE TIME: A=FB N=FE -> FE, A=DF N=FE -> FF, A=FB N=00 -> FE. N NEVER MATTERED. A ALWAYS DID.
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

Where you could take it

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.

More like this

Raiders of the Lost Aisle II

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.

Isocubes: a real Filmation-style engine

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.