7 hours ago7 hr Localization 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" (akaKing of Kings 3 engine, developer: Lager Network Technologies). The cursorslive in a loose file: data\mouse.adfI've fully reverse engineered the sibling container format (.lpq, the bigmulti-entry archives) by disassembling Fairyland.exe with Ghidra, but thesingle-resource .adf format is resisting the last step (LZO decompression ofthe pixel payload). Posting my findings in case someone here has alreadydocumented 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 bydecompiling 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 oneEach 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, ignorePayload at [0..1]: standard **LZO1X** stream (lzo1x_decompress_safe fromliblzo2 works out of the box, no extra transform needed). Decompressesdirectly to a completely standard Windows BMP (BITMAPINFOHEADER etc, nothingproprietary 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 UIarchive, and 10991/10992 from a much bigger model archive (the 1 failure wasthe 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 thebig 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 - Iconfirmed it decrypts cleanly to all-zero bytes using the exact sameXOR-chain cipher as the .lpq directory (seed 0x55555555, dword-aligned,chained on the previous *encrypted* dword, running continuously from fileoffset 0). This is a strong indicator the whole file uses the same cipherprimitive as .lpq.At file offset 0x46 (uint16): a record count (1 for single-image files, 3for nui_char_05.adf which has 3 sub-images, etc).Following that: one variable-length record per image. In every sample, therecord 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 00Example values (comp_size / decomp_size, both plausible and self-consistentwith 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 followedimmediately by the ASCII bytes "BM" (Windows bitmap signature) - e.g.`f9 42 4d ...`. This strongly suggests the leading byte is meant to beinterpreted as the LZO1X literal-run opcode (0xf9 = 249; per LZO1X'sfirst-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 cleanthis way.THE PROBLEM:Feeding this straight into lzo1x_decompress_safe (liblzo2), with or withoutre-applying the XOR-chain to the payload itself, always fails withLZO_E_LOOKBEHIND_OVERRUN (-6) shortly after that initial literal run - intwo 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 thestream is genuinely stock LZO1X from that point on.Byte right after the literal run in nsys014.adf is 0x73, which understandard LZO1X opcode rules (0x40-0x7F range) should decode as a perfectlyvalid short match (length 5, small distance) - yet the safe decompressorrejects it as looking behind the start of the output buffer. I don't know ifthat'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 .lpqloader (a wrapper resembling CreateFileA, at 0x005953f0 in this exe build)and enumerated ALL its callers - there are exactly two: the confirmed .lpqloader (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'snot what parses "FGF300" files despite superficially similar structure.Tracing outward from the "data\\mouse.adf" string literal itself leads intoa huge, generic startup/init function (FUN_004024b0) that fans out intohundreds of subsystems, impractical to fully trace via static xref-chasingalone 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 PythonXOR-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 .adfzero-padding region; it's the .adf payload's exact transform - if any -that's still unresolved.) Fairyland adf files.zip
Create an account or sign in to comment