Plan, scope & roadmap
PLAN.md @ developmeteor-decomp — Decompilation plan for FINAL FANTASY XIV 1.23b
This is the master plan for a decompilation of the FINAL FANTASY XIV 1.x
(specifically patch 1.23b) Windows client. The end goal is a readable,
buildable, and — for the parts that matter — byte-matching C/C++
re-derivation of the five shipped PE binaries, scoped to whatever depth
is useful for the rest of the workspace (garlemald-server, garlemald-client,
project-meteor-server, SeventhUmbral).
Current recovered-match count: 68,017 _rosetta/*.cpp files / 782,774 B (4.13 %) across all five binaries (regenerated by make update-docs from make reconcile).
1. Goals and non-goals
Goals
- Recover enough of the client's C/C++ source to:
- Document every wire-protocol packet (opcodes, struct layouts,
bitfield meanings) — this directly unblocks
garlemald-server. - Document the in-memory game-state layout (Actor, Inventory, Map,
Director, Quest, Battle) so
garlemald-serverand the LSB cross-reference effort have a ground-truth reference instead of spreadsheet folklore. - Document the file-format layer (sqpack hashing,
.dat/.idx, ZiPatch, BattleCommand.csv decoders, BGM/cutscene format) so tooling miners (ffxiv-mozk-tabetai-miner,mirke-menagerie-miner,wiki-scraper) can be replaced with first-party readers. - Recover combat / damage / hit-rate / status-effect formulas as
compilable C — calibration ground truth for the
battle-command-parser-derived tables and theyoutube-watcheratlas damage samples. - Provide a reverse-engineered open-source client capable enough
to drive automated client-side replay against
garlemald-serverwithout depending on Wine + the original.exe.
- Document every wire-protocol packet (opcodes, struct layouts,
bitfield meanings) — this directly unblocks
Non-goals (for now)
- Shipping a playable open-source client. Renderer and audio
paths are huge time-sinks and are already covered well enough by
the original .exe + WineD3D for our automation purposes
(
ffxiv-actor-cli). - A 100 % matching decomp of every binary. Fully matching 11.8 MB
of MSVC 2005
.textis many person-years. We prioritise function matching on the high-value subsystems above and accept functional (non-matching) re-derivation for the long tail (UI, settings, installers, telemetry). - Decompiling
MSSMIXER/ Miles Sound System / DirectX wrapper glue / VC++ runtime / STL. These are off-the-shelf middleware. We identify them, exclude them from the work-pool, and link against prebuilt equivalents.
2. Binary inventory
All five shipped PEs (from ffxiv-install-environment/target/prefix/.../FINAL FANTASY XIV/):
| Binary | Size (bytes) | Linker | Build timestamp | Image base | Entry RVA | .text size | Sections |
|---|---|---|---|---|---|---|---|
ffxivgame.exe | 15,996,808 | 8.0 | 2012-09-11 16:30:23 | 0x400000 | 0x5d4baa | 11,784,192 | 6 |
ffxivboot.exe | 12,961,112 | 8.0 | 2010-09-16 11:46:54 | 0x400000 | 0x507a6a | 9,527,296 | 6 |
ffxivconfig.exe | 3,471,240 | 8.0 | 2012-09-11 16:37:31 | 0x400000 | 0x1dec0 | 303,104 | 5 |
ffxivupdater.exe | 640,344 | 8.0 | 2010-09-16 11:42:12 | 0x400000 | 0x3fa4b | 434,176 | 5 |
ffxivlogin.exe | 403,296 | 8.0 | 2011-01-28 09:25:41 | 0x400000 | 0x26838 | 258,048 | 4 |
All are PE32, IMAGE_FILE_MACHINE_I386 (0x14c), GUI subsystem,
Characteristics=0x103. Linker version 8.0 means the toolchain is
Visual Studio 2005 (MSVC _MSC_VER=1400, cl.exe 14.00.x). That
identification is load-bearing for matching decomp; see §4.
ffxivgame.exe sections (the prize):
.text vaddr=0x00001000 vsize=0xb3b56d rsize=0xb3c000 RX
MSSMIXER vaddr=0x00b3d000 vsize=0x00006d rsize=0x001000 RX -- Miles Sound System mixer (off-the-shelf)
.rdata vaddr=0x00b3e000 vsize=0x326032 rsize=0x327000 R
.data vaddr=0x00e65000 vsize=0x117940 rsize=0x0bf000 RW
.tls vaddr=0x00f7d000 vsize=0x0000a9 rsize=0x001000 RW
.rsrc vaddr=0x00f7e000 vsize=0x01a54c rsize=0x01b000 R
Priority order for decomp work: ffxivgame.exe first (everything
gameplay), ffxivboot.exe second (its bytes-9.5 MB .text is mostly
the launcher GUI but it embeds the early-network code path that the
real game inherits a lot of from), then the small binaries
opportunistically.
3. Strategy: hybrid matching + functional decomp
There are three established models; we adopt a hybrid.
| Model | Output | Difficulty | Verifiability | Examples |
|---|---|---|---|---|
| Matching decomp | Byte-identical .exe | Highest | objdiff zero-diff per function | LEGO Island, OoT, FF7-decomp |
| Functional decomp | Equivalent C, any compiler | Medium | Behavioural test (round-trip a packet, etc.) | most game-engine REs |
| Fully-RE / clean-room | New code, original spec only | Low–Medium | None per-function — only end-to-end | OpenRA, OpenMW, ScummVM |
Our hybrid:
- Matching for the wire/protocol/file-format layer. Packet
encoders/decoders, sqpack readers, ZiPatch, BattleCommand parser,
Blowfish/cipher routines. These are small (≤ 1 KB per function),
numerically dense, and correctness is checkable byte-for-byte by
re-encoding a known input. Exactly the cases where matching is
cheap and pays off: the moment
objdiffis green, the function is unambiguously correct. - Functional for game-state and gameplay logic. Battle math,
status effects, mob AI, quest/event scripting host, director
framework. Re-derive into clean C++ with whatever helpers we want;
match against behavioural fixtures (saved packet captures, save
states from
data-backups/, OCR damage samples inffxiv_youtube_atlas_context.md). - Excluded middleware: Miles Sound System (
MSSMIXER+ linkedmss32.dll), DirectX 9 wrappers, MSVC C/C++ runtime, ATL/MFC fragments, RSA/CryptoAPI shims, CRT zlib. Identified and skipped. Update (2026-05-25): these are now not just skipped but named. A Ghidra FidDb built from the statically-linked libraries (compiled from source with the VC8 toolchain) named 899 functions inffxivgame.exe: MSVC CRT (47), zlib 1.2.3 (19), Lua 5.1.4 VM (~353 — the scripting host's interpreter is off-the-shelf Lua), and OpenSSL 1.0.0 (~480 — the binary statically links a full crypto suite, not just RSA/CryptoAPI shims). DirectX 9 (d3d9/d3dx9_41) is dynamically linked, so it's already named via the import table — no static D3DX to match. Pipeline + per-library results:docs/fid_signature_matching.md. - Renderer: deferred. We document the call-graph + buffer
layouts so
garlemald-clientcould, if it ever wants, re-implement on Vulkan/Metal.
Function matching uses a pinned MSVC 14.00.50727.42 (VS 2005 RTM)
or 14.00.50727.762 (VS 2005 SP1) cl.exe running under Wine on
Apple Silicon, mirroring the LEGO Island decomp setup. SP1 is the
working hypothesis; we confirm by matching a hand-picked "Rosetta
Stone" function (a small cdecl strchr-like helper) against both and
seeing which matches with default /O2 flags.
4. Toolchain plan
Static analysis
- Ghidra 11.x (free, scriptable, headless mode, JDK 17, runs
natively on Apple Silicon). Primary disassembler + decompiler.
Project:
meteor-decomp/build/ghidra/ffxivgame.gpr. - rizin / cutter as a secondary opinion — its decompiler often recovers different control-flow shapes than Ghidra and the diff is informative.
- objdump (LLVM) — sanity check section layouts, run after every rebuild.
- decomp.me — function-level collaboration; we register a "msvc 2005 x86" preset and post bite-sized functions there for matching.
Diff + verification
- objdiff (
https://github.com/encounter/objdiff) — the matching-decomp standard. Cross-platform, reads PE/PDB/ELF, gives per-function delta highlights. Configured with the same MSVC toolchain. tools/compare.py— wrapsobjdifffor batch runs and dumps a CSV of per-function status (matched / partial / unmatched / TODO).
Compiler under Wine
- VS 2005 SP1
cl.exe+link.exefrom Microsoft's archive, plus the matching SDK (Windows Server 2003 SDK / Platform SDK 2003 R2). - Wrapped by
tools/cl-wine.shso Make/Ninja can invoke it on macOS. - The MSVC runtime headers we ship are the VS 2005 SP1 headers. Modern STL won't match.
Disassembly + splitting
- There is no
splat-equivalent for x86 PE at the maturity of the MIPS ecosystem. We roll our own:tools/extract_pe.py— parseIMAGE_NT_HEADERS, list sections, dump per-section binaries.tools/ghidra_scripts/DumpFunctions.java— Ghidra headless script that walkscurrentProgram.getFunctionManager()and writes oneasm/<rva>_<symbolname>.sper function plus a JSON symbol map. (Java, not Jython — Ghidra 12 dropped Jython 2.7.)tools/build_split_yaml.py— generateconfig/ffxivgame.yamllisting every(start_rva, end_rva, name, status)tuple — the work-pool for the project.
Symbol seed sources
We do NOT have a PDB. But we have several side-channels:
- Strings + RTTI: Ghidra's
RTTI Analyzerrecovers C++ class names + vtable layouts from PE32 MSVC binaries; this gives us free names for hundreds of classes. (1.x predates/GR-being default-off, so RTTI is fully present in the binary.) - Function-name leaks via
__FILE__/__FUNCTION__macros — anyassert/Verify/MES_LOGcall on a hot path embeds the function name as a literal string in.rdata. Runstrings -td .rdata | grep -E '\\.(cpp|h)'to grep them. - Project Meteor's C# server — the
project-meteor-serverand variant trees are reverse-engineered from the same client; their symbol naming (SetActorPropertyPacket,WeaponSkill, etc.) is our naming convention. SeventhUmbralworkspace — the upstream C++ launcher already has reversed packet structs we can import.ffxiv-actor-cli/logs/*.log+ per-region capture dirs — packet-level traces with annotated opcodes (already in the workspace from earlier sessions).battle-command-parserdecoded enums — every BattleCommand field maps to a struct member in the binary; the column legends inBattleCommand.csvare the field-name source.- Gamer Escape / Fandom / Console Games wikis — see CLAUDE.md;
zone IDs, weather IDs, NPC IDs, item IDs. These appear in
.rdataas numeric constants and let us pin functions ("the function that references zone ID 166 must touch Gridania").
5. Repository layout
meteor-decomp/
├── PLAN.md <- this file
├── README.md <- quickstart for contributors / agents
├── LICENSE.md <- license for OUR original work
├── NOTICE.md <- crediting upstreams + stating clean-room status
├── .gitignore <- excludes orig/, build/, ghidra projects
├── Makefile <- top-level: split / build / diff
├── orig/
│ ├── README.md <- "do not commit binaries; symlink from ffxiv-install-environment"
│ └── (symlinks to the five .exe files, populated by tools/symlink_orig.sh)
├── asm/
│ ├── ffxivgame/ <- one .s per function, named <rva>_<symbol>.s
│ ├── ffxivboot/
│ └── ...
├── src/
│ ├── ffxivgame/
│ │ ├── net/ <- packet encoders/decoders (matching target)
│ │ ├── sqpack/ <- file-format readers (matching target)
│ │ ├── battle/ <- combat math (functional target)
│ │ ├── director/ <- event/quest framework (functional target)
│ │ ├── actor/ <- Actor hierarchy (functional target)
│ │ ├── ui/ <- HUD/menus (deferred)
│ │ └── render/ <- DX9 binding (deferred)
│ └── ...
├── include/ <- headers shared across decomp targets
├── config/
│ ├── ffxivgame.yaml <- function work-pool (rva ranges + status)
│ ├── symbols.txt <- known symbols (manual + extracted)
│ ├── strings.json <- extracted .rdata strings keyed by RVA
│ └── rtti.json <- recovered RTTI class names + vtables
├── tools/
│ ├── setup.sh <- one-shot: ghidra + JDK + wine + msvc + objdiff
│ ├── symlink_orig.sh <- populate orig/ from ffxiv-install-environment
│ ├── extract_pe.py <- dump PE structure + per-section binaries
│ ├── import_to_ghidra.py <- headless Ghidra import + analysis (Java scripts)
│ ├── ghidra_scripts/
│ │ ├── DumpFunctions.java <- export every function as asm/symbol map
│ │ ├── DumpStrings.java <- .rdata strings → config/<bin>.strings.json
│ │ └── DumpRtti.java <- RTTI → config/<bin>.rtti.json
│ ├── build_split_yaml.py <- ghidra dump → config/ffxivgame.yaml
│ ├── cl-wine.sh <- wraps VS2005 cl.exe under Wine
│ ├── compare.py <- objdiff batch runner → CSV report
│ └── progress.py <- count matched / partial / unmatched
├── docs/
│ ├── pe-layout.md <- the 6-section breakdown above
│ ├── compiler-detection.md <- how we pinned MSVC 8.0 → VS 2005 SP1
│ ├── matching-workflow.md <- per-function workflow
│ ├── known-libraries.md <- Miles, DX9, CRT — what to ignore
│ ├── seed-symbols.md <- where each name in symbols.txt came from
│ └── prior-art.md <- LEGO Island, OoT, FF7-decomp pointers
└── build/ <- gitignored
├── ghidra/
├── obj/
└── reports/
Module priorities (highest to lowest), with a sketch of the public surface we expect to recover for each:
net/— opcode constants, packet base class hierarchy, Blowfish cipher, packet header (CRC/sequence/etc.). Unblocks garlemald-server's wire layer immediately.sqpack/—Sqpack::Hash,.dat/.idxindex lookup, ZiPatch unpacking. Replaces the workspace's hand-coded sqpack readers.actor/— Actor base class, ActorParam tables, motion-pack IDs. Cross-referencesffxiv_1x_battle_commands_context.mdandffxiv_mozk_tabetai_context.md.battle/— damage formula, hit/crit roll, status-effect ticks, Battle Regimens. Calibrates againstffxiv_youtube_atlas_context.mddamage samples.director/— OpeningDirector, QuestDirector, ZoneDirector, WeatherDirector, ContentArea, PrivateArea. Cross-references garlemald's existing director scaffolding.ui/,render/— deferred.
6. Phased roadmap
Phase 0 — Bootstrap (this PR)
- Scaffold directory tree (this commit).
- Write PLAN.md (this file), README, .gitignore, NOTICE.
- Provide
tools/extract_pe.pyworking today (no Ghidra/Wine yet). - Provide
tools/symlink_orig.shso binaries don't have to be copied or committed. - Document the PE-layout findings (
docs/pe-layout.md,docs/compiler-detection.md). - Exit criterion: a fresh clone +
make bootstrappopulatesorig/and runstools/extract_pe.pycleanly.
Phase 1 — Static-analysis pipeline ✅ COMPLETE 2026-04-30
- Install Ghidra 12.0.4 + JDK 21 via
brew install ghidra(pullsopenjdk@21as a dep). JDK 25 happens to also work but the brew formula targets 21. - Ghidra headless wrapper at
tools/import_to_ghidra.pycallssupport/launch.shdirectly (so we can override the brew defaultMAXMEM=2G— the 16 MBffxivgame.exeneeds ~6 GB to analyse; the wrapper defaults to 8 GB). - Three Java post-scripts in
tools/ghidra_scripts/:DumpFunctions.java,DumpStrings.java,DumpRtti.java. (Ghidra 12 dropped Jython 2.7; PyGhidra is opt-in / venv-only. Java is the path of least resistance for headless.) tools/build_split_yaml.pyfolds the three JSON dumps intoconfig/<binary>.yaml— the work-pool.- Exit criterion ✅:
make splitproducesasm/<binary>/with one .s per function andconfig/<binary>.yamllisting every function with status=unmatched(ormatchedfor auto-classified middleware).
Phase 2 — Toolchain pinning ✅ COMPLETE 2026-05-01 (first GREEN)
- ✅
tools/find_rosetta.pyscans the binary for the best Rosetta Stone candidate. For ffxivgame.exe the top pick isFUN_00b361b0at RVA 0x007361b0 (86 bytes, 31 integer ops, no calls / no FP / no SEH — score 80 of 1,789 valid candidates). Disassembly cached atbuild/rosetta/ffxivgame.top.txt; full ranked list atbuild/rosetta/ffxivgame.candidates.json. - ✅
src/ffxivgame/_rosetta/FUN_00b361b0.cppis the contributor's starting C draft (Ghidra-derived, annotated). - ✅
tools/cl-wine.shwrapscl.exe/link.exeunder Wine — readsMSVC_TOOLCHAIN_DIRfrom~/.config/meteor-decomp.env, setsINCLUDE/LIB, dispatches via Wine'sZ:drive. - ✅
tools/setup-msvc.shverifies Wine + cl.exe + objdiff are reachable and the cl.exe version is "Microsoft … 14.00.x". - ✅
make rosettacompiles every staged_rosetta/*.cppand invokestools/compare.pyfor the diff. - ✅ Procurement guide at
docs/msvc-setup.md— MSDN subscriber downloads / archive.org / Microsoft Update Catalog / LEGO Island recipe. - ✅ VS 2005 Express RTM installed and operational —
vstudio2005-workspace/install.shextractscl.exe/link.exe/c1.dll/c1xx.dll/c2.dll/mspdb80.dll- headers + libs from the official VS2005EE ISO via msitools
(bypasses Wine's broken msiexec). Reports
cl.exe Version 14.00.50727.42 for 80x86running under CrossOver Wine 9 on macOS Apple Silicon.make setup-msvcpasses (with two PSDK warnings — Express doesn't bundle Platform SDK; Rosetta Stone doesn't need it).
- headers + libs from the official VS2005EE ISO via msitools
(bypasses Wine's broken msiexec). Reports
- ✅
tools/compare.pybyte-level diff implemented — reads the.textsection frombuild/obj/_rosetta/<name>.obj, reads the corresponding bytes fromorig/ffxivgame.exeat the function's RVA, and prints a side-by-side hex diff with GREEN/PARTIAL/MISMATCH verdict + first-mismatch offset. Exit codes 0/1/2 let Make and CI gate on match status. - ✅ Platform SDK 2003 R2 installed —
vstudio2005-workspace/install-psdk.shextractsPSDK-x86.msiviamsiextract(same Wine-bypass technique asinstall.sh), unblocking Win32-touching matches.sdk/PSDK/Include/+sdk/PSDK/Lib/populated. - ✅ First GREEN match landed 2026-05-01 —
FUN_004165b0(28-byte int setter), the first byte- identical recompilation. The full recipe (Ghidra-decompiler- assist + 3 MSVC-2005 source-pattern tricks: element-wide pointers, two-pointer w/ both deref, count > 0 vs != 0) is in~/.claude/projects/-Users-swstegall-Documents-Programming-server-workspace/memory/reference_meteor_decomp_rosetta_match.md. - ✅ Matching at scale — by 2026-05-25, 67,677
_rosetta/*.cppfiles across 5 binaries (3.88 % by file coverage); the YAMLstatus:GREEN count collapsed to 15,618 (0.91 %) during the orchestrator teardown and is no longer maintained — seedocs/decomp-status.md. The jump came from the template-derivation pipeline (Phase 2.5 below), not from hand-writing one function at a time. - Note on RTM vs SP1: cl.exe
.42is RTM, not SP1 (.762). The FFXIV binary's linker version 8.0 is consistent with both — RTM has been sufficient for every match landed so far. - Exit criterion: ✅ met (working toolchain + first
byte-matched function). Future matching wins land as
individual commits to the relevant module's
.cppfiles or via the template pipeline.
Phase 2.5 — Template-derivation pipeline ✅ live
The single-function loop (write C++, compile, diff, iterate) takes
~10–60 minutes per function. At 75k+ functions in ffxivgame.exe
alone, that's the wrong rhythm. The template-derivation pipeline
scales matching by an order of magnitude.
The insight: most functions in a Win32 game binary are not unique. They are dozens of copies of the same compile-time pattern (getter / setter trampolines, scalar deleting destructors, vtable trampolines, SEH catch handlers, bool-nonzero predicates). If we can recover one C++ source for the cluster, we stamp every member GREEN at once.
Pipeline stages (full detail in docs/decomp-status.md):
tools/cluster_shapes.py— group by byte-shape modulo relocations.tools/cluster_relocs.py— decode ModR/M / SIB at every reloc site (full ALU + MOV/LEA + IMUL families).tools/recompute_sizes.py— re-derive true function ends.tools/seed_templates.py --reloc— per-cluster seed-and-stamp; cross-binary multipliers fold ffxivboot / ffxivconfig copies in too.tools/derive_templates.py— naked-asm_emittemplates for clusters that resist source matching (~75 patterns hand-written covering D2/D3 destructors, SEHCatch_All, push-call wrappers, chained-pointer getters, vtable trampolines, etc.).tools/stamp_clusters.py— run a template against every cluster member; stamp matches.tools/validate_clusters.py— re-validate stamped templates against the binary; catches regressions when toolchain changes.tools/update_yaml_status.py— fold per-file results into YAML.tools/find_easy_wins.py— auto-rank single-function matching candidates (smallest unmatched function with the most cross-binary copies, fewest relocations).
Cumulative effect through 2026-05-25: from ~10 hand-matched functions
to 67,677 durable _rosetta/*.cpp files across all 5 binaries (15,618
GREEN in the post-teardown YAML). The
single largest landings: 1,552-sibling stamped cluster (780c628c3)
and the auto-template pass that emitted 10,577 GREEN templates in one
go (d9f64cf19).
Phase 3 — Net layer ▶ in progress (functional track; matching deferred)
- ✅ Wire architecture documented at
docs/wire-protocol.md:- Three IpcChannels:
LobbyProtoChannel,ZoneProtoChannel,ChatProtoChannel(with Up/Down union types each). - Transport is RUDP2 (Sqex::Socket::RUDP2 — SE in-house
protocol, NOT raw TCP). Project Meteor's TCP impl works because
of the launcher's
ws2_32.dllshim. - Crypto is OpenSSL 1.0.0 (29 Mar 2010) statically linked
(
Blowfish part of OpenSSL 1.0.0string at .rdata RVA 0x4048). Blowfish is the per-channel cipher; OpenSSL's full crypto suite (RSA / AES / SHA1/256/512 / X.509) is also present for the SqexId auth flow. - 343
Component::GAM::CompileTimeParameter<id, &PARAMNAME_id>template instantiations recovered — that's the actor-property serialization registry, IDs 100-345 + 579-595.
- Three IpcChannels:
- ✅
tools/extract_net_vtables.pywalks the RTTI dump and emits a per-class slot map atbuild/wire/<binary>.net_handlers.md. For ffxivgame.exe: 576 net-relevant classes / 9,729 vtable slots, each linked to the per-functionasm/<rva>_*.sfile — this is the Phase 3 work pool. Notable entries:LobbyCryptEngine— 9 slots (the cipher API surface)MyGameLoginCallback— 22 slots (login state machine)SqexIdAuthentication— 1 slot- Three
Application::Network::*ProtoChannelclasses Sqex::Socket::RUDP2,RUDPSocket,PollerWinsock,PollerImpl- Three
*ProtoChannel::ClientPacketBuilderinstances Sqex::Crypt::{Cert, Crc32, ShuffleString, SimpleString, CryptInterface}— SE's higher-level crypto shims
- ✅ Cross-referenced with
garlemald-server's existing wire layer (common/src/{packet,subpacket,blowfish}.rs,map-server/src/packets/opcodes.rs) — the Rust impl already models the BasePacketHeader correctly, the opcodes registry is comprehensive, and Blowfish key schedule matches OpenSSL bf_init. - ✅ GAM property registry extracted —
tools/extract_gam_params.pyparses 192 unique(id, namespace, type, decorator)tuples from the mangled CompileTimeParameter types in.rdata. Output:config/<binary>.gam_params.{json,csv}(machine-readable)build/wire/<binary>.gam_params.md(human-readable, grouped by namespace) Six Data classes recovered: Player (92), PlayerPlayer (37), CharaMakeData (26), ClientSelectData / ClientSelectDataN (17 each), ZoneInitData (3). Important finding: the 343?PARAMNAME_<id>@...symbols referenced in mangled CompileTimeParameter types do NOT carry user-meaningful property names — the actual strings in.rdataare generic placeholders (IntData.Value0, etc.). The (namespace, id) tuple IS the property identifier; semantic names (playerWork.activeQuest, etc.) are Project Meteor's invention.
- ✅ Two-systems finding documented — turns out garlemald's
SetActorPropertyPacketand the binary's GAMCompileTimeParameterare parallel wire systems, not the same one (seedocs/wire-protocol.md→ "Two parallel actor-property systems"). The first uses 32-bit Murmur2 hashes of/-path strings; the second uses small ordinal ids per Data-class namespace. The original Phase-3 task #3 (type-checkSetActorPropertyPacketagainst GAM) was a category error. - ✅
include/net/gam_registry.h— auto-generated C++constexprschema declaring all 192 GAM parameters across 6 Data classes. Each row carries(id, TypeKind, element_size, total_bytes, raw_type). Generated bytools/emit_gam_header.pyfromconfig/<binary>.gam_params.json. Future Rust code can consume via FFI or build.rs codegen as the ground-truth schema for lobby-side type checking. - ✅ MurmurHash2 validated —
FUN_00d31490(RVA 0x00931490, 170 bytes) is the binary's backward-walking MurmurHash2 used to derive 32-bit wire ids forSetActorPropertyPacketfrom/-path string keys ("charaWork.parameterSave.hp[0]"→0x4232BCAA). Bit-for-bit step-by-step trace againstgarlemald-server/common/src/utils.rs::murmur_hash2indocs/murmur2.md, plus 6/6 test vectors cross-verified by a standalone Rust binary on 2026-05-01. TheSetActorPropertywire-id derivation in garlemald is correct. - ✅ Down-channel opcode dispatchers extracted —
tools/extract_opcode_dispatch.pywalks each*ProtoDownDummyCallbackvtable slot 1 (the dispatcher; slots 0=destructor, 2..N=handlers), parses its prologue + two-level dispatch tables (byte_table maps opcode → case_index, dword_table → 21-byte case body, each loadingMOV EAX, [ESI + slot*4]), and produces an opcode → vtable_slot map cross-referenced with garlemald-server'sopcodes.rs. Output atbuild/wire/<binary>.opcodes.md. Headline numbers:- 211 total real Down opcodes across 3 channels (Zone 197 of 502 possible; Lobby 10 of 23; Chat 4 of 200).
- 60 opcodes the binary handles that garlemald has no entry for — potentially missing handlers in the Rust impl.
- 5 opcodes garlemald has labelled RX-only that the binary handles in Down — likely miscategorized in garlemald (need both directions or just TX).
- 19 garlemald TX opcodes that don't appear in any Down channel
— server-invented opcodes the client doesn't actually handle, or
out-of-scope ranges (e.g., the ≥ 0x1000 world-server↔map-server
coordination opcodes).
This is the canonical cross-reference for garlemald's
map-server/src/packets/opcodes.rsgoing forward.
- 🟡 Up direction reconnaissance —
tools/extract_up_opcodes.pydocumented the architectural finding that Up packets don't have a flat dispatcher analogous to Down. The opcode is stored at[ClientPacketBuilder + 0x1C]and set by the constructor (MOV [this+0x1C], <stack-arg>); each per-opcode send is a separate "send Xxx" function that builds a CPB and passes the opcode dynamically (not as a literal immediate the static scanner can recover). Validated that all 30 garlemaldOP_RX_*values appear as PUSH immediates in.text(necessary but not sufficient — confirms no garlemald RX opcode is invented). Full Up-opcode enumeration requires Ghidra-driven cross-reference analysis (per-callsite constant propagation through the CPB constructor's arg0); deferred. - ⏸ Functional decomp — pending PRs:
Map every Project Meteor— done above for the Down direction. Up direction reconnaissance done; full enumeration deferred.OP_*constant to its handler vtable slot- ✅
LobbyCryptEngine's 9 slots decoded (tools/extract_crypt_engine.py→build/wire/ffxivgame.crypt_engine.md). All 9 are overrides of the abstractCryptEngineInterface(whose slots 1..8 are__purecall). Semantics: dtor, PrepareHandshake (32-byte "Test Ticket Data..." seed +__time64timestamp into req header), threereturn false / 0interface stubs (slots 2, 3, 5), SetSessionKey (frees + reallocates 4168-byte BF_KEY, callsBF_set_key(key, len=16)), Encrypt buffer (32-byte chunk-aligned, ECB), Decrypt buffer (same shape), and areturn truecapability-probe stub (slot 8). The per-block primitives at RVA0x0005aac0/0x0005aa30are statically- linked OpenSSLBF_encrypt/BF_decrypt; the key schedule at0x0005abf0is OpenSSLBF_set_keywith one non- canonical quirk: the byte-cycling step usesMOVSX(sign- extend) instead ofMOVZX, so keys with bytes ≥ 0x80 produce a different schedule than stock OpenSSL. The P/S init constants at VA0x01267278(P[18]) and0x012672C0(S[4][256]) are canonical pi-derived (Schneier 1993 / OpenSSLbf_pi.h), confirmed bit-for-bit. Garlemald- server'scommon/src/blowfish_tables.rsmatches both tables byte-for-byte; the sign-extension quirk is reproduced incommon/src/blowfish.rs:74-78. One alignment divergence: the lobby slots round buffer length DOWN to multiples of 32 (= 4 Blowfish blocks); garlemald'sencipher/decipherrequire 8-aligned and reject 24-byte inputs. If the server ever produces a payload whose length isn't a multiple of 32, the client will silently leave the trailing bytes plaintext. - ✅ Lobby
*ProtoChannel::Recv/Sendpaths decoded —include/net/lobby_proto_channel.h. The CPB shape is a 4-slot vtable shared by all three channels (Lobby / Zone / Chat): dtor,Begin()returning&this[0x10],BuildHeader(out)that fillsheader[0]=0x14, header[1]=0x00, header[2..4]=this->[0x1c] (connection_type), header[8..12]=(u32)time(NULL), andSend(buf, len)whose dispatch helpers in this build areMOV EAX, [ESP+N] ; RETno-op stubs (the real send path lives in the surrounding RUDP2 / IpcChannel framework). Chat'sBuildHeaderdiffers only at byte 8, where it hardcodes0x0Ainstead of calling_time64. The receive dispatcher isLobbyProtoDownCallbackInterfaceslot 1 (RVA 0x009a4160) — same byte-table + dword-table jump pattern as the Down opcode dispatchers already documented for zone/chat. Bytes 4..7 (packet_size + num_subpackets per garlemald) and 12..15 (high half of the u64 timestamp) are not written by the client's BuildHeader and remain caller-populated. 32-byte alignment quirk RESOLVED: only the Lobby uses Blowfish (zone and chat traffic is plaintext — confirmed by the absence of a concrete CryptEngine subclass for them in the binary AND the absence of blowfish call sites in garlemald's world-server / map-server). The slot-6/7 round-DOWN-to-32 vs garlemald's 8-aligned policy is non-equivalent BUT BENIGN in practice: Project Meteor's reference C# server uses the same 8-aligned policy as garlemald (Common Class Lib/BasePacket.cs::EncryptPacketcallsEncipher(data, offset+0x10, subpacketSize-0x10)— not 32-aligned), and Project Meteor has been working against the real 1.x client for years. Both servers allocate fixed-size lobby buffers (MemoryStream(0x98),(0x210),(0x280), etc.) where the meaningful content typically fills only the prefix and the trailing region is zero padding. The 0..31 bytes the client fails to decrypt fall inside that trailing zero region — the client never reads past the field boundaries it cares about, so the garbled trailing bytes are invisible. Worked examples ininclude/net/lobby_proto_channel.hforSelectCharacterConfirm(0x98 buffer, last 8 bytes areunknownIpzero-padding) andAccountList(0x280 buffer, last 16 bytes are zero pad past 8 entries × 72 bytes). Garlemald'sencipher/decipherare correct as written; no code change needed. Future packet builders that pack meaningful data into the last 32 bytes of a non-32-aligned encryption body would need review. - ✅ Cross-validate
garlemald-server/lobby-server/src/data/chara_info.rsagainst the GAM CharaMakeData registry. Definitive answer inbuild/wire/<binary>.chara_make_validation.md:- The dispatcher fn in
CharaMakeData::MetadataProvider(vtable slot 2 at RVA 0x001ad010) is a 26-way jump table that maps GAM id → property name string in.data.tools/extract_paramnames_dispatch.pywalks it to recover the names:tribe,size,hair,hairOption1, …,initialBonusItem,initialTown. All 26 resolved. - Player's dispatcher (slot 2 at RVA 0x001add90) similarly
resolves all 92 GAM ids to names like
guildleveId,craft_assist_buff_type,anima,companyId,latest_aetheryte, etc. Property categories: ~20guildleve*fields, ~14company*(Free Company state), 7snpc*(1.x Soul-Sync NPCs), 5harvest_*(gathering history), 5profile_*(item/monster encounter logs), Anima resource, mailbox, festival counters. - ClientSelectData's slot 2 (RVA 0x001ad580) is global-id
keyed and resolves all 17 names —
displayName,graphics,loginFlag,mainSkill,mainSkillLevel,physicalLevel(Utf8String — pre-formatted display string),tribe(also Utf8String — race display name),zoneName/territoryName(1-byte ID codes with string lookup elsewhere),guardian,birthdayMonth/Day,initial*,currentJob. Garlemald-server'slobby-server/src/data/chara_info.rs::build_for_chara_listproduces this payload as a hand-rolled flat blob; the schema cross-reference is inbuild/wire/<binary>.chara_list_validation.md(regenerable viamake validate-chara-list). Five likely bugs surfaced:current_level: u16writes 2 bytes where GAM says 1 (mainSkillLevel: signed char).tribe: u8written where GAM declaresUtf8String.location1/2: full strings written where GAM declares 1-byte IDs (this one's less certain — wire form may legitimately differ from in-memory).initial_town: u32 (twice): 4-byte write × 2 where GAM declares one 2-byte field. The duplicate is likely a port bug masking a separate field (favourite_aetheryte?). Definitive resolution requires decompiling the binary'sCharacterListPacket::Deserialize— TBD.
- ClientSelectDataN's slot 2 (RVA 0x001ad990) is global-id keyed (contiguous ids 100..116, no gaps) and resolves 17 names. Same fields as CSD but renumbered — likely a later wire-format revision; both versions kept in the binary.
- ZoneInitData's slot 2 (RVA 0x001af640) uses a chained-
compare cascade rather than a JT (more compact for 3 ids).
Resolves
startCity(int),zoneName(Utf8String),zoneId(i8). - PlayerPlayer's slot 2 (RVA 0x001aee30) is global-id keyed
with a dual-bound prologue (CMP-JG-JZ-SUB-CMP-JA, same shape
as Lobby's). Its main JT covers ids 203 + 321..345 (26 real
cases via byte_table at VA 0x5af1a0); a JZ branch handles id
579; a JA-target sub-dispatch handles ids 580..595. All 37
names recovered, verified by reading the actual string
contents at each PUSH target:
tribe,size,hair, …,voice,loginCount,questScenario,questScenarioSavework,questScenarioComplete,questGuildleve,questGuildleveSavework,questGuildleveComplete,npcLinkshellChatErrand,guardian,birthdayMonth,birthdayDay,initialMainSkill,initialEquipSet,initialBonusItem,initialTown,npcLinkshellChatCalling,npcLinkshellChatExtra,actionSaveWork. Type checks confirm 192/192 GAM ids now have resolved property names. (Earlier "slot 4 was the dispatcher" analysis was wrong — slot 2 is the canonical name dispatcher; slot 4 is a local-index alternate.) - Pattern (dispatcher walk + the global-id vs local-index
distinction) documented in memory at
reference_meteor_decomp_paramname_dispatcher.mdfor reapplication to ClientSelectData / ClientSelectDataN / ZoneInitData. - The wire format IS GAM-id-ordered, with two non-GAM
u32 skipsub-record headers (between ids 107 and 108, and between ids 115 and 116) and a 16-byteseek 0x10trailer. - garlemald's parser aligns field-for-field, surfacing
three concrete bugs:
appearance.face_featuresshould beface_cheek(id 112)appearance.earsshould beface_jaw(id 114) — 1.x doesn't expose ears as a separate slotinfo.current_class: u16lumps GAM id 122initialMainSkill- id 123
initialEquipSet(loses the equipment-set value)
- id 123
- Three trailing
u32 skipreads ARE GAM id 124initialBonusItem: int[3](starter items the parser silently drops)
- Suggested patch in
build/wire/<binary>.chara_make_validation.md § Suggested patch. Apply to garlemald-server when ready.
- The dispatcher fn in
- Exit criterion (unchanged):
garlemald-servercan replace its hand-written packet structs with#include-able C++ frommeteor-decomp/include/net/, and round-trips a capture session. - Matching upgrade path: when Phase 2 unblocks (VS 2005 SP1
procurement), revisit each functional
.cppinsrc/ffxivgame/net/and re-derive matching codegen viamake rosetta-style iteration. The functional source we ship now is the starting C, not the final.
Phase 4 — Pack / ChunkRead / InstallUnpacker (matching target) ▶ active matching
See docs/sqpack.md (file system) and
docs/install-unpacker.md (consumer)
for the full reconnaissance write-ups. Key correction: 1.x is
resource-id-addressed, not string-path-hashed (the ARR-era
Sqpack hash was added later for DQX/ARR). There is no Sqpack::Hash
in 1.x — files live at <game>/data/<b3>/<b2>/<b1>/<b0>.DAT where
b3..b0 are the bytes of a 32-bit resource_id. The class
hierarchy on the read side is
Sqex::Data::PackRead : Sqex::Data::ChunkRead<u32, u32>. The only
direct in-game consumer of PackRead is Component::Install:: InstallUnpacker::Unpack (slot 2 of vtable RVA 0x00d0d53c).
Status snapshot (2026-05-02) — the bytewise heat map for Phase 4:
| Group | GREEN | PARTIAL | Source |
|---|---|---|---|
| Sqex::Data (PackRead / ChunkRead) | 3 (~155 B) | 3 (~485 B) | src/ffxivgame/sqpack/ |
| Sqex::Misc::Utf8String | 2 (63 B) | 2 (~316 B) | src/ffxivgame/sqex/Utf8String.cpp |
| Sqex slab allocator | 0 | 2 (~327 B) | src/ffxivgame/sqex/Allocator.cpp |
| Component::Install::InstallUnpacker | 3 (~317 B) | 1 (144 B) + 1 deferred (490 B) | src/ffxivgame/install/ |
| CRT helper sweep | 32+ (across 5 binaries) | — | src/ffxivgame/crt/ |
Work pool (in dependency order — the rest):
- ✅ Reconnaissance complete — anchor functions identified, class hierarchy recovered, struct layout sketched.
- ✅
PackRead::~PackReadGREEN (first Phase-4 match,26369ae5) +PackRead::ReadNextGREEN +PackRead::RewindGREEN +Utf8String::~Utf8StringGREEN +Utf8Stringdefault ctor GREEN +InstallUnpacker::WaitForReadyGREEN +ResourceQueue::TryEnqueueGREEN +ChunkSource::ReleaseChunkGREEN +_invalid_parameter_noinfoGREEN. - 🟡
PackReadctor PARTIAL (130/132 B, 98 %). - 🟡
PackRead::ProcessChunkPARTIAL (180/177 B; buffer-guard cookie blocker —/GSepilogue ordering sensitive to local layout). - 🟡
ChunkReadUInt::ReadNextChunkHeaderPARTIAL (74/81 B, 91 %). - 🟡
Sqex slab Utf8StringAlloc/FreePARTIAL (222/225 + 104/105 B, 99 %; pending Ghidra-GUI on slab descriptor / mutex globals). - 🟡
Utf8String::ReservePARTIAL (144/153 B, 94 %). - 🟡
ChunkSource::AcquireChunkPARTIAL (144/144 with 21 byte mismatches — close to GREEN, just needs cookie / register-allocation iteration). - 🟡
InstallUnpacker::Unpack(FUN_00cc6700) Iteration #1 PARTIAL (428/490, 249 mismatches). The biggest remaining Phase-4 target. Deferred pending parent-class layout recovery + helper signatures — seedocs/install-unpacker.mdanddocs/ghidra-tasks.md. - 🔲 Functional re-derive of the path builder at 0x0004b3a0
(615 B). Verifiable by feeding known resource_ids and
string-comparing the output against a Python reference (single
line of
f"{(rid >> 24) & 0xFF:02X}/.../{(rid) & 0xFF:02X}.DAT"). - 🔲 Decompression layer — locate via zlib magic /
inflatecall sites. - 🔲 ZiPatch unpacker — separate target in
ffxivupdater.exe, block types FHDR / APLY / APFS / ETRY / ADIR / DELD.
Exit criterion: tools/sqpack-cat <resource_id> opens the
right DAT file and dumps the file bytes, with PackRead::~PackRead
- ctor + at least one read method byte-matched. (PackRead destructor
- Rewind + ReadNext now GREEN; the missing piece is matching ctor
- ProcessChunk.)
Phase 5 — Actor + Battle (functional target)
- Decompile Actor base class (vtable from RTTI), ActorParam tables.
- Decompile battle math:
ComputeDamage,ComputeHit,ComputeCrit,ApplyStatus. Cross-check against LSB's XI cousins (see CLAUDE.md "Land Sand Boat" cross-reference) and the YouTube atlas damage samples. - Status-effect tick loop.
- Battle Regimen (combo) resolver.
- Exit criterion: a self-contained
damage_simulatorexecutable insrc/ffxivgame/battle/that reads a damage roll setup from JSON and produces a number that matches a recorded damage sample within ±1 (rounding tolerance).
Phase 6 — Director / Quest framework (functional target)
- Decompile Director base class, ContentArea, PrivateArea, the
XML/CSVcontent loader. - Decompile the Lua VM glue (the client embeds Lua 5.x — already
hinted at by
project_meteor_discord_context.md— function names likeprocessEvent,onTalk,Seq000will jump out). - Decompile OpeningDirector, ZoneDirector, WeatherDirector.
- Exit criterion: garlemald can drive the original .exe through an opening-cinematic capture cycle using meteor-decomp-derived director sequencing instead of garlemald's current Lua scaffolding.
Phase 7+ — Long tail
UI, render, audio, settings, telemetry. Fully optional.
7. Per-function workflow
Each function the project tackles follows the same loop. This is
designed so an automated agent (or a human) can pick up a row from
config/ffxivgame.yaml with status=unmatched and complete it.
- Claim: post
/claim FUN_<va> <binary>on the coordination issue to reserve the function (NOT a YAML edit —config/<bin>.yamlis gitignored/regenerated). Seedocs/claim-protocol.md. - Read assembly:
asm/ffxivgame/<rva>_<sym>.s. If the symbol isFUN_xxxx(Ghidra-generated), guess a better name from nearby strings, RTTI, or seed-symbol cross-references. - Read decompiler output: open in Ghidra, copy the
pseudo-C, paste into
src/ffxivgame/<module>/<sym>.cpp. - Clean up: replace
local_4,iVar1, etc. with meaningful names. Replace integer constants with named enum values where possible. Add#includes. - Match (matching modules) or smoke-test (functional):
- Matching:
make diff FUNC=<sym>. Iterate untilobjdiffreports zero delta. Common knobs: argument order, struct padding,__cdeclvs__fastcall, inline vs not,/Oyframe-pointer omission,_allocavs__chkstk. - Functional: write a small
tests/<module>/<sym>_test.cppthat exercises the function on a known input/output pair drawn from a packet capture, save state, or wiki dump.
- Matching:
- Commit + PR: one function per commit, message
decomp: match Sqpack::Hash @0x004a1230ordecomp: functional ComputeDamage @0x008c5a40. Open a PR from your fork intodevelop. You do NOT flip a YAML status: the committedsrc/<bin>/_rosetta/FUN_<va>.cppfile IS the solved set, opening the PR pins the claim, and merging it auto-releases the claim and recomputes the progress numbers (reconcile.yml). Seedocs/claim-protocol.mdandCONTRIBUTING.md.
8. Legal & copyright
The original PE binaries are copyright Square Enix. They MUST NOT
be committed to this repository. Contributors fetch their own copy
from a legitimate ffxiv-install-environment install (the
workspace's installer pipeline) and tools/symlink_orig.sh makes
them visible to the build without copying.
The decompiled C/C++ in src/, include/, and the YAML/JSON
config in config/ are original work derived through clean-room
reverse engineering of the binary's behaviour. Everything we
publish here is licensed under AGPL-3.0-or-later, matching
garlemald-server and garlemald-client. New source files get the
standard AGPL header; see CLAUDE.md § "Source-file license
headers" — copy the header verbatim from a garlemald-client
sibling, with the project tagline:
meteor-decomp — clean-room decompilation of FINAL FANTASY XIV 1.x client binaries
NOTICE.md credits Project Meteor Server (AGPL-3.0), Seventh
Umbral (BSD-2-clause-style), LandSandBoat (GPL-3.0 — referenced
only, no code copied), and the LEGO Island decomp project for
prior-art on MSVC 2005 matching.
9. Cross-references in this workspace
Lean on these at every step instead of re-deriving knowledge:
ffxiv_classic_wiki_context.md— opcodes, region IDs, motion IDs.project_meteor_discord_context.md— first-hand notes from Ioncannon / Tiam / Decimus on packet field layouts.ffxiv_linkchannel_context.md— FFXIV 1.0 Opcodes spreadsheet, Actor Param Names spreadsheet, Motion IDs spreadsheet (mined).ffxiv_1x_battle_commands_context.md— every BattleCommand row is a struct member in the binary.ffxiv_mozk_tabetai_context.md— every item / shop / recipe ID appears as a numeric constant in.rdataand helps pin functions.ffxiv_youtube_atlas_context.md— damage samples for battle formula calibration.mirke-menagerie-context.md— quest dialogue text we'll see as literals in.rdata.land-sand-boat-server/xi-private-server.md— XI structural cousin for damage / aggro / status-effect grammar.porting-progress-context.md— the workspace's master roadmap; meteor-decomp slots in as a Tier 1 unblocker (the source-of-truth for net/sqpack), and a Tier 3 calibrator (battle formulas).
10. Definition of "done" for the workspace
We don't need to finish meteor-decomp. The workspace declares victory when:
- garlemald-server's wire layer is generated from
meteor-decomp/include/net/, not hand-rolled — every opcode matches. - garlemald-server's Sqpack reader, ZiPatch reader, and
BattleCommand reader are calls into
meteor-decomplibs, not duplicates. - garlemald-server's damage / hit / crit / status-tick formulas
are line-for-line ports of
src/ffxivgame/battle/*.cpp. - The opening-cinematic and quest-framework events run through meteor-decomp-derived director sequencing rather than garlemald's current "best effort" Lua.
After that, anything else is bonus. If the long tail never gets done, fine.
Back to meteor-decomp
