Commodore 64 · 1985 · Gargoyle GamesTir Na Nog
inside the machine

A complete teardown of the C64 release: both compression layers peeled off, the engine run under a purpose-built emulator, and the display, drawing, input and data structures reconstructed from the running code.

01 — Origins

An adventure that had to invent its own graphics hardware

Tir Na Nog was written by Greg Follis and Roy Carter for Gargoyle Games, published for the ZX Spectrum and Amstrad CPC in 1984. You play Cúchulainn, wandering the Celtic otherworld to reassemble the Seal of Calum. It was received as a landmark: Crash gave it 92%, Zzap!64 87% for the Commodore version.

The C64 port arrived in 1985. Two strings sitting in the option screen give the credits the box does not:

$8E69  "(c) 1985 gargoyle games"
$8E83  "cbm 64 conversion by"
$8E9A  "design design software"

Design Design — the Manchester outfit behind Dark Star and Halls of the Things — did the conversion, and it shows. The C64 has no monochrome high-resolution overlay mode of the kind the Spectrum version leaned on, so the port builds its own: a letterboxed hi-res bitmap window driven by five raster interrupts a frame, double-buffered, with the scenery kept in a private linear framebuffer that has nothing to do with the VIC-II's memory layout.

40,646bytes of code and data after unpacking
2compression layers over the payload
5raster interrupts per frame
20rotoscoped frames in the walk cycle
Cuchulainn at the altar of the seal
The altar of the seal, rendered by the emulator written for this teardown. Everything on this page comes out of that emulator or out of the binary itself.

02 — Getting in

Two packers, one of them the original

The file is a T64 tape image holding a single PRG, $0801-$7578, fronted by a BASIC line that says SYS 2059. Everything behind it is high-entropy noise. Peeling it took two passes, and the two layers turn out to have completely different authors.

Layer 1 — the crack

The release was crunched by "Commo Bam". Its stub is 42 bytes long and does something neat with them: it copies a 17-byte helper into screen RAM at $0400 and the depacker proper into the stack page, so that the unpacked image can cover almost all of memory.

CRUNCH_ENTRY:
$7551  LDX #$10          ; 17 bytes of border-flicker helper ...
$7553  LDA $756D,X
$7556  STA $0400,X       ; ... executed from the screen
$755E  SEI
$755F  INC $01           ; $37 -> $38: all RAM, write under the ROMs
$7561  LDA $746A,Y       ; 256 bytes of depacker ...
$7564  STA $00FB,Y       ; ... into zero page and the stack
$756A  JMP $0100

Layer 2 — Gargoyle's own loader

Underneath is the loader that shipped on the original tape, and it is much more characterful. It relocates itself into $0107-$01FF, runs a spin loop of about 39,000 iterations whose counters are literal bytes inside its own instruction stream, and then does not jump anywhere: it executes an RTS whose "return address" is two relocated data bytes, landing inside the unpack loop.

The format itself is a segmented RLE over an XOR-encrypted stream. One byte at a time, decrypted with EOR #$DC, with $5E as the escape:

$086C  JSR getbyte        ; control byte after the escape
$086F  CMP #$03           ;  >= 3  -> run length, next byte is the value
$0871  BCC short          ;   == 2 -> one literal $5E
                          ;   0/1  -> new output address follows (hi=0 ends)

Try it. The decoder below is the same algorithm, byte for byte:

src $6677 dst $0A00 literal

encrypted input stream (each byte is XORed with $DC on the way in)

output

Running both layers under emulation reproduces a 40,646-byte image spanning $0400-$A2C5, which then relocates itself again and starts the game at $CBFA.

03 — Memory

Where 40KB of Celtic otherworld lives

This map is not guesswork: the emulator tags every byte the running game executes, reads or writes, and these are the regions that came back. Hover for detail.

The interesting part is the top 16KB

