Files
OpenFUT/fifa17-recon/tools/ghidra_queries/q_cd_state_3.py
T
funman300 e578443d73 fifa17-recon: tradingEnabled is 0, and that is why the transfer options are greyed out
Card-subsystem pass, 11 agents plus three adversarial verifiers. Full writeup in
docs/plan-2026-08-06-card-subsystem.md. Two of the results below correct things I
committed earlier today.

THE GREYED-OUT TRANSFER OPTIONS ARE EXPLAINED. "Place on Transfer List" and "List on
Transfer Market" have been disabled in the reveal screen and nobody knew why.
TO_TRADE_PILE (FUN_1801a7260) requires BOTH item+0x49 tradeable AND a service gate at
vtable slot +0x270. That slot is `movzx eax, byte [rcx+0x1fd2e]; ret`, and 0x1fd2e is
the tradingEnabled gate byte. Read live and reproduced independently:

  slot +0x2b0 friendlySeasons  disp 0x1fd3a  VALUE=1
  slot +0x2c8 draftMode        disp 0x1fd3d  VALUE=1
  slot +0x2e0 packOpeningAnim  disp 0x1fd45  VALUE=1
  slot +0x270 tradingEnabled   disp 0x1fd2e  VALUE=0

tradingEnabled is the FIRST gate byte found that is not 1. This partly rehabilitates
the settings work from this morning: that plan died because every gate it targeted
already read 1, and the conclusion drawn was that the settings array does not matter.
It does. It matters for a flag nobody was looking at, and tradingEnabled is ALREADY in
_SETTINGS_KEEP, plumbed and never sent because _SETTINGS_MODE defaults to off.

So the fix is two things, not one: FUT_SETTINGS=keep AND untradeable false. Shipping
only the boolean would look like the finding failed.

THE DISCARD "MISS" NEVER EXISTED, which corrects e3092ca. fcc_discardcoins is resident
and complete, the client lookup runs and is correct, and it lands at item+0x3c. The
tile simply binds +0x38, which is OUR value, and nothing falls back to +0x3c. So the
client was not failing a lookup; it was faithfully displaying the 0 we sent. Same
observable, completely different mechanism, and the version in e3092ca is wrong.
FUT_DISCARD_SEND remains exactly the right fix, now for the right reason.

WHAT FUT PAYS FOR STAFF IS NO LONGER UNKNOWN. Same formula, but the rating input is the
table `value` column: gkcoachcards 9000081 value 66 gives 36, and the client's own
+0x3c reads 36. That closes the gap I flagged in e3092ca as not-guessed.

CLUB ITEM SUBTYPES, the standing unknown in CARD_SYSTEM.md, are settled: kit 9,
stadium 10, badge 11 are cardtype 7 (not 9), ball 30, league logo 31 by elimination.
All five constants in fut_clubitems.FAMILIES are wrong and all five currently sit in
the TROPHY block 0x91..0x96. Note the probe route the doc preferred could never have
answered this: probe_shelf()'s candidate set lacks 9, 10 and 11, so it would have spent
a launch and returned nothing for three of five families.

THE CARD MODEL FIELD MAP now exists, 28 rows, every field we send with the byte it
lands on and whether the client keeps it. Built by diffing what we serve against the
parsed records in the live heap (stride 0x180, anchored by a satellite back-pointer
rather than by assuming the +0x38 offset). Corrections that change what we serve:
+0x54 is the discard LEVEL not itemType, +0x49 is untradeable INVERTED, +0x5c is
itemState, definitionId is not an atom at all.

A HIGH-CONFIDENCE ABSENCE CLAIM WAS REFUTED IN VERIFICATION: playStyle IS stored, at
+0x88. Its controls were raw scalars while playStyle is a DECODED scalar, so the
control was the wrong FORM. That is a new variant of the absence trap, which has now
cost six wrong verdicts, and it is recorded in the doc.

FIX TO MY OWN PATCH from e3092ca: purchased() and last_pack() lacked the _with_discard
wrapper that items() had, so the pending pile, which is the one place a quick-sell
value is actually read, served unstamped cards. Found by verification, not testing.
All three read paths now stamp.

