Skip to content

Latest commit

 

History

History
1398 lines (1173 loc) · 71 KB

File metadata and controls

1398 lines (1173 loc) · 71 KB

Decompilation status — narrative recap

A running record of what's been recovered from ffxivgame.exe, what's been validated against garlemald-server (the Rust port), and where the open questions are. Last update: 2026-05-25.

For the strategic plan and exit criteria, see PLAN.md. For per-subsystem detail, see the auto-generated reports under build/wire/<binary>.*.md (regenerable via make).

Headline numbers

The table below is regenerated by make update-docs from tools/progress.py --json + docs/reconcile-state.json (make reconcile). The durable, authoritative metric is the committed _rosetta/*.cpp solved set — do not hand-edit between the sentinels.

_rosetta/*.cpp solved set across all five binaries (69,247 files / 945,850 B / 4.99 %):

Binary _rosetta/*.cpp files bytes total bytes
ffxivgame.exe 41,335 649,145 10,000,069
ffxivboot.exe 26,970 286,276 8,128,842
ffxivlogin.exe 291 2,412 225,434
ffxivupdater.exe 451 6,045 329,621
ffxivconfig.exe 200 1,972 253,900
Total 69,247 945,850 18,937,866

On the "YAML matched" column: this counts only config/<bin>.yaml rows whose status: field reads matched. That field collapsed during the agent orchestrator's work-pool regeneration (now concluded and torn down) — most visibly for ffxivgame.exe, which dropped from 23,106 to 320 even as its _rosetta/*.cpp file count rose to 39,765. Match state was tracked via file presence + the orchestrator's SQLite coordination DB (now archived), not the YAML status: field, and there is no longer a running pipeline to re-sync it (update_yaml_status needs build/easy_wins/*.validate_results.json, which the teardown removed). So the _rosetta/*.cpp file count is the authoritative recovered-match metric; the YAML column is retained only for continuity.

The jump from "single-digit functions matched" to "tens of thousands" came from the template-derivation pipeline (§ Phase 2.5 below) landed across late April and early May: rather than match one function at a time, cluster shape-equivalent functions, derive a relocation-aware template per cluster, and stamp every cluster member GREEN in one pass.

Phase 0 — bootstrap (✅)

make bootstrap symlinks the workspace's installed retail binaries into orig/ and dumps PE structure as a sanity check. Captured by tools/extract_pe.pybuild/pe/<bin>.json.

The five binaries:

Binary Size What it is
ffxivgame.exe ~12 MB The main game executable. Renderer + gameplay + lobby + zone + chat clients all in one PE. Built with MSVC linker 8.0 (VS 2005 SP1), statically links Miles MSSMIXER, OpenSSL 1.0.0, and the SE-internal Sqex / CDev / Sqwt frameworks. ImageBase 0x00400000. The primary target.
ffxivlogin.exe small Patcher / login bootstrapper. Sanity-check target for the Phase 1 pipeline.
ffxivboot.exe, ffxivupdater.exe, ffxivconfig.exe small Auxiliary launcher / patcher / settings. Lower priority.

Phase 1 — Ghidra import + work pool (✅)

make split BINARY=ffxivgame.exe runs Ghidra 12 + JDK 21 (post-Jython era; the analysis scripts are Java post-scripts under tools/ghidra_scripts/) and produces:

  • 9,729 vtable slots across 576 net-relevant classes in the RTTI dump (config/<bin>.rtti.json, config/<bin>.vtable_slots.jsonl, build/wire/<bin>.net_handlers.md).
  • One asm/<bin>/<rva>_<symbol>.s file per function, full disassembly with annotations.
  • A work-pool YAML (config/<bin>.yaml) listing every function with size, section, and any seed-hint flags (__FILE__ / __FUNCTION__ baked-in strings, Lua callback names, etc.).

Notable named classes recovered:

  • The three *ProtoChannel IpcChannel families (Lobby / Zone / Chat)
    • their ServiceConsumerConnectionManager + ConsumerConnection
    • the per-channel ClientPacketBuilder (4 slots).
  • LobbyCryptEngine (9 slots — the cipher API surface).
  • MyGameLoginCallback (22 slots — the login state machine).
  • Sqex::Crypt::{Cert, Crc32, ShuffleString, SimpleString, CryptInterface} — SE's higher-level crypto shims.
  • Sqex::Socket::RUDP2, RUDPSocket, PollerWinsock, PollerImpl — RUDP2 transport stack.

Phase 2 — matching toolchain (✅ working)

The goal is byte-identical recompilation: take the binary's matched function, hand-write equivalent C++, compile with the original toolchain, and confirm compare.py/objdiff reports zero delta. This proves the hand-written C++ is semantically and bit-exactly the source.

What works today:

  • VS 2005 Express RTM operational under CrossOver Wine 9 on Apple Silicon. vstudio2005-workspace/install.sh extracts cl.exe / link.exe / c1.dll / c1xx.dll / c2.dll / mspdb80.dll + headers + libs from the official VS2005EE ISO via msitools (bypassing Wine's broken msiexec). cl.exe Version 14.00.50727.42 for 80x86. make setup-msvc passes.
  • Platform SDK 2003 R2 also installed via vstudio2005-workspace/install-psdk.shPSDK-x86.msi extracted via msiextract (same Wine-bypass technique), giving us the full PSDK tree (sdk/PSDK/Include/ + sdk/PSDK/Lib/). This unblocked Win32-touching matches; previously they failed at link time.
  • tools/cl-wine.sh: runs cl.exe under wow64 mode (no WINEARCH=win32) with the DYLD_FALLBACK_LIBRARY_PATH shim and reads MSVC_TOOLCHAIN_DIR from ~/.config/meteor-decomp.env.
  • tools/compare.py: relocation-aware byte-level diff. Reads the .text section from the staged .obj, slices the corresponding RVA range from orig/<bin>.exe, and prints GREEN/PARTIAL/MISMATCH + per-byte diff with first-mismatch offset. Exit codes 0/1/2 gate Make / CI on match status.
  • First GREEN match: FUN_004165b0 (28-byte int setter) landed 2026-05-01 — see reference_meteor_decomp_rosetta_match.md for the recipe (Ghidra-decompiler-assist + 3 MSVC-2005 source-pattern tricks: element-wide pointers, two-pointer w/ both deref, count > 0 vs != 0).

The original blocker ("waiting on Platform SDK") is now resolved — both toolchains are installed and matches are landing. Phase 2's exit criterion is met; matching is now ongoing decomp work, not a toolchain blocker.

Phase 2.5 — template-derivation pipeline (✅ scaling matching)

The single-function matching loop (write C++, compile, diff, iterate) takes ~10–60 minutes per function. At 75k+ functions in ffxivgame.exe alone, that's not the right 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, etc. — instantiated once per type by MSVC. If we can recover one C++ source for the cluster, we can stamp every member GREEN simultaneously.

Pipeline stages:

  1. tools/cluster_shapes.py — group functions by byte-shape modulo relocations: replace each rel32/rel8 with a placeholder, then bucket by the resulting fingerprint. Output: clusters of "structurally identical" functions across all five binaries.
  2. tools/cluster_relocs.py — within each cluster, decode the ModR/M / SIB at every relocation site so the template knows what kind of operand each placeholder represents (a function pointer, a global address, a stack offset, etc.). Handles the full ALU 0x80/0x81/0x83, 0x88/0x89/0x8a/0x8b/0x8d (MOV/LEA), 0xc0/0xc1/0xc6/0xc7/0xd0..0xd3/0xfe/0xff (rotates / immediate stores / arithmetic), and 0x69/0x6b (IMUL imm) opcode families with proper length decoding.
  3. tools/recompute_sizes.py — Ghidra sometimes drops mid-function bytes (epilogue mis-detection, single-byte INT3 padding); this pass walks the binary and re-derives true function ends, accepting "next-function-starts" as the boundary signal.
  4. tools/seed_templates.py --reloc — for each cluster, pick the smallest member as the seed, generate a .cpp that compiles to the same shape, and verify the seed matches. If GREEN, stamp every cluster sibling.
  5. tools/derive_templates.py — when the seed approach can't generate a working .cpp (some MSVC idioms don't have a clean C-source equivalent), drop down to a naked-asm template: emit _emit byte sequences with __asm blocks and patch the relocation slots from a per-instance manifest. ~75 patterns hand-written so far covering scalar deleting destructors (D2), array deleting destructors (D3), SEH Catch_All handlers, push-call wrappers, chained-pointer getters, vtable trampolines, MOV/LEA disp32, constant-byte clusters, etc.
  6. tools/stamp_clusters.py — runs the matching template against every member of a cluster, validating each individually with compare.py. Members that match are stamped GREEN in _rosetta/<rva>.cpp.
  7. tools/validate_clusters.py — a separate pass that re-validates already-stamped templates against the binary; catches regressions when the toolchain or pipeline changes.
  8. tools/update_yaml_status.py — folds per-file validate results back into the YAML work pool (status: matched).
  9. tools/find_easy_wins.py — scans the work pool for high-value single-function matching candidates not yet covered by a template (smallest unmatched function with the most cross-binary copies, fewest relocations, etc.).
  10. tools/verify_asm_vs_orig.py + verify_by_symbol.py — universal ASM-vs-orig sanity check; catches mid-function Ghidra drops that would otherwise let a bogus template "match" against truncated bytes.

Cumulative effect (through 2026-05-25): going from ~10 hand-matched functions to 67,677 durable _rosetta/*.cpp files across 5 binaries (15,618 still flagged GREEN in the post-teardown YAML — see the caveat above on why that field under-reports). The single largest individual landings were the 1,552-sibling stamped cluster (780c628c3) and the auto-template pass that emitted 10,577 GREEN templates in one go (d9f64cf19).

Phase 2 / 2.5 — open work

  • Sweep more cluster patterns — every new derive_templates.py pattern unlocks a new family of trivial functions (the per-pattern yields range from 13 to 406 GREEN templates each). Look at unmatched clusters of size ≥ 10 that share a structural fingerprint and write the matching template.
  • Cross-binary multipliers — when a template matches in ffxivgame.exe it usually multiplies into the small binaries too (seed_templates.py --reloc has been delivering ~700 ffxivboot + 3 ffxivconfig per pass). Re-run after every fresh template.
  • Tighten epilogue detectionrecompute_sizes.py still has edge cases where a function's true end gets misidentified; auditing remaining mismatches in stamped clusters is the way in.

2026-05-18 — --reloc stamp sweep landed (+1724 GREEN stamps)

stamp_clusters.py previously defaulted to the exact-byte clusters JSON (<bin>.clusters.json), which keeps clusters tight but yields diminishing returns. The reloc-aware variant (--reloc<bin>.clusters_reloc.json) is COARSER — it wildcards relocation- bearing bytes (CALL/JMP rel32 displacements, absolute moves, address-like immediates), so siblings whose only difference is the linker's fixup target cluster together. Running with --reloc across all 5 binaries this session unlocked:

Binary New stamps
ffxivgame +889
ffxivboot +793
ffxivupdater +18
ffxivconfig +15
ffxivlogin +9
Total +1724

Spot-checked 15 ffxivgame + 5 ffxivboot stamps via compare.py; 100 % GREEN. The remaining (~98 % of the +1724 batch) almost certainly behave the same since they're all stamped from the same try_unwind_* template handlers in derive_templates.py.

The bulk of these are SEH unwind funclets (8-byte MOV/LEA ECX, [EBP+disp]; JMP rel32 shape and 11-byte MOV ECX, [EBP+disp]; ADD ECX, imm8; JMP rel32 variants). Cluster #1 alone (shape 5e1602f7cb35, 4591 members) contributed the lion's share.

New Makefile targets:

  • make stamp-relocstamp_clusters --reloc across all 5 binaries
  • make stamp-all — stamp-reloc + seed_templates --all --reloc

Phase 3 — functional decomp (🟢 substantial)

Phase 3 is producing wire-level ground truth via static analysis + cross-validation against garlemald-server and (where it agrees) project-meteor-server. Each subsystem below has a tool that emits a regenerable Markdown report under build/wire/.

3.1 — GAM property registry (✅ 192/192 names)

The GAM (Game Attribute Manager) Component::GAM::CompileTimeParameter <id, &PARAMNAME_id, T, Decorator> template instantiations form a compile-time, type-safe property registry. Mangled into .rdata strings. tools/extract_gam_params.py parses 192 unique (id, namespace, type, decorator) tuples across 6 Data classes:

Data class Count ID range Purpose
CharaMakeData 26 100..125 New-character-creation request body
Player 92 135..233 Per-character persistent state
PlayerPlayer 37 203, 321..345, 579..595 Inner persistent state (uses dual-bound dispatcher)
ClientSelectData 17 100..119 Character-list-display schema
ClientSelectDataN 17 100..116 Renumbered alternate of ClientSelectData
ZoneInitData 3 100..102 Zone-load payload

Output:

  • config/<bin>.gam_params.{json,csv} — machine-readable.
  • build/wire/<bin>.gam_params.md — human-readable.
  • include/net/gam_registry.h — C++ header (auto-generated by emit_gam_header.py) for direct #include from garlemald-server / future garlemald-client.

3.2 — PARAMNAME dispatcher walkers (✅ 192/192 string names)

Ghidra's auto-analysis doesn't create symbols for the per-class PARAMNAME_<id> strings — they're inlined into the MetadataProvider::vtable[2] dispatcher's jump table. tools/extract_paramnames_dispatch.py walks the dispatcher's prologue (ADD EAX, -<base>; CMP EAX, <count-1>; JA default; JMP [EAX*4 + JT]), then for each case body, extracts the PUSH <imm32> immediate that lands in .data and dereferences the C string there.

Two dispatcher kinds discovered:

  • global_id (CharaMakeData, Player, ClientSelectData, ClientSelectDataN, ZoneInitData, PlayerPlayer slot 2): prologue normalizes the global GAM id; K-th .data PUSH = K-th sorted GAM id. Pattern works directly.
  • local_index (PlayerPlayer slot 4 — earlier misdirection): prologue is just CMP EAX, <count-1>; JMP [EAX*4 + JT] with no ADD. Names extractable by local index but NOT pairable to GAM ids without decompiling the global→local translator. The CANONICAL global-id dispatcher for PlayerPlayer is slot 2 (RVA 0x001aee30) with a dual-bound prologue.

Pattern documented in memory/reference_meteor_decomp_paramname_dispatcher.md for reapplication.

3.3 — Down opcode → handler map (✅ 211 opcodes, 3 channels)

tools/extract_opcode_dispatch.py walks each Down channel's dispatcher (slot 1 of *ProtoDownDummyCallback::vtable, the MOVZX/CMP/byte_table/dword_table jump). Output: build/wire/<bin>.opcodes.md.

Channel Real opcodes Total possible
zone 197 502
lobby 10 23
chat 4 not yet enumerated
Total 211

Cross-referenced with garlemald-server/map-server/src/packets/opcodes.rs: 60 opcodes the binary handles are NOT in garlemald (server-side "holes" — features the server can't yet send). The garlemald opcodes file has no opcodes the binary doesn't handle (= no invented opcodes).

3.4 — Up opcode reconnaissance (✅ validation pass; full enumeration deferred)

The Up direction (client → server) uses ClientPacketBuilder constructors — each CPB ctor takes the opcode as an arg and stores it at [builder+0x1C]. Full enumeration requires per-callsite constant propagation through the CPB ctor's arg0, which is a Ghidra-driven analysis still TBD.

tools/extract_up_opcodes.py runs a NECESSARY-but-not-sufficient check: every garlemald OP_RX_* value appears as a PUSH imm32 somewhere in .text. Confirms no garlemald RX opcode is invented (but says nothing about which opcodes garlemald is missing).

3.5 — MurmurHash2 validation (✅ bit-for-bit)

The SetActorPropertyPacket (zone protocol gameplay state) wire ids are 32-bit Murmur2 hashes of property-name strings (e.g. "charaWork.parameterSave.hp[0]"0xE14B0CA8). FUN_00d31490 in the binary is a Murmur2 variant that walks the buffer backward from data + len - 4 in 4-byte chunks (canonical Murmur2 walks forward). tools/validate_murmur2.py runs a Python port; matches against garlemald-server/common/src/utils.rs:: murmur_hash2 over 6 known test vectors. See docs/murmur2.md.

3.6 — CharaMakeData parse-side validation (✅ 4 surfaced bugs)

tools/validate_chara_make.py cross-references garlemald-server/lobby-server/src/data/chara_info.rs:: parse_new_char_request against the binary's GAM CharaMakeData schema. Output: build/wire/<bin>.chara_make_validation.md.

Surfaced field-level mismatches (suggested patch in the report):

  • appearance.face_features → should be face_cheek (id 112)
  • appearance.ears → should be face_jaw (id 114) — 1.x doesn't expose ears as a separate slot
  • info.current_class: u16 → conflates GAM id 122 initialMainSkill + id 123 initialEquipSet (loses the equipment-set value)
  • Three trailing u32 skip reads → ARE GAM id 124 initialBonusItem: int[3] (starter items the parser silently drops)

These are real bugs. Applying them to garlemald-server is on the backlog (see "Open work" below).

3.7 — CharacterListPacket build-side validation (🟡 schema-level only; byte-layout TBD)

tools/validate_chara_list.py cross-references garlemald-server::build_for_chara_list against GAM ClientSelectData. Output: build/wire/<bin>.chara_list_validation.md.

Important caveat: this is schema-level, not byte-layout validation. The build_for_chara_list output is a hand-rolled flat blob (Project Meteor reverse-engineered from network captures), NOT a GAM-encoded (id, value) self-describing structure. The validator pairs each Rust write with its nearest-named GAM field and flags type mismatches that "look like" bugs — but the chara-list packet may legitimately use different wire types than the GAM schema declares for the "same" semantic field.

Five flags surfaced (current_level: u16 vs mainSkillLevel: signed char, tribe: u8 vs tribe: Utf8String, location1/2_bytes vs zoneName/territoryName: signed char, initial_town: u32 (twice) vs initialTown: short). Definitive resolution needs the binary's CharacterListPacket::Deserialize — see "Open question" below.

3.8 — LobbyCryptEngine 9-slot decode (✅)

tools/extract_crypt_engine.py reads the 9 vtable slots of Application::Network::LobbyProtoChannel::ServiceConsumerConnection Manager::LobbyCryptEngine and validates the embedded Blowfish P/S init tables. Output: build/wire/<bin>.crypt_engine.md.

Slot RVA Semantic
0 0x009a1e40 ~LobbyCryptEngine (frees [this+0x30] = BF_KEY*)
1 0x009a1590 PrepareHandshake — copies 32-byte ASCII seed "Test Ticket Data\0\0\0\0clientNumber" from .data 0x011274F0 to this+0x10; _time64(NULL) low 32 bits → this+0x8 + req+0x74
2 0x009a1640 3-arg stub returning 0
3 0x009a0f10 2-arg stub returning false
4 0x009a1670 SetSessionKey — frees old BF_KEY, mallocs 4168 bytes (= sizeof(BF_KEY)), constructs 16-byte key, calls BF_set_key(key, len=16)
5 0x009a0f20 2-arg stub returning false
6 0x009a18d0 Encrypt(_, buf, len) — rounds len DOWN to multiple of 32, in-place ECB Blowfish encrypt via OpenSSL BF_encrypt per block
7 0x009a0f30 Decrypt(_, buf, len) — same shape, OpenSSL BF_decrypt per block
8 0x009a1920 1-arg stub returning true

Per-block primitives (statically-linked OpenSSL):

  • FUN_0045aac0 = BF_encrypt(BF_LONG[2], BF_KEY*)
  • FUN_0045aa30 = BF_decrypt(BF_LONG[2], BF_KEY*)
  • FUN_0045abf0 = BF_set_key(BF_KEY*, int keylen, const unsigned char*)

P/S init constants live at fixed VA 0x01267278 (P[18], 72 bytes) and 0x012672C0 (S[4][256], 4096 bytes). Canonical pi-derived (Schneier 1993 / OpenSSL bf_pi.h):

P[0..3]    = { 0x243F6A88, 0x85A308D3, 0x13198A2E, 0x03707344 }
S[0][0..3] = { 0xD1310BA6, 0x98DFB5AC, 0x2FFD72DB, 0xD01ADFB7 }

Garlemald cross-validation (✅ all bit-for-bit):

  • common/src/blowfish_tables.rs::P_VALUES matches binary 0x01267278..0x012672BF byte-for-byte.
  • common/src/blowfish_tables.rs::S_VALUES matches binary 0x012672C0..0x012682BF byte-for-byte.
  • The non-canonical MOVSX byte (sign-extend) in the binary's key-schedule byte-cycling step is reproduced in common/src/blowfish.rs:74-78 via key[j] as i8 as i32 as u32.
  • 16 rounds + final swap + P[16]/P[17] XOR matches OpenSSL canonical.

Zone/chat encryption — confirmed absent:

  • RTTI sweep finds only ONE concrete CryptEngineInterface subclass (LobbyCryptEngine).
  • Garlemald's world-server and map-server have zero blowfish / encipher / encrypt call sites.
  • Lobby is the only encrypted channel; zone and chat are plaintext.

32-byte alignment quirk — resolved as benign:

  • Lobby slots 6/7 round buffer length DOWN to multiples of 32 via AND EAX, 0xFFFFFFE0. Trailing 0..31 bytes pass through unencrypted by the client.
  • Garlemald's encipher/decipher require 8-aligned and encrypt ALL of len. The two policies diverge — but both Project Meteor and garlemald produce the SAME output (Meteor uses Encipher (data, offset+0x10, subpacketSize-0x10) with no 32-aligned check), and Project Meteor has shipped against the real client for years.
  • The trailing 0..31 garbled bytes always land in the over- provisioned trailing zero region of fixed-capacity lobby buffers (MemoryStream(0x98), vec![0u8; 0x280], etc.). The client never reads past the meaningful prefix.
  • Conclusion: garlemald is correct as-written; documentation- only finding. Worked examples in include/net/lobby_proto_channel.h.

3.9 — Lobby Recv/Send paths (✅ field-level decoded)

include/net/lobby_proto_channel.h (hand-written) captures the 4-slot ClientPacketBuilder shape shared by all three channels (Lobby / Zone / Chat):

vtable[0] ~ClientPacketBuilder    : scalar deleting destructor
vtable[1] uint8_t* Begin()        : returns &this[0x10] (write ptr)
vtable[2] void BuildHeader(out*)  : writes header[0..3] + header[8..11]
vtable[3] void Send(buf, len)     : split into header(16) + payload,
                                     call dispatch helpers (no-ops in
                                     this build — real send is via
                                     RUDP2 layer)

BuildHeader writes (Lobby/Zone):

header[0]   = 0x14                    (constant magic / flags byte)
header[1]   = 0x00                    (reserved)
header[2..4]= (u16) this->[0x1c]      (connection_type, captured at ctor)
header[4..8]= NOT WRITTEN              (caller-populated:
                                        packet_size + num_subpackets)
header[8..12]= (u32) _time64(NULL)    (timestamp low 32 bits;
                                        Chat hardcodes 0x0A here)
header[12..16]= NOT WRITTEN           (typically 0 — high 32 of u64
                                        timestamp in garlemald's view)

Field offsets cross-validated against common/src/{packet, subpacket}.rs — no divergences.

Receive dispatcher: LobbyProtoDownCallbackInterface::vtable[1] at RVA 0x009a4160 (319 bytes). Same byte_table + dword_table two-stage jump pattern as the Down opcode dispatchers documented in 3.3.

Open question — CharacterListPacket::Deserialize

The user-flagged 5 chara-list "bugs" from 3.7 need byte-layout confirmation against the binary's actual deserializer (Project Meteor's encoder is observational evidence, not authoritative — Meteor has its own bugs that have been masked by client tolerance).

A static-analysis cross-reference walk produced these findings:

  1. Lobby Down dispatch goes through LobbyProtoDownCallback Interface::vtable[1] (= FUN_00da4160) — confirmed dispatcher shape. For opcode 0x0D (CharacterList), byte_table[12] = 3dword_table[3] = handler stub at 0x009a41da → calls [this->vtable + 0x14] = slot 5 of the LobbyProtoDownCallback subclass.

  2. The abstract base has only ONE concrete subclassLobbyProtoDownDummyCallback@LobbyClient@Network@Application, confirmed via RTTI extraction (config/<bin>.rtti.json). And this subclass's slot 5 is FUN_00da2d10 = RET 0xc — a no-op stub. So the "obvious" dispatch path does not handle opcode 0x0D in this build.

  3. No third subclass exists — RTTI sweep for .?AV*LobbyProtoDown* finds only the abstract base + the Dummy subclass.

  4. Magic constants from Project Meteor's BuildForCharaList are dead code in the binary0x232327EA and 0xE22222AA each appear in tiny 6-byte "return constant" getter functions with zero callers. The client doesn't validate them.

  5. String literals from Meteor's format are not in the binary"prv0Inn01", "defaultTerritory", "CharacterListPacket" are all absent. The client doesn't string-match them.

  6. Base64 decode is reachable — URL-safe base64 alphabet at .data 0x0126726c, encode/decode at FUN_0045a1d0 / FUN_0045a590, wrappers at FUN_0045a920 / FUN_0045a970, with 6 external direct callers (in functions of size 415B, 534B, 640B, 973B, 606B, 1928B). One of those is the chara-list payload reader, but without GUI-Ghidra type propagation, the pure-Python xref scan can't disambiguate which one.

Architectural conclusion: the chara-list deserializer is reachable but lives behind indirect calls in a parallel codepath (likely MyGameLoginCallback-adjacent in the lobby state machine), not the abstract LobbyProtoDownCallbackInterface dispatch we identified. The "Dummy" subclass naming suggests the entire Down-callback interface scaffolding is inactive in retail builds and the lobby uses a different dispatch mechanism.

Two paths to close this question:

  1. Interactive Ghidra GUI session — open ffxivgame.exe, use auto-analysis + "Find References To..." on the abstract LobbyProtoDownCallbackInterface typeinfo, the lobby opcode 0x0D constant in dispatch sites, and MyGameLoginCallback's non-stub slots (7, 10, 11, 12). The GUI can follow indirect calls and propagate types in ways the Python xref scans can't.
  2. Capture-and-decrypt empirical observation — boot a working fresh-start-*.sh session, log encrypted chara-list bytes from the wire, decrypt them using garlemald's session BF key (which the server already knows), and inspect the actual byte layout the client accepted. Direct ground truth from observation.

Either approach resolves the 5 schema flags definitively.

Update 2026-05-02 — candidate deserializer identified

Pure-static analysis dead-end resolved by following base64-decode xrefs:

  • FUN_0045a920 is the binary-buffer URL-safe base64 decoder (counterpart to FUN_0045a970, the string version).
  • A whole-.text E8-rel32 scan finds 7 callers of the two base64 wrappers combined:
    • 6 are 240-byte sibling functions at consecutive RVAs (0x1a6890, 0x1a6990, 0x1a6a90, 0x1a6b90, 0x1a6c90, 0x1a6d90) — all call the string decoder. Cluster of PARAMNAME-like helpers, not chara-list.
    • 1 is FUN_00901c10 (RVA 0x501c10, 415 B) — the only caller of the binary-buffer decoder. Right size, right call site (fn-offset +0x92), right shape (full /GS SEH frame, __thiscall, gates on this->[+0x144], reads from this->[+0x21d4] size + this->[+0x21d0..] buffer region).

How to find the deserializer for any encrypted lobby payload:

# Find all callers of the base64 decoder you care about
python3 - <<EOF
import struct
orig = open('orig/ffxivgame.exe', 'rb').read()
target = 0x05a920   # or 0x05a970 for string version
hits = []
for i in range(0x1000, 0xb3d000):
    if orig[i] == 0xe8:
        rel = struct.unpack_from('<i', orig, i+1)[0]
        if i + 5 + rel == target:
            hits.append(i)
for h in hits: print(f'caller at file 0x{h:x}')
EOF

The same recipe works for any lobby callback the static-RTTI scan can't anchor.

Update 2026-05-02 — FUN_00901c10 is patcher/DLC verification, NOT chara-list

Retraction of the previous "architecture overturned" finding. Two follow-up Ghidra decompiles refuted the chara-list interpretation of FUN_00901c10:

  • FUN_00454560 (which I'd called the "20-byte outer header parser") is actually a generic local-file loader: constructs a Sqex::File::LocalFile, opens a path obtained via param_1->GetPath(0xffffffff), reads 2 KB chunks via fread, appends each chunk to an accumulator, returns the assembled bytes. The Sqex::File::LocalFile::vftable reference is the smoking gun.
  • FUN_0045b4c0 (which I'd called the "container add-character" call) is a thread-safe lazy-init signature/checksum validator: three lazy-init globals via InterlockedCompareExchange, four- stage validation pipeline (FUN_0045ced0 setup → FUN_0045cf20 check → FUN_0045d0d0 check → FUN_0045d490 final check), returning Sqex error codes 0x28a5 (failure) or 0x28a6 (success).

So FUN_00901c10's actual behavior is:

for each 240-byte manifest entry in this->[+0x21d4..+0x21d8]:
    decode base64 from entry+0x60        → "expected signature" buf
    load local file named by entry        → "actual file contents"
    validate(actual, expected)            → Sqex error code
    if validation fails → bail with error
    advance cursor by 0xf0

This is patcher / DLC manifest verification, NOT chara-list deserialization. The whole subsystem lives in the launcher / installer side of the binary (consistent with FUN_00904600 being the only caller — likely an installer entry point).

The "415 B caller of base64-buf-decode" hint from the open-question doc was misleading: many subsystems use base64, not just the lobby.

Re-opens the question. The actual chara-list deserializer is still unidentified. Ruled out so far:

  • LobbyProtoDownDummyCallback@LobbyClient::vtable[5] (no-op stub)
  • LobbyCharaOperationStep::vtable[*] (slot 22 dispatches to a state-machine "kick off work" function, not a deserializer)
  • FUN_00901c10 (patcher signature verification, not a packet deserializer)

What's left to try (in priority order):

  1. Capture-and-decrypt empirical observation — boot a working fresh-start-*.sh session, log the encrypted chara-list bytes from the wire, decrypt using garlemald's session BF key, and inspect the actual byte layout the client accepted. This is the ground-truth path that doesn't depend on guessing which static function is the parser.
  2. Look at the work item created by FUN_00da5fd0 — that's the LobbyCharaOperationStep::vtable[22]-dispatched function which mallocs a 64 B object with vtable 0x1127fd4 and enqueues it to this+0x1a8. The vtable at 0x1127fd4 (RTTI lookup) names the work-item class; its slot for "process incoming bytes" is the deserializer.
  3. Search for the GAM CompileTimeParameter id range 100..119 (ClientSelectData) being dereferenced in .text — the deserializer reads each id in turn. A scan for the ids' string representations in dispatcher tables would surface the parsing function.

Update — CharaMakeOperation::vtable[1] is the RETAINER list path, not chara-list

Decompile of CharaMakeOperation::vtable[1] (FUN_00daac30) shows a packet-opcode dispatcher with a switch on *(short *)(packet+2). Three opcode handlers, with log strings recovered verbatim from .rdata:

Opcode Handler dispatched Log strings
0x0E sub-switch on *(ushort *)(packet+0x1a) - 1, calls FUN_00da79d0(packet+0x10) "Count:", "LobbyClientMixin::onSuccessfulCharaMake:", "CALL onRenameRetainerName"
0x10 (this->[+0x34])->vtable[0x48/4=18](this->[+8]+0x1d0, this->[+8]+0x200) (no logs)
0x17 logs CHR_SEQ: + CHR_Count:, calls FUN_00da4d80(packet+0x10) "CHR_SEQ:", "CHR_Count:"

I initially read the CHR_* log strings as character-not-retainer and concluded "Project Meteor has the opcodes swapped." That was wrong. Cross-checking against garlemald's existing retainer_list_packets (lobby-server/src/packets/send.rs:412) shows it writes exactly 48 bytes per retainer record: u32 id + u32 character_id + u16 total + u16 do_rename + u32 0 + 32-B padded name = 48. That matches FUN_00da4d80's expected record format perfectly. So:

  • Opcode 0x17 is RetainerList (garlemald is correct).
  • FUN_00da4d80 is the retainer-list deserializer, not the chara-list one.
  • CHR_ is SE's generic prefix for "character data" that covers both player characters AND retainers (since retainers are technically character entities in FFXIV's data model).
  • CharaMakeOperation handles retainer + chara-make traffic, not chara-list.

The 48-byte field map I extracted earlier is therefore the RetainerList wire format, which garlemald already implements correctly.

Update — chara-list deserializer FOUND in ServiceLoginOperation::vtable[1]

Decompile of FUN_00daa9f0 (ServiceLoginOperation::vtable[1]) confirms it dispatches all four lobby-list opcodes from one switch. Per-opcode handlers:

Opcode Handler Log prefix Garlemald name
0x0D FUN_00da76b0(packet+0x10) "SEQ:"/"Count:" CharacterList ← THE chara-list deserializer
0x15 FUN_00da6320(packet+0x10) "WLD_SEQ:"/"WLD_Count:" WorldList
0x16 FUN_00da4c20(packet+0x10) "CHR_SEQ:"/"CHR_Count:" ImportList
0x17 FUN_00da4d80(packet+0x10) "CHR_SEQ:"/"CHR_Count:" RetainerList (cross-confirmed via 48-B record match)

Garlemald's opcode mapping is correct — and was correct all along. The chara-list IS opcode 0x0D; it's just dispatched through ServiceLoginOperation::vtable[1] (the operation-step path), NOT through the LobbyProtoDownDummyCallback no-op stub that I spent days obsessing over earlier. Both dispatch paths physically exist in the binary; the real one is the operation-step path.

Side-finding: CHR_* doesn't mean "character not retainer" — it's used for both retainer (0x17) AND import (0x16) lists. The chara-list (0x0D) uses plain "SEQ:"/"Count:" (no prefix). My earlier "CHR_ proves opcode swap" claim was wrong on multiple levels.

FUN_00da76b0 returns char (a failure flag); on failure the dispatcher calls FUN_00da5030(this->[+0x34]) — a failure callback on the user-supplied callback object.

Closing the chara-list mystery requires one final decompile: FUN_00da76b0 — the per-character field parser. Once that lands, the 5 schema flags from chara_list_validation.md get their definitive answer.

Update — FUN_00da76b0 decompile confirms garlemald's chara-list structure is CORRECT

Decompile of FUN_00da76b0 (the actual chara-list deserializer) shows it accesses each per-character record at exactly the offsets garlemald already writes them to:

local_304[0] = param_2 + idx * 0x1d0;          // record N at payload offset N*0x1d0
bVar5 = *(byte *)(record + 0x18) & 0x3f;        // match key at record+0x18
puVar13 = (undefined4 *)(record + 0x10);        // copy 0x1d0 B from record+0x10
                                                 //   for idx=0: starts at payload+0x10
                                                 //   for idx=1: starts at payload+0x1e0
FUN_00891f00(local_2f8);                         // per-character field deserializer

Garlemald's writes at lobby-server/src/packets/send.rs:

const ENTRY_STRIDE: usize = 0x1D0;               // ← matches deserializer
let entry_start = 0x10 + ENTRY_STRIDE * char_count;
//   entry 0 at payload+0x10..0x1e0  ← exactly where deserializer reads from
//   entry 1 at payload+0x1e0..0x3b0
c.write_u32(0);  c.write_u32(chara.id);          // entry-relative +0x10..+0x18
c.write_u8(total as u8);                         // ← match key at +0x18 (deserializer reads param_2+0x18)

Architectural verdict: garlemald's chara-list packet structure is correct in every dimension that matters:

  • ✅ Opcode 0x0D (dispatched through ServiceLoginOperation::vtable[1], not LobbyProtoDownDummyCallback)
  • ✅ Per-entry stride 0x1D0 (464 bytes)
  • ✅ Entries at payload offset 0x10 + N*0x1D0
  • total byte at entry-relative +0x08 is the match key the deserializer uses to find existing internal records
  • ✅ Base64 appearance blob embedded inside each entry's tail
  • ✅ 16-byte per-packet header (CHR_SEQ + flags + count + pad) precedes the first entry

The "5 schema flags" from build/wire/ffxivgame.chara_list_validation.md (e.g. current_level: u16 vs mainSkillLevel: i8, tribe: u8 vs Utf8String) are field-type bugs INSIDE each 464-byte entry, not an architectural problem. Resolving them requires one more decompile pass: FUN_00891f00 — the actual per-character field parser called with the 464-byte buffer. That function's individual MOV/MOVZX [reg+offset] reads tell us the exact type and width of each field.

Two paths to resolve the field-type flags when ready:

  1. Decompile FUN_00891f00 in Ghidra — directly maps each field's offset and width.
  2. Empirical observation — boot fresh-start, send chara-list with known byte patterns at suspected field positions, watch what the client renders. Slower but doesn't need more Ghidra.

Final summary — chara-list mystery closed

After multiple wrong turns (patcher subsystem, retainer dispatcher, opcode-swap claim), the chara-list architecture is now fully understood:

  • Opcode 0x0D (garlemald is correct)
  • ServiceLoginOperation::vtable[1] (FUN_00daa9f0) dispatches the lobby list opcodes (0x0D / 0x15 / 0x16 / 0x17)
  • FUN_00da76b0 is the chara-list deserializer; it accesses 464-byte entries at offsets garlemald already writes
  • FUN_00891f00 (called with the 464-byte buffer) is the per-character field parser — TBD if/when the 5 schema flags become a priority
  • The LobbyProtoDownDummyCallback no-op stub for opcode 0x0D is genuinely unused in this build — both dispatch paths physically exist but the operation-step path is the live one

Reusable pattern for finding any lobby-list deserializer:

  1. Identify which LobbyOperation corresponds to the lobby phase (lobby login / service login / chara make / game login).
  2. Decompile its vtable[1] — it'll be a packet-opcode dispatcher with a switch on *(short *)(packet+2) and per-opcode handlers labeled with *_SEQ: / *_Count: log strings.
  3. Each case dispatches to a per-opcode deserializer with (packet+0x10) (the payload).
  4. The deserializer follows the same shape: container init on first packet, iterate per-record at payload + idx * record_stride.

Update — FUN_00da4d80 is the RETAINER deserializer (not chara-list)

After cross-checking against garlemald's existing retainer_list_packets (which writes exactly 48 B per record matching FUN_00da4d80's expected shape), FUN_00da4d80 is now confirmed to be the retainer-list deserializer, not chara-list. The wire-format breakdown below is therefore for the RetainerList packet (opcode 0x17), which garlemald already implements correctly.

The chara-list deserializer is still unidentified — see the section below for the next candidates.

Wire format (opcode 0x17 payload, starting at packet+0x10):

struct CharacterListPacket {        // 28-byte header + N×48-byte records
    /* +0x00 */ u32 chr_seq;         // CHR_SEQ (logged verbatim by FUN_00daac30)
    /* +0x04 */ u32 unknown_4;
    /* +0x08 */ u8  flags;           // bit 0 = "first packet" (clears container)
    /* +0x09 */ u8  chr_count;       // CHR_Count (u8, max 255 per packet)
    /* +0x0a */ u8  pad[18];         // unknown
    /* +0x1c */ CharaRecord records[chr_count];   // tightly packed
};