The game puts the VIC into bank 3 ($C000-$FFFF) and then packs that bank with no space wasted anywhere:

RangeSizeUse
$C000-$C87F2176 Bcompose buffer — 34 columns × 64 bytes, column-major
$C880-$C8BF64 Bone all-black sprite shape, shared by all eight sprites
$C000-$D3FF5 KBbitmap page B (only rows 8-15 are ever displayed)
$E000-$F3FF5 KBbitmap page A
$F400-$F7E71000 Bvideo matrix: bitmap colour for rows 0-15, text for the panel
$F7F8-$F7FF8 Bsprite pointers
$F800-$FFFF2 KBthe panel font

Note the overlap in the first two rows. The graphics window only shows character rows 8-15 of each bitmap page, so the top 2KB of page B is dead space — and that is exactly where the engine keeps its compose buffer. The part of page B a player could theoretically see is covered by black sprites (see below).

the game's 2KB panel font
All 256 characters of the panel font at $F800, lifted out of the running machine. The compass rose, the arrow heads and the object icons are all just characters.

04 — Display

Five interrupts, one screen, two graphics modes

There is no single video mode that gives you a hi-res bitmap window and a proportional text panel and per-band horizontal scrolling. So the game changes its mind five times per frame. The interrupt handler at $7C49 is a tiny state machine driven by $EB, the band index:

$7C5A  LDX $EB              ; which band are we opening?
$7C6E  LDA $D016
$7C71  AND #$F8
$7C73  ORA $7C10,X          ; this band's fine X scroll (0-7 pixels)
$7C76  STA $D016
$7C79  LDA $7C42,X          ; this band's video matrix + bitmap base
$7C7C  STA $D018
$7CB2  LDA $7C3D,X          ; arm the next compare line
$7CB5  STA $D012

Click a band to see what it is for.

$D012$D011 $D018meaning

Curtains made of sprites

All eight sprites are switched on, expanded in both directions, coloured black, and pointed at the same 64 bytes of solid $FF. Four sit at x=8 and four at x=312. They are not objects at all — they are shutters that hide the ragged left and right edges of the scrolling bitmap, and on page B they also hide the compose buffer bleeding into the top of the window.

$906A  STA $C880,X      ; 64 bytes of $FF = one solid sprite
$9072  STA $D015        ; all eight enabled
$9075  STA $D017        ; ... Y expanded
$9078  STA $D01D        ; ... X expanded
$9084  STA $F7F8,X      ; every pointer -> the same block

05 — The blitter

A framebuffer the VIC-II cannot see

The C64 bitmap is stored in 8-byte character cells, which is miserable for anything that scrolls sideways. So the engine does not draw into it. It draws into a private buffer at $C000 laid out column-major: 64 consecutive bytes per 8-pixel-wide column, 34 columns across. Scrolling one character to the left is then a straight 64-byte shift, and the scenery renderer never has to think about cell geometry.

contents of the compose buffer
The compose buffer at $C000, unpacked back into a 272×64 image. It holds the scenery only — the figure is never in here.

Once per frame BLIT_BG copies it into the VIC bitmap. The whole routine is 90 bytes, eight unrolled LDA ($62),Y / STA ($60),Y pairs and two pointer fixups, and the trick is that source and destination share the Y register while the destination pointer creeps forward by 312 — so Y's own growth supplies the missing 8 and each cell lands one character row further down.

bg_cell:
$82AD  LDA ($62),Y      ; compose buffer, linear
$82AF  STA ($60),Y      ; bitmap, interleaved     (x8, unrolled)
$82D4  CLC
$82D5  LDA $60
$82D7  ADC #$38          ; +$0138 = 320 - 8: next character row
$82E5  TYA                ; after 8 rows, Y = 64 ...
$82E7  ADC $62           ; ... = exactly one column of source
$82F0  LDA $60
$82F2  SBC #$B8          ; -$09B8: rewind 8 rows, step 8 pixels right

