tools(fifa17): resolve the itemState comparator live — it is CASE-SENSITIVE
The plan recorded this as "almost certainly unresolvable statically", because `FUN_180008190` is only a forwarding stub through a slot the host fills at runtime: `mov rax,[DAT_1802ddfd8]; mov r9,[rax+0x248]; jmp r9`. It IS resolvable — just not from disk. Read read-only out of the running client (pid 6580): the slot forwards through two FIFA17.exe thunks into msvcr120.dll+0x3c330, whose body is strncmp (`test r8,r8` count, `test al,al` NUL stop, `cmp al,[rcx+rdx]`, then MSVC's 0x8080../0xfefe.. NUL-detect fast path). No `or ..,0x20`, no folding table: the compare is raw bytes. So the casing in the table at 0x180229cc0 is a CONTRACT. A mis-cased token does not degrade gracefully — FUN_180166660 returns 0xffffffff, the record keeps 0 = invalid, and the item fails the squad builder. This confirms what fut::item_state already emits; it was previously true by convention and is now true by measurement. The probe follows the chain and attributes each hop to its module, which needs care under Wine: PE sections are mapped anonymously, so a module is identified by the nearest preceding named mapping rather than the containing one.
This commit is contained in:
@@ -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.
|
||||
|
||||
Executable
+161
@@ -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("<i", buf[1:5])[0]
|
||||
elif buf[0] == 0xFF and buf[1] == 0x25: # jmp [rip+rel32]
|
||||
nxt = mem.q(va + 6 + struct.unpack("<i", buf[2:6])[0])
|
||||
if not nxt:
|
||||
print(" IAT slot is empty.")
|
||||
return 2
|
||||
va = nxt
|
||||
else:
|
||||
body = (va, path, off)
|
||||
print(" -> 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())
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user