Skip to content

Latest commit

 

History

History
201 lines (153 loc) · 8.9 KB

File metadata and controls

201 lines (153 loc) · 8.9 KB

04 — RNC ProPack 1, with a rotating XOR key over the literals

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.

The census comes first, and it is unambiguous

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 %.

The container: an offset table whose first entry is its own length

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.

The stream is not stock RNC — every literal is XORed

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.

Where it comes from: 584 bytes at the front of the game

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.

Three variants, and the CRC tells them apart

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.

Solving a key rather than searching for one

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:

  1. 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);
  2. 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);
  3. 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.

What the variants correlate with — and what they do not

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.

Compression follows the floppy origin, for the sixth time

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.

An honest note on the decoder

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.