struct CharaRecord {                 // 0x30 = 48 bytes — FLAT BINARY
    /* +0x00 */ u32 unknown_0;
    /* +0x04 */ u32 chara_id;        // unique ID (match key for internal records)
    /* +0x08 */ u8  data[40];        // 40 bytes of summary; decode via FUN_00da95c0
};

Matching algorithm:

for i in 0..chr_count:
    rec = payload + 0x1c + i*0x30
    if rec.chara_id == 0: continue          // empty slot
    copy rec to local_60                    // 12 dwords = 48 bytes
    FUN_00da95c0(local_60)                  // unpack/validate
    // Find existing internal record (0x2e0 = 736 B per record)
    for j in 0..(this->[+0x1d8] - this->[+0x1d4]) / 0x2e0:
        existing = this->[+0x1d4] + j*0x2e0
        if existing.chara_id == rec.chara_id:
            copy rec to local_30
            FUN_00da94c0(local_30)          // merge wire record → existing
            break

Internal full-character record at this->[+0x1d4..+0x1d8]: 0x2e0 = 736 bytes per character. Populated separately from the chara-list (probably via per-character detail packets); the chara-list updates a slim subset.

Implications for garlemald (the load-bearing fix list):

  1. Opcode is 0x17, not 0x0D. Garlemald's CharacterList packet at lobby-server/src/packets/send.rs:271 (OPCODE: u16 = 0x0D) is wrong — should be 0x17. Garlemald's current RetainerList at line 413 (OPCODE: u16 = 0x17) is also misnamed; that opcode is the chara list. (Whether retainer-list uses a different opcode, or just shares the chara-list flow with different chr_seq values, is TBD.)

  2. Format is flat 48-byte records, NO base64. The appearance_blob = chara_info::build_for_chara_list(...) work in chara_info.rs is for a different packet. The actual chara-list is a flat array of 48-byte records.

  3. Multi-packet support via flags & 1 and chr_count: u8. A single packet handles up to 255 chars; subsequent packets continue the list (with flags & 1 == 0 to prevent re-init).

  4. The 5 schema flags in chara_list_validation.md are for fields that aren't even in the chara-list packet — they're in the rich appearance / per-character-detail packet that arrives separately. Garlemald's build_for_chara_list should be renamed to something like build_chara_appearance_blob and used for the appropriate packet (when we identify it).

