Pobtastic / Screen Compression

Created Sun, 26 Apr 2026 21:14:17 +0100 Modified Mon, 27 Apr 2026 21:00:05 +0000

What Does It Do?

Samantha Fox Strip Poker displays a different background for each of its six reveal screens. Storing six full uncompressed ZX Spectrum bitmaps would cost 6 × 6144 = 36,864 bytes — most of the available RAM for a 48K game that also has to hold poker logic, fonts, sprites, and music. Instead, each background is stored in a compact RLE-like format that mixes literal pixel bytes with encoded runs, guided by a separate control table.

The PrintScreen routine at $5EC2 handles the full decode pipeline: it reads from the compressed stream, writes to the screen bitmap at $4000, fills attributes, pulses the border, and finally kicks off the music player.

The Data Layout

A lookup table at $5F33 holds six two-byte little-endian pointers, one per screen. PrintScreen indexes into this table using the screen ID passed in A to locate the start of the packed data block for that room.

; Table: Screen References
@label=TableScreenReferences
g$5F33 DEFW $9B43    ; Screen 01 data.
 $5F35 DEFW $AAEA    ; Screen 02 data.
 $5F37 DEFW $BE9D    ; Screen 03 data.
 $5F39 DEFW $CCF8    ; Screen 04 data.
 $5F3B DEFW $D8C1    ; Screen 05 data.
 $5F3D DEFW $89B8    ; Screen 06 data.

Each packed data block begins with a single 16-bit little-endian value: the address of the control table for that screen. The control table is a sequence of 16-bit ZX Spectrum screen addresses ($4000$57FF), terminated by $0000. Each entry marks a position within the output bitmap where an RLE run begins. Following those two header bytes is the raw stream: a mixture of literal pixel bytes and (count, value) run pairs, consumed sequentially by the decoder.

For example, the block for Screen 06 starts at $89B8 with $77,$96, meaning its control table begins at $9677.

Subroutine: Print Screen

PrintScreen clears the screen buffer, then resolves the packed-data pointer from the table before beginning the decode.

; Print Screen
;
; Decode the room bitmap from TableScreenReferences into #N$4000, repaint attributes,
; pulse the border, refresh the panel row, and call the sound routine.
;
; A The screen ID to print (room index)
@label=PrintScreen
c$5EC2 CALL $5E9D    ; Call ClearScreenBuffer.
; Resolve the packed pointer from the table, then read the bitmap stream.
 $5EC5 ADD A,A       ; {Load #REGhl with TableScreenReferences+(#REGa*#N$02) for the selected
 $5EC6 LD E,A        ; room index.
 $5EC7 LD D,$00      ;
 $5EC9 LD HL,$5F33   ;
 $5ECC ADD HL,DE     ; }
 $5ECD LD E,(HL)     ; {Fetch the packed-data pointer from that table slot into
 $5ECE INC HL        ; #REGhl.
 $5ECF LD D,(HL)     ;
 $5ED0 EX DE,HL      ; }

With HL now pointing at the start of the packed data block, the first 16-bit field — the control table address — is loaded into IX. DE is set to $4000, the base of the ZX Spectrum screen bitmap, and will act as the main write pointer throughout the decode. The shadow register pair DE' mirrors this as an auxiliary tracking pointer. HL' holds the current “goal” address from the control table: when DE' catches up to it, the decoder switches into run mode for that position.

 $5ED1 LD E,(HL)     ; {Read one 16-bit stream field into #REGde and leave
 $5ED2 INC HL        ; #REGhl past it.
 $5ED3 LD D,(HL)     ;
 $5ED4 INC HL        ; }
 $5ED5 PUSH DE       ; {Copy #REGde into #REGix for indexed reads (using the
 $5ED6 POP IX        ; stack).}
 $5ED8 LD DE,$4000   ; Point #REGde to #N$4000 (the main bitmap write pointer).
 $5EDB EXX           ; Switch to the shadow registers.
 $5EDC LD L,(IX+$00) ; {Load #REGhl' with the 16-bit value stored at #REGix.
 $5EDF LD H,(IX+$01) ; }
 $5EE2 LD DE,$4000   ; #REGde'=#N$4000 (auxiliary bitmap pointer).
 $5EE5 EXX           ; Switch back to the normal registers.

The Decode Loop

The main decode loop runs until the bitmap write pointer D reaches $58 — the byte immediately above the top of the screen bitmap ($4000$57FF). On every iteration it switches to the shadow registers to compare the auxiliary pointer DE' against the current goal address HL'. If they match, a packed run is waiting at this position; otherwise a single literal byte is consumed.

@label=PrintScreen_UnpackLoop
*$5EE6 EXX           ; Switch to the shadow registers.
 $5EE7 AND A         ; Clear the carry for the wide subtract.
; #REGhl': required bitmap destination from packing tables (via #REGix).
; #REGde': how far the twin bitmap crawl has moved up from #N$4000 alongside it.
; Same → unpack runs (PrintScreen_DecodePackedRun); different → emit one literal byte (PrintScreen_CopyBitmapByte).
 $5EE8 PUSH HL       ; Protect #REGhl'; subtraction borrows HL' briefly as scratch.
 $5EE9 SBC HL,DE     ; Goal minus twin walk is zero only when #REGhl' equals #REGde'.
 $5EEB POP HL        ; Restore #REGhl' so the stored goal survives for the next compare.
 $5EEC EXX           ; Switch back to the normal registers.
 $5EED JR NZ,$5EF4   ; Jump to PrintScreen_CopyBitmapByte if #REGhl' did not match #REGde'.
 $5EEF CALL $5F1B    ; Call PrintScreen_DecodePackedRun (cursors aligned: emit a packed run).
 $5EF2 JR $5EFB      ; Skip the per-byte path; go test the row limit.

