Reverse-engineering notes on HeroQuest II: Legacy of Sorasil (Gremlin
Graphics, 1994) for the Amiga CD32 — a three-floppy A1200 board-game
adaptation moved onto a CD with its floppy loader intact, packed with RNC
ProPack 1 wearing an XOR layer nothing else on this format uses, and running
an ECS-class display on AGA silicon: no BPLCON3, no BPLCON4, no FMODE
and no Akiko anywhere on the disc.
This repository documents a 25,436-sector volume of which 95.4 % is empty and the emptiness is in front of the files; 97 files, 92 of them packed; five Red Book tracks of which the game can only reach two; a game that unpacks to 4.09 MB, inside the 2.7–13.3 MB band that nine earlier discs have not broken; and a retail title screen that still calls the game Masters.
Documentation only. No game asset, no extracted art, no audio and no
executable code is committed here. The tools in tools/ reproduce
every figure, table and image in these pages from your own legally obtained
copy.
| Doc | Contents |
|---|---|
| 00-overview.md | The disc in one table, what is on it, and the hunk/file offset convention |
| 01-disc-and-filesystem.md | ISOCD 1.03, a date typed into the volume-set field, and 24,272 zero sectors in front of the files |
| 02-timestamps.md | A six-minute copy session, three later files, and a master 11m34s after the executable |
| 03-boot-chain.md | Seven lines, three floppy volume names, and two C: commands byte-identical to other studios' |
| 04-compression.md | RNC ProPack 1 with a rotating XOR key — three variants, 106 blocks, every CRC verified |
| 05-executable.md | A hunk file that decrunches and relocates itself in 584 bytes |
| 06-display-and-akiko.md | AGA measured to zero and Akiko measured to zero, and the 228-byte program that settles the colour count |
| 07-graphics-formats.md | Plane-separated screens, 64 × 64 dungeon maps of 8-byte cells, 24 bytes per row on all 36 sprite sheets |
| 08-text-and-licence.md | 362 strings × 3 languages, accents mapped onto ( ) * +, and the Hasbro / Games Workshop notice |
| 09-audio.md | Six ProTracker modules, 39 raw effects, and three Red Book tracks nothing plays |
| 10-input-and-saves.md | Two ports feeding one pointer, and a save that stores 24 bytes and reads 238 |
| 11-archaeology.md | A title screen for a different game, a logo with the copyright removed, and a Sheffield pub |
| 12-open-questions.md | Fourteen things unresolved, with the measurement beside each |
| notes/file-inventory.md | All 97 files with LBA, size, timestamp, SHA-1, packing variant and unpacked size |
| cd32-platformnotes-doc | Platform checklist — what to look for on any CD32 or CDTV disc. Shared by every Amiga CD pipeline in this series; this repository does not keep a copy |
Raw tool output — the ISO listing, the sector map, the entropy census, the
timestamp log, the decrunch log, the LVO histogram, the register histogram, the
copper list, the module dump, the three text dumps, the path check and the
string dumps — is in notes/.
RNC ProPack 1 with a rotating XOR key, and three variants on one disc. The
container is stock — an 18-byte header with both CRC-16s present and correct,
and 18 + packed == filesize on all 88 whole-file blocks. The stream is not:
every literal byte is XORed with the low byte of a 16-bit key that rotates one
bit right after each non-empty literal run, so match copies are untouched and a
stock decoder produces output of exactly the right length that fails the CRC.
The two extra instructions are in the game's own 584-byte decruncher:
0x0d6 move.b (a3)+,(a5)+
0x0d8 eor.b d5,-1(a5)
0x0dc dbra d0,0x0d6
0x0e0 ror.w #1,d5
106 blocks, three variants, every one identified by its own CRC: 88 with the
fixed key 0x5ED0, 3 with a key read out of the stream (0xBE1A, the
executable's own hunks), and 15 plain. The key was solved rather than
searched: CRC-16/ARC is linear over GF(2), so decoding once with key 0 and
recording each output byte's literal run turns 65,536 candidate decodes into 128
precomputed CRCs. A naive brute force finds a false positive — one collision
is what 65,536 candidates against a 16-bit CRC should give, and it duly turned
up.
The executable decrunches and relocates itself in 584 bytes. /QuestII has
six hunks and, as it sits on the disc, no relocations, no symbols and no debug
hunks — impossible for a six-hunk program, and the tell. Three hunks store
ULONG reloc_offset, ULONG longwords|memflags, RNC block, each decrunching to
its real content followed by a complete HUNK_RELOC32 table: 4,505 relocations
in the code hunk, 1,019 in the data hunk, none of which LoadSeg can see. Hunk 0
walks the segment list, tests byte 8 of each hunk for the RNC magic, decrunches
in place, applies the relocations itself — clearing the table as it goes, which
leaves 22 KB of free scratch space — and finishes by patching the return address
it pushed as its first instruction so that its rts enters hunk 1. And the
hunk numbers in the embedded tables are off by one, because the walk starts at
the second segment; three of the five target ranges do not fit the hunk their
number names and do fit the next one.
AGA measured to zero, three independent ways. graphics.library is opened
and never called — not one movea.l of its base exists in 113,112 bytes —
so LoadRGB4 and LoadRGB32 are both zero. A base-relative register scan (the
program does lea $DFF000,aN 83 times and then addresses everything as
d16(An), so an absolute scan sees 13 registers out of 57) finds no
BPLCON3, BPLCON4, FMODE or DIWHIGH. And a walk backwards from every
FFFF FFFE in the whole decrunched tree finds no copper list that writes one.
The disc's single real copper list is 256 bytes: 320 × 200, BPLCON0 = $5000
for five bitplanes, and at raster line 204 a switch to $6000 for six — with
BPLCON2 = $0024 leaving KILLEHB clear, so the bottom 60 lines are genuine
Extra-Half-Brite and nothing here needs an A1200.
And the disc states its own colour count. /loaderblackpal, 228 bytes, is
the only file with a symbol table intact. It opens freeanim.library first,
opens three more libraries in the gap, closes it, calls LoadView(NULL) — and
then runs BLACKCOLOURS: lea $DFF180,a0 / moveq #31,d7 / move.w #0,(a0)+ / dbra. Thirty-two colour registers. A program with 64 or 256 to blank would
have written 64 or 256.
Akiko is zero and the false positive is the same one Gloom has. No
$00B80038, no $00B80000 pointer load, no $C0DE0000, in the 52.5 MB image,
in all 97 files or in all 111 decrunched blocks. Six bare 00 B8 00 hits in the
code hunk, of which two are inside a descending 16-bit table whose four visible
words — 00c3 00b8 00ae 00a4 — are exactly the four the platform checklist
quotes from Gloom. It is ProTracker's chromatic period table, three octaves from
826 down to 109 at 2^(−1/12) a step, and $00B8 is entry 33. The false positive
is a property of every Amiga program that ships a note table.
The disc reconstructs its own floppy release. The boot script assigns
"Legacy of Sorasil DISK 1:", DISK 2: and DISK 3: to cd0:, and the loader
addresses all 91 of its files through those volume names as full paths
compiled into the data hunk — 13 files on disk 1, 39 on disk 2, 39 on disk 3.
Ninety exist. The ninety-first, Legacy of Sorasil DISK 2:Level.Map, does not,
and it sits at the end of the table right after the nine dungeon maps that do.
Two C: commands are other studios' copies. c/setpatch 40.12 is
byte-identical to Liberation's and Prey CD32's — three discs, three studios,
three publishers — and c/Assign 37.4 is byte-identical to Prey's, Speris',
Legends' and Liberation's: five discs, five studios, 1993 to 1996, one
3,220-byte file. Hash everything in c/.
Five Red Book tracks and the game can play two. CD_PLAYTRACK has one call
site, which passes 2 or 3, and the "track finished" handler toggles strictly
between 2 and 3. Tracks 4, 5 and 6 — 7 minutes 15 seconds, 9.8 % of the disc —
are named by nothing.
A fifth answer to the text-encoding question. Not CP437, not Latin-1, not
accents dropped: accents remapped onto ASCII punctuation. ) is Ä, * is
Ö, + is Ü, ( is Ç, < and > are the opening and closing single quotes and
> doubles as the apostrophe. Zero bytes above 0x7F in any of the three
languages, and the 70-character alphabet string in the code confirms it from the
other side.
Nineteen strings were never translated, and eighteen of them are the ending.
Entries 304–321 — the complete closing narration, from the flowers growing again
to Alamon revealing himself as the Knight Sorasil — are byte-identical in
English.Bin, Deutsch.Bin and Francais.Bin. So are LOAD, SAVE, RESET,
EXIT, and Please insert Legacy of Sorasil Patch, which names a fourth floppy
volume nothing on the disc assigns.
95.4 % of the volume is empty, in front of the files. Descriptors 16–18, path tables 19–20, trademark block 21, root directory 22 — and then 24,272 zero sectors before the first file at 24,295, in a 25,436-sector volume. The 32 unclaimed sectors at the end are the ordinary run, on the seventh disc to show exactly 32 across a range from 772 to 255,552 sectors.
And the archaeology. The retail title screen reads HEROQUEST II / M A S T E R S on a disc that calls the game Legacy of Sorasil in every other
place; the publisher's logo ships twice, and the copy in Sketch/ that no string
names is the same picture with the copyright line deleted, differing in 220
of 40,000 bytes and only in plane 0, rows 192–199; the save is loaded with the
string FrogAndParrot in a register, which is a pub on Division Street in
Sheffield; a complete floppy disk-swap wait loop sits in the binary with no
bsr, jsr, jmp or longword reference to it anywhere; the save stores 24
bytes and reads 238 back, with the divu.w #10 that caused it still in place;
and the data preparer in the volume descriptor, Kevin Dudley, is the name the
game's own credits screen gives under Programming.
Python 3, numpy, Pillow and capstone. Everything takes the disc image or a
directory of extracted files and prints or writes what the documents quote.
| Tool | What it does |
|---|---|
isoread.py |
walk the ISO 9660 volume, list or extract every file with LBA, size, date, SHA-1 |
sectormap.py |
sector map against the declared volume size; report every unclaimed run |
tmsector.py |
find the 'TM' tag, follow the pointer, hash the block in three pieces |
timestamps.py |
sort every directory record into the mastering log |
census.py |
size, entropy, zlib ratio, first longword vs size, last non-zero byte, magic |
rnc.py |
RNC ProPack method 1 (Dragonstone's, unchanged) |
rnc1x.py |
the disc's variant — the XOR layer, and the three key sources |
xorkey.py |
solve a block's 16-bit key from its CRC by GF(2) linearity |
decrunch.py |
every block on the disc: whole files, containers, and the executable's hunks |
hunk.py |
AmigaDOS hunk reader — header, code/data/bss, reloc, symbol, debug |
lvo.py |
attribute every jsr d16(a6) to a library by tracking the last a6 load |
customregs.py |
base-relative custom-chip register histogram (lea $DFF000,aN + d16(An)) |
scan.py |
absolute register references, Akiko patterns, io_Command immediates |
agascan.py |
Akiko and AGA sweep over an image or a whole tree |
copper.py |
disassemble a copper list at an offset |
copperfind.py |
find every copper list in a tree and report whether any writes an AGA register |
planar.py |
planar bitmap to PNG, interleaved or plane-separated, with or without a palette |
stride.py |
byte autocorrelation, to find a headerless bitmap's row pitch |
palettes.py |
find $0RGB runs and score them for precision and Extra-Half-Brite |
mods.py |
every ProTracker module: title, samples, pattern count, header vs file size |
text.py |
the three language files: offset table, string dump, comparison |
paths.py |
check every path the game names against the disc, and rebuild the floppy layout |
inventory.py |
build notes/file-inventory.md |
m68kdis.py |
Capstone disassembly with the raw bytes beside every instruction |
strings.py |
printable-ASCII extractor with file offsets |
The Capstone M68K backend prints wrong but plausible immediates,
displacements and branch targets on this code. Every constant, offset and branch
target quoted in these documents was re-read from the raw bytes;
tools/m68kdis.py prints the bytes beside every instruction so that the
re-reading is reproducible. Hunk offsets, file offsets and unrelocated absolute
addresses are distinguished throughout — the convention is stated at the end of
00-overview.md.
python tools/isoread.py "HeroQuest II ... [Track 01 of 06].iso" -x _work/files
python tools/sectormap.py "HeroQuest II ... [Track 01 of 06].iso"
python tools/tmsector.py "HeroQuest II ... [Track 01 of 06].iso"
python tools/timestamps.py "HeroQuest II ... [Track 01 of 06].iso"
python tools/census.py _work/files
python tools/decrunch.py _work/files _work/unpacked
python tools/agascan.py "HeroQuest II ... [Track 01 of 06].iso" _work/files _work/unpacked
python tools/lvo.py _work/unpacked/QuestII.hunk1
python tools/customregs.py _work/unpacked/QuestII.hunk1
python tools/copper.py _work/unpacked/QuestII.hunk4 2 3534
python tools/mods.py _work/unpacked
python tools/text.py _work/unpacked/English.Bin
python tools/paths.py _work/unpacked/QuestII.hunk1 _work/unpacked/QuestII.hunk2 --files _work/files
_work/ is in .gitignore and stays out of the repository, as does every image
the tools render.