Hello everyone,
I’m looking for some help with deciphering/extracting the ADF files from the game Fairyland Online. I’ve attached some files for anyone who would like to investigate them, as well as the game executable in case it is needed for analysis.
The specific file I’m interested in is mouse.adf, as I believe it may contain the game’s mouse cursors.
Any help in figuring out the structure of these files or extracting their contents would be greatly appreciated.
Thank you very much in advance to everyone who takes the time to help!
----
Extract the mouse cursor sprites from the client of "Fairyland Online" (aka
King of Kings 3 engine, developer: Lager Network Technologies). The cursors
live in a loose file: data\mouse.adf
I've fully reverse engineered the sibling container format (.lpq, the big
multi-entry archives) by disassembling Fairyland.exe with Ghidra, but the
single-resource .adf format is resisting the last step (LZO decompression of
the pixel payload). Posting my findings in case someone here has already
documented this engine, or can spot what I'm missing.
Tools used: Ghidra 12.1.3 (headless, scripted in Java), objdump, pefile,
capstone, liblzo2 via Python ctypes, python-lzo.
PART 1 - .lpq FORMAT (SOLVED, for reference / context)
--------------------------------------------------------
Magic: "LPQ\x1a" (dword 0x1A51504C) + dword 0x20 (constant)
Header struct at file offset 0x08, 40 (0x28) bytes:
struct+0x00 (file 0x08): total file size
struct+0x04 (file 0x0c): constant 0x1000000
struct+0x08 (file 0x10): approx. total decompressed size
struct+0x0c (file 0x14): flags (bit 0x02 = directory is XOR-"encrypted")
struct+0x10 (file 0x18): seek_pos -> file offset where the directory table
(ring buffer) starts
struct+0x14 (file 0x1c): number of entries to read in this pass
struct+0x18 (file 0x20): read position within the ring buffer
struct+0x24 (file 0x2c): ring buffer capacity (total slots)
Directory table: `capacity` slots of 28 (0x1c) bytes each, located at
`seek_pos`. If the "encrypted" flag is set, the whole block that gets read
(count*28 bytes) is passed through this XOR-chain cipher (found by
decompiling FUN_0059dcc0 in Fairyland.exe):
prev = 0x55555555 # seed / IV
for each little-endian dword D in the block, in order:
original = D # the still-"encrypted" value
D = original XOR prev
prev = original # chains on the ENCRYPTED value,
# not the decrypted one
Each decrypted directory entry (7 dwords / 28 bytes):
[0] absolute file offset of the compressed resource
[1] compressed size
[2] decompressed size
[3] flag (=1 for valid entries)
[4] small value, purpose unknown (not needed for extraction)
[5],[6] constants copied from the header, ignore
Payload at [0..1]: standard **LZO1X** stream (lzo1x_decompress_safe from
liblzo2 works out of the box, no extra transform needed). Decompresses
directly to a completely standard Windows BMP (BITMAPINFOHEADER etc, nothing
proprietary in the pixel data itself).
There's usually a special entry called "(listfile)" (real filenames,
newline-separated) whose compressed data sits immediately BEFORE seek_pos.
Verified against real files: 207/207 entries extracted correctly from a UI
archive, and 10991/10992 from a much bigger model archive (the 1 failure was
the expected "(patchlist)" metadata entry, not a real asset).
PART 2 - .adf FORMAT (WHERE I'M STUCK)
----------------------------------------
These are small, standalone, single-or-few-resource files (as opposed to the
big multi-thousand-entry .lpq archives). Confirmed samples analyzed:
nui_char_05.adf, mouse.adf, nfui001_00.adf, nsys004.adf, nsys014.adf,
nsys032.adf.
Magic: "FGF300" (6 bytes), then 2 more bytes.
Right after the magic, there's a run of bytes that LOOKS like padding
(0x55 55 55 55 ...) up to file offset 0x46. This is NOT raw padding - I
confirmed it decrypts cleanly to all-zero bytes using the exact same
XOR-chain cipher as the .lpq directory (seed 0x55555555, dword-aligned,
chained on the previous *encrypted* dword, running continuously from file
offset 0). This is a strong indicator the whole file uses the same cipher
primitive as .lpq.
At file offset 0x46 (uint16): a record count (1 for single-image files, 3
for nui_char_05.adf which has 3 sub-images, etc).
Following that: one variable-length record per image. In every sample, the
record contains (readable in RAW/plaintext form, i.e. NOT XOR-encrypted -
decrypting this region actually destroys it):
- a constant 4-byte marker: 4c 0f 1d 70
- a "dimension-ish" field (varies)
- alpha/flag dword: ff 00 00 00
- COMPRESSED SIZE (dword)
- ASCII marker "LrtJ" (4c 72 74 4a)
- DECOMPRESSED SIZE (dword)
- flag dword: 01 00 00 00
Example values (comp_size / decomp_size, both plausible and self-consistent
with file size):
nfui001_00.adf: 3315 / 3084 (single 2407-byte file)
nsys014.adf: 3825 / 3598
nsys032.adf: 7140 / 6939
nsys004.adf: 15045 / 14906 (first of 3 records)
Right after the record, the payload begins with one byte followed
immediately by the ASCII bytes "BM" (Windows bitmap signature) - e.g.
`f9 42 4d ...`. This strongly suggests the leading byte is meant to be
interpreted as the LZO1X literal-run opcode (0xf9 = 249; per LZO1X's
first-command rule, byte-17=232, i.e. "copy the next 232 bytes literally") -
and indeed, "BM" plus a nice chunk of following bytes decode perfectly clean
this way.
THE PROBLEM:
Feeding this straight into lzo1x_decompress_safe (liblzo2), with or without
re-applying the XOR-chain to the payload itself, always fails with
LZO_E_LOOKBEHIND_OVERRUN (-6) shortly after that initial literal run - in
two different files it fails at EXACTLY the same output length (235 bytes),
which smells like the 232-byte literal-run "success" is a coincidence
(copying raw bytes verbatim doesn't validate anything) rather than proof the
stream is genuinely stock LZO1X from that point on.
Byte right after the literal run in nsys014.adf is 0x73, which under
standard LZO1X opcode rules (0x40-0x7F range) should decode as a perfectly
valid short match (length 5, small distance) - yet the safe decompressor
rejects it as looking behind the start of the output buffer. I don't know if
that's because:
a) it's genuinely not LZO1X past the literal run (different variant, or
scrambled/substituted somehow), or
b) there's some other per-chunk transform I'm missing, or
c) my offset/alignment for where the "true" stream starts is still off by
a few bytes despite the clean "BM" landmark.
Static tracing dead end: I found the file-open helper used by the .lpq
loader (a wrapper resembling CreateFileA, at 0x005953f0 in this exe build)
and enumerated ALL its callers - there are exactly two: the confirmed .lpq
loader (FUN_0059e1d0), and one more (FUN_0059de50) which turned out to be a
*generic* container loader that hard-checks for magic "LPQ\x1a" - so it's
not what parses "FGF300" files despite superficially similar structure.
Tracing outward from the "data\\mouse.adf" string literal itself leads into
a huge, generic startup/init function (FUN_004024b0) that fans out into
hundreds of subsystems, impractical to fully trace via static xref-chasing
alone without an interactive decompiler session.
WHAT WOULD HELP
----------------
- Anyone who has already documented Lager Network Technologies' .adf format
(King of Kings 3 uses the same engine/format family, so KoK3 tooling /
threads would likely apply directly).
- A sanity check on whether the payload after the small record header really
is LZO1X, or something else (custom LZ variant / substitution box applied
before or after LZO / etc).
- Anyone comfortable doing live/dynamic analysis (x64dbg or similar) who
could set a breakpoint on the file read for data\\mouse.adf and step
through to the real decompression call - that would settle this in
minutes versus my current blind static guessing.
Happy to share the exe, sample .adf files, my Ghidra scripts, or the Python
XOR-chain implementation on request. Thanks in advance for any pointers.
APPENDIX - XOR-chain reference implementation (Python)
---------------------------------------------------------
def dexor_chain(data: bytes, seed: int = 0x55555555) -> bytes:
import struct
n = len(data) // 4
out = bytearray(data[:n * 4])
prev = seed
for i in range(n):
cur = struct.unpack_from("<I", out, i * 4)[0]
dec = cur ^ prev
struct.pack_into("<I", out, i * 4, dec)
prev = cur # chains on the RAW/encrypted dword
return bytes(out)
(Confirmed correct against the .lpq directory table and against the .adf
zero-padding region; it's the .adf payload's exact transform - if any -
that's still unresolved.)
Fairyland adf files.zip
By
UZ.- ·