Files
OpenFUT/fifa17-recon/tools/service_ptr_probe.py
T
funman300 beb505b0fa 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.
2026-08-21 20:54:29 +00:00

162 lines
5.9 KiB
Python
Executable File

#!/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())