Literal Byte Path

When DE' has not yet reached the next run goal, the decoder simply reads the next byte from the stream in HL and writes it verbatim to the bitmap. Both the main write pointer DE and the auxiliary tracker DE' are advanced by one, as is the stream pointer HL.

@label=PrintScreen_CopyBitmapByte
*$5EF4 LD A,(HL)     ; Read the next literal value from the stream (*#REGhl).
 $5EF5 LD (DE),A     ; Write it to *#REGde (main bitmap write pointer).
 $5EF6 EXX           ; Switch to the shadow registers.
 $5EF7 INC DE        ; Increment #REGde' by one (next cell on the auxiliary copy).
 $5EF8 EXX           ; Switch back to the normal registers.
 $5EF9 INC DE        ; Increment #REGde by one (main bitmap step).
 $5EFA INC HL        ; Increment the stream pointer by one.

Row Limit Test

After each byte (literal or run) the decoder checks the high byte of DE against $58. As long as D < $58 there are still bitmap rows to fill.

@label=PrintScreen_EndOfRowTest
*$5EFB LD A,D        ; {Jump to PrintScreen_UnpackLoop if #REGd is less than #N$58 (while bitmap rows
 $5EFC CP $58        ; remain).
 $5EFE JR C,$5EE6    ; }

Subroutine: Decode Packed Run

When the cursors align, this routine is called. It reads the run length and repeated value from the stream, then writes that many copies of the value into the bitmap — advancing both DE and DE' for each copy. After the run it advances IX by two and loads the next goal address into HL', ready for the next comparison in the main loop.

@label=PrintScreen_DecodePackedRun
*$5F1B LD B,(HL)     ; Load the run length count into #REGb from *#REGhl.
 $5F1C INC HL        ; Advance #REGhl past the count.
 $5F1D LD A,(HL)     ; {Fetch the repeated pixel into #REGa and advance #REGhl.
 $5F1E INC HL        ; }
@label=PrintScreen_DecodePackedRun_Loop
*$5F1F EXX           ; Switch to the shadow registers.
 $5F20 INC DE        ; Increment #REGde' (next cell on the auxiliary copy).
 $5F21 EXX           ; Switch back to the normal registers.
 $5F22 LD (DE),A     ; Draw one strip of eight pixels into the room bitmap at #N$4000.
 $5F23 INC DE        ; Point the main write position at the next byte in that bitmap.
 $5F24 DJNZ $5F1F    ; Jump to PrintScreen_DecodePackedRun_Loop while #REGb is still non-zero (DJNZ).
 $5F26 INC IX        ; {Increment #REGix by two (to the next 16-bit control word).
 $5F28 INC IX        ; }
 $5F2A EXX           ; Switch to the shadow registers.
 $5F2B LD L,(IX+$00) ; {#REGhl' receives the word at #REGix (next cursor pair).
 $5F2E LD H,(IX+$01) ; }
 $5F31 EXX           ; Switch back to the normal registers.
 $5F32 RET           ; Return.

After the Decode

Once the bitmap is fully written, PrintScreen continues with the finishing touches: the attribute area ($5800$5AFF) is flood-filled with $78 (white ink, white paper), the border is driven white then black via OUT ($FE), the footer panel is refreshed, and finally the music player at $FA70 is called to play the background tune for the duration of the screen display.

; After the bitmap: repaint attributes, border, panel, then sound.
 $5F00 LD HL,$5800   ; {Fill #N$0300 attribute bytes from #N$5800 with
 $5F03 LD DE,$5801   ; #COLOUR$78.
 $5F06 LD BC,$02FF   ;
 $5F09 LD (HL),$78   ;
 $5F0B LDIR          ; }
 $5F0D LD A,$07      ; {Set the border to #INK$07.
 $5F0F OUT ($FE),A   ; }
 $5F11 CALL $5EB2    ; Call ShowContinuePrompt.
 $5F14 CALL $FA70    ; Call PlayMusic.
 $5F17 XOR A         ; {Set the border to #INK$00.
 $5F18 OUT ($FE),A   ; }
 $5F1A RET           ; Return.

How It Fits Together

---
title: "Subroutine: Print Screen"
---
flowchart TD
    A["PrintScreen\n(A = screen ID)"]-->B["Clear screen buffer"]
    B-->C["Look up packed-data pointer\nfrom table at $5F33"]
    C-->D["Read control table pointer\ninto IX; set DE=DE'=$4000"]
    D-->E["Load first goal address\ninto HL' from IX"]
    E-->F{"DE' == HL'?"}
    F--Yes-->G["DecodePackedRun\nread (count, value)\nwrite count copies\nadvance IX to next goal"]
    F--No-->H["Copy one literal byte\nfrom stream to bitmap\nadvance DE, DE', HL"]
    G-->I{"D < $58?"}
    H-->I
    I--Yes-->F
    I--No-->J["Flood-fill attributes\nwith $78 (white/white)"]
    J-->K["Pulse border\nwhite then black"]
    K-->L["Refresh footer panel"]
    L-->M["PlayMusic"]
    M-->N[Return]

The interplay between the two write pointers (DE in the normal registers and DE' in the shadow registers) is the key insight. The shadow DE' acts as a pure counter from $4000 upwards, never detouring through any special logic. The control table provides the “expected” value of that counter at each run boundary. As long as DE' hasn’t reached the goal, the decoder knows it’s in a literal region; the moment they match, it knows a run is encoded at that exact position in the stream.

It’s a tidy solution that keeps the inner literal-byte path to four instructions (read, write, two increments) while still supporting arbitrary-length runs at arbitrary positions — all without a flag byte in the stream itself.