From cd5983ecdd27b0dd0b042507482d44ec9733b42a Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 21 Aug 2026 23:10:44 +0000 Subject: [PATCH] fifa17: cardtype 9 is unnameable -- measured, and the gap closes as a negative Serving owned balls (subtype 30), league logos (31) and fcc_misccards (231/232/233/236) was the last projection gap. The open guess was that their caption would come from `localizedName` on the wire, "probably", and they were withheld out of caution. Measured against the running client instead (new tools/cardtype_dispatch_probe.py, read-only, reproducible, every step with a positive control). They cannot be named at all: 1. The merge jump table at rva 0x141eb4 is indexed cardtype-1 with 10 entries. Cardtypes 1..5 and 10 each get a DB-merge arm; cardtypes 6,7,8,9 ALL land on one shared tail at 0x180141e8a that runs no query and writes no name. 2. `cmp [reg+0x4c], 9` (cardtype): ZERO sites in .text. For contrast, cardtype 1 has 13 and cardtype 7 has 6. 3. `cmp [reg+0x50], 30` and `..., 31` (cardsubtypeid -- the field that actually selects a club-item caption): ZERO sites each, while kit 9, stadium 10 and badge 11 all appear, which is the control. The only cardtype-9 subtypes present anywhere are the four misccards ids, and all four are one boolean predicate near 0x1801a72da that returns FALSE for them: an exclusion, not a resolver. That predicate is NOT identified and is not claimed to be. 4. The cardtype-7 resolver is gated `cmp [rax+0x4c], 7` at 0x1800f6f04, so a cardtype-9 item never reaches it. Its jne path formats AWARD_LABEL_%i -- the trophy path, not a fallback that would name a ball. Nothing reads a localizedName for these subtypes, so sending one cannot become a caption. Withholding them is a measured limit of the client, not caution, and no server change can lift it. CORRECTION: FUN_180119bd0 was recorded as "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 virtual: vtable base 0x18021c2a0, slot +0x498, index 147 -- independently reproducing the recorded "manager vtable slot +0x498" by a different method. Finding the boundary needs the constructor-LEA trick; walking back over .text-pointing qwords runs 826 slots through several adjacent vtables. Bonus: the shared tail cardtypes 6-9 fall into IS the discard level ladder (movzx [rdi+0xb4]; cmp 0x4b; cmp 0x41; store [rdi+0x54]), confirming discard::discard_level instruction for instruction against the live client. Adapter 244 tests, fmt clean. --- .../docs/plan-2026-08-06-card-subsystem.md | 40 +++- fifa17-recon/tools/cardtype_dispatch_probe.py | 202 ++++++++++++++++++ .../src/fut/content_taxonomy.rs | 19 +- 3 files changed, 256 insertions(+), 5 deletions(-) create mode 100755 fifa17-recon/tools/cardtype_dispatch_probe.py 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,