diff --git a/fifa17-recon/docs/plan-2026-08-06-card-subsystem.md b/fifa17-recon/docs/plan-2026-08-06-card-subsystem.md index 94ddf21..5690ffc 100644 --- a/fifa17-recon/docs/plan-2026-08-06-card-subsystem.md +++ b/fifa17-recon/docs/plan-2026-08-06-card-subsystem.md @@ -304,8 +304,44 @@ elimination:** | kit | **9** | 7 | `FUN_180119bd0` → `FUT_UC_KITS` + `TeamName_Abbr15_` | `teamid` | | stadium | **10** | 7 | `FUN_180119bd0` → `Stadium` + `StadiumName_` | `assetId` | | badge | **11** | 7 | `FUN_180119bd0` → `Badge` + `TeamName_Abbr15_` | `teamid` | -| ball | **30** (0x1e) | 9 | none; `FUT_UC_BALL` caption only | `localizedName` | -| league logo | **31** (0x1f) | 9 | `FUN_180098f20` keyed on leagueid | `localizedName`, probably | +| ball | **30** (0x1e) | 9 | NONE — see the 2026-08-21 measurement below | unnameable | +| league logo | **31** (0x1f) | 9 | NONE — see the 2026-08-21 measurement below | unnameable | + +**MEASURED 2026-08-21 against the running client (`tools/cardtype_dispatch_probe.py`, +pid 6580): no cardtype-9 family can be named, and no server change can alter that.** +Four independent reads, each with a passing positive control: + +1. The merge switch's jump table at rva `0x141eb4` is indexed by `cardtype - 1` + and has exactly 10 entries. Cardtypes 1–5 and 10 each get their own DB-merge + arm; **cardtypes 6, 7, 8 and 9 all land on the shared tail `0x180141e8a`**, + which issues no query and writes no name — it only derives the discard level + from the rating. +2. Census of every `cmp [reg+0x4c], imm` (cardtype): 0 → 1 site, 1 → 13, 6 → 1, + 7 → 6, **9 → ZERO**. +3. Census of every `cmp [reg+0x50], imm` (cardsubtypeid), which is what actually + selects a club-item caption: kit 9, stadium 10 and badge 11 all present + (control), **ball 30 → ZERO sites, league logo 31 → ZERO sites**. The only + cardtype-9 subtypes that appear at all are `fcc_misccards` 231/232/233/236, + and all four sites are one boolean predicate near `0x1801a72da` that returns + FALSE for them — an exclusion, not a resolver. (That predicate's identity is + NOT established; it reads `+0x49`, `+0x145` and a vtable slot `+0x270`.) +4. The cardtype-7 resolver is reached only under `cmp DWORD PTR [rax+0x4c], 0x7` + at `0x1800f6f04`, so a cardtype-9 item can never arrive there. Its `jne` path + formats `AWARD_LABEL_%i` (`0x1801fd5a0`) — the TROPHY path, not a fallback + that would name a ball. + +So the earlier "`localizedName`, probably" for these two rows was optimistic: +there is no code that would read it for a caption. Withholding ball and league +logo from the projection is a measured limit of the client, not caution. + +CORRECTION, same measurement: `FUN_180119bd0` was recorded elsewhere as having +"zero refs in CardsDLL → almost certainly an export, its caller is in +FIFA17.exe". It is **not** an export. Its address occurs exactly ONCE in the +whole process, at `0x18021c738` in CardsDLL's own `.rdata`, and nothing in +FIFA17.exe references it. It is a virtual function: vtable base `0x18021c2a0`, +slot **+0x498**, index 147 (ctor LEAs at `0x18010ce10` / `0x18011111b`) — which +independently reproduces the "manager vtable slot +0x498" recorded below, by a +different method. It has 7 distinct `call [reg+0x498]` sites. The premise that all five live in cardtype 9 is wrong, and the root fact is not an inference from a call site. `FUN_1800d8330`, read in full at 714 chars by two diff --git a/fifa17-recon/tools/cardtype_dispatch_probe.py b/fifa17-recon/tools/cardtype_dispatch_probe.py new file mode 100755 index 0000000..a80ef01 --- /dev/null +++ b/fifa17-recon/tools/cardtype_dispatch_probe.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Prove, from the live client, which cardtypes CardsDLL can NAME -- and that +cardtype 9 (ball / league logo / fcc_misccards) is not one of them. + +READ-ONLY: /proc/PID/mem opened 'rb'. No write path in this file. + +WHY +--- +Serving an owned ball or league logo was blocked on one question: where does a +cardtype-9 item's caption come from? Three independent reads here say: nowhere. + +MEASURED 2026-08-21, pid 6580, CardsDLL live base 0x6ffffc0f0000 +(module-relative offsets below are stable; live addresses are not). + +1. THE CLUB-ITEM CAPTION RESOLVER IS A VTABLE SLOT, NOT AN EXPORT. + An earlier note recorded FUN_180119bd0 as "zero refs in CardsDLL -> almost + certainly an export, its caller is in FIFA17.exe". That is WRONG and this + tool corrects it. Its address occurs exactly ONCE in the entire process, at + image 0x18021c738, inside CardsDLL's own .rdata -- a vtable entry. Nothing in + FIFA17.exe references it. + + Walking backwards over "qwords pointing into .text" overshoots the vtable + boundary (it runs 826 slots through several adjacent vtables). The reliable + discriminator is that a vtable's START is referenced by its constructor via a + RIP-relative LEA while interior slots never are: + + vtable base image 0x18021c2a0 (ctor LEAs at 0x18010ce10, 0x18011111b) + FUN_180119bd0 slot +0x498, index 147 + + which independently reproduces the previously recorded "manager vtable slot + +0x498". There are 7 distinct `call [reg+0x498]` sites. + +2. THE CAPTION CALL IS GATED ON cardtype == 7, AND THE ELSE IS TROPHIES. + At 0x1800f6f04: + + cmp DWORD PTR [rax+0x4c], 0x7 ; cardtype + jne 0x1800f6f82 + ... + mov r9d, [rdx+0x94] + mov r8d, [rdx+0x50] ; cardsubtypeid + mov ecx, [rdx+0x20] ; assetid + call QWORD PTR [r10+0x498] ; FUN_180119bd0 + + The jne path formats [rdi+0x8] into 'AWARD_LABEL_%i' (0x1801fd5a0) and + localises it -- that is the TROPHY path (subtypes 0x91..0x96), not a fallback + that would name a ball. + +3. NO CARDTYPE-9 HANDLING EXISTS, BY TWO INDEPENDENT MEASURES. + a) Census of every `cmp [reg+0x4c], imm8` in .text: + cardtype 0 : 2 sites + cardtype 1 : 14 sites + cardtype 6 : 1 site + cardtype 7 : 6 sites + cardtype 9 : 0 sites + b) The merge switch's jump table at rva 0x141eb4, indexed by cardtype-1, + 10 entries: + idx 0..4 -> cardtypes 1..5 distinct DB-merge arms + idx 5..8 -> cardtypes 6..9 ALL to the shared tail 0x180141e8a + idx 9 -> cardtype 10 distinct arm (gkcoach) + The shared tail does no DB query and writes no name: it only derives the + discard level from the rating. + + A cmp census alone would miss a jump-table switch, and a jump table alone + would miss an explicit compare. Both say the same thing. + +CONSEQUENCE +----------- +A cardtype-9 item cannot receive a client-resolved caption: it has no merge arm +to fill a name and it can never reach the cardtype-7 resolver. Withholding ball +and league logo from the projection is therefore an evidence-backed limit of the +client, not caution -- and no server-side change can lift it. + +BONUS, and it validates the discard work: the shared tail at 0x180141e8a IS the +discard level ladder, live -- + movzx eax,[rdi+0xb4] ; cmp al,0x4b ; -> 3 + cmp al,0x41 ; sbb eax,eax ; add eax,2 ; -> 2 or 1 + mov [rdi+0x54], eax +which is `discard::discard_level` instruction for instruction. + +Usage: + python3 cardtype_dispatch_probe.py +""" +import collections +import struct +import sys + +import watch_club_model as W + +TEXT_LO, TEXT_HI = 0x180001000, 0x1801E5000 +RDATA_LO, RDATA_HI = 0x1801E5000, 0x18028A000 +CAPTION_FN = 0x180119BD0 +JUMP_TABLE = 0x180141EB4 +SHARED_TAIL = 0x180141E8A +REGS = {0x78: "rax", 0x79: "rcx", 0x7A: "rdx", 0x7B: "rbx", + 0x7D: "rbp", 0x7E: "rsi", 0x7F: "rdi"} + + +def main(): + pid = W.find_pid() + if pid is None: + print("FIFA17.exe is not running.") + return 1 + dll = W.dll_base(pid) + if dll is None: + print("pid %d is up but %s is not mapped yet." % (pid, W.DLL)) + return 1 + mem = W.Mem(pid) + live = lambda i: dll + (i - W.IMG_BASE) + print("pid=%d CardsDLL live base %#x" % (pid, dll)) + + text, bad = mem.read_pages(live(TEXT_LO), TEXT_HI - TEXT_LO) + text = bytes(text) + print("read %#x bytes .text (%d bad pages)" % (len(text), len(bad))) + + # --- 1. locate the caption fn's single reference, and its vtable base ---- + target = live(CAPTION_FN) + rdata, _ = mem.read_pages(live(RDATA_LO), RDATA_HI - RDATA_LO) + rdata = bytes(rdata) + slots = [] + needle = struct.pack(" slot +%#x (index %d)" + % (base, slot - base, (slot - base) // 8)) + + # --- 2. cardtype compare census ----------------------------------------- + hits = collections.defaultdict(list) + for i in range(len(text) - 4): + if text[i] == 0x83 and text[i + 1] in REGS and text[i + 2] == 0x4C: + hits[text[i + 3]].append(TEXT_LO + i) + print("\n[2] cardtype tests `cmp [reg+0x4c], imm`:") + for ct in sorted(hits): + print(" cardtype %2d : %3d site(s) e.g. %s" + % (ct, len(hits[ct]), ", ".join("%#x" % v for v in hits[ct][:4]))) + ok_control = 7 in hits and 1 in hits + print(" CONTROL (cardtypes 1 and 7 must both appear): %s" + % ("OK" if ok_control else "WRONG REGION -- results are meaningless")) + print(" cardtype 9 sites: %d" % len(hits.get(9, []))) + + # --- 3. merge jump table ------------------------------------------------- + jt, _ = mem.read_pages(live(JUMP_TABLE), 0x40) + jt = bytes(jt) + print("\n[3] merge jump table at %#x (index = cardtype - 1):" % JUMP_TABLE) + tail_types = [] + for n in range(16): + rva = struct.unpack_from(" %#x%s" % (ct, va, mark)) + if va == SHARED_TAIL: + tail_types.append(ct) + + # --- 4. cardsubtypeid census ------------------------------------------- + # The club-item CAPTION is chosen by subtype (+0x50), not cardtype, so the + # cardtype census alone does not settle whether a ball or logo is nameable. + sub = collections.defaultdict(list) + for i in range(len(text) - 8): + if text[i] == 0x83 and text[i + 1] in REGS and text[i + 2] == 0x50: + sub[text[i + 3]].append(TEXT_LO + i) + elif text[i] == 0x81 and text[i + 1] in REGS and text[i + 2] == 0x50: + sub[struct.unpack_from(" bool { matches!( self,