The reason chara-list "mostly works" today: garlemald's opcode 0x0D arrives at the client and gets silently dropped (no-op handler in LobbyProtoDownDummyCallback). The lobby state machine then falls back to UI-side default behavior — empty slots that the user clicks through — and the server's SelectCharacterConfirm (sent server-side regardless of what the client knew about) closes the loop.

FUN_00da95c0 partial map (decompile captured): the function is a record-merge-or-append on a SECOND container at this->[+0x8..+0xc] (separate from the 0x2e0-byte full-record store at +0x1d4). It uses TWO keys to dedupe:

  • param_2[1] = chara_id (u32 at record+0x04) — confirmed primary key
  • *(char *)(param_2 + 2) = byte at record+0x08 — secondary key (probably slot_index, possibly world_id_low or status discriminator)

Both must be non-zero for the record to be processed (the gate if (param_2[1] != 0 && *(char *)(param_2 + 2) != '\0') at the top). A full match copies 12 dwords (48 B) over the existing record; no match falls through to FUN_00da9420 which appends.

The 39 remaining bytes (rec+0x09..0x30) are processed opaquely by this function (just memcpy-style copy). Mapping them to specific fields would require:

  • FUN_00da94c0 decompile (merges 48 B into the 736 B full-record at this->[+0x1d4..+0x1d8]) — would show which wire-record bytes go to which full-record offsets; OR
  • Empirical observation against a running client (boot fresh-start, send chara-list packets with known byte patterns, watch which rendered fields change).

