beb505b0fa
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.
162 lines
5.9 KiB
Python
Executable File
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())
|