Watch the address arithmetic run. Left: the linear source. Right: where each byte lands in VIC bitmap memory.

$60=$EA18 $62=$C000 Y=0
compose buffer (column-major)
VIC hi-res bitmap

06 — The figure

Twenty rotoscoped frames and a one-byte invisibility switch

Cúchulainn is not a sprite. He is composited into the bitmap with a mask, in the middle of the background copy: the renderer blits the background columns to his left, drops him in, then carries on with the columns to his right. One pass, no overdraw, nothing to erase next frame.

$7EA4  LDA $7EC7,X      ; background columns before the figure
$7EA7  JSR BLIT_BG
$7EAC  LDA $7EDC,X      ; figure width in columns for this frame
$7EB1  JSR DRAW_FIGURE  ; leaves the pointers advanced past him
$7EB9  JSR BLIT_BG      ; and on with the background

The composite itself is four instructions with every operand patched at run time:

FIG_BLIT_UNROLLED:
$7FD9  LDA $C4C0,Y      ; background   <- operand patched from $62
$7FDC  AND $4180,Y      ; mask         <- 0 bits where he is opaque
$7FDF  ORA $3580,Y      ; ink          <- his own pixels
$7FE2  STA $EAB0,Y      ; bitmap       <- operand patched from $60

And then this, which is the sort of thing that only happens in hand-written assembler:

$7FBE  LDX #$19          ; $19 = opcode for ORA abs,Y
$7FC0  LDA $B7
$7FC2  CMP #$6E
$7FC4  BNE +
$7FCC  LDX #$D9          ; $D9 = opcode for CMP abs,Y
$7FCE  STX $7FDF         ; overwrite the ORA in the inner loop

CMP has the same length and addressing mode as ORA but does not touch the accumulator. Swapping one opcode byte turns "background, then ink" into "background only" — the figure vanishes and the scenery behind him is written instead. A whole visual state, implemented as a single byte poke into the middle of the drawing loop.

The animation, straight out of the tables

Frame index $AA runs 1..20. Ink pointers come from $2000/$2100; $8292,X maps each frame onto the mask it shares with its mirror. Fourteen frames of walk (two seven-frame strides), three narrow standing frames, two wide, and one six-column thrust:

all twenty animation frames
Cyan = ink bits, blue = the mask silhouette. Reconstructed directly from $2400-$41FF using the same pointer arithmetic the blitter uses.
$AA = 1
walk cycle
$AA$7EC7$7EDCtotal

Left column plus figure width plus right column always adds back up to the 34-column window, which is how the renderer knows it can use the same table entry twice.

07 — Scrolling

Four-pixel steps out of an eight-pixel machine

Redrawing the bitmap gives you 8-pixel granularity. $D016 gives you 0-7 pixels of hardware fine scroll, but only if the content stays put. The engine wants smooth motion without redrawing twice as often, so it alternates:

  1. Even frames render into page A at $E000 and publish $D016 = 0.
  2. Odd frames render into page B at $C000, with the scenery drawn 4 pixels further along, and publish $D016 = 4.
  3. The end-of-frame interrupt latches both values at once, so the page flip and the fine-scroll change happen on the same raster line.
$7CCD  LDA $D1           ; render phase
$7CCF  BNE +
$7CD1  JSR REDRAW_PAGE_E000
        ...
$7EBE  STA $7C47         ; next frame's $D016 = $00
$7EC3  STA $7C48         ; next frame's $D018 = $D8 (page A)
; the odd phase publishes $04 / $D0 instead
$7C8C  LDA $7C48         ; ... and the last interrupt of the frame
$7C8F  STA $7C45         ; latches them into the band tables

Top: what is in each bitmap page. Bottom: what the VIC actually shows after the fine scroll is applied. The visible image advances 4 pixels a frame while each page is only ever redrawn on alternate frames.

Ten consecutive game frames captured while walking east.