Correcting an overstatement in e3092ca: "turning the flag off is a true revert" holds
for the read paths, which copy, but NOT for cards minted while armed, because _item()
stamps at creation and those persist (9 items currently). Kept deliberately: the pack
reveal serves itemList straight from open_pack(), not through purchased(), so removing
creation-stamping would leave the screen that matters unstamped. Persisted values are
correct and self-heal, since every read recomputes and overwrites.

Nothing here has been on screen. Six patches are proposed in the doc as pasteable text,
env-flagged, defaulting off, none applied.

Live: 439 contract checks, 414 card-family checks, market suite, all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 10:07:01 -07:00

171 lines
7.2 KiB
Python

"""D3 Q3: (a) where the lifecycle atoms are parsed AT ALL, (b) who reads the state
slots on the item record, (c) who writes the pile slot +0x60.
ESTABLISHED (q1/q2 + live, controls passed): record_off = 0x188 - N for the deser's
stack struct; itemState -> +0x5c (free==1 live), owners -> +0x48 (1 live),
!untradeable -> +0x49 (0 live), discardValue sent -> +0x38, computed -> +0x3c,
vtable -> +0x70, pile -> +0x60 (1 club / 6 purchased, NOT from the wire).
(a) THE ABSENCE QUESTION, done soundly. Every SAX deserializer in this DLL ends its
key loop with the value-SKIP FUN_180135ff0 as the default arm. So the set of callers of
FUN_180135ff0 IS the set of deserializers -- a bounded, enumerable population. We
decompile each and look for the lifecycle atoms in ALL dispatch forms at once by
matching the decompiler's own text: `case 0xNNN:`, `== 0xNNN`, `!= 0xNNN`, and
`< 0xNNN` / `- 0xNNN` ladder steps.
CONTROL: the same scan must find 0xd7 (discardValue) and 0x172 (itemState) inside
FUN_18013fe00, both of which we have already read with our own eyes as `case` labels.
If those two do not come back, the scan is broken and no absence below counts.
(b) publisher hunt: group every function by the set of register+displacement memory
operands it uses; a function that touches 0x38/0x3c/0x5c/0x49/0x60 together is reading
the item record.
CONTROL: the same grouping must rediscover FUN_18013fe00 itself as a heavy toucher.
(c) pile writers: instructions storing an immediate 1 or 6 into [reg+0x60].
"""
import traceback, os, re
from collections import defaultdict
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/"
os.makedirs(OUT, exist_ok=True)
LIFE = {0x172: "itemState", 0x226: "pile", 0x227: "pileSizeClientData", 0x228: "pileType",
0x207: "owners", 0x361: "untradeable", 0x362: "untradeableCount",
0x331: "tradeId", 0x332: "tradepile", 0x333: "tradePile",
0x19b: "loans", 0x16f: "itemLoans", 0xed: "duplicateItemLoans",
0x335: "tradeState", 0x1c0: "maximumTradePileSize",
0xd7: "discardValue(CONTROL)"}
PAT = {a: re.compile(r"(case %s:|== %s\b|!= %s\b|< %s\b|- %s\b|\+ %s\b)"
% tuple([hex(a)] * 6)) for a in LIFE}
def dump(tag, va, echo=True):
f = func(va)
if f is None:
print("%s %#x -> NO FUNCTION" % (tag, va))
return ""
src = dec(va)
print("=" * 78)
print("%s %#x %s len(src)=%d (FULL)" % (tag, va, f.getName(), len(src)))
print("=" * 78)
if echo:
print(src)
with open(OUT + "q3_%s_%x.c" % (tag, va), "w") as fh:
fh.write(src)
return src
try:
print("###### A. deserializer census (callers of the value-SKIP FUN_180135ff0)")
ents = sorted({e for (_, _, _, e) in xrefs_to(0x180135ff0) if e})
print(" deserializer population: %d functions" % len(ents))
rows = []
for i, e in enumerate(ents):
try:
src = dec(e, 120)
except Exception as ex:
print(" dec failed %#x %s" % (e, ex))
continue
hit = {}
for a, nm in LIFE.items():
m = PAT[a].findall(src)
if m:
hit[a] = len(m)
if hit:
rows.append((e, fname(e), len(src), hit))
print(" functions mentioning at least one lifecycle atom: %d" % len(rows))
for e, nm, ln, hit in rows:
print(" %#x %-18s len=%-7d %s"
% (e, nm, ln, ", ".join("%s(0x%x)x%d" % (LIFE[a], a, n)
for a, n in sorted(hit.items()))))
print()
print(" --- per-atom summary over the whole deserializer population")
for a, nm in sorted(LIFE.items()):
fs = [(e, n) for e, n, _, h in rows if a in h]
print(" 0x%-4x %-24s parsed in %d deserializer(s): %s"
% (a, nm, len(fs), ", ".join("%s(%#x)" % (n, e) for e, n in fs) or "NONE"))
print()
print("###### B. publisher hunt: displacement fingerprints over the whole .text")
disp_of = defaultdict(set)
count_of = defaultdict(lambda: defaultdict(int))
it = listing.getInstructions(True)
tot = 0
while it.hasNext():
ins = it.next()
tot += 1
f = None
for i in range(ins.getNumOperands()):
try:
objs = ins.getOpObjects(i)
except Exception:
continue
if len(objs) < 2:
continue
regs = [o for o in objs if hasattr(o, "getName")]
if not regs:
continue
if any(str(r) in ("RSP", "RBP", "ESP", "EBP") for r in regs):
continue
for o in objs:
try:
v = int(o.getValue())
except Exception:
continue
if 0 <= v <= 0x200:
if f is None:
f = fm.getFunctionContaining(ins.getAddress())
if f is None:
break
f = int(f.getEntryPoint().getOffset())
disp_of[f].add(v)
count_of[f][v] += 1
print(" scanned %d instructions, %d functions with reg+disp operands" % (tot, len(disp_of)))
KEY = {0x38, 0x3c, 0x49, 0x5c, 0x60}
cands = [(len(KEY & d), e, sorted(KEY & d)) for e, d in disp_of.items() if len(KEY & d) >= 3]
cands.sort(reverse=True)
print(" functions touching >=3 of {0x38,0x3c,0x49,0x5c,0x60}: %d" % len(cands))
for k, e, s in cands[:60]:
print(" %#x %-18s %d/%d %s" % (e, fname(e), k, len(KEY), [hex(x) for x in s]))
print(" CONTROL: is FUN_18013fe00 in the fingerprint map?",
0x18013fe00 in disp_of,
sorted(hex(x) for x in (KEY & disp_of.get(0x18013fe00, set()))))
print()
print("###### C. every function whose displacement set contains 0x5c AND 0x49")
for e, d in sorted(disp_of.items()):
if 0x5c in d and 0x49 in d:
print(" %#x %-18s" % (e, fname(e)))
print()
print("###### D. small getters: functions <= 0x20 bytes that read [reg+0x5c] or [reg+0x49] or [reg+0x60]")
for e, d in sorted(disp_of.items()):
f = func(e)
if f is None:
continue
sz = int(f.getBody().getNumAddresses())
if sz <= 0x20 and d & {0x49, 0x5c, 0x60, 0x48}:
print(" %#x %-18s size=%d disp=%s" % (e, fname(e), sz, sorted(hex(x) for x in d)))
print()
print("###### E. writers of the pile slot: STORE of imm into [reg+0x60]")
it = listing.getInstructions(True)
while it.hasNext():
ins = it.next()
s = str(ins)
if "+ 0x60]" in s and ins.getMnemonicString().upper() == "MOV" and s.rstrip().endswith((",0x1", ",0x6", ",0x2", ",0x3", ",0x4", ",0x5", ",0x0")):
if "RSP" in s or "RBP" in s:
continue
f = fm.getFunctionContaining(ins.getAddress())
print(" %#x %-46s in %s" % (int(ins.getAddress().getOffset()), s,
f.getName() if f else "?"))
print()
print("###### F. the two byte-0x49 comparators found in q2")
for tag, va in (("cmp49_a", 0x1801a7260), ("cmp49_b", 0x1801a8940), ("wr49", 0x180130d10)):
dump(tag, va)
except Exception:
traceback.print_exc()