e578443d73
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 correctse3092ca. 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 ine3092cais 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 ine3092caas 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 frome3092ca: 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 ine3092ca: "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>
197 lines
7.2 KiB
Python
197 lines
7.2 KiB
Python
"""D3 Q1: the itemState vocabulary and the lifecycle-field arms in the shared item deser.
|
|
|
|
HYPOTHESIS: FUN_18013fe00 (shared ITEM element deser) has an arm for atom 0x172
|
|
(itemState) that reads a STR and maps it through the enum table documented at
|
|
0x180229d20 (stride 0x10: WAITING_FOR_GAME, inGame, forSale, offered, activeBadge,
|
|
activeHomeKit, activeAwayKit, activeBall, activeStadium, active). That documented
|
|
list omits "free", which we send on every card, so either the table is longer than
|
|
recorded or "free" is the default/no-match value. Also locate the arms for
|
|
pile 0x226, pileType 0x228, owners 0x207, untradeable 0x361, untradeableCount 0x362,
|
|
tradeId 0x331, loans 0x19b, itemLoans 0x16f, duplicateItemLoans 0xed,
|
|
tradeState 0x335, contract 0xa9?(unknown, resolved from tsv below).
|
|
|
|
CONTROL (same syntactic form as the target): the item deser is known to store our
|
|
discardValue (atom 0xd7) at item+0x38. If the immediate-scan below does not find
|
|
0xd7 in FUN_18013fe00 in the SAME form (case label / cmp / ladder) then the scan is
|
|
broken and every absence claim in this batch is void. Second control: 0x172 itself
|
|
appears in docs as reaching the enum table, so xrefs_to(0x180229d20) must be
|
|
non-empty.
|
|
|
|
ABSENCE TRAP GUARD: we do not grep "== 0x". We enumerate every immediate operand of
|
|
every instruction in the function (scalar operands of any size), which catches
|
|
`cmp ==`, `cmp !=`, jump-table `case` labels only indirectly, and running-sum
|
|
sub/dec ladders (the ladder deltas are computed and searched too). Jump tables are
|
|
handled separately by walking every switch construct Ghidra knows about.
|
|
|
|
OUTPUT: full decompiles printed untruncated with len(src); full table dump; full
|
|
immediate census.
|
|
"""
|
|
import traceback, os
|
|
|
|
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/"
|
|
os.makedirs(OUT, exist_ok=True)
|
|
|
|
ATOMS = {
|
|
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", 0xd7: "discardValue(CONTROL)", 0x19: "allowUntradeableForSquadBuildingSets",
|
|
}
|
|
|
|
|
|
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 fname=%s len(src)=%d (FULL, NOT TRUNCATED)" % (tag, va, f.getName(), len(src)))
|
|
print("=" * 78)
|
|
if echo:
|
|
print(src)
|
|
with open(OUT + "q1_%s_%x.c" % (tag, va), "w") as fh:
|
|
fh.write(src)
|
|
return src
|
|
|
|
|
|
def immediates(va):
|
|
"""Every scalar operand of every instruction in the function containing va."""
|
|
f = func(va)
|
|
out = {}
|
|
if f is None:
|
|
return out
|
|
body = f.getBody()
|
|
it = listing.getInstructions(body, True)
|
|
n = 0
|
|
while it.hasNext():
|
|
ins = it.next()
|
|
n += 1
|
|
for i in range(ins.getNumOperands()):
|
|
for o in ins.getOpObjects(i):
|
|
try:
|
|
v = int(o.getValue())
|
|
except Exception:
|
|
continue
|
|
out.setdefault(v & 0xFFFFFFFFFFFFFFFF, []).append(
|
|
(int(ins.getAddress().getOffset()), str(ins)))
|
|
print("[immediates] %s %#x: %d instructions, %d distinct scalars"
|
|
% (f.getName(), va, n, len(out)))
|
|
return out
|
|
|
|
|
|
def report_atoms(va, imms):
|
|
"""Direct hits + running-sum ladder reconstruction."""
|
|
print("--- atom census for %#x (%s)" % (va, fname(va)))
|
|
for a, name in sorted(ATOMS.items()):
|
|
hits = imms.get(a, [])
|
|
if hits:
|
|
print(" DIRECT 0x%-4x %-38s x%d first@%#x %s"
|
|
% (a, name, len(hits), hits[0][0], hits[0][1]))
|
|
# ladder reconstruction: walk instructions in address order, keep a running
|
|
# sum of sub/dec/add immediates on the dispatch register, report any partial
|
|
# sum that equals one of our atoms.
|
|
f = func(va)
|
|
if f is None:
|
|
return
|
|
it = listing.getInstructions(f.getBody(), True)
|
|
running = {}
|
|
while it.hasNext():
|
|
ins = it.next()
|
|
m = ins.getMnemonicString().lower()
|
|
if m not in ("sub", "add", "dec", "inc", "cmp", "lea"):
|
|
continue
|
|
try:
|
|
reg = str(ins.getOpObjects(0)[0])
|
|
except Exception:
|
|
continue
|
|
val = None
|
|
for o in ins.getOpObjects(1) if ins.getNumOperands() > 1 else []:
|
|
try:
|
|
val = int(o.getValue())
|
|
except Exception:
|
|
pass
|
|
if m == "dec":
|
|
val = 1
|
|
if m == "inc":
|
|
val = -1
|
|
if val is None:
|
|
continue
|
|
if m in ("sub", "dec"):
|
|
running[reg] = running.get(reg, 0) + val
|
|
elif m in ("add", "inc"):
|
|
running[reg] = running.get(reg, 0) - val
|
|
cur = running.get(reg)
|
|
if cur in ATOMS:
|
|
print(" LADDER 0x%-4x %-38s @%#x %s (running sum on %s)"
|
|
% (cur, ATOMS[cur], int(ins.getAddress().getOffset()), ins, reg))
|
|
|
|
|
|
def switch_labels(va):
|
|
"""Every switch case label Ghidra resolved inside the function."""
|
|
f = func(va)
|
|
if f is None:
|
|
return
|
|
from ghidra.program.model.symbol import FlowType # noqa
|
|
it = listing.getInstructions(f.getBody(), True)
|
|
tot = 0
|
|
while it.hasNext():
|
|
ins = it.next()
|
|
ft = ins.getFlowType()
|
|
if ft is not None and ft.isJump() and ft.isComputed():
|
|
tgts = ins.getFlows()
|
|
print(" SWITCH @%#x %s -> %d targets" % (int(ins.getAddress().getOffset()), ins, len(tgts)))
|
|
tot += len(tgts)
|
|
if tot:
|
|
print(" (computed-jump targets total %d)" % tot)
|
|
|
|
|
|
try:
|
|
print("###### PART A: the itemState enum table at 0x180229d20")
|
|
# walk generously in both directions; entries are (char* name, ...) stride 0x10
|
|
base = 0x180229d20
|
|
for off in range(-0x200, 0x400, 0x10):
|
|
a = base + off
|
|
try:
|
|
p = qword(a)
|
|
q = qword(a + 8)
|
|
except Exception:
|
|
continue
|
|
s = ""
|
|
if 0x180000000 <= p < 0x181000000:
|
|
try:
|
|
s = rd_str(p, 64)
|
|
except Exception:
|
|
s = "<unreadable>"
|
|
print(" %#x (%+#5x) p=%#018x q=%#018x str=%r" % (a, off, p, q, s))
|
|
|
|
print()
|
|
print("###### PART B: who references the table")
|
|
for t in (0x180229d20,):
|
|
for frm, typ, fn, ent in xrefs_to(t):
|
|
print(" xref %#x %s in %s (%#x)" % (frm, typ, fn, ent))
|
|
|
|
print()
|
|
print("###### PART C: shared item deser FUN_18013fe00")
|
|
src = dump("itemdeser", 0x18013fe00)
|
|
imms = immediates(0x18013fe00)
|
|
report_atoms(0x18013fe00, imms)
|
|
switch_labels(0x18013fe00)
|
|
|
|
print()
|
|
print("###### PART D: full sorted immediate dump for 0x18013fe00 (values < 0x1000)")
|
|
for v in sorted(k for k in imms if k < 0x1000):
|
|
print(" 0x%-4x n=%-3d %s" % (v, len(imms[v]), imms[v][0][1]))
|
|
|
|
print()
|
|
print("###### PART E: callees of the item deser")
|
|
try:
|
|
for c in callees(0x18013fe00):
|
|
print(" callee", c)
|
|
except Exception as e:
|
|
print(" callees() failed:", e)
|
|
|
|
except Exception:
|
|
traceback.print_exc()
|