08 — The frame

Eight stages, and a job queue bolted to the raster

The main loop is a semaphore spin. The band-0 interrupt decrements $C5; when it goes negative the loop runs one game frame and reloads it with 3. Logic therefore runs at a quarter of the interrupt rate, roughly 12–13 Hz, while the display keeps its full 50 Hz.

Deferred jobs

Work that must not happen mid-frame is posted by setting one of eight flags at $7C08. After the last band, the interrupt walks them from the top down and dispatches through a table using the oldest trick in the 6502 book — push the address, then RTS to it:

RUN_JOB:
$7CC1  TXA
$7CC2  ASL
$7CC3  TAX
$7CC4  LDA $7C2E,X      ; high byte
$7CC7  PHA
$7CC8  LDA $7C2D,X      ; low byte
$7CCB  PHA
$7CCC  RTS                ; "return" into the job

The flag is cleared before the job runs, so a job is free to re-post itself for the next frame. That is how ambient animation — the thing at $B9F4 that picks a job from ($A3 + $8A) & 3 every frame — keeps ticking without any timers.

09 — Input

Five keys per action, one CIA read each

The instruction screen offers five alternative keys for most actions — walk left is Z C B M . — so that whatever your hand is doing, something under it works. Naively that is 40 key tests. The engine does nine.

The trick is that the five keys for one action are deliberately chosen to sit in the same matrix column across five different rows. Pull all five rows low at once, mask off every column except the one you care about, and a single compare answers "is any of them down?".

scan_next:
$AC32  LDA $AC50,Y      ; row select: several rows at once
$AC35  STA $DC00
$AC38  LDA $DC01
$AC3B  ORA $AC59,Y      ; force the columns we do not care about high
$AC3E  CMP #$FF
$AC40  BEQ +            ; still all high -> nothing pressed
$AC42  LDA $AC62,Y      ; otherwise OR this action's bit
$AC45  ORA $C0
$AC47  STA $C0

Press keys below. The scan table and the resulting $C0 mask are the real ones, read out of the binary.

Hover or click a key.

F1 toggles "autorun mode", which simply makes the scanner return a canned byte from $C6 instead of reading the keyboard — the game plays itself with a constant input mask.

10 — Sound

The entire audio engine

There is no music and no player. The SID is initialised once — all 29 registers zeroed, one voice given an attack/decay of $19, volume 15 — and then this is every sound the game can make:

SOUND_BEEP:            ; A/Y = frequency
$9055  STA $D400
$9058  STY $D401
$905B  LDA #$20
$905D  STA $D404         ; sawtooth, gate off
$9060  LDA #$21
$9062  STA $D404         ; sawtooth, gate on
$9065  RTS

Eight instructions. Footsteps, doors and the sídhe all come out of the same one-voice retrigger at different frequencies. Profiling a few million cycles of normal play produced zero SID writes outside of it — a striking amount of the machine's budget went to the raster engine instead.

11 — The world map

Twenty-two locations, each a rectangle with holes in it

The world is not a grid of rooms. It is a single coordinate space (0-255 in each axis) divided into 22 named regions. Each region has its own bounds list: a table of 4-byte records that say "if your cross-axis position is X, you may move along the search axis from lo to hi." That is how corridors, walls and doorways are expressed — not as tiles, but as allowed ranges on a number line.

; location record (26 bytes, copied to $80-$99 by ENTER_LOCATION):
;   offset 0-1: scenery shape pointer
;   offset 2-3: more shape data
;   offset 4-5: axis-0 bounds list pointer  -> $84/$85
;   offset 6-7: axis-1 bounds list pointer  -> $86/$87
;   offset 8-9: transition/exit table pointer
;   offset 10+: flags, name pointer, etc.
;
; bounds list entry (4 bytes):
;   byte 0: location ID (0 = any location)
;   byte 1: cross-axis position ($FF = end of list)
;   byte 2: lower bound on search axis
;   byte 3: upper bound on search axis

