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 3cba09d..93b6456 100644 --- a/fifa17-recon/docs/plan-2026-08-06-card-subsystem.md +++ b/fifa17-recon/docs/plan-2026-08-06-card-subsystem.md @@ -553,15 +553,42 @@ depend on it (`FUN_180108c00` carries the same mapping independently), but the dispatch table that reaches it was not identified, and trophies are a whole unimplemented family. -**Case sensitivity of the `itemState` string match.** Almost certainly -unresolvable statically: `FUN_180008190` is a single indirect call through -`DAT_1802ddfd8 + 0x248`, a runtime-populated service pointer. Send the exact -casing from the table and do not experiment on the live save. +**Case sensitivity of the `itemState` string match. RESOLVED 2026-08-21 — +CASE-SENSITIVE.** It was expected to be unresolvable statically, because +`FUN_180008190` is nothing but a forwarding stub through a runtime-populated +slot: + +``` +mov rax, [DAT_1802ddfd8] ; service object, handed to CardsDLL by the host +mov r9, [rax + 0x248] +jmp r9 +``` + +Resolved read-only against the running client (pid 6580) with +`fifa17-recon/tools/service_ptr_probe.py`, which follows the chain and +attributes each hop to a module (Wine maps PE sections anonymously, so the +module comes from the nearest preceding named mapping): + +``` +*(service + 0x248) = 0x146d1c020 FIFA17.exe+0x20f9020 e9 … jmp rel32 + → 0x145e27fe0 FIFA17.exe+0x1204fe0 ff 25 jmp [rip+…] + → 0x6ffffd11c330 msvcr120.dll+0x3c330 function body +``` + +The body is `strncmp`: `sub rdx,rcx` / `test r8,r8` (count) / `test al,al` +(NUL stop) / `cmp al,[rcx+rdx]`, then MSVC's 8-byte fast path with the +`0x8080808080808080` and `0xfefefefefefefeff` NUL-detect constants. There is no +`or ..,0x20` and no folding table anywhere in the body, so the compare is raw +bytes. + +CONSEQUENCE: a mis-cased token does not degrade, it matches nothing — +`FUN_180166660` returns `0xffffffff`, the record keeps `0` = `invalid`, and the +item fails the squad builder's `state == 1 || state == 2` test. The casing in +the table at `0x180229cc0` is a contract. Send it verbatim; do not experiment on +the live save. ### Needs a live probe (read-only, no launch) -**Resolve `DAT_1802ddfd8 + 0x248`** in the running process and identify the string -comparator. That answers the casing question without a launch. **Re-read `+0x30` after a refetch** to decide between "monotonic clock" and "sequence counter". Low value; nothing we send reaches it. diff --git a/fifa17-recon/tools/service_ptr_probe.py b/fifa17-recon/tools/service_ptr_probe.py new file mode 100755 index 0000000..5e1f9fc --- /dev/null +++ b/fifa17-recon/tools/service_ptr_probe.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Resolve the runtime string comparator behind `DAT_1802ddfd8 + 0x248`, and +settle whether the `itemState` match is case-sensitive. + +READ-ONLY. /proc/PID/mem is opened 'rb'; there is no write path in this file. + +WHY THIS EXISTS +--------------- +`itemState` arrives on the wire as a STRING ("free", "activeHomeKit", ...) and +the client turns it into its runtime enum by comparing that string against its +own table. The compare goes through `FUN_180008190`, whose whole body is: + + mov rax, [DAT_1802ddfd8] ; the service object, populated at runtime + mov r9, [rax + 0x248] ; slot 0x248 + jmp r9 ; tail-jump + +The slot is empty on disk, so `plan-2026-08-06-card-subsystem.md` section 5 +recorded the casing question as "almost certainly unresolvable statically" and +listed this as a read-only live probe. It is worth answering: every shaper in +openfut-adapter-fifa17 emits these tokens, and if the comparator folded case then +our table's casing would be a convention rather than a contract. + +WHAT IT DOES +------------ +Reads the slot in the live process and follows the forwarding chain +(`e9` rel32 thunk -> `ff 25` IAT jump -> body), attributing each hop to a module. +Wine maps PE images as anonymous, so a mapping's own path is usually empty; the +module is recovered from the nearest PRECEDING named mapping, which is the PE +header page. + +At the body it decides case sensitivity from the instruction stream rather than +from a symbol name: a case-insensitive comparator MUST fold case, so it carries +an `or ..,0x20` / lowercase-table lookup. A byte compare with no folding is +case-SENSITIVE. + +MEASURED 2026-08-21 (pid 6580): + slot -> 0x146d1c020 (thunk) -> 0x145e27fe0 (IAT) -> msvcr120.dll + 0x3c330 + body is strncmp: `sub rdx,rcx` / `test r8,r8` (count) / `test al,al` (NUL) / + `cmp al,[rcx+rdx]` with NO case folding, plus the MSVC NUL-detect constants + 0x8080808080808080 and 0xfefefefefefefeff. + => the itemState match is CASE-SENSITIVE. Emit the table's exact casing. + +Usage: python3 service_ptr_probe.py +""" +import re +import struct +import sys + +import watch_club_model as W + +DAT_SERVICE = 0x1802DDFD8 +SLOT = 0x248 +MAX_HOPS = 8 + +# A case-insensitive comparator has to fold case somewhere. These are the two +# ways MSVC does it; neither appears in a plain strcmp/strncmp/memcmp. +FOLD_OR_IMM8 = b"\x0c\x20" # or al, 0x20 +FOLD_OR_EAX = b"\x83\xc8\x20" # or eax, 0x20 + + +def mappings(pid): + out = [] + with open("/proc/%d/maps" % pid) as fh: + for line in fh: + m = re.match(r"([0-9a-f]+)-([0-9a-f]+) (\S{4}) \S+ \S+ \S+\s*(.*)", line) + if m: + out.append((int(m.group(1), 16), int(m.group(2), 16), m.group(3), m.group(4))) + return out + + +def attribute(maps, va): + """(module_path, perms, offset_from_module_base) for `va`. + + Wine maps PE sections anonymously, so the owning mapping usually has no + path; the module is the nearest preceding NAMED mapping (its header page). + """ + named = None + for start, end, perms, path in maps: + if path: + named = (start, path) + if start <= va < end: + if named: + return named[1], perms, va - named[0] + return path or "[anonymous]", perms, None + return None, None, None + + +def main(): + pid = W.find_pid() + if pid is None: + print("FIFA17.exe is not running.") + return 1 + base = W.dll_base(pid) + if base is None: + print("pid %d is up but %s is not mapped." % (pid, W.DLL)) + return 1 + + mem = W.Mem(pid) + maps = mappings(pid) + glob = base + (DAT_SERVICE - W.IMG_BASE) + svc = mem.q(glob) + print("pid=%d %s base=%#x" % (pid, W.DLL, base)) + print("DAT_1802ddfd8 @ %#x -> service %#x" % (glob, svc or 0)) + if not svc: + print("service pointer is NULL; the host has not handed CardsDLL its table yet.") + return 2 + + va = mem.q(svc + SLOT) + print("*(service + %#x) = %#x" % (SLOT, va or 0)) + if not va: + print("slot %#x is empty." % SLOT) + return 2 + print() + + body = None + for hop in range(MAX_HOPS): + buf = mem.read(va, 16) + if not buf or len(buf) < 6: + print("hop %d: %#x unreadable" % (hop, va)) + return 2 + path, perms, off = attribute(maps, va) + where = "%s+%#x" % (path, off) if off is not None else str(path) + print("hop %d: %#x [%s] %s %s" % (hop, va, perms, where, buf[:8].hex())) + if buf[0] == 0xE9: # jmp rel32 + va = va + 5 + struct.unpack(" function body") + break + if body is None: + print("chain did not settle within %d hops." % MAX_HOPS) + return 2 + + addr, path, off = body + code = mem.read(addr, 256) or b"" + folds = FOLD_OR_IMM8 in code or FOLD_OR_EAX in code + print() + print("=" * 70) + print("COMPARATOR: %s+%#x (%#x)" % (path, off if off is not None else 0, addr)) + print("case folding in first %d bytes: %s" % (len(code), "YES" if folds else "NO")) + if folds: + print("VERDICT: case-INSENSITIVE. itemState casing is a convention, not a contract.") + else: + print("VERDICT: case-SENSITIVE. A byte compare with no folding means the") + print(" wire token must match the table's casing EXACTLY -- a") + print(" mis-cased token silently resolves to itemState 0 (invalid).") + print(" openfut-adapter-fifa17's fut::item_state table is therefore") + print(" a contract: emit its casing verbatim.") + print("=" * 70) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/openfut-adapter-fifa17/src/fut/item_state.rs b/openfut-adapter-fifa17/src/fut/item_state.rs index ad1804d..45d6dac 100644 --- a/openfut-adapter-fifa17/src/fut/item_state.rs +++ b/openfut-adapter-fifa17/src/fut/item_state.rs @@ -14,6 +14,18 @@ //! absent key leaves `0` = [`INVALID`], and an item left at `0` fails the squad //! builder's `state == 1 || state == 2` acceptance test. Always send it. //! +//! **The casing is a CONTRACT, not a convention** — measured, not assumed. The +//! table lookup compares through a slot the host fills at runtime +//! (`FUN_180008190` is just `mov rax,[DAT_1802ddfd8]; mov r9,[rax+0x248]; jmp r9`), +//! so this was long recorded as unresolvable without a live process. Resolved +//! read-only against the running client on 2026-08-21 +//! (`fifa17-recon/tools/service_ptr_probe.py`): the slot forwards through two +//! FIFA17.exe thunks into `msvcr120.dll+0x3c330`, whose body is `strncmp` — a +//! plain byte compare (`cmp al,[rcx+rdx]`) with NO case folding anywhere. So a +//! mis-cased token does not "mostly work": it matches nothing, decodes to +//! [`INVALID`], and the item fails the squad builder. Emit these strings +//! verbatim. +//! //! (Source: `fifa17-recon/docs/plan-2026-08-06-card-subsystem.md` §4, which also //! corrects `CARD_SYSTEM.md`'s earlier ten-row reading — that one started at //! `0x180229d20`, the MIDDLE of the table, and so missed `invalid`, `free`,