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.
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:
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:
| Range | Size | Use |
|---|---|---|
| $C000-$C87F | 2176 B | compose buffer — 34 columns × 64 bytes, column-major |
| $C880-$C8BF | 64 B | one all-black sprite shape, shared by all eight sprites |
| $C000-$D3FF | 5 KB | bitmap page B (only rows 8-15 are ever displayed) |
| $E000-$F3FF | 5 KB | bitmap page A |
| $F400-$F7E7 | 1000 B | video matrix: bitmap colour for rows 0-15, text for the panel |
| $F7F8-$F7FF | 8 B | sprite pointers |
| $F800-$FFFF | 2 KB | the 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).
$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 | $D018 | meaning |
|---|
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.
$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.
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:
$2400-$41FF using the same pointer arithmetic the blitter uses.| $AA | $7EC7 | $7EDC | total |
|---|
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:
- Even frames render into page A at
$E000and publish$D016 = 0. - Odd frames render into page B at
$C000, with the scenery drawn 4 pixels further along, and publish$D016 = 4. - 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.
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.
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
| # | Name | Record | Axis 0 list | Axis 1 list | Bounds 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)
| Value | Direction |
|---|---|
| 0 | North |
| 1 | East |
| 2 | South |
| 3 | West |
$A2 (movement)
| Value | Meaning |
|---|---|
| 0 | Forward |
| 1 | Right |
| 2 | Backward |
| 3 | Left |
$C0 (input bits)
| Bit | Action |
|---|---|
| $01 | Walk right |
| $02 | Walk left |
| $04 | Camera left |
| $08 | Camera right |
| $10 | Thrust |
| $20 | Pick up |
| $40 | Drop |
| $80 | Nominate |
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 $B399 — neswnesw,
doubled so rotating past west wraps to north without a branch.
15 — Method
How this was taken apart
No ROM images, no VICE, no existing disassembly. The chain was:
- Parse the T64 container and extract the single PRG.
- Write an NMOS 6502 core, including the undocumented opcodes that crunchers like, plus a C64 memory model with correct PLA banking.
- Run the crack's depacker under it and dump memory. Discover a second layer, run that too.
- 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. - Add VIC-II raster timing, CIA timers and the keyboard matrix, drive the menus, and get into the game.
- 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.
- 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.
- 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 (
ORAtoCMP) 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+RTSdispatch 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 tableswork/cpu6502.py,work/c64.py,work/kernal.py— the emulatorwork/render.py— scanline-accurate VIC renderer and PNG writerwork/memmap.py,work/profile.py— the instrumentationwork/gen_disasm.py— regenerates the listing from the binary