The master table at $83C7 has 22 little-endian pointers, one per location. ENTER_LOCATION at $8396 takes a location ID, looks up the pointer, and copies all 26 bytes into zero page $80-$99 — which is how $84-$87 (the bounds list pointers) get set. The whole location definition is just a memcpy.

All 22 locations

#NameRecordAxis 0 listAxis 1 listBounds entries

Edge wrapping

When you walk off the edge of a location, the coordinate wraps (e.g. X goes from 8 to 177) and ADVANCE_POSITION at $B768 detects the jump (position hit $02 or $FE). It calls DO_TRANSITION, which loads the new location, resets the render phase, and starts the four-frame slide-in animation driven by the $A5 state machine. There is no explicit connection table — the world map is the bounds lists, and the edges fall out of where the bounds stop.

12 — Movement

Two coordinates, four directions, one collision check

Player position is a pair of bytes: $A0 = X, $A1 = Y. Facing is $A3 (0-3: N/E/S/W). The game runs at roughly 12.5 Hz; each tick, GAME_LOGIC reads the keyboard, decides whether you are walking, and calls CHECK_MOVE to see if the next step is legal.

CHECK_MOVE:
$ACDA  LDY #(($A2&2)<<1)|$A3  ; index = movement-dir × 2 + facing
$ACE7  LDA $A0,X              ; the coordinate to change (X or Y)
$ACEA  ADC MOVE_DELTA,Y       ; +4 or +0 (the step)
$ACF3  SBC MOVE_BOUND,Y        ; subtract the boundary value
$ACFB  LDA ($AD),Y             ; check against the location's bounds
$AD11  CLC                      ; clear = may move, set = blocked

The delta table at $B935 is eight bytes: +1, +1, -1, -1 for the four direction/facing combinations, then four more for the case where $A5 (the transition state) adds a slide-in component. When the step is clear, PERFORM_MOVE at $AE4D also checks whether any entity (scenery or sídhe) at $A6/$A8 blocks the destination, and if not, the step happens. The walk animation cycles through frames 1-14 (two seven-frame strides) with the figure composited into the bitmap at the appropriate column offset.

Direction system

$A3 (facing)

ValueDirection
0North
1East
2South
3West

$A2 (movement)

ValueMeaning
0Forward
1Right
2Backward
3Left

$C0 (input bits)

BitAction
$01Walk right
$02Walk left
$04Camera left
$08Camera right
$10Thrust
$20Pick up
$40Drop
$80Nominate

13 — Objects and the sídhe

A linked list, a computed jump, and a defeat state machine

Objects live in a linked list rooted at $AF00. Each node is 20 bytes: position (X, Y), type ID, flags, a next pointer at offsets 4-5, and shape/panel data. $B5 is how many you are carrying, $B6 is which one is nominated.

PICK_UP:
$A80F  LDX $B6            ; nominated index
$A811  INX                ; 1-based
$A812  LDA #$AF           ; walk the list from $AF00
$A822  LDY #$04           ; offset 4-5 = next pointer
$A834  DEX                ; follow B6+1 links
$A835  BNE loop
$A857  LDA $CB            ; check if this object matches the held one
$A85D  CMP ($64),Y        ; compare against record offset 2

Drop works in reverse: walk the list backward from $88/$89, check if the player is within 3 units of the object's stored position, and if so, unlink it and plant it at the player's coordinates. Nominate just cycles $B6 through 0..B5-1 and prints the name.

The sídhe

Everything that moves on its own — scenery props, the sídhe, ambient birds — is an entity in a linked list rooted at $009B. Each entity record has a handler address at offsets 3-4, and the engine dispatches to it using a second PHA+PHA+RTS trick:

ENTITY_DISPATCH:
$95BE  PHA         ; push A (high byte of handler addr)
$95BF  TXA         ; X = low byte
$95C0  PHA         ; push X
$95C1  RTS         ; "return" to (A<<8)|X = the handler