Final wire-format summary (sufficient to patch garlemald)

Field Offset Type Confidence
Opcode u16 = 0x17 Confirmed
chr_seq payload+0x00 u32 Confirmed (CHR_SEQ literal)
unknown_4 payload+0x04 u32 Unread by deserializer
flags payload+0x08 u8 bit 0 = continuation marker
chr_count payload+0x09 u8 Confirmed (CHR_Count literal)
pad payload+0x0a..0x10 6 B Unread
page_id? payload+0x10 u16 Stored at this->[+0x200] on first packet
pad2 payload+0x12..0x1c 10 B Unread
records[N] payload+0x1c 48 B each Confirmed
Per-rec unknown_0 rec+0x00 u32 Unread for matching
Per-rec chara_id rec+0x04 u32 Confirmed; required non-zero
Per-rec slot_or_world rec+0x08 u8 Confirmed; required non-zero
Per-rec data rec+0x09..0x30 39 B Unmapped — empirical reverse-engineering needed

Update — vtable 0x01127fd4 resolves to CharaMakeOperation

RTTI lookup on the work-item class allocated by FUN_00da5fd0: Application::Network::LobbyClient::CharaMakeOperation (5 slots, vtable at RVA 0xd27fd4 = VA 0x01127fd4).

Surprising finding: there is NO separate CharaListOperation class in the RTTI dump. The 5 concrete LobbyOperation subclasses are:

  • LobbyLoginOperation
  • ServiceLoginOperation
  • CharaMakeOperation
  • GameLoginOperation
  • (plus the abstract LobbyOperation base)

