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).
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>.yamlrows whosestatus:field readsmatched. That field collapsed during the agent orchestrator's work-pool regeneration (now concluded and torn down) — most visibly forffxivgame.exe, which dropped from 23,106 to 320 even as its_rosetta/*.cppfile count rose to 39,765. Match state was tracked via file presence + the orchestrator's SQLite coordination DB (now archived), not the YAMLstatus:field, and there is no longer a running pipeline to re-sync it (update_yaml_statusneedsbuild/easy_wins/*.validate_results.json, which the teardown removed). So the_rosetta/*.cppfile 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.
make bootstrap symlinks the workspace's installed retail binaries
into orig/ and dumps PE structure as a sanity check. Captured by
tools/extract_pe.py → build/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. |
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>.sfile 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
*ProtoChannelIpcChannel families (Lobby / Zone / Chat)- their
ServiceConsumerConnectionManager+ConsumerConnection - the per-channel
ClientPacketBuilder(4 slots).
- their
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.
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.shextracts 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-msvcpasses. - Platform SDK 2003 R2 also installed via
vstudio2005-workspace/install-psdk.sh—PSDK-x86.msiextracted 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 (noWINEARCH=win32) with theDYLD_FALLBACK_LIBRARY_PATHshim and readsMSVC_TOOLCHAIN_DIRfrom~/.config/meteor-decomp.env.tools/compare.py: relocation-aware byte-level diff. Reads the.textsection from the staged.obj, slices the corresponding RVA range fromorig/<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 — seereference_meteor_decomp_rosetta_match.mdfor 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.
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:
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.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 ALU0x80/0x81/0x83,0x88/0x89/0x8a/0x8b/0x8d(MOV/LEA),0xc0/0xc1/0xc6/0xc7/0xd0..0xd3/0xfe/0xff(rotates / immediate stores / arithmetic), and0x69/0x6b(IMUL imm) opcode families with proper length decoding.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.tools/seed_templates.py --reloc— for each cluster, pick the smallest member as the seed, generate a.cppthat compiles to the same shape, and verify the seed matches. If GREEN, stamp every cluster sibling.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_emitbyte sequences with__asmblocks 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), SEHCatch_Allhandlers, push-call wrappers, chained-pointer getters, vtable trampolines, MOV/LEA disp32, constant-byte clusters, etc.tools/stamp_clusters.py— runs the matching template against every member of a cluster, validating each individually withcompare.py. Members that match are stamped GREEN in_rosetta/<rva>.cpp.tools/validate_clusters.py— a separate pass that re-validates already-stamped templates against the binary; catches regressions when the toolchain or pipeline changes.tools/update_yaml_status.py— folds per-file validate results back into the YAML work pool (status: matched).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.).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).
- Sweep more cluster patterns — every new
derive_templates.pypattern 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.exeit usually multiplies into the small binaries too (seed_templates.py --relochas been delivering ~700 ffxivboot + 3 ffxivconfig per pass). Re-run after every fresh template. - Tighten epilogue detection —
recompute_sizes.pystill has edge cases where a function's true end gets misidentified; auditing remaining mismatches in stamped clusters is the way in.
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-reloc—stamp_clusters --relocacross all 5 binariesmake stamp-all— stamp-reloc +seed_templates --all --reloc
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/.
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 byemit_gam_header.py) for direct#includefromgarlemald-server/ future garlemald-client.
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.dataPUSH = K-th sorted GAM id. Pattern works directly.local_index(PlayerPlayer slot 4 — earlier misdirection): prologue is justCMP EAX, <count-1>; JMP [EAX*4 + JT]with noADD. 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 (RVA0x001aee30) with a dual-bound prologue.
Pattern documented in
memory/reference_meteor_decomp_paramname_dispatcher.md for
reapplication.
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).
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).
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.
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 beface_cheek(id 112)appearance.ears→ should beface_jaw(id 114) — 1.x doesn't expose ears as a separate slotinfo.current_class: u16→ conflates GAM id 122initialMainSkill+ id 123initialEquipSet(loses the equipment-set value)- Three trailing
u32 skipreads → ARE GAM id 124initialBonusItem: 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).
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.
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_VALUESmatches binary0x01267278..0x012672BFbyte-for-byte.common/src/blowfish_tables.rs::S_VALUESmatches binary0x012672C0..0x012682BFbyte-for-byte.- The non-canonical
MOVSX byte(sign-extend) in the binary's key-schedule byte-cycling step is reproduced incommon/src/blowfish.rs:74-78viakey[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/encryptcall 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/decipherrequire 8-aligned and encrypt ALL oflen. The two policies diverge — but both Project Meteor and garlemald produce the SAME output (Meteor usesEncipher (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.
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.
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:
-
Lobby Down dispatch goes through
LobbyProtoDownCallback Interface::vtable[1](=FUN_00da4160) — confirmed dispatcher shape. For opcode0x0D(CharacterList),byte_table[12] = 3→dword_table[3] = handler stub at 0x009a41da→ calls[this->vtable + 0x14]= slot 5 of the LobbyProtoDownCallback subclass. -
The abstract base has only ONE concrete subclass —
LobbyProtoDownDummyCallback@LobbyClient@Network@Application, confirmed via RTTI extraction (config/<bin>.rtti.json). And this subclass's slot 5 isFUN_00da2d10=RET 0xc— a no-op stub. So the "obvious" dispatch path does not handle opcode 0x0D in this build. -
No third subclass exists — RTTI sweep for
.?AV*LobbyProtoDown*finds only the abstract base + the Dummy subclass. -
Magic constants from Project Meteor's
BuildForCharaListare dead code in the binary —0x232327EAand0xE22222AAeach appear in tiny 6-byte "return constant" getter functions with zero callers. The client doesn't validate them. -
String literals from Meteor's format are not in the binary —
"prv0Inn01","defaultTerritory","CharacterListPacket"are all absent. The client doesn't string-match them. -
Base64 decode is reachable — URL-safe base64 alphabet at
.data 0x0126726c, encode/decode atFUN_0045a1d0/FUN_0045a590, wrappers atFUN_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:
- Interactive Ghidra GUI session — open
ffxivgame.exe, use auto-analysis + "Find References To..." on the abstractLobbyProtoDownCallbackInterfacetypeinfo, the lobby opcode0x0Dconstant in dispatch sites, andMyGameLoginCallback'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. - Capture-and-decrypt empirical observation — boot a working
fresh-start-*.shsession, 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.
Pure-static analysis dead-end resolved by following base64-decode xrefs:
FUN_0045a920is the binary-buffer URL-safe base64 decoder (counterpart toFUN_0045a970, the string version).- A whole-
.textE8-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(RVA0x501c10, 415 B) — the only caller of the binary-buffer decoder. Right size, right call site (fn-offset+0x92), right shape (full/GSSEH frame,__thiscall, gates onthis->[+0x144], reads fromthis->[+0x21d4]size +this->[+0x21d0..]buffer region).
- 6 are 240-byte sibling functions at consecutive RVAs
(
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.
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 aSqex::File::LocalFile, opens a path obtained viaparam_1->GetPath(0xffffffff), reads 2 KB chunks viafread, appends each chunk to an accumulator, returns the assembled bytes. TheSqex::File::LocalFile::vftablereference 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 viaInterlockedCompareExchange, four- stage validation pipeline (FUN_0045ced0setup →FUN_0045cf20check →FUN_0045d0d0check →FUN_0045d490final check), returning Sqex error codes0x28a5(failure) or0x28a6(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):
- Capture-and-decrypt empirical observation — boot a working
fresh-start-*.shsession, 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. - Look at the work item created by
FUN_00da5fd0— that's theLobbyCharaOperationStep::vtable[22]-dispatched function whichmallocs a 64 B object with vtable0x1127fd4and enqueues it tothis+0x1a8. The vtable at0x1127fd4(RTTI lookup) names the work-item class; its slot for "process incoming bytes" is the deserializer. - 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.
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
0x17is RetainerList (garlemald is correct). FUN_00da4d80is 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.
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.
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 deserializerGarlemald'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 throughServiceLoginOperation::vtable[1], notLobbyProtoDownDummyCallback) - ✅ Per-entry stride
0x1D0(464 bytes) - ✅ Entries at payload offset
0x10 + N*0x1D0 - ✅
totalbyte at entry-relative+0x08is 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:
- Decompile
FUN_00891f00in Ghidra — directly maps each field's offset and width. - 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.
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_00da76b0is the chara-list deserializer; it accesses 464-byte entries at offsets garlemald already writesFUN_00891f00(called with the 464-byte buffer) is the per-character field parser — TBD if/when the 5 schema flags become a priority- The
LobbyProtoDownDummyCallbackno-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:
- Identify which
LobbyOperationcorresponds to the lobby phase (lobby login / service login / chara make / game login). - 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. - Each case dispatches to a per-opcode deserializer with
(packet+0x10)(the payload). - The deserializer follows the same shape: container init on first
packet, iterate per-record at
payload + idx * record_stride.
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):
-
Opcode is 0x17, not 0x0D. Garlemald's
CharacterListpacket atlobby-server/src/packets/send.rs:271(OPCODE: u16 = 0x0D) is wrong — should be0x17. Garlemald's currentRetainerListat 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 differentchr_seqvalues, is TBD.) -
Format is flat 48-byte records, NO base64. The
appearance_blob = chara_info::build_for_chara_list(...)work inchara_info.rsis for a different packet. The actual chara-list is a flat array of 48-byte records. -
Multi-packet support via
flags & 1andchr_count: u8. A single packet handles up to 255 chars; subsequent packets continue the list (withflags & 1 == 0to prevent re-init). -
The 5 schema flags in
chara_list_validation.mdare 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'sbuild_for_chara_listshould be renamed to something likebuild_chara_appearance_bloband 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_00da94c0decompile (merges 48 B into the 736 B full-record atthis->[+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).
| 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 |
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:
LobbyLoginOperationServiceLoginOperationCharaMakeOperationGameLoginOperation- (plus the abstract
LobbyOperationbase)
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'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/cpp—compute_fSTR,compute_pDIF,compute_physical_damage,wpn_dmg_to_rank,pdif_cap_for_skill(re-derived from LSB's XI-cousinphysical_utilities.lua)damage_simulator.cpp— CLI front-end (reads flatkey=valuefixtures or--inlineargs)fixtures/battle/case_attack_{low,med,high}.json— calibration fixtures against YouTube atlas damage samplestests/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 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.
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.
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). |
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. |
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.
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.
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)5a7121a9—fopen+ 25 cross-binary multipliers6a642ebb—__EH_prolog3_catch_GS+memset(4 GREEN, 7 with multipliers)5b55ef56—strcmp+strlen(10 GREEN with multipliers)ede50a9—strncmp(4 GREEN)
The 490-byte slot-2 method is the highest-value remaining Phase 4 target. To match it we need (in priority order):
- Parent class layout beyond the inferred fields — especially
what's at
m_field_40 + 0x60andm_field_40 + 0x2140(atomic- counter accesses suggest a nested counter struct). - Helper function signatures for
FUN_00cc5db0(268 B chunk- source acquire),FUN_00cc5e40(124 B release),FUN_00cc6510(343 B), andFUN_00cc6620(71 B wait-for-ready spin). - The "alt" Utf8String ctor at
0x00445cf0— distinct fromSqex::Misc::Utf8String::Utf8String @ 0x00047260, likely a different overload or a Sqwt-namespace string class. FUN_008edbf0(122 BWaitablePredicate::TryReady) — now writable sinceInterlockedIncrementIAT entry is resolved.
Each of these is a separate Ghidra GUI task. See
ghidra-tasks.md for the list.
In rough priority order:
Push— 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 fullInstallUnpacker::UnpackGREEN__declspec(naked)rewrite OR Phase 2.7emit_text_blobfallback. Accepted as PARTIAL — semantic behavior is correct, byte-identical reproduction is blocked on register-allocation reshaping that doesn't yield from source alone.Push— 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 (ChunkSource::AcquireChunkGREEN0x32ours vs0x16orig) — 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.- Push
Utf8String::Reserve+Utf8StringAlloc/FreeGREEN — pending Ghidra GUI on the slab-allocator globals (seeghidra-tasks.md). - Sweep more cluster patterns in
derive_templates.py— every pattern unlocks 13–406 more GREEN templates. Resolve✅ Closed 2026-05-02 —CharacterListPacket::Deserialize(open question above) — closes the chara-list bugs.FUN_00da76b0confirms garlemald's chara-list packet structure is correct architecturally; the 5 schema flags are field-type bugs inside each 464-byte entry, resolvable viaFUN_00891f00decompile or empirical client testing.Apply the 4 surfaced✅ Done 2026-05-01 — seechara_make_validationpatches togarlemald-server::parse_new_char_request.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 inlobby-server/src/data/chara_info.rs.- Full Up-opcode enumeration (per-callsite arg propagation through CPB ctor's arg0).
LobbyCryptEngine::vtable[6/7]callsite trace — would definitively show whatlenarg is passed in retail traffic (currently inferred as benign via the worked example argument).- Decompile
*ProtoChannel::Recv/Sendpaths into C++ headers underinclude/net/for the remaining fields not yet captured inlobby_proto_channel.h. - Close Phase 9 #5 — opcode → receiver registration. 35 of 42
Receivers are now mapped to specific
LuaActorImpl/NullActorImplvtable 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").
| 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 |
| 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 |
| 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 |
| 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 |
| Tool | Role |
|---|---|
tools/progress.py |
Per-binary headline numbers (matched / total / _rosetta/*.cpp) |
Sourced from ffxivDecomp (github.com/Yokimitsuro/ffxivDecomp), an independent docs-only RE of FFXIV 1.23b
ffxivgame.exe, used with permission (seeNOTICE.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.
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
0x0e000000 → DAT_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).
| 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) |
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.
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.