When a sídhe catches you, $922C sets $AC = 1, triggering the defeat handler. It drops every carried object (one per frame, by calling pick-up in a loop), plays a defeat sound at frequency $0C8F, and blanks the figure. When $AC counts up to $12, GAME_RESET puts you back at the altar (position 50,70, facing north) with nothing in your hands.

Thrust

Pressing space starts a thrust: $C4 is set to 1, and the animation jumps through a 14-entry frame table at $AAA9. At frame $14 (20) the THRUST_HIT routine at $A419 fires — it computes the screen position of the target entity from the shape tables and calls $A5CB to apply the effect (banish a sídhe, break a barrier, pick up an object). Two hits per thrust cycle: one on the forward swing, one on the recovery.

; thrust animation sequence ($AAA9):
;  1,2,3,$14, 4,$0F,$10, 8,9,$A,$14, $B,$11,$10, 0
;                ^-- hit                    ^-- hit

14 — The world data

Names, riddles, and the compass

Underneath the engine, the adventure data in $0E00-$1F40 is plain NUL-terminated ASCII with + as a line break. Every location name:

Object records at $1B91 carry their riddle text in the same string — which is why "the backdoor key is me" is glued to feldspar in the binary. The four Seal fragments are there: dagdas cauldron, lughs spear, stone of fal, nuadas sword.

The compass is an eight-byte string at $B399neswnesw, doubled so rotating past west wraps to north without a branch.

Rotating the camera. Each press swings the view; the scenery is re-projected and the panel's compass follows.

15 — Method

How this was taken apart

No ROM images, no VICE, no existing disassembly. The chain was:

  1. Parse the T64 container and extract the single PRG.
  2. Write an NMOS 6502 core, including the undocumented opcodes that crunchers like, plus a C64 memory model with correct PLA banking.
  3. Run the crack's depacker under it and dump memory. Discover a second layer, run that too.
  4. Reimplement the handful of KERNAL entry points the game calls ($E544, $FFE4, the IRQ dispatch at $FF48) as Python traps, so no copyrighted ROM is needed. The game runs entirely from RAM after that.
  5. Add VIC-II raster timing, CIA timers and the keyboard matrix, drive the menus, and get into the game.
  6. Write a scanline-accurate renderer: snapshot the VIC state at the start of every one of the 312 raster lines and compose the frame band by band. Without that, half the screen is missing — which is itself how the raster split was discovered.
  7. Instrument every memory access: tag each byte as executed / read / written, and record per-subroutine access profiles. That is what located the compose buffer, the shape tables and the input table.
  8. Disassemble and annotate the routines the profile pointed at.

Findings worth stealing

  • A private column-major framebuffer, blitted into VIC layout once per frame
  • Dead space inside a bitmap page reused as scratch, hidden behind sprites
  • 4-pixel scroll from page flipping plus $D016
  • Self-modifying opcode swap (ORA to CMP) as a render state
  • Multi-row keyboard scans that read five keys in one compare
  • An RTS-dispatched job queue hung off the last raster interrupt
  • Locations as bounds-list ranges on a number line, not tile grids
  • A second PHA+PHA+RTS dispatch for per-entity AI handlers
  • Edge wrapping detected by coordinate underflow/overflow, not a connection table
  • Object records in a linked list with position-based proximity for drop
  • The entire sound engine is 8 instructions: one SID voice, gate on/off

Artifacts

  • TIRNANOG.asm — annotated disassembly of the loaders, raster engine, renderer, blitters, input and data tables
  • work/cpu6502.py, work/c64.py, work/kernal.py — the emulator
  • work/render.py — scanline-accurate VIC renderer and PNG writer
  • work/memmap.py, work/profile.py — the instrumentation
  • work/gen_disasm.py — regenerates the listing from the binary