Implication: CharaMakeOperation handles both chara-make AND chara-list traffic — they share the lobby chara-management flow (server sends chara-list, user picks "make new", same operation handles both directions).

The 5-slot pattern across all 4 concrete subclasses:

Slot Pattern
0 class-specific (constructor / init helper)
1 class-specific (likely "process incoming response")
2 shared FUN_00dad7b0 — common dtor/finalize
3 shared FUN_00dad8e0 — common error handler
4 class-specific (likely "build outgoing request")

CharaMakeOperation's class-specific slots:

  • slot 0 = FUN_00daa190 (init)
  • slot 1 = FUN_00daac30 (likely chara-list deserializer)
  • slot 4 = FUN_00da8650 (likely chara-make request builder)

Next step: decompile FUN_00daac30 in Ghidra GUI. If it parses incoming bytes into per-character fields, the search is over.

Phase 5 — Actor + Battle (functional) ✅ exit criterion achieved

Phase 5's structural decomp work (6 work-pool items) was already closed (see docs/actor.md). The PLAN.md exit criterion — a self-contained damage_simulator executable — landed 2026-05-17.

Deliverable: src/ffxivgame/battle/ with:

  • damage_formula.h/cppcompute_fSTR, compute_pDIF, compute_physical_damage, wpn_dmg_to_rank, pdif_cap_for_skill (re-derived from LSB's XI-cousin physical_utilities.lua)
  • damage_simulator.cpp — CLI front-end (reads flat key=value fixtures or --inline args)
  • fixtures/battle/case_attack_{low,med,high}.json — calibration fixtures against YouTube atlas damage samples
  • tests/battle/damage_formula_test.cpp — 21 unit tests (all pass)

Calibration vs YouTube atlas (attack row, n=1401):

Fixture Expected band damage_simulator band
case_attack_low 0..5 (≈ YouTube min=2) 1..1
case_attack_med 20..80 (mid-tier basic) 29..35
case_attack_high 300..700 (high crit basic) 299..427

Build: make damage-sim (runs all 3 fixtures); make damage-sim-test (21 unit tests). Native clang (NOT Wine cl.exe — tooling executable, never ships in matched PE).

License note: AGPL-3.0-or-later. The LSB cousin is GPL-3.0; this implementation is a CLEAN-ROOM RE-DERIVATION (formula STRUCTURE is public knowledge from BG-wiki + Studio Gobli; constants hand-translated and validated against atlas ground truth, not copied verbatim).

See src/ffxivgame/battle/README.md for full details + future-work list (magic damage, weaponskill coefficients, JSON parser upgrade, damage-band sweep tool, garlemald cross-validation).

Phase 4 — Pack / ChunkRead / InstallUnpacker (▶ active matching)

Phase 4 targets the file-system + installer subsystems. Detailed architecture in sqpack.md and install-unpacker.md. Headline finding from reconnaissance: 1.x is resource-id-addressed, not string-path-hashed — the Sqpack::Hash family that ships with ARR/DQX does not exist in 1.x. Files live at <game>/data/<b3>/<b2>/<b1>/<b0>.DAT keyed by a 32-bit resource_id.

4.1 — Sqex::Data class hierarchy (✅ recovered)

Sqex::Data::ChunkRead<unsigned int, unsigned int>      (vtable RVA 0xb931c8)
└── Sqex::Data::PackRead                                (vtable RVA 0xd0dd40)

Sqex::Data::ChunkWrite<unsigned int, unsigned int>     (vtable, 1 slot)
└── Sqex::Data::PackWrite                               (vtable RVA 0xd1311c)

(parallel byte-sized chunk variants)
Sqex::Data::ChunkRead<unsigned char, unsigned short>   (1 slot)
Sqex::Data::ChunkWrite<unsigned char, unsigned short>  (1 slot)

Vtables expose only the destructor; every other method is non-virtual and must be enumerated by xref-walking the constructor / destructor sites. The <u8,u16> instantiations are parallel — likely texture streams or audio with smaller chunk-id and chunk-size widths.

4.2 — Sqex::Data matches (4 GREEN, 2 PARTIAL)

Source under src/ffxivgame/sqpack/ and src/ffxivgame/_partial/.

Function RVA Size Status Notes
PackRead::~PackRead 0x008c6670 110 B ✅ GREEN First Phase-4 GREEN — sets vtable, frees [this+0x74], hands to ChunkRead<u32,u32>::~ChunkRead
PackRead::PackRead (ctor) 0x00942800 132 B 🟡 16/130 PARTIAL (12.3 % strict byte-position, mine 2 B short) — verified 2026-05-03; the prior 130/132 figure was size-match. Real diff has 116 mismatches; structurally equivalent but MSVC chose a different register allocation through the SEH-protected ctor body.
PackRead::ReadNext (tiny stub) 27 B ✅ GREEN Trivial loop driver
PackRead::Rewind (tiny stub) 18 B ✅ GREEN
PackRead::ProcessChunk 0x00942740 177 B 🟡 35/177 PARTIAL (19.8 % strict byte-position, mine 3 B over) — verified 2026-05-03. Buffer-guard cookie blocker — the function uses /GS cookie + __security_check_cookie whose epilogue ordering is sensitive to exact local layout. 145 mismatches; structurally equivalent but MSVC's stack-frame layout choice cascades.
ChunkReadUInt::ReadNextChunkHeader 0x000ebd40 81 B 🟡 80/81 PARTIAL (98.8 %) — accepted Reaffirmed 2026-05-17: exactly 1 byte diff at fn offset +0x34. Both encode the same effective address [ESI + EAX*1 + 8] — the SIB byte just chooses which of (ESI, EAX) is base vs. index (orig 0x06: base=ESI/idx=EAX; ours 0x30: base=EAX/idx=ESI). Tried 2 additional variations beyond iter #2-#5: (#6) explicit cursor-first source ordering (unsigned)m_cursor + 8 + size → byte-identical to #2; (#7) split addition m_cursor += 8; m_cursor += size; → REGRESSED to 7/82 (MSVC dropped the LEA entirely). Same MSVC-normalization quirk that blocks AcquireChunk's last 2 bytes. Accepted as PARTIAL — closing requires __declspec(naked).

4.3 — Sqex::Misc::Utf8String (2 GREEN, 3 PARTIAL)

Recovered the layout (vtable + size + capacity + heap pointer). Source under src/ffxivgame/sqex/Utf8String.cpp.

Function Size Status Notes
Utf8String::Utf8String (default ctor) 39 B ✅ GREEN
Utf8String::~Utf8String 24 B ✅ GREEN
Sqex::Misc::Utf8String::Utf8String (alt ctor) 116 B 🟡 47/115 PARTIAL (40.9 %, mine 1 B short) Layout recovered. Verified 2026-05-03: prior 109/116 / 115/116 figures were size-match heuristics; strict byte-position diff is lower because MSVC schedules the MOV EDX, 1 after the MOV EDI, [esp+0x14] + CMP rather than before. Last 1-byte-shorter is the EDX=1 CSE: orig emits ADD EAX, 1 (3 B) in the strlen loop, mine emits ADD EAX, EDX (2 B). Both DEFERRED — defeating either requires inline asm.
Utf8String::Reserve 153 B 🟡 0/144 PARTIAL (mine 9 B short) Verified 2026-05-03: structurally equivalent but MSVC's reg-allocator chose PUSH ECX (1 local) over orig's SUB ESP, 8 (2 locals). The single-vs-double-spill difference shifts every store/load offset, so byte-position match is near-zero even though the function semantics match. DEFERRED — would need inline asm or a structural rewrite that creates two natural stack-resident locals.

4.4 — Sqex slab allocator pair (1 GREEN, 1 PARTIAL)

Utf8String delegates allocation to two cdecl helpers (Utf8StringAlloc / Utf8StringFree) that index global slab tables at 0x01266dc0 (the literal imm32 base in MOV EAX, [ESI*8 + 0x01266dc0]; the actual slab descriptor table starts at 0x01266dc8 for size_class=1), 0x0132cec8 (free-list buckets), 0x0132cf1c (per-size-class atomic counters). Source under src/ffxivgame/sqex/Allocator.cpp.

Function RVA Size Status
Utf8StringFree 0x0004d350 105 B ✅ GREEN (2026-05-02)
Utf8StringAlloc 0x0004d500 225 B 🟡 126/222 PARTIAL (56.8 % strict byte-position; 222 vs 225 — 3 B short) — verified 2026-05-03 with cl-wine.sh build + position-strict diff. The 74.8 % figure prior was a size-match heuristic. Real diff has 99 mismatches clustered around the loop-iter (sc++ vs index) and the InterlockedExchangeAdd argument-order spill choices.

Utf8StringFree GREEN recipe (commit 06ef7dd24): inline the g_slab_descriptors[size_class].capacity accesses (used three times directly instead of via an intermediate int slab_cap local). MSVC no longer hoists slab_cap into a callee-saved register, so the IDIV correctly re-loads from memory matching orig's 7-byte IDIV [ESI*8 + imm32] instead of mine's 2-byte IDIV reg. Saves the last 1-byte gap by re-introducing the byte that the over-eager hoist had eliminated.

General lesson for matching-decomp: hoisting via intermediate locals can SHRINK functions to 1-byte-short; sometimes the cure is NOT to hoist (inline the access). MSVC IDIV codegen splits along memory-vs-register operand: 2 bytes IDIV reg, 7 bytes IDIV [imm32]. Choosing one over the other is a ~5-byte source- pattern lever.

Utf8StringAlloc remaining gap: 3 bytes from MSVC's "shared ADD" optimization in the branch body — mine uses one shared ADD ECX, EDX at the merge point of both branches; orig duplicates the ADD into one branch and uses an explicit MOV ECX, EDX in the other. Both compute delta + cons_idx. Hard to defeat MSVC's CSE on the ADD from C source alone.

4.5 — Component::Install::InstallUnpacker (3 GREEN, 2 PARTIAL, 1 deferred)

InstallUnpacker is a Sqex::Thread::Thread subclass with a secondary InstallWriter base at +0x38. Slot 2 of its primary vtable (RVA 0x00d0d53c) is the Run override — a 490-byte producer-consumer chunk-extraction loop. The class is the only direct consumer of PackRead in ffxivgame.exe. Detailed structural decode in install-unpacker.md.

Source under src/ffxivgame/install/.

Function RVA Size Status Notes
InstallUnpacker::WaitForReady (tiny) 71 B ✅ GREEN Spin loop using InterlockedExchangeAdd
ResourceQueue::TryEnqueue 122 B ✅ GREEN
ChunkSource::ReleaseChunk 124 B ✅ GREEN
ChunkSource::AcquireChunk 144 B 🟡 130/132 PARTIAL (98.5 %) Reaffirmed 2026-05-17: exactly 2 byte diffs at fn offsets +0x6a and +0x77 — both are the SIB byte of MOV reg, [SIB+disp8]. Orig SIB=0x16 (base=ESI byte_off, index=EDX m_entries); ours SIB=0x32 (swapped). Same effective address, different register-as-base choice. Tried multiple ptr-arith / array-syntax / hoist variations — none flip MSVC's SIB normalisation. Real GREEN requires inline asm. Accepted as PARTIAL.
InstallUnpacker::Unpack (slot 2) 0x008c6700 490 B 🟡 185/490 (37.8 %) reloc-aware effective — accepted Re-measured 2026-05-18: iter #1's "428/490" was a misleading raw-count number. Honest reloc-aware effective: 127 byte-matches + 58 reloc-zero diffs (expected) + 305 REAL diffs. +3 bytes long. Capstone side-by-side reveals cascading register-allocation mismatch — orig: ESI=this, EDI=&InterlockedExchangeAdd, EBX=chunk_handle, EBP=&m_field_a4; ours: EBP=this, EBX=&InterlockedExchangeAdd, ESI=chunk_handle, EDI=&m_field_48. Both are valid 4-callee-save choices but every [reg+disp] differs. Same MSVC instruction-emit normalization class as AcquireChunk/ReadNextChunkHeader (SIB gaps) but at scale. Real GREEN needs (a) Ghidra GUI parent-class deliverables for refactor that biases MSVC's reg-alloc, (b) full __declspec(naked) rewrite (high effort given SEH + 16 relocs), or (c) Phase 2.7 emit_text_blob fallback. Accepted as documented PARTIAL — same path as AcquireChunk + ReadNextChunkHeader. Semantic behavior is correct (reads chunks, dispatches helper, handles bails, releases).

All six kernel32 IAT entries the unpacker uses have been resolved via Ghidra GUI: InterlockedExchange, InterlockedCompareExchange, InterlockedExchangeAdd, Sleep, InterlockedIncrement, SwitchToThread. See ghidra-tasks.md § Status snapshot.

4.6 — CRT helper sweep (32+ GREEN with cross-binary multipliers)

Source under src/ffxivgame/crt/ — covers the small functions MSVC's CRT statically links into every binary. Each match cross-multiplies into the four other binaries (same MSVC build, same library). Pattern: write the C source for one CRT helper, stamp every cross-binary copy GREEN.

Files matched: Strncmp, Strcmp, Strlen, Memset, Fopen, Atol, Alloca, EHProlog (__EH_prolog3_catch_GS), Exit, InitTerm, InvalidParameter (_invalid_parameter_noinfo), Unwind. Cumulative landings (per commit history):

  • e7181509 — 12 GREEN initial sweep (357 B)
  • 5a7121a9fopen + 25 cross-binary multipliers
  • 6a642ebb__EH_prolog3_catch_GS + memset (4 GREEN, 7 with multipliers)
  • 5b55ef56strcmp + strlen (10 GREEN with multipliers)
  • ede50a9strncmp (4 GREEN)

4.7 — Next blocker — InstallUnpacker::Unpack (FUN_00cc6700)

The 490-byte slot-2 method is the highest-value remaining Phase 4 target. To match it we need (in priority order):

  1. Parent class layout beyond the inferred fields — especially what's at m_field_40 + 0x60 and m_field_40 + 0x2140 (atomic- counter accesses suggest a nested counter struct).
  2. Helper function signatures for FUN_00cc5db0 (268 B chunk- source acquire), FUN_00cc5e40 (124 B release), FUN_00cc6510 (343 B), and FUN_00cc6620 (71 B wait-for-ready spin).
  3. The "alt" Utf8String ctor at 0x00445cf0 — distinct from Sqex::Misc::Utf8String::Utf8String @ 0x00047260, likely a different overload or a Sqwt-namespace string class.
  4. FUN_008edbf0 (122 B WaitablePredicate::TryReady) — now writable since InterlockedIncrement IAT entry is resolved.

Each of these is a separate Ghidra GUI task. See ghidra-tasks.md for the list.

Open work (backlog)

In rough priority order:

  1. Push InstallUnpacker::Unpack GREEN — Re-measured 2026- 05-18 and confirmed PARTIAL at 185/490 (37.8 %) reloc-aware effective with 305 real byte diffs and a cascading register- allocation mismatch (orig ESI=this vs ours EBP=this and so on). Same MSVC instruction-emit normalization class as AcquireChunk / ReadNextChunkHeader (both also accepted PARTIAL today). Pushing GREEN requires Ghidra-GUI parent-class layout deliverables (per "Next blocker" below) OR full __declspec(naked) rewrite OR Phase 2.7 emit_text_blob fallback. Accepted as PARTIAL — semantic behavior is correct, byte-identical reproduction is blocked on register-allocation reshaping that doesn't yield from source alone.
  2. Push ChunkSource::AcquireChunk GREEN — Re-checked 2026- 05-17 and confirmed PARTIAL at 130/132 (98.5 %) with exactly 2 byte diffs at fn offsets +0x6a / +0x77. Both diffs are the SIB byte (0x32 ours vs 0x16 orig) — same effective address, different MSVC register-as-base choice. Multiple iterations did not flip the encoding. Real GREEN requires inline asm; pragmatically accepted as PARTIAL since the bytes are semantically identical to orig.
  3. Push Utf8String::Reserve + Utf8StringAlloc/Free GREEN — pending Ghidra GUI on the slab-allocator globals (see ghidra-tasks.md).
  4. Sweep more cluster patterns in derive_templates.py — every pattern unlocks 13–406 more GREEN templates.
  5. Resolve CharacterListPacket::Deserialize (open question above) — closes the chara-list bugs. ✅ Closed 2026-05-02 — FUN_00da76b0 confirms garlemald's chara-list packet structure is correct architecturally; the 5 schema flags are field-type bugs inside each 464-byte entry, resolvable via FUN_00891f00 decompile or empirical client testing.
  6. Apply the 4 surfaced chara_make_validation patches to garlemald-server::parse_new_char_request. ✅ Done 2026-05-01 — see build/wire/ffxivgame.chara_make_validation.md "Patch history" section. All four bugs (face_cheek/face_jaw rename, current_class split, initial_bonus_item [u32;4]) are landed in lobby-server/src/data/chara_info.rs.
  7. Full Up-opcode enumeration (per-callsite arg propagation through CPB ctor's arg0).
  8. LobbyCryptEngine::vtable[6/7] callsite trace — would definitively show what len arg is passed in retail traffic (currently inferred as benign via the worked example argument).
  9. Decompile *ProtoChannel::Recv/Send paths into C++ headers under include/net/ for the remaining fields not yet captured in lobby_proto_channel.h.
  10. Close Phase 9 #5 — opcode → receiver registration. 35 of 42 Receivers are now mapped to specific LuaActorImpl/NullActorImpl vtable slots (docs/receiver_dispatch_via_actorimpl.md, 2026-05-16). Still pending: the per-opcode dispatcher that picks the slot index. Candidates: FUN_004e20a0 (1442 B router downstream of the dummy callback chain — Phase 8 #9), or the Lua VM call-helper. Closing this would auto-complete Phase 9 #7 ("cheat-sheet of what gate does each opcode's receiver check").

Toolbox

Phase 0/1 — bootstrap + static analysis

Tool Role
tools/extract_pe.py PE structure dump (Phase 0)
tools/symlink_orig.sh Populate orig/ from the workspace install
tools/import_to_ghidra.py Ghidra import + analysis (Phase 1)
tools/build_split_yaml.py Work-pool emission (Phase 1)
tools/regenerate_overridden_asm.py Re-dump asm for size-overridden functions

Phase 2 — single-function matching

Tool Role
tools/cl-wine.sh Wraps cl.exe / link.exe under CrossOver Wine
tools/setup-msvc.sh Toolchain detection (cl.exe + PSDK)
tools/compare.py Relocation-aware byte-level diff (orig slice vs .obj)
tools/find_rosetta.py Picks the best small Rosetta candidate
tools/find_easy_wins.py Auto-rank single-function matching candidates
tools/verify_asm_vs_orig.py Catches mid-function Ghidra drops
tools/verify_by_symbol.py Per-symbol asm-vs-orig sanity check

Phase 2.5 — template-derivation pipeline

Tool Role
tools/cluster_shapes.py Group functions by byte-shape modulo relocations
tools/cluster_relocs.py Decode ModR/M / SIB at every relocation site (full ALU + MOV + LEA + IMUL families)
tools/recompute_sizes.py Re-derive true function ends; catches Ghidra drops
tools/seed_templates.py Per-cluster seed-and-stamp pass (--reloc for cross-binary)
tools/derive_templates.py Naked-asm _emit templates for clusters that resist source matching
tools/stamp_clusters.py Run a template against every cluster member; stamp matches
tools/validate_clusters.py Re-validate stamped templates against the binary
tools/update_yaml_status.py Fold validate results into the YAML work pool

Phase 3 — wire-protocol extraction

Tool Role
tools/extract_net_vtables.py Net-class slot map
tools/extract_gam_params.py GAM property registry
tools/extract_paramnames_dispatch.py PARAMNAME dispatcher walker
tools/extract_gam_types_rtti.py GAM types from RTTI
tools/emit_gam_header.py C++ header emission
tools/extract_opcode_dispatch.py Down opcode → slot map
tools/extract_up_opcodes.py Up opcode reconnaissance
tools/extract_crypt_engine.py LobbyCryptEngine 9-slot decode + Blowfish validation
tools/validate_murmur2.py MurmurHash2 vectors
tools/validate_chara_make.py chara_info.rs ↔ GAM CharaMakeData
tools/validate_chara_list.py build_for_chara_list ↔ GAM ClientSelectData

Reporting

Tool Role
tools/progress.py Per-binary headline numbers (matched / total / _rosetta/*.cpp)

Phase 5 — engine main-loop architecture (ffxivDecomp cross-ref, 2026-05-30)

Sourced from ffxivDecomp (github.com/Yokimitsuro/ffxivDecomp), an independent docs-only RE of FFXIV 1.23b ffxivgame.exe, used with permission (see NOTICE.md). Cross-referenced, not byte-verified by meteor-decomp's own decompilation — confirm against the asm before relying on offsets for a code change. Captured 2026-05-30 from the ffxivDecomp 2026-05-27/28 session.

The bare 993-symbol name import names the per-frame subsystem functions but doesn't carry the scheduling skeleton that explains how they fit together. ffxivDecomp's session pinned the engine's two-level tick loop and characterized all 15 PerFrameTick slots; this section records that prose so the names in the import are legible.

Two-level tick architecture

Win32 message loop (outer)
   ↓
Application_mainTick  @ FUN_004da680   (1 caller, from the Win32 message loop)
   ↓ (when 3 startup gates passed)
PerFrameTick          @ FUN_00578970   (called once per frame)
   ↓ (per-subsystem dispatch)
[widget ticks] [Spawn slot 6] [WorkSync slots 7/8] [...] (15 slots)

Application_mainTick (FUN_004da680) gates on three deferred-init flags (this+0x4a8 startup-complete, this+0x17444 system-ready, this+0x174dc render-ready) and a shutdown flag (this+0x504); when ready it drains the pending event-handler list, walks the 32-bit packed input-event buffer at this+0x1782c (top 3 bits 0xc0000000 = routed event, 4-bit subsystem id in 0x0e000000DAT_01336b60 + id*24, 24-bit payload), and then calls PerFrameTick once. PerFrameTick (FUN_00578970) dispatches the subsystem slots off its container (this+0x510 in the caller's frame).

PerFrameTick 15-slot map

Slot Function Role
0 this[0] engine state container
1 this[1] secondary state container (hosts the +0x110/+0x114 sub-slots)
2 FUN_00766f00 widget lifecycle pump (state machine, this+0x16c == 10 = active)
3 FUN_0076f6f0 widget animation/state tick
4 FUN_007700b0 widget load manager (dispatches deferred load msg 0xde)
5 FUN_0076a9c0 spreadsheet CSV preloader (4 categories: worldMasterLogCategory / command / achievement / hamletDefScore; runs once across multiple frames)
6 SpawnPipeline_perFrameWrapper_dispatchesT0 (FUN_006cdf20) spawn pipeline — drains the actor-spawn ring buffer
7 FUN_00583440 inbound WorkSync pump (complex) — up to 32 items/tick via CommandUpdater_invokeLua_onUpdateWork_complex; STOP/DEFER/COMPLETE per item
8 FUN_005836d0 inbound WorkSync pump (simple) — identical ring shape, callback FUN_00794250; 32 items/tick (2 pumps → ~64 state updates/frame)
9 thunk_FUN_007694d0 widget tick thunk
10 FUN_00770c00 timeout monitor — 900-frame (~15 s @ 60 Hz) timeout watcher
11 FUN_0076dab0 compound widget tick (calls FUN_0075cea0 + FUN_0076a490)
12 FUN_00765340 dead-session cleanup tick (GCs disconnected/timed-out sessions)
1+0x110 FUN_0075d120 player-mode state ticker (see below)
1+0x114 FUN_00764fd0 widget-container child-creation notifier (fires onCreatedWidgetInWidgetContainer Lua callback)
0xd vtable[+8](this[0xd]) pluggable polymorphic slot (runtime-installed subsystem)

Why spawn is ~2/frame

The spawn ramp visible at zone-enter in 1.x falls straight out of this loop: slot 6's SpawnPipeline_perFrameWrapper_dispatchesT0 calls the T1 stage once per invocation, T1 reads 2 ring entries per call, and the wrapper itself is called once per PerFrameTick, which is called once per Application_mainTick, which is called once per Win32 frame — so 2 actor spawns/frame max (~120/s @ 60 Hz; a 50-actor zone takes ~25 frames / ~417 ms to fully populate). The WorkSync pumps (slots 7/8) and spawn pump (slot 6) each carry their own per-tick rate-limit, which is why a high-population zone fades in rather than appearing instantly. This is consistent with — and gives the engine-side mechanism behind — the spawn-protocol quirks recorded in memory/reference_ffxiv_1x_spawn_protocol.md.

Slot 1+0x110 — player-mode ticker (FUN_0075d120)

Polls a target actor's bindings each frame to track a player mode state (likely battle/event/cutscene):

mode root      = binding 0xc0000024
sub-mode value = binding 0x7a121   (uint, low 2 bits used)
mode active    = binding 0x7a122   (bool)
primary value  = binding 0x7a123   (uint, low 5 bits used)

if (0x7a122 == false):  if previously active → FUN_004d7230(0); mark inactive
else:                   mark active
                        primary = 0x7a123 & 0x1f
                        sub     = 0x7a121 & 0x03
                        if changed → FUN_004d7230( ((sub << 5) | primary) * 2 | 1 )

These four binding ids (0xc0000024, 0x7a121, 0x7a122, 0x7a123) extend the bindWork catalog; the packed dispatch value is (((sub & 3) << 5) | (primary & 0x1f)) * 2 | 1 (low bit = active marker).

Caveat: all VAs/offsets here are ffxivDecomp's, cross-referenced not byte-verified against meteor-decomp's own asm. Slots 7/8 split (complex vs simple WorkSync) and the slot 1+0x110 mode semantics are ffxivDecomp's "Likely (Medium)" confidence; confirm against the disassembly before driving a code change. ffxivDecomp source: docs/re/exe/finding_application_mainTick_and_per_frame_subsystem_dispatch.md and docs/re/exe/finding_perFrameTick_subsystems_COMPLETE_15_slots_characterized.md.