Reproduce with:
python tools/census.py _work/files # sizes, entropy, magic
python tools/decrunch.py _work/files _work/unpacked # every block, CRC-checked
python tools/xorkey.py _work/files/OverG.Rnc 20 # solve a block's key
Raw output: notes/census.txt,
notes/decrunch-log.txt.
91 of the 97 files score above entropy 7.0. The six that do not are the
boot script, loaderblackpal and the three C: commands — every file that is
not game data is raw, and every file that is game data is packed. That is a
much sharper split than any other disc in this series, and it is visible before
a magic number is looked at.
88 files begin with RNC 0x01. Extensions say nothing: .Rnc, .Bin,
.Pak, .Bit, .Map, .Glu and .Raw are all RNC blocks, and one of the
seven files actually named .Rnc (OverG.Rnc) is not an RNC block at all
but a container of five (below). Scan by magic, not by name.
And the relation holds across the whole family: on all 88,
18 + packed_length == filesize
exactly — an 18-byte RNC ProPack header and nothing after the stream. That is the check the platform checklist asks for and it identified the container before a bit was decoded.
Totals: 106 RNC blocks in 92 files, 2,156,143 bytes on disc unpacking to 4,285,931 bytes of distinct content — 49.1 %.
Three files (CreditsG.Bin, OverG.Rnc, Pack.Bin) and the game's own hunks
use a wrapper that turns up all over this disc:
ULONG offset[0] == 4 * n, i.e. the length of the table itself
ULONG offset[1]
...
ULONG offset[n-1]
RNC blocks, in order
Reading n = file[0..4] / 4 gives the entry count for free, and the blocks
chain: each block's offset + 18 + packed_length lands on the next offset, to
within the single byte of padding an odd packed length leaves.
CreditsG.Bin 3 entries 12, 89678, 144198
OverG.Rnc 8 entries 32, 11552, 22010, 31356, 0, 43168, 43168, 43168
Pack.Bin 5 entries 20, 21388, 46126, 69980, 104264
OverG.Rnc is worth a second look: slot 4 is zero and slots 5, 6 and 7
all point at the same block. A zero offset is an empty slot; three slots
sharing one offset is three regions of the game asking for one tune. Both are
in doc 11.
The same shape carries the text (doc 08), the sound bank (doc 09) and several sprite banks (doc 07): a big-endian offset table whose first entry is its own length is this studio's one container format, used at four levels of the disc.
RNC ProPack method 1 is documented and Dragonstone's tools/rnc.py decodes it.
Run on these files it produces output of exactly the right length and
fails the CRC-16 every time, which is the informative failure: the Huffman
and LZ layers are stock, so the container, the bit reader and the token loop
are all correct, and something is wrong with the bytes.
The something is visible in the output. English.Bin should begin with a table
of 32-bit offsets and instead begins
d0 d0 d5 78 d0 d0 d5 78 d0 d0 d5 d8 d0 d0 d5 06 ...
which is that table with 0xD0 XORed into every byte.
The executable decrunches its own hunks (doc 05) and its decruncher is the specification. Two instructions in it are not in ProPack:
QuestII.hunk0, hunk offset 0x0d6:
0x0d6 1a db move.b (a3)+,(a5)+ ; copy one literal byte
0x0d8 bb 2d ff ff eor.b d5,-1(a5) ; XOR it with the key
0x0dc 51 c8 ff f8 dbra d0,0x0d6
0x0e0 e2 5d ror.w #1,d5 ; rotate the key, once per run
d5 is a 16-bit key. Every byte of a literal run is XORed with its low
byte; after each non-empty run the whole key is rotated right one bit, so it
cycles with period 16. Match copies are not touched — they take their bytes
from output that has already been de-XORed — which is why the structural decode
succeeds and only the bytes are wrong.
The bmi that skips an empty literal run jumps past the ror, so a run of
length zero does not advance the key. That detail matters: get it wrong and the
key desynchronises after the first empty run.
Nothing has to be guessed, because every block carries a CRC-16 of its unpacked
data in its own header. tools/rnc1x.py tries each variant and keeps the one
that verifies.
| Variant | Key | Blocks | Where |
|---|---|---|---|
| plain | 0x0000 — no obfuscation at all | 15 | HQ2Title.Rnc, Intro.Rnc, ShopG.Rnc, TitleG.Rnc, and 11 of the 15 container blocks |
| fixed key | 0x5ED0, carried in the decruncher | 88 | 84 whole files and 4 container blocks |
| stream key | 16 bits read from the stream — 0xBE1A on all three | 3 | the three crunched hunks of /QuestII |
All 106 blocks verify against their own CRC-16, and 88 of them verify against the same key.
The stream-key variant is the game executable's own, and it is the one the
584-byte stub implements: after the two RNC flag bits it reads sixteen more and
puts them in d5. The data files have no such field — the game's other
decruncher, the one inside hunk 1, carries 0x5ED0 as a constant instead. Two
decrunchers, one program, one bit of layout between them.
tools/xorkey.py recovers a block's key by measurement, and the method is
worth recording because it makes an otherwise 65,536-way search free.
CRC-16/ARC with a zero initial value and no final inversion is linear over
GF(2): crc(A xor B) == crc(A) xor crc(B) for equal-length messages. So:
- decode the block once with key 0, recording for each output byte which literal run it descends from (a match copy inherits its source byte's run);
- the mask the real key would have XORed in is
mask[i] = ror16(K, run[i]) & 0xFF, which is a XOR of at most 128 basis masks — one per (run class 0..15, bit 0..7); - CRC each basis mask once; every candidate key is then a XOR of at most 128 precomputed words.
128 CRCs instead of 65,536 decodes. It is what found 0x5ED0 and it is what
established that the plain blocks really are key 0 and not a fourth variant.
One caution from doing it the slow way first. A naive brute force that
decodes and CRCs 65,536 times finds a false positive — a 16-bit CRC over
65,536 candidates expects about one collision, and on English.Bin it produced
0x41FC, which passes the CRC and yields obvious garbage. Check the content
of a recovered key against a second file before believing it.
The three container files mix variants inside one file:
CreditsG.Bin [0] xor 0x5ED0 [1] plain [2] plain
OverG.Rnc [0..3] plain [4] empty [5..7] xor 0x5ED0
Pack.Bin [0..4] plain
OverG.Rnc's four screens are plain and its music is keyed; CreditsG.Bin's
music is keyed and its two bitmaps are plain. So the containers were built by
concatenating blocks that had already been packed, at different times, with the
packer's obfuscation switched on or off — and the container is a later
assembly step that did not re-pack anything.
Nothing else correlates. The plain blocks are not the oldest by timestamp, not one content type, not one directory and not one size class: they include two ProTracker modules, four full-screen bitmaps, the 3,100-row credits scroll and five sprite banks, and the keyed set includes two other ProTracker modules and four other full-screen bitmaps. Why these fifteen is in doc 12.
The platform checklist's rule is that compression on this format tracks whether the title came from floppy, not whether it is on a CD. This disc is a three-floppy A1200 game that kept its loader (doc 03) and it packs 88 of 97 files at 49.1 % — the sixth positive case, against four negatives that are all titles with no floppy ancestor or no floppy loader left.
It also lands the tenth measurement of the 2.7–13.3 MB band. On disc the game is 2.16 MB, which would sit below the band; unpacked it is 4,285,931 bytes = 4.09 MB, between Gloom (3.86) and Legends (4.4). Measure the decompressed size.
tools/rnc.py is Dragonstone's ProPack method-1 decoder, unchanged;
tools/rnc1x.py adds only the two-instruction XOR layer above and the choice
of where the key comes from. It reproduces 106 of 106 blocks with their
CRC-16s matching, which is the same standard Speris' Imploder transcription met
and the one Liberation's RNC-magic codec did not. Nothing here was guessed
and nothing is approximate.