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>
This commit is contained in:
funman300
2026-08-06 10:07:01 -07:00
parent e3092ca0f9
commit e578443d73
82 changed files with 6456 additions and 2 deletions
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -460,9 +460,14 @@ class Store:
return moved
def purchased(self):
# Stamped on read exactly like items(). Leaving this out was a real defect:
# the pending pile is the ONE place a quick-sell value is actually read, so
# the club showed real numbers while the reveal screen showed 0 for anything
# already sitting in the pile. Found by a verification pass, not by testing.
"""Items still held in the purchased/unassigned pile (returned by
GET /purchased/items); they move to the club via FutMoveCard (PUT /item)."""
return self.load().get("purchased", [])
pur = self.load().get("purchased", [])
return [_with_discard(dict(it)) for it in pur] if DISCARD_SEND else pur
def active_squad(self):
sq = self.load()["squads"]
@@ -557,7 +562,9 @@ class Store:
return items
def last_pack(self):
return self.load().get("purchased", [])
# Same stamping as purchased(); this is the reveal-screen read path.
pur = self.load().get("purchased", [])
return [_with_discard(dict(it)) for it in pur] if DISCARD_SEND else pur
@@ -0,0 +1,104 @@
"""ADVERSARIAL BATCH 1.
HYPOTHESES UNDER ATTACK (all from another agent, assumed WRONG until reproduced):
H1 item+0x49 = (untradeable == false), written by atom 0x361 in FUN_18013fe00.
H2 FUN_1801a7260 (TO_TRADE_PILE) requires item+0x49 != 0, and the eight flags are
ENABLE flags.
H3 FUN_18003e370 publishes 8 names in the order DISCARD, MODIFY, TO_ACTIVE_SQUAD,
TO_TRADE_PILE, ... and FUN_1800e2a40 fills those 8 bytes in that order.
H4 item+0x54 is the discard LEVEL written at 0x180141e8a..0x180141ea3, not itemType.
H5 the itemState table starts at 0x180229cc0 with 12 entries.
H6 FUN_180166660 has exactly one caller.
H7 FUN_1801a8620 (+0x38) and FUN_1801a8090 (+0x3c) have exactly one xref each.
CONTROL: for every "exactly one caller" claim I also run the SAME xrefs_to call on a
function that is known to have many callers (FUN_180135ff0, the value-SKIP, ~134) and
on the FNV hasher 0x180180d00, so a zero/one result cannot be a broken scan.
Everything is printed IN FULL; no truncation.
"""
import traceback, sys
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q1_raw.txt"
try:
f = open(OUT, "w")
def P(*a):
s = " ".join(str(x) for x in a)
f.write(s + "\n")
P("=" * 30, "CONTROL: xrefs machinery works", "=" * 30)
for nm, a in (("FUN_180135ff0 value-SKIP", 0x180135FF0),
("FUN_180180d00 FNV hasher", 0x180180D00),
("FUN_1801c7620 BOOL prim", 0x1801C7620)):
xr = xrefs_to(a)
ents = sorted(set(e for _, t, _, e in xr if "CALL" in t and e))
P("%s: %d refs, %d distinct calling funcs" % (nm, len(xr), len(ents)))
P()
P("=" * 30, "H7 discard getters", "=" * 30)
for nm, a in (("FUN_1801a8620 (+0x38 DISCARD_CREDITS?)", 0x1801A8620),
("FUN_1801a8090 (+0x3c CALCULATED?)", 0x1801A8090),
("FUN_1801a80c0 (CARD_LEVEL?)", 0x1801A80C0)):
P("---", nm)
fn = fm.getFunctionAt(addr(a))
P(" function at addr:", fn.getName() if fn else None)
for frm, t, cf, e in xrefs_to(a):
P(" ref %#x %s in %s@%#x" % (frm, t, cf, e))
P(" BODY:")
P(dec(a))
P()
P("=" * 30, "H6 FUN_180166660 callers", "=" * 30)
for frm, t, cf, e in xrefs_to(0x180166660):
P(" ref %#x %s in %s@%#x" % (frm, t, cf, e))
P(dec(0x180166660))
P()
P("=" * 30, "H5 itemState table walk from 0x180229c00", "=" * 30)
a = 0x180229C00
for i in range(40):
p = qword(a + i * 0x10)
q = qword(a + i * 0x10 + 8)
s = ""
if 0x180000000 <= p < 0x181000000:
try:
s = rd_str(p, 60)
except Exception:
s = "?"
P(" %#x p=%#018x q=%#018x %r" % (a + i * 0x10, p, q, s))
P()
P("=" * 30, "H2 TO_TRADE_PILE predicate + siblings", "=" * 30)
for a in (0x1801A7260, 0x1801A8940, 0x1801A71C0, 0x1801A7210, 0x1801A7250,
0x1801A7180, 0x1801A7320, 0x1801A71E0, 0x1801A8900, 0x1801A89F0):
fn = fm.getFunctionAt(addr(a))
P("### %#x %s xrefs=%d" % (a, fn.getName() if fn else "NO FUNC", len(xrefs_to(a))))
for frm, t, cf, e in xrefs_to(a):
P(" ref %#x %s in %s@%#x" % (frm, t, cf, e))
P(dec(a))
P()
P()
P("=" * 30, "H3 publisher + filler, FULL", "=" * 30)
for a in (0x18003E370, 0x1800E2A40):
P("### %#x len-of-decompile follows" % a)
d = dec(a)
P(" len(src) =", len(d))
P(d)
P()
P()
P("=" * 30, "H4 level write at 0x180141e60..0x180141ec0 raw disasm", "=" * 30)
ins = listing.getInstructions(addr(0x180141E40), True)
n = 0
while ins.hasNext() and n < 60:
i = ins.next()
if int(i.getAddress().getOffset()) > 0x180141EC0:
break
P(" %#x %s" % (int(i.getAddress().getOffset()), i))
n += 1
f.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,20 @@
"""BATCH 10: disassemble the undefined thunk at 0x18011c670 (slot +0x270 of the
0xed84b12 service = the second gate on TO_TRADE_PILE)."""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q10_raw.txt"
try:
f = open(OUT, "w")
def P(*a): f.write(" ".join(str(x) for x in a) + "\n")
P("bytes at 0x18011c670:", read_bytes(0x18011C670, 64).hex())
it = listing.getInstructions(addr(0x18011C670), True)
n = 0
while it.hasNext() and n < 40:
i = it.next(); a = int(i.getAddress().getOffset())
if a > 0x18011C6F0: break
P(" %#x %s" % (a, i)); n += 1
P()
for t in (0x18011C4C0, 0x18011C500):
P("### %#x" % t); P(dec(t)); P()
f.close(); print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,118 @@
"""ADVERSARIAL BATCH 2 -- the ABSENCE claims, re-tested with a DIFFERENT method.
The other agent tested "+0x49 is compared in exactly two places" and "itemState 5/6
are never tested" with a LOAD/COMPARE-PAIR scan keyed on displacement. That method
has a structural blind spot: a compare performed on a value RETURNED BY AN ACCESSOR
never shows the displacement at the compare site. FUN_1801a8940 is exactly such an
accessor for +0x49 and it has a caller (FUN_1800bc580) the agent never opened.
MY METHOD (different): enumerate EVERY instruction in .text whose textual form
contains the displacement, with no filter on opcode class at all -- so ==, !=, switch
case labels and sub/dec ladders are all caught at the LOAD, and the containing
function is then read. Plus a byte-pattern census of the two-instruction accessor
shape 48 8b 4x 18 / <load disp> which finds getters my displacement scan would
attribute to the getter rather than to its caller.
CONTROLS (same syntactic form as the targets -- a raw displacement load):
0x38 and 0x3c : known-live fields, must come back non-zero
0x4c : the other agent reported 37 pairs, must come back >= 37
0xdeadbe : impossible displacement, must come back 0 (proves the scan can
return zero for a real absence rather than always finding noise)
"""
import re, traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q2_raw.txt"
try:
f = open(OUT, "w")
def P(*a):
f.write(" ".join(str(x) for x in a) + "\n")
TARGETS = [0x38, 0x3c, 0x48, 0x49, 0x4c, 0x54, 0x58, 0x5c, 0x60, 0x88, 0x90]
pats = {d: re.compile(r"\+\s*0x%x\s*\]" % d) for d in TARGETS}
impossible = re.compile(r"\+\s*0xdeadbe\s*\]")
hits = {d: [] for d in TARGETS}
imp = []
n = 0
it = listing.getInstructions(True)
while it.hasNext():
i = it.next()
s = i.toString()
n += 1
for d, p in pats.items():
if p.search(s):
hits[d].append((int(i.getAddress().getOffset()), s))
if impossible.search(s):
imp.append(int(i.getAddress().getOffset()))
P("instructions scanned:", n)
P("IMPOSSIBLE-DISPLACEMENT CONTROL 0xdeadbe hits:", len(imp), "(must be 0)")
P()
for d in TARGETS:
fns = {}
for a, s in hits[d]:
fn = fm.getFunctionContaining(addr(a))
k = (fn.getName(), int(fn.getEntryPoint().getOffset())) if fn else ("?", 0)
fns.setdefault(k, []).append((a, s))
P("### displacement +0x%02x : %d instructions in %d functions" % (d, len(hits[d]), len(fns)))
if d in (0x49, 0x48):
for (nm, e), lst in sorted(fns.items(), key=lambda x: x[0][1]):
P(" %s @%#x (%d)" % (nm, e, len(lst)))
for a, s in lst:
P(" %#x %s" % (a, s))
elif d == 0x5c:
P(" functions:")
for (nm, e), lst in sorted(fns.items(), key=lambda x: x[0][1]):
P(" %s @%#x n=%d" % (nm, e, len(lst)))
P()
P("=" * 30, "+0x5c FULL instruction list (itemState 5/6 absence retest)", "=" * 30)
for a, s in hits[0x5C]:
fn = fm.getFunctionContaining(addr(a))
P(" %#x %-52s %s" % (a, s, fn.getName() if fn else "?"))
P()
P("=" * 30, "ACCESSOR CENSUS: byte pattern 48 8b 4x 18 followed by a load", "=" * 30)
seen = {}
for reg in (0x41, 0x51, 0x49, 0x59, 0x71, 0x79):
pat = bytes([0x48, 0x8B, reg, 0x18])
for a in find_all(pat, blocks=(".text",)):
try:
nxt = read_bytes(a + 4, 8)
except Exception:
continue
seen.setdefault(a, nxt)
P("call-shape candidates:", len(seen))
interest = {}
for a, nxt in seen.items():
disp = None
if nxt[0] == 0x8B and (nxt[1] & 0xC0) == 0x40:
disp = nxt[2]
elif nxt[0] == 0x0F and nxt[1] in (0xB6, 0xB7) and (nxt[2] & 0xC0) == 0x40:
disp = nxt[3]
elif nxt[0] == 0x83 and (nxt[1] & 0xC0) == 0x40:
disp = nxt[2]
elif nxt[0] == 0x8A and (nxt[1] & 0xC0) == 0x40:
disp = nxt[2]
if disp in (0x38, 0x3C, 0x48, 0x49, 0x4C, 0x54, 0x58, 0x5C, 0x60, 0x88, 0x90):
fn = fm.getFunctionContaining(addr(a))
interest.setdefault(disp, []).append((a, fn.getName() if fn else "?",
int(fn.getEntryPoint().getOffset()) if fn else 0))
for d in sorted(interest):
P("### accessor-shape loads of +0x%02x : %d" % (d, len(interest[d])))
for a, nm, e in sorted(interest[d], key=lambda x: x[2]):
P(" %#x in %s @%#x" % (a, nm, e))
if e:
nc = [(fr, t, cf, ce) for fr, t, cf, ce in xrefs_to(e) if "CALL" in t]
P(" callers: %d -> %s" % (len(nc), sorted(set(cf for _, _, cf, _ in nc))))
P()
P("=" * 30, "THE UNOPENED +0x49 CONSUMER: FUN_1800bc580", "=" * 30)
d = dec(0x1800BC580)
P("len(src) =", len(d))
P(d)
f.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,100 @@
"""ADVERSARIAL BATCH 3.
My batch-2 displacement census turned up FOUR +0x5c sites the other agent's
constant-collecting scan did not report, including MOV dword [RDI+0x5c],0x5 and
MOV dword [RDI+0x5c],0x6 in FUN_180147070 -- i.e. the client WRITES forSale and
offered. Their claim "forSale(5) and offered(6): NEVER TESTED ANYWHERE" and the
action "nothing reads them" are under direct attack here.
Also under attack:
- "no other code path can produce the greyout from wire data": FUN_1800bc580 is a
THIRD +0x49 consumer (it counts untradeable squad members). What uses that count?
- the FUN_1800e2a40 <-> FUN_18003e370 vtable link the agent flagged as a gap.
- the +0x23f playStyle mapper, the 0x226 pile mapper, and the record-offset anchor
inside FUN_18013fe00 (printed IN FULL, with len).
CONTROL for the vtable hunt: I search for the 8-byte pointer to FUN_1800e2a40 AND,
in the same pass, for the pointer to FUN_1801a7260 (which the agent reported has NO
8-byte pointer, only 4-byte .pdata RVAs) and to FUN_18003e370. A hunt that finds all
three or none tells me the search itself is sound.
"""
import traceback, struct
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q3_raw.txt"
try:
f = open(OUT, "w")
def P(*a):
f.write(" ".join(str(x) for x in a) + "\n")
P("=" * 25, "A. itemState WRITERS/READERS the other scan missed", "=" * 25)
for a in (0x180147070, 0x1801A6FC0, 0x1800A47B0, 0x18011DC50, 0x1800D73D0):
d = dec(a)
P("### %#x len=%d xrefs:" % (a, len(d)))
for frm, t, cf, e in xrefs_to(a):
P(" %#x %s in %s@%#x" % (frm, t, cf, e))
P(d)
P()
P("=" * 25, "B. the third +0x49 consumer: who calls FUN_1800bc580", "=" * 25)
for frm, t, cf, e in xrefs_to(0x1800BC580):
P(" %#x %s in %s@%#x" % (frm, t, cf, e))
P("--- FUN_1801a8890 (the sibling predicate counted into param_2):")
P(dec(0x1801A8890))
P("--- FUN_1801a80a0:")
P(dec(0x1801A80A0))
P()
P("=" * 25, "C. vtable link FUN_1800e2a40 <- FUN_18003e370 slot 0x40", "=" * 25)
for nm, a in (("FUN_1800e2a40", 0x1800E2A40), ("FUN_1801a7260", 0x1801A7260),
("FUN_18003e370", 0x18003E370), ("FUN_1800eb850", 0x1800EB850)):
pat = struct.pack("<Q", a)
hits = find_all(pat, blocks=(".rdata", ".data"))
P(" %s ptr8 hits: %s" % (nm, [hex(h) for h in hits]))
for h in hits:
# walk backwards to find the table start (first qword that is not a .text ptr)
start = h
while True:
try:
v = qword(start - 8)
except Exception:
break
if not (0x180001000 <= v < 0x1801E5000):
break
start -= 8
P(" table start %#x, slot +%#x" % (start, h - start))
for i in range(0, 40):
try:
v = qword(start + i * 8)
except Exception:
break
if not (0x180001000 <= v < 0x1801E5000):
P(" +%#04x %#x <END>" % (i * 8, v))
break
fn = fm.getFunctionAt(addr(v))
P(" +%#04x %#x %s%s" % (i * 8, v, fn.getName() if fn else "",
" <== TARGET" if v == a else ""))
P()
P("=" * 25, "D. FUN_18013fe00 FULL", "=" * 25)
d = dec(0x18013FE00, timeout=600)
P("len(src) =", len(d))
P(d)
P()
P("=" * 25, "E. mappers", "=" * 25)
for nm, a in (("playStyle FUN_180136480", 0x180136480),
("pile FUN_180142650", 0x180142650),
("owners helper FUN_1800d7b50", 0x1800D7B50),
("BOUGHT_FOR mapper FUN_1800d7b30", 0x1800D7B30),
("family FUN_1800d8330", 0x1800D8330)):
P("### " + nm)
dd = dec(a)
P(" len=%d" % len(dd))
P(dd)
P()
f.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,120 @@
"""ADVERSARIAL BATCH 4 -- the remaining serve-changing and absence claims.
- FUN_180141660: is the +0x54 level write really on the COMMON tail, or only on the
"DB Error" path? If only on the error path the whole level story changes.
- FUN_1801b3640: CMP dword [RAX+0x5c],R15D -- a REGISTER compare the other agent's
constant-collecting scan could not evaluate. If R15D can be 5 or 6 their
"forSale/offered are never tested" absence claim dies.
- FUN_18003e550: the listing panel. Does "List on Transfer Market" have its own
enable predicate the eight-flag array does not cover?
- FUN_1800eb850: are DISCARD_CREDITS / CALCULATED_DISCARD_CREDITS really the two
names, pushed from 0x1801a8620 / 0x1801a8090?
- 0x226 pile census, re-tested by xrefs to the mapper FUN_180142650 (a DIFFERENT
method from decompiling all 134 skip-callers).
- itemState string-writer absence, re-tested by xrefs to every one of the 12 string
literals, with the ITEM-TYPE table strings ('player','staff') as a control that
has known extra users.
- FUN_180008190: resolve the indirect string compare through the global vtable.
"""
import traceback, struct
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q4_raw.txt"
try:
f = open(OUT, "w")
def P(*a):
f.write(" ".join(str(x) for x in a) + "\n")
P("=" * 25, "A. FUN_180141660 -- is the level write a common tail?", "=" * 25)
fn = fm.getFunctionAt(addr(0x180141660))
body = fn.getBody()
P("body:", body, " min %#x max %#x" % (int(body.getMinAddress().getOffset()),
int(body.getMaxAddress().getOffset())))
# every RET in the function, and every branch target landing at/after 0x180141e77
rets, brs = [], []
it = listing.getInstructions(body, True)
while it.hasNext():
i = it.next()
m = i.getMnemonicString()
a = int(i.getAddress().getOffset())
if m == "RET":
rets.append(a)
if m.startswith("J"):
for r in i.getFlows():
t = int(r.getOffset())
if 0x180141E70 <= t <= 0x180141EB0:
brs.append((a, m, t))
P("RET sites:", [hex(x) for x in rets])
P("branches into the tail 0x180141e70..0x180141eb0:")
for a, m, t in brs:
P(" %#x %s -> %#x" % (a, m, t))
P()
P("FUN_180141660 decompile:")
d = dec(0x180141660, timeout=600)
P("len =", len(d))
P(d)
P()
P("=" * 25, "B. FUN_1801b3640 -- the register compare on +0x5c", "=" * 25)
ins = listing.getInstructions(addr(0x1801B3860), True)
n = 0
while ins.hasNext() and n < 90:
i = ins.next()
a = int(i.getAddress().getOffset())
if a > 0x1801B38E0:
break
P(" %#x %s" % (a, i))
n += 1
P()
P("R15 setup search 0x1801b3640..0x1801b3894:")
ins = listing.getInstructions(addr(0x1801B3640), True)
while ins.hasNext():
i = ins.next()
a = int(i.getAddress().getOffset())
if a > 0x1801B3894:
break
s = i.toString()
if "R15" in s:
P(" %#x %s" % (a, s))
P()
d = dec(0x1801B3640, timeout=600)
P("FUN_1801b3640 len =", len(d))
P(d)
P()
P("=" * 25, "C. FUN_18003e550 listing panel + FUN_1800eb850 discard push", "=" * 25)
for a in (0x18003E550, 0x1800EB850):
d = dec(a, timeout=600)
P("### %#x len=%d" % (a, len(d)))
P(d)
P()
P("=" * 25, "D. pile mapper xrefs (different method for the 0x226 census)", "=" * 25)
for frm, t, cf, e in xrefs_to(0x180142650):
P(" %#x %s in %s@%#x" % (frm, t, cf, e))
P()
P("=" * 25, "E. itemState string literals: every xref", "=" * 25)
names = ["invalid", "free", "WAITING_FOR_GAME", "inGame", "forSale", "offered",
"activeBadge", "activeHomeKit", "activeAwayKit", "activeBall",
"activeStadium", "active",
"player", "staff"] # last two = CONTROL, known to be used elsewhere
for nm in names:
hits = find_all(nm.encode() + b"\x00", blocks=(".rdata", ".data"))
P("### %-18s literal hits: %s" % (nm, [hex(h) for h in hits]))
for h in hits:
for frm, t, cf, e in xrefs_to(h):
P(" ref %#x %s in %s@%#x" % (frm, t, cf, e))
P()
P("=" * 25, "F. FUN_180008190 indirect compare + FUN_180130d10 + FUN_1801c3480", "=" * 25)
for a in (0x180008190, 0x180130D10, 0x1801C3480):
d = dec(a, timeout=600)
P("### %#x len=%d" % (a, len(d)))
P(d)
P()
f.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,62 @@
"""ADVERSARIAL BATCH 5 -- consequences of the one serve-changing action, and the
service gate the other agent left open.
1. untradeable:false flips item+0x49 to 1 on EVERY card. Besides TO_TRADE_PILE that
byte feeds FUN_1800bc580, which counts untradeable members of the 11-slot active
squad. Who consumes that count, and does flipping it change anything else?
2. FUN_1801a7260's other gate: slot +0x270 of the service FUN_180009c80 resolves.
Identify the service vtable and that slot if possible.
"""
import traceback, struct
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q5_raw.txt"
try:
f = open(OUT, "w")
def P(*a):
f.write(" ".join(str(x) for x in a) + "\n")
P("=" * 25, "1. consumers of the untradeable-squad count", "=" * 25)
for a in (0x1800BB2A0, 0x1800BBA10):
d = dec(a, timeout=600)
P("### %#x len=%d" % (a, len(d)))
P(d)
P()
P("=" * 25, "2. the service locator used by FUN_1801a7260", "=" * 25)
for nm, a in (("FUN_1800d7170", 0x1800D7170), ("FUN_180009c80", 0x180009C80),
("FUN_180018bd0", 0x180018BD0), ("FUN_180009b60", 0x180009B60)):
P("### " + nm)
P(dec(a))
P()
P("=" * 25, "3. any vtable with >= 0x280 bytes containing plausible slot 0x270", "=" * 25)
# find .rdata runs of >= 0x50 consecutive .text pointers; report those long enough
for b in mem.getBlocks():
if b.getName() != ".rdata" or not b.isInitialized():
continue
s = int(b.getStart().getOffset())
e = int(b.getEnd().getOffset())
a = (s + 7) & ~7
run_start = None
while a + 8 <= e:
try:
v = qword(a)
except Exception:
break
ok = 0x180001000 <= v < 0x1801E5000
if ok and run_start is None:
run_start = a
elif not ok and run_start is not None:
ln = a - run_start
if ln >= 0x280:
P(" vtable-ish run %#x..%#x len %#x slot+0x270 -> %#x %s" %
(run_start, a, ln, qword(run_start + 0x270),
(lambda fn: fn.getName() if fn else "")(fm.getFunctionAt(addr(qword(run_start + 0x270))))))
run_start = None
a += 8
f.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,52 @@
"""ADVERSARIAL BATCH 6 -- pin the service behind GUID 0xed84b11/0xed84b12 whose
vtable slot +0x270 is the OTHER gate on TO_TRADE_PILE. If that gate is an online /
transfer-market-availability check it may block the menu even with untradeable:false,
which is the single biggest risk to the headline recommendation.
METHOD: the class that implements an interface references the same GUID constant when
it registers. Scan .text for the 4-byte immediates and report every function.
CONTROL: the same scan for 0x10c80b95 (the CardInventory-ish service FUN_18003e370
uses) and 0xed80ed8 -- if those come back with registrars and 0xed84b11 does not, the
absence is about this GUID and not about the scan.
"""
import traceback, struct
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q6_raw.txt"
try:
f = open(OUT, "w")
def P(*a):
f.write(" ".join(str(x) for x in a) + "\n")
for g in (0xED84B11, 0xED84B12, 0x10C80B95, 0x10C80B96, 0xED80ED8):
pat = struct.pack("<I", g)
hits = find_all(pat, blocks=(".text", ".rdata", ".data"))
fns = {}
for h in hits:
fn = fm.getFunctionContaining(addr(h))
k = fn.getName() if fn else "(data)"
fns.setdefault(k, []).append(h)
P("### GUID %#x : %d byte hits in %d functions" % (g, len(hits), len(fns)))
for k, v in sorted(fns.items()):
P(" %-24s %s" % (k, [hex(x) for x in v]))
P()
P("=" * 25, "the registrar bodies", "=" * 25)
seen = set()
for g in (0xED84B11, 0xED84B12):
for h in find_all(struct.pack("<I", g), blocks=(".text",)):
fn = fm.getFunctionContaining(addr(h))
if fn is None:
continue
e = int(fn.getEntryPoint().getOffset())
if e in seen:
continue
seen.add(e)
P("### %s @%#x" % (fn.getName(), e))
P(dec(e))
P()
f.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,62 @@
"""ADVERSARIAL BATCH 7 -- finish the two open links.
(a) 0x10c80b96 appears as data at 0x1800e1662, inside the function at vtable slot
+0xa8 of the table 0x180215a80 -- the same table whose slot +0xd0 is
FUN_1800e2a40. If that holds it independently proves the FUN_18003e370 ->
FUN_1800e2a40 link the other agent could only infer semantically.
(b) 0xed84b12 appears as data at 0x180113f52. Whatever class that belongs to is the
service FUN_1801a7260 calls slot +0x270 on. Find its vtable and read slot 0x270.
"""
import traceback, struct
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q7_raw.txt"
try:
f = open(OUT, "w")
def P(*a):
f.write(" ".join(str(x) for x in a) + "\n")
for a in (0x1800E1662, 0x180113F52):
fn = fm.getFunctionContaining(addr(a))
P("### data GUID at %#x -> containing function %s @%#x" %
(a, fn.getName() if fn else None,
int(fn.getEntryPoint().getOffset()) if fn else 0))
if fn:
e = int(fn.getEntryPoint().getOffset())
P(dec(e))
hits = find_all(struct.pack("<Q", e), blocks=(".rdata", ".data"))
P(" 8-byte pointer to it: %s" % [hex(h) for h in hits])
for h in hits:
start = h
while True:
try:
v = qword(start - 8)
except Exception:
break
if not (0x180001000 <= v < 0x1801E5000):
break
start -= 8
P(" run start %#x, this fn at slot +%#x" % (start, h - start))
# find the first non-stub entry -- the secondary vtable base
base = start
while qword(base) == 0x1801C577A:
base += 8
P(" first non-stub entry at %#x (offset +%#x from run start)" % (base, base - start))
P(" => slot of this fn relative to first non-stub: +%#x" % (h - base))
for i in range(0, 90):
v = qword(base + i * 8)
if not (0x180001000 <= v < 0x1801E5000):
break
f2 = fm.getFunctionAt(addr(v))
mark = ""
if i * 8 == 0x270:
mark = " <== SLOT 0x270"
if i * 8 == 0x40:
mark = " <== SLOT 0x40"
P(" +%#05x %#x %s%s" % (i * 8, v, f2.getName() if f2 else "", mark))
P()
f.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,42 @@
"""BATCH 8: the two GUID-returning stubs are undefined functions. Read them as raw
instructions and find the vtable that holds them. CONTROL: both stubs must decode to
'mov eax, <guid>; ret' -- if they do not, my reading of them as interface-id getters
is wrong and I say so."""
import traceback, struct
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q8_raw.txt"
try:
f = open(OUT, "w")
def P(*a): f.write(" ".join(str(x) for x in a) + "\n")
for lo, hi in ((0x1800E1650, 0x1800E1690), (0x180113F40, 0x180113F80)):
P("### raw %#x..%#x" % (lo, hi))
P(" bytes:", read_bytes(lo, hi - lo).hex())
it = listing.getInstructions(addr(lo), True)
while it.hasNext():
i = it.next()
a = int(i.getAddress().getOffset())
if a >= hi: break
P(" %#x %s" % (a, i))
P()
for cand in (0x1800E1660, 0x180113F50, 0x180113F4C, 0x180113F40):
hits = find_all(struct.pack("<Q", cand), blocks=(".rdata", ".data"))
P("ptr8 to %#x : %s" % (cand, [hex(h) for h in hits]))
for h in hits:
start = h
while True:
try: v = qword(start - 8)
except Exception: break
if not (0x180001000 <= v < 0x1801E5000): break
start -= 8
base = start
while qword(base) == 0x1801C577A: base += 8
P(" run %#x, first non-stub %#x, this at +%#x from non-stub" % (start, base, h - base))
for i in range(0, 100):
v = qword(base + i * 8)
if not (0x180001000 <= v < 0x1801E5000): break
fn = fm.getFunctionAt(addr(v))
if i*8 in (0x40, 0x270, 0x308, 0x290, 0x2b0, 0x148, 0xd0, 0x20):
P(" +%#05x %#x %s" % (i*8, v, fn.getName() if fn else ""))
P(" table length: %#x" % (i*8))
f.close(); print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,31 @@
"""BATCH 9. The cast helper is vtable slot +0x18 (FUN_180009c80 calls
(*(*svc))[0x18] with the interface GUID). So vtable_base = cast_stub_slot_addr - 0x18.
- 0x10c80b96 class: stub ptr at 0x180215b28 -> base 0x180215b10 -> slot +0x40 must be
FUN_1800e2a40 if the FUN_18003e370 link is real. (CONTROL for the arithmetic.)
- 0xed84b12 class: stub ptr at 0x18021c2b8 -> base 0x18021c2a0 -> slot +0x270 is the
other gate on TO_TRADE_PILE.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv2/q9_raw.txt"
try:
f = open(OUT, "w")
def P(*a): f.write(" ".join(str(x) for x in a) + "\n")
for nm, base, slots in (("iface 0x10c80b96 (CONTROL)", 0x180215B10, (0x18, 0x40)),
("iface 0xed84b12", 0x18021C2A0, (0x18, 0x270, 0x290, 0x2b0, 0x308, 0x148))):
P("### %s vtable base %#x" % (nm, base))
for s in slots:
v = qword(base + s)
fn = fm.getFunctionAt(addr(v))
P(" +%#05x -> %#x %s" % (s, v, fn.getName() if fn else ""))
P()
for a in (0x1801B1CE0,):
pass
v = qword(0x18021C2A0 + 0x270)
P("=== slot 0x270 body ===")
P(dec(v, timeout=300))
P("=== xrefs to it ===")
for frm, t, cf, e in xrefs_to(v):
P(" %#x %s in %s@%#x" % (frm, t, cf, e))
f.close(); print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,74 @@
"""ADVERSARIAL BATCH 1.
HYPOTHESES UNDER TEST (all from another agent, assumed WRONG until reproduced):
H1 FUN_1800d8330 maps cardsubtypeid -> cardtype and returns 9 for exactly
{0x1e,0x1f,0x91..0x96,0xe7..0xe9,0xec}; and returns 7 for 9,10,11.
H2 FUN_180119bd0 arms: 9 -> KITS, 10 -> Stadium, 0xb -> Badge, else "".
H3 FUN_1801a8640 == *(u32*)(*(u64*)(param_1+0x18)+0x50) i.e. cardsubtypeid.
H4 FUN_1800f6c40 calls vtable+0x498 only when item+0x4c == 7, args
(item+0x50, item+0x94, item+0x20); and sets IS_KIT_%d when item+0x50==9.
H5 FUN_180141660 tail writes item+0x54 = level(rating@+0xb4): 3 if >=0x4b,
else 2 - (rating < 0x41). <-- CONTRADICTS the live-map "+0x54 = itemType".
CONTROL: FUN_1800d8330 must decompile non-empty and its case labels must be
recoverable; it is a jump table, which is the form that DEFEATED an earlier scan.
Every decompile is written to disk IN FULL with its length printed, so no claim
here can rest on a truncated body.
Output: /tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv/
"""
import traceback, os
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv"
try:
os.makedirs(OUT, exist_ok=True)
TARGETS = {
"FUN_1800d8330": 0x1800d8330,
"FUN_180119bd0": 0x180119bd0,
"FUN_1801a8640": 0x1801a8640,
"FUN_1800f6c40": 0x1800f6c40,
"FUN_180141660": 0x180141660,
"FUN_1801a8570": 0x1801a8570,
"FUN_1801a8560": 0x1801a8560,
"FUN_1801a8800": 0x1801a8800,
"FUN_1801a8040": 0x1801a8040,
"FUN_180136480": 0x180136480,
}
for name, a in TARGETS.items():
src = dec(a)
p = os.path.join(OUT, name + ".c")
open(p, "w").write(src)
print("WROTE %-16s len=%6d -> %s" % (name, len(src), p))
print()
print("=== small functions printed IN FULL ===")
for name in ("FUN_1800d8330", "FUN_1801a8640", "FUN_1801a8570", "FUN_1801a8560",
"FUN_1801a8800", "FUN_1801a8040", "FUN_180119bd0"):
src = open(os.path.join(OUT, name + ".c")).read()
print("\n----------8<---------- %s (len=%d) ----------" % (name, len(src)))
print(src)
print()
print("=== CONTROL: case labels of FUN_1800d8330 via the listing ===")
f = func(0x1800d8330)
print("entry 0x%x body %s" % (int(f.getEntryPoint().getOffset()), f.getBody()))
it = listing.getInstructions(f.getBody(), True)
n = 0
while it.hasNext():
ins = it.next()
n += 1
print("instruction count: %d" % n)
# enumerate caseD_ labels inside the body
st = prog.getSymbolTable()
labs = []
rng = f.getBody()
for sym in st.getAllSymbols(True):
a2 = sym.getAddress()
if a2 is not None and rng.contains(a2) and str(sym.getName()).startswith("caseD_"):
labs.append((str(sym.getName()), int(a2.getOffset())))
print("caseD_ labels in FUN_1800d8330: %d -> %s" % (len(labs), sorted(set(l[0] for l in labs))))
except Exception:
traceback.print_exc()
@@ -0,0 +1,124 @@
"""ADVERSARIAL BATCH 2.
MAIN ATTACK: the claim "cardtype 9 has NO resolver at all, so ball and leaguelogo
display strings must come off the wire (localizedName + description)". That claim
CHANGES WHAT WE SERVE, so it is priority 1.
Counter-evidence to chase: .rdata at 0x1802041d0 holds 'fcc_leaguelogos' and
0x1802041e0 holds 'LeagueName_Abbr_15_%d', sitting immediately beside 'FUT_UC_KITS'
(0x180204180) which IS a resolver literal. If some function formats
LeagueName_Abbr_15_%d for a league logo, the "must come off the wire" claim is wrong.
H6 vtable+0x490 = FUN_18011a860 is a GENERIC name resolver taking
(cardtype@+0x4c, cardsubtypeid@+0x50, resourceId@+0x18). Does it have a
cardtype-9 arm?
H7 'fcc_leaguelogos' / 'LeagueName_Abbr_15_%d' are referenced by some function.
H8 FUN_18012ee20 has EXACTLY ONE caller (the club URL builder). [absence claim]
H9 FUN_1800fed90 is the ONLY function whose switch case set is exactly
{0x91..0x96}. [absence claim -- re-tested here by a DIFFERENT method than
the original caseD_ symbol enumeration: I enumerate switch tables from the
instruction/flow side via getBasicBlocks + scalar operands, AND repeat the
symbol method, and compare the two.]
H10 FUN_180141660 (the merge) is called on every deserialized item.
CONTROL for the xref questions: 'FUT_UC_KITS' at 0x180204180 MUST come back with
>=1 referencing function (we already know FUN_180119bd0 uses it). If the xref
method returns 0 for FUT_UC_KITS the method is broken and every negative is void.
Same syntactic form (a .rdata string address referenced by a LEA) as the targets.
"""
import traceback, os
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv"
try:
print("=== CONTROL + targets: xrefs to .rdata string addresses ===")
STRS = {
"FUT_UC_KITS (CONTROL)": 0x180204180,
"FUT_UC_BALL": 0x180239120,
"fcc_leaguelogos": 0x1802041d0,
"LeagueName_Abbr_15_%d": 0x1802041e0,
"leagues": 0x1802041b0,
"Badge (0x1802041b8)": 0x1802041b8,
"countryid": 0x1802041c0,
"fcc_myclubs": 0x180204190,
"TeamName_Abbr15_%d?": None,
}
for name, a in STRS.items():
if a is None:
continue
try:
xs = xrefs_to(a)
except Exception as e:
print(" %-24s XREF ERROR %s" % (name, e)); continue
fns = sorted(set((x[2], x[3]) for x in xs))
print(" %-24s 0x%x %d refs, funcs: %s" %
(name, a, len(xs), ["%s@0x%x" % (n, e) for n, e in fns]))
print()
print("=== find TeamName_Abbr15_%d and StadiumName_%d addresses then xref ===")
for lit in (b"TeamName_Abbr15_%d\x00", b"StadiumName_%d\x00", b"LeagueName_Abbr_15_%d\x00",
b"fcc_leaguelogos\x00", b"fcc_balls\x00", b"fcc_stadium\x00",
b"fcc_badgecards\x00", b"fcc_kitcards\x00", b"fcc_misccards\x00"):
hits = find_all(lit, blocks=(".rdata", ".data", ".text"))
print(" %-26s %d hit(s) at %s" % (lit.rstrip(b"\x00").decode(), len(hits),
[hex(h) for h in hits]))
for h in hits:
xs = xrefs_to(h)
fns = sorted(set((x[2], x[3]) for x in xs))
print(" -> %d refs: %s" % (len(xs), ["%s@0x%x" % (n, e) for n, e in fns]))
print()
print("=== H6: generic resolver FUN_18011a860 (vtable +0x490) FULL ===")
src = dec(0x18011a860)
open(os.path.join(OUT, "FUN_18011a860.c"), "w").write(src)
print("len=%d" % len(src))
print(src)
print()
print("=== H8: callers of FUN_18012ee20 (itemState code -> atom) ===")
for fa in (0x18012ee20, 0x180141660, 0x180166660, 0x1800fed90):
try:
cs = callers(fa)
except Exception:
cs = [(x[0], x[2], x[3]) for x in xrefs_to(fa)]
print(" FUN_%x callers: %s" % (fa, cs))
print()
print("=== H9: switch case-set enumeration, TWO methods ===")
st = prog.getSymbolTable()
# method 1: caseD_ symbols grouped by containing function
import collections
bysym = collections.defaultdict(set)
n = 0
for sym in st.getAllSymbols(True):
nm = str(sym.getName())
if not nm.startswith("caseD_"):
continue
n += 1
a2 = sym.getAddress()
f = fm.getFunctionContaining(a2)
if f is None:
continue
try:
v = int(nm.split("_")[-1], 16)
except ValueError:
continue
bysym[int(f.getEntryPoint().getOffset())].add(v)
print(" method1: %d caseD_ symbols over %d functions" % (n, len(bysym)))
TARGET = set(range(0x91, 0x97))
exact = [hex(k) for k, v in bysym.items() if v == TARGET]
superset = [hex(k) for k, v in bysym.items() if TARGET <= v and v != TARGET]
overlap = [hex(k) for k, v in bysym.items() if (TARGET & v) and not (TARGET <= v)]
print(" functions with case set EXACTLY {0x91..0x96}: %s" % exact)
print(" functions whose case set is a SUPERSET: %s" % superset)
print(" functions with PARTIAL overlap: %s" % overlap)
print(" CONTROL FUN_1800d8330 present in method1? %s -> %s" %
(0x1800d8330 in bysym, sorted(hex(x) for x in bysym.get(0x1800d8330, []))))
print()
print("=== H10: callers of the merge FUN_180141660 ===")
xs = xrefs_to(0x180141660)
print(" %d refs: %s" % (len(xs), sorted(set("%s@0x%x" % (x[2], x[3]) for x in xs))))
except Exception:
traceback.print_exc()
@@ -0,0 +1,92 @@
"""ADVERSARIAL BATCH 3.
PRIORITY-1 ATTACK: FUN_180098f20 is the ONLY referencer of both 'fcc_leaguelogos'
and 'LeagueName_Abbr_15_%d'. If it resolves a league-logo display name from the DB,
then the claim "cardtype 9 has no resolver at all, so ball and leaguelogo need
localizedName + description off the wire" is WRONG, and that claim changes what we
serve.
ALSO:
H11 FUN_180108c00 deserializes atom 0x32f (tournamentType) and computes
subtype = value + 0x91. (the trophy claim)
H12 FUN_1801bfac0 arm iVar5 == 0x1e -> FUT_UC_BALL, and the 0x1f arm.
H13 DAT_18022315c is the string "rare" (supports low-dword-of-uStack_130 = rareflag)
H14 the deser's stack struct -> record copy: which stack slot becomes record+0x58.
CONTROL for the "who calls X" questions: FUN_180119bd0 must come back with >=1
caller (we already proved FUN_1800f6c40 calls it through vtable slot +0x498 --
though that is an INDIRECT call, so a direct-xref method may legitimately return 0;
that is exactly why the control matters and why a 0 here is NOT an absence).
"""
import traceback, os
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv"
try:
print("=== H13: strings at the DAT_ addresses used as DB column names ===")
for a in (0x18022315c, 0x1801eeeb0, 0x1802ef590, 0x18021ce7c, 0x18021ce7f, 0x1801e9caf):
try:
print(" 0x%x -> %r" % (a, rd_str(a, 40)))
except Exception as e:
print(" 0x%x -> ERR %s" % (a, e))
print()
print("=== PRIORITY 1: FUN_180098f20 FULL (the fcc_leaguelogos referencer) ===")
src = dec(0x180098f20)
open(os.path.join(OUT, "FUN_180098f20.c"), "w").write(src)
print("len=%d" % len(src))
print(src)
print()
print("=== who calls FUN_180098f20 ? ===")
for fa, label in ((0x180098f20, "leaguelogo resolver"),
(0x180119bd0, "CONTROL kit/stadium/badge resolver (indirect-only expected)"),
(0x18011a860, "generic resolver +0x490"),
(0x180094580, "third FUT_UC_KITS user"),
(0x1800991a0, "fcc_myclubs user"),
(0x180099490, "leagues/countryid/Badge user")):
xs = xrefs_to(fa)
print(" 0x%x %-52s %d refs: %s" %
(fa, label, len(xs), sorted(set("%s@0x%x" % (x[2], x[3]) for x in xs))))
print()
print("=== H11: FUN_180108c00 FULL (tournamentType -> subtype 0x91+) ===")
src = dec(0x180108c00)
open(os.path.join(OUT, "FUN_180108c00.c"), "w").write(src)
print("len=%d" % len(src))
print(src[:9000])
if len(src) > 9000:
print("... [remainder in FUN_180108c00.c]")
print()
print("=== FUN_1800fed90 FULL (the 0x91..0x96 switch) ===")
src = dec(0x1800fed90)
open(os.path.join(OUT, "FUN_1800fed90.c"), "w").write(src)
print("len=%d" % len(src))
print(src)
print()
print("=== re-decompile the item deser MYSELF (do not trust the other agent's copy) ===")
src = dec(0x18013fe00, timeout=600)
p = os.path.join(OUT, "FUN_18013fe00.c")
open(p, "w").write(src)
print("len=%d -> %s" % (len(src), p))
# print only the lines that matter for H14
for i, ln in enumerate(src.splitlines(), 1):
if ("uStack_130" in ln or "local_100" in ln or "FUN_180141660" in ln
or "local_13c" in ln or "local_138" in ln):
print(" %4d: %s" % (i, ln))
print()
print("=== also dump the card-detail builder for the 0x1e / 0x1f arms ===")
src = dec(0x1801bfac0, timeout=600)
open(os.path.join(OUT, "FUN_1801bfac0.c"), "w").write(src)
print("len=%d" % len(src))
for i, ln in enumerate(src.splitlines(), 1):
if ("0x1e" in ln or "0x1f" in ln or "FUT_UC_BALL" in ln or "FUN_1801a8640" in ln
or "Stadium" in ln or "Badge" in ln or "FUT_UC_KITS" in ln
or "LeagueName" in ln or "fcc_" in ln):
print(" %4d: %s" % (i, ln))
except Exception:
traceback.print_exc()
@@ -0,0 +1,116 @@
"""ADVERSARIAL VERIFICATION BATCH 1.
HYPOTHESES UNDER ATTACK (from the D4 report):
H-A record+0x54 is card LEVEL derived from rating by an unconditional ladder in
the tail of FUN_180141660, NOT itemType.
H-B FUN_1801a87f0 is a one-byte read of record+0xb4 and all four OVERALL_RATING
publishers call it.
H-C playStyle lands at record+0x88, FUN_180136480 accepts only 0xfb..0x111.
H-D atom 0x173 itemType never becomes an int.
CONTROLS.
* For every "no such thing" statement I enumerate case labels, `== 0x`, `!= 0x`
AND sub/dec ladders, and I state which form the positive control used.
* Positive control for the dispatch enumeration: atoms 0x274 (rating) and 0x287
(resourceId), both known-present, must be found by the SAME enumerator.
* Positive control for the literal-xref method: a literal whose xref count is
independently known.
Everything is written to files; nothing is truncated.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv/"
try:
import re
def dump(name, s):
p = OUT + name
open(p, "w").write(s)
print("WROTE %s (%d chars)" % (p, len(s)))
targets = {
"merge_141660": 0x180141660,
"deser_13fe00": 0x18013FE00,
"playersmerge_135890": 0x180135890,
"acc_rating_1a87f0": 0x1801A87F0,
"acc_cardlevel_1a80c0": 0x1801A80C0,
"acc_playstyle_1a85c0": 0x1801A85C0,
"acc_league_1a8550": 0x1801A8550,
"acc_attr_1a8450": 0x1801A8450,
"acc_dream_1a8830": 0x1801A8830,
"acc_assetid_1a8010": 0x1801A8010,
"acc_asset2_1a8020": 0x1801A8020,
"mapper_playstyle_136480": 0x180136480,
"family_d8330": 0x1800D8330,
"resid_166ca0": 0x180166CA0,
}
blob = []
src = {}
for nm, a in targets.items():
f = func(a)
s = dec(a, 600)
src[nm] = s
blob.append("=" * 78)
blob.append("### %s @ %#x ghidra_fn=%s entry=%#x len=%d" % (
nm, a, f.getName() if f else "NONE",
int(f.getEntryPoint().getOffset()) if f else 0, len(s)))
blob.append(s)
dump("v1_bodies.txt", "\n".join(blob))
# ---- dispatch-form enumeration over the item deser, ALL FOUR FORMS
d = src["deser_13fe00"]
print("\n--- deser FUN_18013fe00 len=%d ---" % len(d))
cases = sorted(set(int(x, 16) for x in re.findall(r"case\s+0x([0-9a-fA-F]+)", d)))
cases += sorted(set(int(x) for x in re.findall(r"case\s+(\d+)", d)))
eq = sorted(set(int(x, 16) for x in re.findall(r"==\s*0x([0-9a-fA-F]+)", d)))
ne = sorted(set(int(x, 16) for x in re.findall(r"!=\s*0x([0-9a-fA-F]+)", d)))
lt = sorted(set(int(x, 16) for x in re.findall(r"<\s*0x([0-9a-fA-F]+)", d)))
sub = sorted(set(int(x, 16) for x in re.findall(r"-\s*0x([0-9a-fA-F]+)", d)))
print("case labels (%d): %s" % (len(cases), [hex(c) for c in cases]))
print("== 0x (%d): %s" % (len(eq), [hex(c) for c in eq]))
print("!= 0x (%d): %s" % (len(ne), [hex(c) for c in ne]))
print("< 0x (%d): %s" % (len(lt), [hex(c) for c in lt]))
print("- 0x ladders (%d): %s" % (len(sub), [hex(c) for c in sub]))
for probe, label in [(0x274, "rating CONTROL"), (0x287, "resourceId CONTROL"),
(0x173, "itemType"), (0x23F, "playStyle"),
(0x172, "itemState"), (0x207, "owners"),
(0x361, "untradeable"), (0x1B, "amount"),
(0x226, "pile"), (0x6B, "cardassetid"), (0x23, "assetId"),
(0x18A, "leagueId"), (0x1D1, "nation"), (0x6C, "cardsubtypeid")]:
forms = []
if probe in cases:
forms.append("case")
if probe in eq:
forms.append("==")
if probe in ne:
forms.append("!=")
print(" atom %#x %-18s dispatch forms: %s" % (probe, label, forms or "NONE FOUND"))
# ---- who writes offset 0x54 anywhere in the two functions?
print("\n--- textual writes to +0x54 / 0x54 in merge and deser ---")
for nm in ("merge_141660", "deser_13fe00", "playersmerge_135890"):
for ln_no, ln in enumerate(src[nm].split("\n")):
if "0x54" in ln or "0xb4" in ln:
print(" %-20s %4d| %s" % (nm, ln_no, ln.strip()))
# ---- OVERALL_RATING literal: locate it MYSELF, then xref
print("\n--- OVERALL_RATING literal census ---")
hits = find_all(b"OVERALL_RATING\x00")
print("occurrences of 'OVERALL_RATING\\0':", [hex(h) for h in hits])
for h in hits:
xs = xrefs_to(h)
print(" %#x xrefs=%d" % (h, len(xs)))
for frm, t, fn, ent in xs:
print(" from %#x %s in %s @%#x" % (frm, t, fn, ent))
# control: a literal with an obviously different xref profile
for lit in (b"CARD_LEVEL\x00", b"PLAY_STYLE\x00", b"LEAGUE_ID\x00",
b"ATTRIBUTE_VALUE\x00", b"IS_DREAM_PLAYER\x00", b"ASSET_ID\x00"):
hs = find_all(lit)
print("\n%s occurrences: %s" % (lit, [hex(x) for x in hs]))
for h in hs:
xs = xrefs_to(h)
print(" %#x xrefs=%d -> %s" % (h, len(xs), sorted(set(x[2] for x in xs))))
except Exception:
traceback.print_exc()
@@ -0,0 +1,88 @@
"""ADVERSARIAL VERIFICATION BATCH 2.
Q1 COMPLETENESS GAP the D4 report admitted: are there raw, non-accessor reads of
record+0xb4 anywhere in the binary? 0xb4 cannot be encoded as a signed disp8,
so EVERY [reg+0xb4] reference must carry the literal disp32 bytes b4 00 00 00.
Scanning .text for those four bytes and decoding the containing instruction is
therefore an EXHAUSTIVE search, not a sample. Same scan for 0x54 and 0x88.
Positive control: the scan must find FUN_1801a87f0 (+0xb4), FUN_180141660's
ladder (+0xb4 and +0x54) and FUN_1801a85c0 (+0x88).
Q2 FUN_18013f4d0 -- the family-6 handler the deser tail calls with (record,
resourceId, AMOUNT). If it stores amount in the record, the standing
"amount is dropped" verdict is wrong.
Q3 the +0xe0 mystery: FUN_1801a8540, FUN_1800e5940 (manager publisher),
FUN_1800e6e20 (player publisher) in full.
Q4 FUN_180166660 itemState mapper, FUN_1800d7b50/b30/b10/af0 value readers.
Q5 who calls FUN_18013fe00 and FUN_180141660 (is the ladder really on every path).
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv/"
try:
def scan_disp(off):
pat = bytes([off & 0xFF, (off >> 8) & 0xFF, (off >> 16) & 0xFF, (off >> 24) & 0xFF])
hits = find_all(pat, blocks=(".text",))
rows = []
for h in hits:
ins = listing.getInstructionContaining(addr(h))
if ins is None:
continue
a = int(ins.getAddress().getOffset())
txt = str(ins)
if ("0xb4]" in txt or "0x54]" in txt or "0x88]" in txt or
hex(off) in txt.lower()):
f = fm.getFunctionContaining(ins.getAddress())
rows.append((a, txt, f.getName() if f else "?"))
return rows
for off, label in ((0xB4, "record+0xb4 rating"),
(0x54, "record+0x54 disputed"),
(0x88, "record+0x88 playStyle")):
rows = scan_disp(off)
print("\n==== EXHAUSTIVE disp32 scan for [reg+%#x] (%s): %d instructions"
% (off, label, len(rows)))
seen = {}
for a, txt, fn in rows:
seen.setdefault(fn, []).append((a, txt))
for fn in sorted(seen):
print(" %-24s" % fn, ["%#x %s" % (a, t) for a, t in seen[fn]])
bodies = []
for nm, a in (("f_13f4d0_family6", 0x18013F4D0),
("acc_1a8540", 0x1801A8540),
("acc_1a86b0", 0x1801A86B0),
("acc_1a8590_nation", 0x1801A8590),
("acc_1a86a0_team", 0x1801A86A0),
("pub_mgr_1800e5940", 0x1800E5940),
("pub_player_1800e6e20", 0x1800E6E20),
("itemstate_166660", 0x180166660),
("rd_d7b50", 0x1800D7B50), ("rd_d7b30", 0x1800D7B30),
("rd_d7b10", 0x1800D7B10), ("rd_d7af0", 0x1800D7AF0),
("stamp_d84e0", 0x1800D84E0)):
f = func(a)
s = dec(a, 600)
bodies.append("=" * 78)
bodies.append("### %s @ %#x len=%d" % (nm, a, len(s)))
bodies.append(s)
open(OUT + "v2_bodies.txt", "w").write("\n".join(bodies))
print("\nWROTE v2_bodies.txt")
print("\n==== callers ====")
for nm, a in (("FUN_18013fe00 item deser", 0x18013FE00),
("FUN_180141660 merge", 0x180141660),
("FUN_180135890 players merge", 0x180135890),
("FUN_1801a87f0 rating acc", 0x1801A87F0),
("FUN_1801a80c0 cardlevel acc", 0x1801A80C0),
("FUN_1801a85c0 playstyle acc", 0x1801A85C0),
("FUN_1801a8550 league acc", 0x1801A8550),
("FUN_1801a8540", 0x1801A8540)):
xs = xrefs_to(a)
cs = sorted(set("%s@%#x" % (x[2], x[3]) for x in xs if x[1].startswith("UNCONDITIONAL_CALL") or "CALL" in x[1]))
print("%-30s xrefs=%d callers=%s" % (nm, len(xs), cs))
except Exception:
traceback.print_exc()
@@ -0,0 +1,42 @@
"""ADVERSARIAL BATCH 3: exhaustive [reg+disp32] scan, tightened.
0xb4 / 0x54 / 0x88 / 0xe0 cannot be a signed disp8, so every [reg+off] reference
must carry the disp32 bytes literally. The scan is therefore exhaustive over .text.
Filter: keep only instructions whose printed operand ends in "+ 0x<off>]", drop LEA
and the unwind-stub noise.
Positive controls that MUST appear: FUN_1801a87f0 (+0xb4 read),
FUN_180141660 (+0xb4 read and +0x54 write), FUN_1801a85c0 (+0x88 read),
FUN_1801a80c0 (+0x54 read and write).
"""
import traceback
try:
for off in (0xB4, 0x54, 0x88, 0xE0):
pat = bytes([off, 0, 0, 0])
hits = find_all(pat, blocks=(".text",))
rows = []
for h in hits:
ins = listing.getInstructionContaining(addr(h))
if ins is None:
continue
txt = str(ins)
if ("+ %s]" % hex(off)) not in txt:
continue
mn = txt.split()[0]
if mn in ("LEA", "NOP"):
continue
f = fm.getFunctionContaining(ins.getAddress())
fn = f.getName() if f else "?"
if fn.startswith("Unwind") or fn.startswith("_guard"):
continue
rows.append((int(ins.getAddress().getOffset()), txt, fn))
rows = sorted(set(rows))
print("\n==== [reg+%#x] exhaustive disp32 scan: %d non-LEA, non-unwind instructions"
% (off, len(rows)))
byf = {}
for a, t, fn in rows:
byf.setdefault(fn, []).append((a, t))
for fn in sorted(byf):
print(" %-26s %s" % (fn, "; ".join("%#x %s" % x for x in byf[fn])))
except Exception:
traceback.print_exc()
@@ -0,0 +1,79 @@
"""ADVERSARIAL BATCH 4.
CARD SIDE
A. FUN_1801aa7f0 and FUN_1800e6410 read [reg+0xb4] as a byte but sit OUTSIDE the
accessor range [0x1801a7000,0x1801a9000) the D4 report swept. Do they read an
item record? If so the "OVERALL_RATING has exactly four publishers, all through
FUN_1801a87f0" completeness argument has a hole.
B. FUN_1801356c0 -- the family-2 (manager) merge. Does it clobber +0xdd..+0xfb the
way the players merge does? That decides whether leagueId at +0xe0 survives for
managers.
C. FUN_180134cb0 -- writes +0xfc..+0x101, which FUN_1801a86b0 reads as the
per-attribute chemistry delta.
D. disp8 scan for [reg+0x54]: 0x54 fits a signed disp8 so the disp32 trick does
NOT apply; iterate EVERY instruction in .text instead. Positive control:
FUN_180141660 and FUN_1801a80c0 must appear.
ROUTE SIDE
E. FUN_18012ec50 club ?type= switch, FUN_18012f4f0 club/stats switch,
FUN_1801308c0 consumables suffix, FUN_18012ddf0 query builder -- full, so the
"exactly 30 / exactly 7 / no /stats/team" absences can be re-tested against
case labels AND == AND != AND ladders.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/adv/"
try:
import re
bodies = []
src = {}
for nm, a in (("rating_reader_1aa7f0", 0x1801AA7F0),
("rating_reader_e6410", 0x1800E6410),
("mgr_merge_1356c0", 0x1801356C0),
("chem_134cb0", 0x180134CB0),
("clubtype_12ec50", 0x18012EC50),
("clubstats_12f4f0", 0x18012F4F0),
("consum_1308c0", 0x1801308C0),
("clubsearch_12ddf0", 0x18012DDF0)):
s = dec(a, 600)
src[nm] = s
bodies.append("=" * 78)
bodies.append("### %s @ %#x len=%d" % (nm, a, len(s)))
bodies.append(s)
open(OUT + "v4_bodies.txt", "w").write("\n".join(bodies))
print("WROTE v4_bodies.txt")
for nm in ("clubtype_12ec50", "clubstats_12f4f0"):
s = src[nm]
cases = re.findall(r"case\s+(0x[0-9a-fA-F]+|\d+):", s)
eq = re.findall(r"==\s*(0x[0-9a-fA-F]+|\d+)", s)
ne = re.findall(r"!=\s*(0x[0-9a-fA-F]+|\d+)", s)
sub = re.findall(r"-\s*(0x[0-9a-fA-F]+|\d+)U?\s*<", s)
print("\n%s len=%d cases=%d %s\n ==%s !=%s ladders=%s"
% (nm, len(s), len(cases), cases, eq, ne, sub))
# ---- D: exhaustive instruction walk for [reg+0x54]
print("\n==== EVERY instruction in .text referencing [reg + 0x54] ====")
blk = [b for b in mem.getBlocks() if b.getName() == ".text"][0]
it = listing.getInstructions(blk.getStart(), True)
n = 0
found = []
while it.hasNext():
ins = it.next()
if ins.getAddress().getOffset() > int(blk.getEnd().getOffset()):
break
n += 1
t = str(ins)
if "+ 0x54]" in t:
f = fm.getFunctionContaining(ins.getAddress())
found.append((int(ins.getAddress().getOffset()), t,
f.getName() if f else "?"))
print("instructions walked: %d ; hits: %d" % (n, len(found)))
byf = {}
for a, t, fn in found:
byf.setdefault(fn, []).append("%#x %s" % (a, t))
for fn in sorted(byf):
print(" %-26s %s" % (fn, "; ".join(byf[fn])))
except Exception:
traceback.print_exc()
@@ -0,0 +1,58 @@
"""Q1 recon: the itemState enum table and the club?type= strings.
HYPOTHESIS: the itemState enum table at 0x180229d20 (stride 0x10, 10 entries) is
referenced by (a) a string->enum mapper in the deserializer and (b) an equip path
that WRITES activeBadge/activeHomeKit/... The equip path is the place most likely
to switch on cardsubtypeid for cardtype 9.
CONTROL: the table dump itself. The doc states the ten names; if the dump does not
reproduce WAITING_FOR_GAME, inGame, forSale, offered, activeBadge, activeHomeKit,
activeAwayKit, activeBall, activeStadium, active in that order, my table read is
wrong and every conclusion downstream is void.
Also: locate the literals for club?type= singular names (stadium/ball/equippables)
and the family caption keys, with occurrence counts, so later queries can pick a
unique anchor.
"""
import traceback
try:
print("=== A: itemState enum table 0x180229d20, stride 0x10, 14 entries ===")
T = 0x180229D20
for i in range(14):
e = T + i * 0x10
q0 = qword(e)
q1 = qword(e + 8)
s = ""
if 0x180000000 <= q0 < 0x181000000:
try:
s = rd_str(q0, 64)
except Exception:
s = "?"
print(" [%2d] %#x: q0=%#018x %-24r q1=%#x" % (i, e, q0, s, q1))
print()
print("=== B: xrefs to the table start and to each row ===")
for i in range(12):
e = T + i * 0x10
xs = xrefs_to(e)
if xs:
print(" row %d @%#x:" % (i, e))
for frm, typ, fn, ent in xs:
print(" from %#x %s in %s(%#x)" % (frm, typ, fn, ent))
print()
print("=== C: string literals of interest, all occurrences ===")
pats = [
b"activeBadge", b"activeHomeKit", b"activeAwayKit", b"activeBall",
b"activeStadium", b"itemState", b"forSale", b"inGame",
b"equippables", b"stadium", b"Stadium", b"ball", b"Ball",
b"badge", b"Badge", b"kit", b"Kit", b"clubLogo", b"leagueLogo",
b"CLUBLOGO", b"LEAGUELOGO", b"BADGE", b"STADIUM", b"BALL", b"KIT",
]
for p in pats:
hits = find_all(p)
print(" %-16r n=%d %s" % (p.decode(), len(hits),
" ".join("%#x" % h for h in hits[:12])))
except Exception:
traceback.print_exc()
@@ -0,0 +1,56 @@
"""Q10: the fcc_ table vocabulary and the classifier's neighbourhood.
Q8/Q9 changed the picture: inside the item deserializer, cardtype 9 items whose
cardsubtypeid is in [0x91,0x95) take a custom-image path, and a SEPARATE
deserializer FUN_180108c00 computes subtype = wireValue + 0x91 and then picks the
loc format by range:
0x91 <= s < 0x95 -> "TOURNY_LOC_%d"
0x95 <= s < 0x97 -> "SEASON_LOC_%d"
so 0x91..0x96 look like TROPHIES, not badges/kits/stadia/balls. Also, cardtype 7
(subtypes 9,10,11) has an arm that defaults a field to 0x23 = 35, and 35 is the
kit cardassetid recorded in tools/fut_clubitems.py.
This query gathers the vocabulary needed to test that:
A. every "fcc_" table name literal in the binary, with the function that queries
it -- the merge's per-family table map;
B. every literal starting "cardsubtype" / "cardtype" (column names);
C. the small helpers around the classifier: FUN_1800d84e0 (called right after it
in the deser), FUN_1800d7b30/b50/af0/b10, FUN_1800d7170.
CONTROL: "fcc_discardcoins" must appear in A, and its query site must be
FUN_18013fe00 (line 784 of the Q8 decompile). If it does not, the literal scan is
not seeing the same code the decompiler is.
"""
import traceback
try:
print("=== A: fcc_ table literals ===")
seen = set()
for h in find_all(b"fcc_"):
s = rd_str(h, 64)
if not s or s in seen:
continue
seen.add(s)
xs = xrefs_to(h)
who = ",".join(sorted({"%s(%#x)" % (fn, ent) for _f, _t, fn, ent in xs}))
print(" %#x %-28r <- %s" % (h, s, who or "-"))
print(" total distinct: %d" % len(seen))
print()
print("=== B: cardtype / cardsubtype column literals ===")
for pat in (b"cardtype", b"cardsubtype", b"carddbid", b"cardassetid"):
for h in find_all(pat):
s = rd_str(h, 64)
xs = xrefs_to(h)
who = ",".join(sorted({"%s(%#x)" % (fn, ent) for _f, _t, fn, ent in xs}))
print(" %#x %-28r <- %s" % (h, s, who or "-"))
print()
print("=== C: helpers ===")
for a in (0x1800D84E0, 0x1800D7B30, 0x1800D7B50, 0x1800D7AF0, 0x1800D7B10):
src = dec(a)
print("-" * 70)
print("FUN_%x len=%d" % (a, len(src)))
print(src if len(src) < 2500 else src[:2500] + "\n...[TRUNCATED, len above]")
except Exception:
traceback.print_exc()
@@ -0,0 +1,37 @@
"""Q11: dump the candidate functions to files for local analysis.
Rationale: the interesting functions are 3k-27k chars each and printing them all to
the transcript is wasteful. Write each decompile to
/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/dec/FUN_<addr>.c and print only name+length here.
Set chosen from Q10:
FUN_180098f20 queries fcc_leaguelogos AND uses carddbid + cardassetid
FUN_180098560 / FUN_1800989f0 / FUN_180042440 / FUN_180043350 fcc_myclubscategories
FUN_1800991a0 fcc_myclubs
FUN_180141660 the merge (carddbid)
FUN_18011a860 / FUN_1801356c0 / FUN_1801362e0 other carddbid users
FUN_18013fe00 the shared item deserializer (full, for local grep)
FUN_18011e9d0 the <0x95 callback from Q9
FUN_18013af30 the remaining scan hit
CONTROL: FUN_18013fe00 must come out at 26234 chars, the length Q8 measured. A
different length means a different function or a different decompiler setting.
"""
import os
import traceback
OUT = ("/tmp/claude-1000/-home-alex-Documents-OpenFUT/"
"8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/dec")
try:
os.makedirs(OUT, exist_ok=True)
for a in (0x180098F20, 0x180098560, 0x1800989F0, 0x180042440, 0x180043350,
0x1800991A0, 0x180141660, 0x18011A860, 0x1801356C0, 0x1801362E0,
0x18013FE00, 0x18011E9D0, 0x18013AF30, 0x180096670, 0x1801017E0):
src = dec(a)
p = os.path.join(OUT, "FUN_%x.c" % a)
with open(p, "w") as f:
f.write(src)
print(" %-14s len=%d -> %s" % ("FUN_%x" % a, len(src), p))
except Exception:
traceback.print_exc()
@@ -0,0 +1,76 @@
"""Q12: the UI group tables around 0x180203260 and every family caption key.
Known: consumables group table at 0x180203260 (7 rows, stride 0x18, indexed by the
switch in FUN_180096670 case 0xb) and staff at 0x180203310 (5 rows, case 8). The
club-item claim "there is no equivalent table" is exactly the kind of absence this
project keeps getting wrong, so walk the WHOLE region 0x180203100..0x180203700 as
stride-0x18 triples and print anything string-shaped, then xref each candidate
table start.
Also print every .rdata literal containing BADGE / STADIUM / BALL / KIT / LOGO /
TROPHY (upper case, i.e. loc keys) with its xrefs. Q4 of the brief.
CONTROL: the consumables table at 0x180203260 must come out as the seven rows
already recorded (TRAINING/CONTRACT/FITNESS/HEALING/PLAYSTYLE/MANAGER_LEAGUE/
TACTIC_TRAINING with codes 0,1,4,3,0x17,0x18,0x11). If the walk does not reproduce
it, the stride/layout assumption is wrong and nothing else in this query counts.
"""
import traceback
def walk(lo, hi, stride):
a = lo
while a < hi:
cells = []
for k in range(0, stride, 8):
try:
q = qword(a + k)
except Exception:
q = 0
s = ""
if 0x180000000 <= q < 0x181000000:
try:
t = rd_str(q, 80)
if t and all(0x20 <= ord(c) < 0x7F for c in t):
s = t
except Exception:
pass
cells.append("%#x%s" % (q, (" %r" % s) if s else ""))
print(" %#x %s" % (a, " | ".join(cells)))
a += stride
try:
print("=== stride-0x18 walk 0x180203200..0x180203460 ===")
walk(0x180203200, 0x180203460, 0x18)
print()
print("=== xrefs to plausible table starts ===")
for a in range(0x180203200, 0x180203460, 8):
xs = xrefs_to(a)
if xs:
print(" %#x:" % a)
for frm, typ, fn, ent in xs:
print(" %#x %s in %s(%#x)" % (frm, typ, fn, ent))
print()
print("=== upper-case family loc keys ===")
seen = set()
for pat in (b"BADGE", b"STADIUM", b"BALL", b"KIT", b"LOGO", b"TROPHY"):
for h in find_all(pat):
# walk back to the start of the C string
p = h
for _ in range(80):
try:
if mem.getByte(addr(p - 1)) & 0xFF == 0:
break
except Exception:
break
p -= 1
s = rd_str(p, 120)
if p in seen or len(s) < 4:
continue
seen.add(p)
xs = xrefs_to(p)
who = ",".join(sorted({"%s(%#x)" % (fn, ent) for _f, _t, fn, ent in xs}))
print(" %#x %-52r <- %s" % (p, s, who or "-"))
except Exception:
traceback.print_exc()
@@ -0,0 +1,55 @@
"""Q13: every FUT_MYCLUB_ loc key, and the table row that carries it.
Q12 reproduced the consumables table (control passed) and showed the staff table's
middle column IS the cardtype (manager 2, headcoach 3, fitnesscoach 4, gkcoach 0xa,
physio 5 -- exactly the merge's switch arms), and a trophies pair:
0x180203380 {0x05, 0, FUT_MYCLUB_OFFLINE_TROPHIES_EARNED}
0x180203398 {0x15, 1, FUT_MYCLUB_ONLINE_TROPHIES_EARNED}
If a badges/kits/stadia/balls row exists in the same shape, its middle column is the
answer. Enumerate EVERY FUT_MYCLUB_ literal, find the pointer to it in .rdata/.data,
and print the 0x18-byte row it sits in for all three possible cell positions, plus
the rows either side.
CONTROL: FUT_MYCLUB_CONSUMABLES_TRAINING_EARNED must resolve to the row
{0, ptr, 'training'} at 0x180203260. Any layout guess that cannot reproduce that row
is wrong.
"""
import traceback
try:
keys = []
for h in find_all(b"FUT_MYCLUB_"):
s = rd_str(h, 120)
keys.append((h, s))
keys.sort()
print("=== %d FUT_MYCLUB_ literals ===" % len(keys))
for h, s in keys:
print(" %#x %r" % (h, s))
print()
print("=== pointer rows ===")
for h, s in keys:
ptrs = find_all(h.to_bytes(8, "little"), blocks=(".rdata", ".data"))
if not ptrs:
print(" %-46r no pointer" % s)
continue
for pa in ptrs:
ctx = []
for off in (-0x18, -0x10, -8, 0, 8, 0x10, 0x18):
try:
q = qword(pa + off)
except Exception:
continue
t = ""
if 0x180000000 <= q < 0x181000000:
try:
u = rd_str(q, 80)
if u and all(0x20 <= ord(c) < 0x7F for c in u):
t = u
except Exception:
pass
ctx.append("%+#5x:%#x%s" % (off, q, (" %r" % t) if t else ""))
print(" %-46r @%#x" % (s, pa))
print(" " + " ".join(ctx))
except Exception:
traceback.print_exc()
@@ -0,0 +1,41 @@
"""Q14: the club URL format strings and their builders.
Q6 found 'type=%s' at 0x180224c7a and 0x180224ff9 with no direct xref, which means
each is the TAIL of a longer literal whose start is what the code references. Dump
every C string in 0x180224a00..0x180225300 and 0x18021e200..0x18021e800 with xrefs,
so the club request builder can be identified and decompiled.
Also dump 0x180228400..0x18022b200 for the transfermarket/club parameter strings.
CONTROL: '&cat=%s' at 0x1802285b8 is already known to be referenced by
FUN_180162c90; it must show that xref here too.
"""
import traceback
def dump(lo, hi, tag):
print("=== %s %#x..%#x ===" % (tag, lo, hi))
p = lo
while p < hi:
try:
b = mem.getByte(addr(p)) & 0xFF
except Exception:
p += 1
continue
if 0x20 <= b < 0x7F:
s = rd_str(p, 160)
if len(s) >= 3:
who = ",".join(sorted({"%s(%#x)" % (fn, ent)
for _f, _t, fn, ent in xrefs_to(p)}))
print(" %#x %-66r %s" % (p, s, who))
p += max(1, len(s)) + 1
else:
p += 1
try:
dump(0x180224A00, 0x180225300, "club/url block")
dump(0x18021E200, 0x18021E800, "route table")
dump(0x180228400, 0x180229000, "params block")
except Exception:
traceback.print_exc()
@@ -0,0 +1,67 @@
"""Q15: find the equip path by the atoms it must name.
The itemState vocabulary is also in the atom table:
0xc activeAwayKit 0xd activeBadge 0xe activeBall 0x10 activeHomeKit
0x12 activeStadium 0xa active 0x12e free 0x164 inGame 0x1e5 offered
and the club ?type= taxonomy switch FUN_18012ec50 shows how a name reaches the wire:
FUN_180180cd0(atom) returns the atom's name string. So whatever chooses which of the
five active* states to send must call FUN_180180cd0 with 0xc/0xd/0xe/0x10/0x12, and
the choice is made from the item's family. That is the mapping the brief wants.
Method: enumerate every caller of FUN_180180cd0, decompile each once, and report the
call sites whose literal argument is one of the atoms of interest:
equip states 0xc 0xd 0xe 0x10 0x12
club families 0x49 badge, 0x179 kit, 0x2d8 stadium, 0x4d ball,
0x18d leaguelogos, 0x10a equippables, 0x4b badges, 0x4f balls,
0x17c kits, 0x18e leagueLogos, 0x2d7 stadia
Print the matching lines with context so the surrounding switch is visible.
CONTROL: FUN_18012ec50 is a known caller and must be reported with its family atoms
(0x49, 0x179, 0x2d8, 0x4d, 0x18d, 0x10a). If it is not in the output, the caller
enumeration or the literal matching is broken.
"""
import re
import traceback
WANT = {0xC: "activeAwayKit", 0xD: "activeBadge", 0xE: "activeBall",
0x10: "activeHomeKit", 0x12: "activeStadium", 0xA: "active",
0x12E: "free", 0x164: "inGame", 0x1E5: "offered",
0x49: "badge", 0x179: "kit", 0x2D8: "stadium", 0x4D: "ball",
0x18D: "leaguelogos", 0x10A: "equippables", 0x4B: "badges",
0x4F: "balls", 0x17C: "kits", 0x18E: "leagueLogos", 0x2D7: "stadia"}
try:
ents = {}
for frm, typ, fn, ent in xrefs_to(0x180180CD0):
if ent:
ents[ent] = fn
print("callers of FUN_180180cd0: %d" % len(ents))
pat = re.compile(r"FUN_180180cd0\((0x[0-9a-f]+|\d+)\)")
nhit = 0
for ent, fn in sorted(ents.items()):
src = dec(ent)
lines = src.splitlines()
found = []
for i, l in enumerate(lines):
for m in pat.finditer(l):
v = int(m.group(1), 0)
if v in WANT:
found.append((i, v))
if not found:
continue
nhit += 1
print("=" * 70)
print("%s @%#x len=%d atoms=%s" %
(fn, ent, len(src),
sorted({"%#x=%s" % (v, WANT[v]) for _i, v in found})))
shown = set()
for i, _v in found:
for j in range(max(0, i - 4), min(len(lines), i + 2)):
if j in shown:
continue
shown.add(j)
print(" %4d: %s" % (j, lines[j].strip()))
print(" ---")
print("functions with hits: %d" % nhit)
except Exception:
traceback.print_exc()
@@ -0,0 +1,35 @@
"""Q16: the equip path -- callers of the itemState serializer.
Q15 found FUN_18012ee20: itemState code -> atom name, with
1->free 2->inGame 5->0x1f8 6->offered 100->activeBadge 0x65->activeHomeKit
0x66->activeAwayKit 0x67->activeBall 0x68->activeStadium 0xff->active
Whoever CALLS it with 0x64..0x68 is the equip path, and the code that picks which of
those five to pass must know the item's family.
Dump: every caller of FUN_18012ee20 in full, plus FUN_18012ddf0 (the club URL
builder) in full, plus FUN_18012ec50's caller chain context.
CONTROL: FUN_18012ddf0 must contain the five-way if/else on *(param_1+0x30) that
Q15 printed (0xa badge, 0xb kit, 0x15 stadium, 0x16 ball, else equippables). If the
full decompile lacks it, this is not the same function.
"""
import traceback
try:
ents = {}
for frm, typ, fn, ent in xrefs_to(0x18012EE20):
if ent:
ents[ent] = fn
print("callers of FUN_18012ee20 (itemState->atom): %d -> %s" %
(len(ents), ["%s(%#x)" % (v, k) for k, v in ents.items()]))
for ent in sorted(ents):
src = dec(ent)
print("=" * 78)
print("CALLER %s @%#x len=%d" % (ents[ent], ent, len(src)))
print(src)
print("=" * 78)
src = dec(0x18012DDF0)
print("CLUB URL BUILDER FUN_18012ddf0 len=%d" % len(src))
print(src)
except Exception:
traceback.print_exc()
@@ -0,0 +1,58 @@
"""Q17: enumerate EVERY switch case label in the binary, then find the ones that
distinguish club subtypes.
Q5's scalar scan failed its control because jump-table case labels are not
instruction immediates. Ghidra, however, names them: it creates symbols of the form
switchD_<addr>_caseD_<n> (and caseD_<n>) at each case target. Walking the symbol
table therefore enumerates switch dispatch in the one form a scalar scan cannot see.
Report every function whose case-value set intersects the club-subtype candidates
{0x1e,0x1f,9,10,11,0x91..0x96} and print the full case set for each.
CONTROL: FUN_1800d8330 must appear with case labels including 0x1e, 0x1f, 0x91..0x96,
0xe7..0xe9 and 0xec. If it does not, the symbol-based enumeration is broken and no
absence claim may be made from it.
"""
import re
import traceback
try:
st = prog.getSymbolTable()
it = st.getAllSymbols(True)
pat = re.compile(r"caseD_([0-9a-fA-F]+)$")
per = {}
n = 0
while it.hasNext():
s = it.next()
m = pat.search(s.getName())
if not m:
continue
n += 1
try:
v = int(m.group(1), 16)
except ValueError:
continue
f = fm.getFunctionContaining(s.getAddress())
key = (f.getName(), int(f.getEntryPoint().getOffset())) if f else ("?", 0)
per.setdefault(key, set()).add(v)
print("case labels found: %d in %d functions" % (n, len(per)))
CAND = {0x1E, 0x1F, 9, 10, 11, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96}
print()
print("=== functions whose case set meets the club-subtype candidates ===")
rows = []
for (name, ent), vals in per.items():
inter = vals & CAND
if len(inter) >= 2:
rows.append((len(inter), name, ent, vals))
rows.sort(reverse=True)
for k, name, ent, vals in rows:
print(" %-26s %#x hits=%d cases=%s" %
(name, ent, k, sorted("%#x" % v for v in vals)))
print()
print("=== control: FUN_1800d8330 ===")
for (name, ent), vals in per.items():
if ent == 0x1800D8330:
print(" YES cases=%s" % sorted("%#x" % v for v in vals))
except Exception:
traceback.print_exc()
@@ -0,0 +1,33 @@
"""Q18: FUN_1800fed90 -- a switch whose case set is EXACTLY {0x91..0x96}.
Q17's case-label enumeration (control passed on FUN_1800d8330) found exactly one
function whose switch discriminates the six high club subtypes and nothing else:
FUN_1800fed90. If 0x91..0x96 are trophies, this is where each one is turned into a
concrete thing, and the six arms should be distinguishable.
Also decompile FUN_1800f4bc0 and FUN_1800f2f70 (case sets 0xa..0x14, i.e. they
distinguish 10 and 11, the other two cardtype-7 subtypes) and FUN_1800d8260 /
FUN_1800d86c0 / FUN_1800d8b50 (small enum->string helpers next to the classifier).
CONTROL: FUN_1800d8b50 is called by the club URL builder FUN_18012ddf0 to render a
value for query key atom 0x243; it should decompile to a code->string table, which
is a known shape. If it comes out as something else, my reading of the URL builder
is wrong.
"""
import traceback
try:
for a in (0x1800FED90, 0x1800F4BC0, 0x1800F2F70, 0x1800D8260, 0x1800D86C0,
0x1800D8B50):
src = dec(a)
print("=" * 78)
print("FUN_%x len=%d" % (a, len(src)))
print(src if len(src) < 9000 else src[:9000] + "\n...[cut at 9000, len above]")
print("=" * 78)
print("=== callers ===")
for a in (0x1800FED90, 0x1800F4BC0):
print(" callers of %#x:" % a)
for frm, typ, fn, ent in xrefs_to(a):
print(" %s(%#x) via %#x %s" % (fn, ent, frm, typ))
except Exception:
traceback.print_exc()
@@ -0,0 +1,89 @@
"""Q19: every constant the code compares a +0x50 (cardsubtypeid) or +0x4c (cardtype)
field against.
The parsed item record has cardsubtypeid at +0x50 and cardtype at +0x4c. Instead of
searching for a constant (which misses jump tables) or for a syntactic form (which
misses != and ladders), search for the FIELD ACCESS and then collect every immediate
that touches the loaded register within the next 8 instructions, whatever the
mnemonic. Both the direct form (CMP dword [reg+0x50], imm) and the load-then-test
form (MOV r32,[reg+0x50]; SUB r32,imm; CMP r32,imm) are covered.
CONTROL: FUN_18011e3c0 is known to do `*(int *)(x + 0x50) - 0x91U < 6`, so it must
appear with 0x91 (and 6) attached to a +0x50 access. If the control is absent the
scan is broken and nothing may be concluded from what it does not find.
"""
import traceback
try:
block = None
for b in mem.getBlocks():
if b.getName() == ".text":
block = b
break
per = {}
it = listing.getInstructions(block.getStart(), True)
window = [] # [(reg_name, remaining_instrs)]
n = 0
while it.hasNext():
ins = it.next()
n += 1
txt = str(ins)
# 1) direct: memory operand with disp 0x50/0x4c and an immediate
for disp in ("0x50", "0x4c"):
if ("+ " + disp + "]") in txt or ("+" + disp + "]") in txt:
imms = []
for i in range(ins.getNumOperands()):
for o in ins.getOpObjects(i):
try:
imms.append(int(o.getValue()))
except Exception:
pass
f = fm.getFunctionContaining(ins.getAddress())
key = (f.getName(), int(f.getEntryPoint().getOffset())) if f else ("?", 0)
rec = per.setdefault(key, {"direct": set(), "near": set()})
for v in imms:
if v not in (0x50, 0x4C) and 0 <= v < 0x1000:
rec["direct"].add((disp, v))
# start a window: whatever register this instruction defines
for r in ins.getResultObjects():
window.append([str(r), 8, key, disp])
# 2) decay window and attach immediates that touch the tracked register
nxt = []
for w in window:
reg, left, key, disp = w
if left <= 0:
continue
if reg in txt:
for i in range(ins.getNumOperands()):
for o in ins.getOpObjects(i):
try:
v = int(o.getValue())
except Exception:
continue
if 0 <= v < 0x1000:
per.setdefault(key, {"direct": set(), "near": set()})
per[key]["near"].add((disp, v))
w[1] = left - 1
nxt.append(w)
window = nxt[-40:]
print("instructions scanned: %d" % n)
print()
CAND = {9, 10, 11, 0x1E, 0x1F, 7, 0x91}
print("=== functions whose +0x50 / +0x4c constants meet {9,10,11,0x1e,0x1f,7,0x91} ===")
for (name, ent), rec in sorted(per.items()):
vals = rec["direct"] | rec["near"]
hit = {v for _d, v in vals} & CAND
if not hit:
continue
print(" %-24s %#x hits=%s" % (name, ent, sorted("%#x" % h for h in hit)))
print(" direct=%s" % sorted("%s:%#x" % (d, v) for d, v in rec["direct"]))
print(" near =%s" % sorted("%s:%#x" % (d, v) for d, v in rec["near"])[:40])
print()
print("=== control FUN_18011e3c0 ===")
for (name, ent), rec in per.items():
if ent == 0x18011E3C0:
print(" direct=%s" % sorted("%s:%#x" % (d, v) for d, v in rec["direct"]))
print(" near =%s" % sorted("%s:%#x" % (d, v) for d, v in rec["near"]))
except Exception:
traceback.print_exc()
@@ -0,0 +1,85 @@
"""Q2: two string clusters that look like family-name tables.
Q1 found 'badge' 0x18022a220, 'kit' 0x18022a228, 'leagueLogo' 0x18022a230,
'ball' 0x18022a278 packed together, and a second cluster 'badge' 0x1802303c8,
'ball' 0x1802303dc, 'equippables' 0x180230f48, 'leagueLogo' 0x180231608.
HYPOTHESIS: cluster 1 is the value list of a {name -> code} enum table like the
itemState one (stride 0x10: char* then int). Cluster 2 is the club?type= route
vocabulary.
CONTROL: the itemState table itself. My Q1 read started mid-table (row0 =
activeBadge with code 0x64, while the doc's list starts at WAITING_FOR_GAME), so
this query re-walks BACKWARDS from 0x180229d20 to find the real table start and
prints it in full. If the ten documented names do not appear in order, my table
walker is wrong.
Then: for every string in each cluster, find the .rdata qword that points at it
(the table row) and print the row's neighbours, plus xrefs.
"""
import traceback
def dump_strings(lo, hi, label):
print("=== strings %s %#x..%#x ===" % (label, lo, hi))
p = lo
while p < hi:
try:
b = mem.getByte(addr(p)) & 0xFF
except Exception:
p += 1
continue
if 0x20 <= b < 0x7F:
s = rd_str(p, 96)
if len(s) >= 2:
print(" %#x %r" % (p, s))
p += max(1, len(s)) + 1
else:
p += 1
def walk_table(start, n, back=0):
print("--- table walk from %#x, %d rows (stride 0x10) ---" % (start, n))
for i in range(-back, n):
e = start + i * 0x10
try:
q0, q1 = qword(e), qword(e + 8)
except Exception:
continue
s = ""
if 0x180000000 <= q0 < 0x181000000:
try:
s = rd_str(q0, 64)
except Exception:
s = "?"
print(" [%3d] %#x ptr=%#x %-26r val=%#x" % (i, e, q0, s, q1))
try:
walk_table(0x180229D20, 8, back=14)
print()
dump_strings(0x18022A200, 0x18022A380, "cluster1")
print()
dump_strings(0x180230300, 0x180230420, "cluster2a")
print()
dump_strings(0x180230F00, 0x180230FA0, "cluster2b")
print()
dump_strings(0x180231380, 0x180231680, "cluster2c")
print()
print("=== xrefs / pointer-rows for cluster strings ===")
for name, a in [("badge", 0x18022A220), ("kit", 0x18022A228),
("leagueLogo", 0x18022A230), ("ball", 0x18022A278),
("badge2", 0x1802303C8), ("ball2", 0x1802303DC),
("equippables", 0x180230F48), ("leagueLogo2", 0x180231608),
("itemState", 0x180231490)]:
print(" %s @%#x" % (name, a))
for frm, typ, fn, ent in xrefs_to(a):
print(" xref from %#x %s in %s(%#x)" % (frm, typ, fn, ent))
ptrs = find_all(a.to_bytes(8, "little"), blocks=(".rdata", ".data"))
for pa in ptrs[:8]:
print(" ptr-row @%#x next-q=%#x prev-q=%#x" %
(pa, qword(pa + 8), qword(pa - 8)))
for frm, typ, fn, ent in xrefs_to(pa):
print(" row xref %#x %s in %s(%#x)" % (frm, typ, fn, ent))
except Exception:
traceback.print_exc()
@@ -0,0 +1,26 @@
"""Q20: the functions that test cardtype==7 / cardsubtypeid in {9,10,11}.
Q19 (control passed: it recovered FUN_18011e3c0's 0x91/0x94/0x96 on a +0x50 field)
flagged:
FUN_1800f6c40 direct [+0x4c]==7 AND [+0x50]==9
FUN_180084720 direct [+0x50]==9 and [+0x50]==0xb
FUN_180094580 near [+0x50] 9 / 0xb / 3
FUN_18015fa80 direct [+0x50]==9
FUN_1801362e0 direct [+0x4c] 1 / 2 / 7
Decompile each. Whatever these do with subtypes 9/10/11 is the club-family
behaviour, and a loc key or asset id in any arm names the family.
CONTROL: FUN_1801362e0 is one of the merge's arms (called from FUN_180141660 case 2,
the manager arm) so it must be a DB lookup on carddbid; if it is not, the +0x4c
attribution is on a different struct and these hits are noise.
"""
import traceback
try:
for a in (0x1800F6C40, 0x180084720, 0x180094580, 0x18015FA80, 0x1801362E0):
src = dec(a)
print("=" * 78)
print("FUN_%x len=%d" % (a, len(src)))
print(src if len(src) < 12000 else src[:12000] + "\n...[cut, len above]")
except Exception:
traceback.print_exc()
@@ -0,0 +1,47 @@
"""Q21: the FUT data-manager vtable slots that name a club item.
FUN_1800f6c40 (the pack/award tile builder) does:
if (item+0x4c == 1) -> ITEM_RARITY / ITEM_LEVEL
else if (item+0x50 == 9) -> "IS_KIT_%d" = 1 <-- names subtype 9
name = mgr->vt[0x490](out, item+0x4c cardtype, item+0x50 subtype, item+0x18)
if (name empty && item+0x4c == 7)
name = mgr->vt[0x498](out, item+0x50 subtype, item+0x94 teamid, item+0x20)
where mgr = FUN_18011a830(). Slots 0x490 and 0x498 are therefore the club-item name
resolvers and must switch on the subtype.
Resolve the manager's vtable, then decompile slots 0x490, 0x498, 0xa08, 0xa38, 0xa40.
CONTROL: slot 0xa08 is the one the item deserializer calls to file a parsed item
(FUN_18013fe00 line 825), and slot 0xa40 is the lookup FUN_18011e3c0 uses with a
resourceId. If the resolved vtable's 0xa08/0xa40 are not functions, the vtable
resolution is wrong.
"""
import traceback
try:
src = dec(0x18011A830)
print("=== FUN_18011a830 (manager accessor) len=%d ===" % len(src))
print(src)
# find the vtable it installs / the object's class
print()
print("=== candidate vtables referenced from FUN_18011a830 and its callees ===")
f = func(0x18011A830)
cands = set()
for ad in f.getBody().getAddresses(True):
ins = listing.getInstructionAt(ad)
if ins is None:
continue
for r in ins.getReferencesFrom():
t = int(r.getToAddress().getOffset())
if 0x1801E5000 <= t <= 0x1802891FF:
cands.add(t)
for t in sorted(cands):
try:
v0, v1 = qword(t), qword(t + 8)
except Exception:
continue
print(" %#x -> %#x %#x (%s / %s)" %
(t, v0, v1, fname(v0) if 0x180000000 <= v0 < 0x181000000 else "-",
fname(v1) if 0x180000000 <= v1 < 0x181000000 else "-"))
except Exception:
traceback.print_exc()
@@ -0,0 +1,31 @@
"""Q22: resolve the manager object's vtable through the global DAT_1802e6398.
FUN_18011a830 just returns DAT_1802e6398, so the vtable is installed wherever that
global is written. Find the writers, decompile the smallest, and read the vtable it
stores. Then dump slots 0x490 / 0x498 / 0xa08 / 0xa38 / 0xa40.
CONTROL: the recovered vtable's slot 0xa08 and 0xa40 must both be real functions
(the item deserializer calls 0xa08 to file an item; FUN_18011e3c0 calls 0xa40 with a
resourceId). If either is not a function, the vtable is wrong.
"""
import traceback
try:
print("=== writers/readers of DAT_1802e6398 ===")
ents = {}
for frm, typ, fn, ent in xrefs_to(0x1802E6398):
ents.setdefault(ent, []).append((frm, typ, fn))
for ent, lst in sorted(ents.items()):
print(" %s(%#x) n=%d types=%s" %
(lst[0][2], ent, len(lst), sorted({t for _f, t, _n in lst})))
# the constructor is a function that WRITES it
writers = [e for e, lst in ents.items()
if any(t == "WRITE" for _f, t, _n in lst)]
print("writers: %s" % ["%#x" % w for w in writers])
for w in writers:
src = dec(w)
print("=" * 70)
print("writer FUN_%x len=%d" % (w, len(src)))
print(src if len(src) < 6000 else src[:6000] + "\n...[cut]")
except Exception:
traceback.print_exc()
@@ -0,0 +1,40 @@
"""Q23: callers of the manager setter FUN_18011d780 -> the manager's vtable.
CONTROL: the vtable found must have real functions at slots 0xa08 and 0xa40.
"""
import traceback
try:
for frm, typ, fn, ent in xrefs_to(0x18011D780):
print("caller %s(%#x) via %#x %s" % (fn, ent, frm, typ))
ents = {ent for _f, _t, _n, ent in xrefs_to(0x18011D780) if ent}
for ent in ents:
src = dec(ent)
print("=" * 70)
print("FUN_%x len=%d" % (ent, len(src)))
print(src if len(src) < 7000 else src[:7000] + "\n...[cut]")
f = func(ent)
cands = set()
for ad in f.getBody().getAddresses(True):
ins = listing.getInstructionAt(ad)
if ins is None:
continue
for r in ins.getReferencesFrom():
t = int(r.getToAddress().getOffset())
if 0x1801E5000 <= t <= 0x1802891FF:
cands.add(t)
print("--- .rdata refs, checked for vtable shape ---")
for t in sorted(cands):
try:
v0, v1 = qword(t), qword(t + 8)
s90, s98 = qword(t + 0x490), qword(t + 0x498)
a08, a40 = qword(t + 0xA08), qword(t + 0xA40)
except Exception:
continue
ok = all(fm.getFunctionAt(addr(x)) is not None
for x in (v0, v1) if 0x180000000 <= x < 0x181000000)
print(" %#x v0=%s v1=%s | +0x490=%s +0x498=%s +0xa08=%s +0xa40=%s%s" %
(t, fname(v0), fname(v1), fname(s90), fname(s98),
fname(a08), fname(a40), " <== VTABLE?" if ok else ""))
except Exception:
traceback.print_exc()
@@ -0,0 +1,31 @@
"""Q24: FUN_180119bd0 -- the cardtype-7 name resolver. This should BE the mapping.
The manager vtable was read out of the live process (read-only): DAT_1802e6398 ->
object -> vtable static 0x18021c2a0, with
+0x490 = FUN_18011a860 (cardtype switch 1,2,3,4,5,10 -- no club arm)
+0x498 = FUN_180119bd0 (called ONLY when +0x490 returned empty AND cardtype==7,
with args (cardsubtypeid, teamid, assetId))
+0xa08 = FUN_18011cca0 (file a parsed item)
+0xa38 = FUN_180113e40 (register trophy: (tournamentId, subtype, name))
+0xa40 = FUN_18011bf40 (lookup by resourceId)
Decompile 0x498, 0xa38, 0xa40 and 0xa08.
CONTROL: FUN_18011a860 must be the same function Q11 dumped (12905 chars) with the
cardtype switch; that is what makes the 0x498 fallback meaningful.
"""
import traceback
try:
for a, tag in [(0x180119BD0, "+0x498 club-item name resolver"),
(0x180113E40, "+0xa38 trophy register"),
(0x18011BF40, "+0xa40 lookup by resourceId"),
(0x18011CCA0, "+0xa08 file parsed item")]:
src = dec(a)
print("=" * 78)
print("%s FUN_%x len=%d" % (tag, a, len(src)))
print(src if len(src) < 14000 else src[:14000] + "\n...[cut, len above]")
print("=" * 78)
print("control: FUN_18011a860 len=%d" % len(dec(0x18011A860)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,53 @@
"""Q25: is there a cardtype-9 sibling of FUN_180119bd0 for balls and league logos?
FUN_180119bd0 settles cardtype 7: 9 -> "FUT_UC_KITS"+TeamName_Abbr15_<teamid>
10 -> "Stadium"+StadiumName_<assetId>
11 -> "Badge"+TeamName_Abbr15_<teamid>
That leaves 0x1e and 0x1f (the only other cardtype-9 subtypes besides trophies
0x91..0x96 and misc 0xe7..0xec) for ball and league logo.
Dump the loc-key string neighbourhood the resolver draws from (0x1801ec700..
0x1801ed400 holds 'Stadium'/'Ball' literals) with xrefs, and xref the exact literals
"Stadium", "Badge", "FUT_UC_KITS" to find any sibling resolver. A function that
references a ball or league-logo caption is the cardtype-9 equivalent.
CONTROL: the literals "Stadium" and "Badge" must show FUN_180119bd0 as an xref. If
they do not, I am looking at different copies of those strings.
"""
import traceback
try:
print("=== atoms 0xd1 and 0x19c (the two club-item wire strings) ===")
for a in (0xD1, 0x19C):
print(" %#x = %d" % (a, a))
print()
print("=== string dump 0x1801ec700..0x1801ed400 ===")
p = 0x1801EC700
while p < 0x1801ED400:
try:
b = mem.getByte(addr(p)) & 0xFF
except Exception:
p += 1
continue
if 0x20 <= b < 0x7F:
s = rd_str(p, 120)
if len(s) >= 3:
who = ",".join(sorted({"%s(%#x)" % (fn, ent)
for _f, _t, fn, ent in xrefs_to(p)}))
print(" %#x %-46r %s" % (p, s, who))
p += max(1, len(s)) + 1
else:
p += 1
print()
print("=== exact-literal xrefs ===")
for lit in (b"Stadium\x00", b"Badge\x00", b"FUT_UC_KITS\x00", b"Ball\x00",
b"BallName", b"LeagueLogo", b"leaguelogo", b"FUT_UC_"):
for h in find_all(lit):
s = rd_str(h, 80)
who = ",".join(sorted({"%s(%#x)" % (fn, ent)
for _f, _t, fn, ent in xrefs_to(h)}))
print(" %-14r %#x %-30r <- %s" % (lit.rstrip(b"\x00").decode(), h, s, who or "-"))
except Exception:
traceback.print_exc()
@@ -0,0 +1,39 @@
"""Q26: FUN_1801bfac0 -- the one function that names ALL the club families.
It references 'Stadium', 'Badge', 'FUT_UC_KITS' and 'FUT_UC_BALL' (and the GK
attribute captions), and Q10 showed it queries fcc_matches. If it switches on
cardsubtypeid it will name the ball subtype, which FUN_180119bd0 (cardtype 7 only)
cannot.
Also dump the string cluster 0x180239000..0x180239180 which holds FUT_UC_BALL,
'Stadium' and 'badge' close together, with xrefs.
CONTROL: FUN_180119bd0 must appear as an xref of 'Stadium' 0x18021ce80 and 'Badge'
0x1802041b8 -- it did in Q25, so the literal identification is sound.
"""
import traceback
try:
src = dec(0x1801BFAC0)
print("=== FUN_1801bfac0 len=%d ===" % len(src))
print(src if len(src) < 20000 else src[:20000] + "\n...[cut, len above]")
print()
print("=== strings 0x180238f80..0x180239200 ===")
p = 0x180238F80
while p < 0x180239200:
try:
b = mem.getByte(addr(p)) & 0xFF
except Exception:
p += 1
continue
if 0x20 <= b < 0x7F:
s = rd_str(p, 120)
if len(s) >= 3:
who = ",".join(sorted({"%s(%#x)" % (fn, ent)
for _f, _t, fn, ent in xrefs_to(p)}))
print(" %#x %-40r %s" % (p, s, who))
p += max(1, len(s)) + 1
else:
p += 1
except Exception:
traceback.print_exc()
@@ -0,0 +1,28 @@
"""Q27: dump FUN_1801bfac0 in FULL to disk (it is >20k chars and was cut in Q26).
It is the card-detail builder and the only function referencing FUT_UC_KITS,
'Stadium', 'Badge' AND FUT_UC_BALL, so its club arms should name every family
including the ball subtype that FUN_180119bd0 (cardtype 7 only) cannot reach.
Also dump FUN_180094580 (references FUT_UC_KITS, and Q19 flagged it testing
item+0x50 against 9 and 0xb) and FUN_180099490 ('Badge').
No truncation: written to files, lengths printed here.
"""
import os
import traceback
OUT = ("/tmp/claude-1000/-home-alex-Documents-OpenFUT/"
"8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/dec")
try:
os.makedirs(OUT, exist_ok=True)
for a in (0x1801BFAC0, 0x180094580, 0x180099490, 0x18015FA80, 0x180102790,
0x1800F6C40, 0x180084720):
src = dec(a)
p = os.path.join(OUT, "FUN_%x.c" % a)
with open(p, "w") as f:
f.write(src)
print(" FUN_%x len=%d" % (a, len(src)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,29 @@
"""Q28: verify the accessor that FUN_1801bfac0 switches on IS cardsubtypeid, and
read the deserializer arms for the club-item string fields.
FUN_1801bfac0 does `iVar5 = FUN_1801a8640(local_78)` and then
iVar5 == 9 -> FUT_UC_KITS (+ FUT_ThirdKit / KitBioAwayDescription variants)
iVar5 == 10 -> "Stadium" + StadiumName_%d + StadiumDetailDesc
iVar5 == 0xb-> "Badge" + TeamName_Abbr15_%d + badgeBioDescription
iVar5 == 0x1e -> "FUT_UC_BALL"
iVar5 == 0x1f -> league-derived id, no generic asset string
iVar5 - 0xe7U < 2 / 0xe9 / 0xec -> misc
That reading only holds if FUN_1801a8640 returns the item's +0x50 cardsubtypeid.
Decompile it and its neighbours FUN_1801a8570 / FUN_1801a8560 / FUN_1801a8020 /
FUN_1801a86a0 / FUN_1801a8800 / FUN_1801a87f0 / FUN_1801a8040.
CONTROL: FUN_1801a86a0 is used in the badge arm as the argument to
TeamName_Abbr15_%d, so it must return the +0x94 teamid. If it returns something
else, my field-offset map for these accessors is wrong.
"""
import traceback
try:
for a in (0x1801A8640, 0x1801A8570, 0x1801A8560, 0x1801A8020, 0x1801A86A0,
0x1801A8800, 0x1801A87F0, 0x1801A8040):
src = dec(a)
print("-" * 70)
print("FUN_%x len=%d" % (a, len(src)))
print(src)
except Exception:
traceback.print_exc()
@@ -0,0 +1,61 @@
"""Q3: walk the whole enum-table block around 0x180229a00..0x180229e00.
Q2 found row @0x180229b50 = {'badge', 0xa}, 0x180229b60 = {'kit', 0xb},
0x180229b70 = {'leagueLogo', 0xc}, and 0x180229c10 = {'ball', 0x16} -- i.e. a
{name -> numeric code} table that NAMES THE CLUB FAMILIES. That is exactly the
mapping the brief asks for, IF the codes are cardsubtypeids.
HYPOTHESIS: one of these tables is the cardsubtypeid vocabulary. Codes 0xa/0xb/0xc
are NOT in the cardtype-9 subtype set (0x1e,0x1f,0x91..0x96), so either it is a
different axis (an "item sub-family" enum) or the mapping is indirect.
CONTROL: the itemState table at 0x180229cc0 (invalid/free/WAITING_FOR_GAME/...)
must reappear intact inside the same walk, with the same codes Q2 printed.
Dump every 0x10 row from 0x180229800 to 0x180229f00, printing ptr, string, value.
Then xref every table start candidate (a row whose predecessor is not a valid
string row) to find the lookup function.
"""
import traceback
try:
LO, HI = 0x180229800, 0x180229F00
rows = []
a = LO
while a < HI:
try:
q0, q1 = qword(a), qword(a + 8)
except Exception:
a += 0x10
continue
s = None
if 0x180000000 <= q0 < 0x181000000:
try:
t = rd_str(q0, 64)
if t and all(0x20 <= ord(c) < 0x7F for c in t):
s = t
except Exception:
pass
rows.append((a, q0, s, q1))
a += 0x10
print("=== enum row walk %#x..%#x ===" % (LO, HI))
prev_ok = False
starts = []
for (a, q0, s, q1) in rows:
mark = ""
ok = s is not None
if ok and not prev_ok:
mark = " <== TABLE START?"
starts.append(a)
prev_ok = ok
print(" %#x ptr=%#018x %-28r val=%#-10x%s" % (a, q0, s or "", q1, mark))
print()
print("=== xrefs to each candidate table start ===")
for a in starts:
print(" start %#x" % a)
for frm, typ, fn, ent in xrefs_to(a):
print(" from %#x %s in %s(%#x)" % (frm, typ, fn, ent))
except Exception:
traceback.print_exc()
@@ -0,0 +1,46 @@
"""Q4: the enum converter helpers and their callers.
Q3 established two request-side vocabularies:
table 0x180229ab0 "subtype filter": any=-1 playerGK=1..physio=9 badge=0xa kit=0xb
leagueLogo=0xc playerTraining=0xd GKTraining=0xe position=0xf playStyle=0x10
managerLeagueModifier=0x11 contract=0x12 fitness=0x13 healing=0x14
stadium=0x15 ball=0x16
table 0x180229c30 "type filter": any=-1 player=1 staff=2 clubInfo=3 training=4
development=5 stadium=6 ball=7
table 0x180229cc0 "itemState": invalid=0 free=1 WAITING_FOR_GAME=2 inGame=2
forSale=5 offered=6 activeBadge=0x64 .. activeStadium=0x68 active=0xff
HYPOTHESIS: the converter functions FUN_180166300 (subtype), FUN_180166340 (type),
FUN_180166660 (itemState) are string<->code helpers; their CALLERS are the request
builder and the equip path. The equip path must choose 0x64..0x68 from the item, and
that choice is the subtype->family mapping we want.
CONTROL: FUN_1800d8330, decompiled in full here, must reproduce the documented
cardtype-9 subtype set {0x1e,0x1f,0x91..0x96,0xe7..0xe9,0xec}. If it does not, my
project copy is not the analysed one.
"""
import traceback
try:
for a, tag in [(0x1800D8330, "CONTROL FUN_1800d8330 cardsubtype->cardtype"),
(0x180166300, "subtype-enum helper"),
(0x180166340, "type-enum helper"),
(0x180166660, "itemState helper A"),
(0x1801666F0, "itemState/other helper B"),
(0x180166790, "helper C")]:
src = dec(a)
print("=" * 78)
print("%s @%#x len=%d" % (tag, a, len(src)))
print(src)
print("=" * 78)
print("=== callers ===")
for a in (0x180166300, 0x180166340, 0x180166660, 0x1801666F0, 0x180166790):
print(" callers of %#x:" % a)
seen = set()
for frm, typ, fn, ent in xrefs_to(a):
if ent in seen:
continue
seen.add(ent)
print(" %s(%#x) via %#x %s" % (fn, ent, frm, typ))
except Exception:
traceback.print_exc()
@@ -0,0 +1,69 @@
"""Q5: who consumes cardtype 9 / the club subtypes?
Two prongs.
A. FUN_180162c90 calls BOTH code->string helpers (type at 0x180162cec, subtype at
0x180163071), so it is the request builder for club?type=...&... Decompile it in
full: it names the query parameters and shows which enum feeds which parameter.
This answers Q3 directly.
B. THE ABSENCE TRAP GUARD. Rather than grep for "== 0x91", scan EVERY instruction in
.text for a scalar operand in the club-subtype set {0x1e,0x1f,0x91..0x96} and
group by containing function, regardless of mnemonic (cmp / sub / mov / lea /
switch-index arithmetic all count), then report functions that carry three or more
DISTINCT members of the set. A dispatch written as a sub-ladder or a jump table
still shows up because the constants themselves must exist somewhere -- and if a
jump table is used, the case labels live in .rdata, so also scan .rdata/.data for
the byte-pattern of a switch index table.
CONTROL for the scan: FUN_1800d8330 is known to contain all eight of 0x1e,0x1f,
0x91..0x96 as switch case labels. If the scan does not list FUN_1800d8330, the scan
is broken and any "no other consumer" conclusion is void.
"""
import traceback
try:
src = dec(0x180162C90)
print("=== A: FUN_180162c90 request builder, len=%d ===" % len(src))
print(src)
print()
print("=== B: scalar scan over .text for {0x1e,0x1f,0x91..0x96} ===")
TARGET = {0x1E, 0x1F, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96}
per = {}
block = None
for b in mem.getBlocks():
if b.getName() == ".text":
block = b
break
it = listing.getInstructions(block.getStart(), True)
n = 0
while it.hasNext():
ins = it.next()
if ins.getAddress().getOffset() > block.getEnd().getOffset():
break
n += 1
for i in range(ins.getNumOperands()):
for o in ins.getOpObjects(i):
try:
v = int(o.getValue())
except Exception:
continue
if v in TARGET:
f = fm.getFunctionContaining(ins.getAddress())
key = (f.getName(), int(f.getEntryPoint().getOffset())) if f else ("?", 0)
per.setdefault(key, {}).setdefault(v, []).append(
int(ins.getAddress().getOffset()))
print(" instructions scanned: %d" % n)
rank = sorted(per.items(), key=lambda kv: -len(kv[1]))
for (name, ent), d in rank:
if len(d) < 3:
continue
print(" %-28s %#x distinct=%d %s" %
(name, ent, len(d), sorted("%#x" % k for k in d)))
print(" --- control present? FUN_1800d8330 ---")
for (name, ent), d in per.items():
if ent == 0x1800D8330:
print(" YES: distinct=%d %s" % (len(d), sorted("%#x" % k for k in d)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,48 @@
"""Q6: the club query taxonomy switch and the club URL builder.
Prior work (tools/fut_club_stats.py header) records FUN_18012ec50 as the 30-arm
atom switch that turns a club ?type= name into a code, with 11 badge, 12 kit,
13 stadium, 14 ball, 15 equippables, 16 leaguelogos.
HYPOTHESIS: something downstream of that code either (a) adds a &cardsubtype=
filter, which would give the mapping outright, or (b) buckets the RESPONSE items
by cardsubtypeid for the combined equippables view.
NOTE ON THE FAILED CONTROL IN Q5: the scalar scan did NOT find FUN_1800d8330,
whose 0x1e/0x1f/0x91..0x96 case labels are a jump table, so scalar scanning cannot
see switch dispatch here. Every "not found" from that scan is void. This query
therefore works from call graph and strings only.
CONTROL here: FUN_18012ec50 must decompile to a switch whose arms match the 30
names already recorded. If it does not, the recorded note (and my starting point)
is wrong.
"""
import traceback
try:
for a, tag in [(0x18012EC50, "CONTROL club ?type= taxonomy switch"),
(0x180096670, "UI group-table switch")]:
src = dec(a)
print("=" * 78)
print("%s @%#x len=%d" % (tag, a, len(src)))
print(src)
print("=" * 78)
print("=== callers of FUN_18012ec50 ===")
for frm, typ, fn, ent in xrefs_to(0x18012EC50):
print(" %s(%#x) via %#x %s" % (fn, ent, frm, typ))
print()
print("=== url-ish literals ===")
for p in [b"/club?", b"/club", b"club?type", b"type=%s", b"&cat=%s",
b"&cardsubtype=%d", b"&count=%d", b"/item", b"/purchased"]:
hits = find_all(p)
print(" %-18r n=%d" % (p.decode(), len(hits)))
for h in hits[:14]:
s = rd_str(h, 120)
fs = []
for frm, typ, fn, ent in xrefs_to(h):
fs.append("%s(%#x)" % (fn, ent))
print(" %#x %-58r <- %s" % (h, s, ",".join(fs) or "-"))
except Exception:
traceback.print_exc()
@@ -0,0 +1,42 @@
"""Q7: every caller of the cardtype classifier, and how each treats cardtype 9.
FUN_1800d8330(cardsubtypeid) -> cardtype. Anything that needs to know "this is a
club item" must either call it or test cardsubtypeid directly. Enumerate its
callers, decompile each, and print EVERY line mentioning a 9 in any comparison or
switch form (case 9:, ==9, !=9, -9, <9, >9), so an arm written as != does not hide.
Also decompile the shared item deserializer FUN_18013fe00 fully? No: too long for
one batch. Instead print its length and the lines around the FUN_1800d8330 call and
around the itemState helper call at 0x1801406e3.
CONTROL: FUN_1800d8330 itself is excluded; the caller list must be non-empty and
must include the item deserializer FUN_18013fe00 (which is documented to compute
the family into item+0x4c). If FUN_18013fe00 is absent from the caller list, the
xref enumeration is broken.
"""
import re
import traceback
try:
seen = {}
for frm, typ, fn, ent in xrefs_to(0x1800D8330):
if ent and ent != 0x1800D8330:
seen.setdefault(ent, fn)
print("=== callers of FUN_1800d8330: %d ===" % len(seen))
pat = re.compile(r"(case\s+9\s*:|[=!<>]=\s*9\b|==\s*9\b|!=\s*9\b|\b9\s*[=!<>]|-\s*9\b)")
for ent, fn in sorted(seen.items()):
src = dec(ent)
lines = src.splitlines()
hits = [(i, l.strip()) for i, l in enumerate(lines) if pat.search(l)]
print("-" * 70)
print("%s @%#x len=%d nine-lines=%d" % (fn, ent, len(src), len(hits)))
for i, l in hits[:40]:
print(" %4d: %s" % (i, l))
print("=" * 70)
src = dec(0x18013FE00)
print("item deser FUN_18013fe00 len=%d" % len(src))
for i, l in enumerate(src.splitlines()):
if "1800d8330" in l or "180166660" in l or "1801666f0" in l:
print(" %4d: %s" % (i, l.strip()))
except Exception:
traceback.print_exc()
@@ -0,0 +1,26 @@
"""Q8: the cardtype-9 arms inside the shared item deserializer.
Q7: FUN_1800d8330 has exactly ONE caller, FUN_18013fe00, and the decompile has
`if (local_13c == 9)` at line 729 and `if ((int)local_138 == 9)` at line 754, where
local_138 is the cardsubtypeid fed to the classifier and local_13c is the cardtype.
Print lines 640..900 of that decompile verbatim (no truncation claim will be made
from a window: the window is stated as a window). Also print lines 380..470 which
contain the itemState conversion calls, and the function tail (last 80 lines),
because the brief warns that locals that look dead are often copied out at the tail.
"""
import traceback
try:
src = dec(0x18013FE00)
lines = src.splitlines()
print("total lines=%d chars=%d" % (len(lines), len(src)))
for lo, hi, tag in [(380, 470, "itemState region"),
(640, 900, "cardtype-9 region"),
(len(lines) - 90, len(lines), "tail")]:
print("=" * 70)
print("--- %s: lines %d..%d ---" % (tag, lo, hi))
for i in range(max(0, lo), min(len(lines), hi)):
print("%5d: %s" % (i, lines[i]))
except Exception:
traceback.print_exc()
@@ -0,0 +1,32 @@
"""Q9: the five functions that carry club-subtype immediates individually.
Q5's scalar scan cannot see jump-table switches (its control, FUN_1800d8330, was
missed), but everything it DID find is a real immediate in an instruction. Those
functions are:
FUN_1801017e0 0x1e 0x91 0x92 0x93 0x94
FUN_180108c00 0x1e 0x91 0x94 0x96
FUN_18011e3c0 0x91 0x94 0x96
FUN_18011e9d0 0x91 0x94 0x96
FUN_18013af30 0x1e 0x1f 0x94
FUN_180067d00 0x94 0x95 0x96
Plus the range test found in Q8 inside the item deserializer at line 738:
(int)cardsubtypeid - 0x91U < 4 -> {0x91,0x92,0x93,0x94} take a custom-image path.
Decompile all six in full. Anything that ties one of these constants to a loc key,
an asset path or a UI slot is the mapping.
CONTROL: FUN_18013af30 must contain 0x1e/0x1f/0x94 somewhere in its text; if a
decompile comes back without the constants the scan attributed to it, the scan's
function attribution is wrong.
"""
import traceback
try:
for a in (0x1801017E0, 0x180108C00, 0x18011E3C0, 0x18011E9D0, 0x18013AF30,
0x180067D00):
src = dec(a)
print("=" * 78)
print("FUN_%x len=%d" % (a, len(src)))
print(src)
except Exception:
traceback.print_exc()
@@ -0,0 +1,95 @@
"""Q1 scaffolding: locate the fcc_discardcoins query site and its containing
function(s), and print the raw instruction stream around the three bind sites.
HYPOTHESIS: the SQL at 0x1802231e4.. is built and bound inside one function whose
frame holds the item pointer; cardtype/level/rare come from three distinct item
struct offsets, and the result is stored at item+0x3c.
CONTROL: 0x18013fe00 (the shared ITEM deserializer, known function) and
0x1800d8330 (the known cardsubtypeid->cardtype mapper) must both resolve to real
functions with sane sizes. If they do not, the project copy is wrong and every
other answer here is void.
Absence discipline: nothing in this file claims absence. It only prints.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q1_out.txt"
try:
lines = []
def P(*a):
s = " ".join(str(x) for x in a)
lines.append(s)
LITS = {
0x1802231E4: "sql frag A",
0x1802231F0: "sql frag B",
0x1802231F4: "fcc_discardcoins",
0x180223208: "sql frag C",
0x180207848: "sql frag D",
0x18022315C: "sql frag E",
}
P("=== LITERALS ===")
for a, tag in sorted(LITS.items()):
try:
P("%#x %-18s %r" % (a, tag, rd_str(a, 120)))
except Exception as e:
P("%#x %-18s READ FAIL %s" % (a, tag, e))
P("")
P("=== XREFS TO LITERALS ===")
for a, tag in sorted(LITS.items()):
xs = xrefs_to(a)
P("%#x %s -> %d refs" % (a, tag, len(xs)))
for frm, typ, fn, ent in xs:
P(" from %#x %-14s in %s @ %#x" % (frm, typ, fn, ent))
P("")
P("=== FUNCTION IDENTITY ===")
for a in (0x18013FE00, 0x1800D8330, 0x180141025, 0x180141119, 0x180141140,
0x180141660, 0x180141E8A, 0x180140F00):
f = func(a)
if f is None:
P("%#x -> NO FUNCTION" % a)
continue
b = f.getBody()
P("%#x -> %s entry=%#x body=[%#x..%#x] size=%d"
% (a, f.getName(), int(f.getEntryPoint().getOffset()),
int(b.getMinAddress().getOffset()), int(b.getMaxAddress().getOffset()),
int(b.getNumAddresses())))
P("")
P("=== RAW INSTRUCTIONS 0x180140f80 .. 0x180141200 ===")
p = 0x180140F80
while p < 0x180141200:
ins = listing.getInstructionAt(addr(p))
if ins is None:
P("%#x <no instruction>" % p)
p += 1
continue
P("%#x %s" % (p, ins))
p += ins.getLength()
P("")
P("=== RAW INSTRUCTIONS 0x180141e40 .. 0x180141f00 (level derivation) ===")
p = 0x180141E40
while p < 0x180141F00:
ins = listing.getInstructionAt(addr(p))
if ins is None:
P("%#x <no instruction>" % p)
p += 1
continue
P("%#x %s" % (p, ins))
p += ins.getLength()
src = dec(0x180141025)
P("")
P("=== FULL DECOMPILE of function containing 0x180141025, len=%d ===" % len(src))
P(src)
with open(OUT, "w") as fh:
fh.write("\n".join(lines))
print("wrote %s (%d lines)" % (OUT, len(lines)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,134 @@
"""Q: the client's fcc_discardcoins lookup does NOT miss (proved live). So why did
every card render "Quick Sell 0" when we omitted discardValue?
HYPOTHESIS H1: the consumer of the price reads item+0x38 (the wire discardValue)
only, and NEVER item+0x3c (where the client's own computation lands). That would
explain both halves of the observation: 0 with the field omitted, exact with it
sent.
RIVAL H2: the consumer reads +0x38 if non-zero else +0x3c. H2 predicts the
omitted-field test should have shown the right numbers, which it did not, but H2
survives if the failing observation was mis-made, so decide it on code.
METHOD: enumerate EVERY instruction in .text whose memory operand is a dword at
displacement 0x38 or 0x3c, tabulate by containing function, and then look at the
functions that touch BOTH (a candidate "sent else computed" selector) versus
functions that touch only one.
CONTROL: FUN_18013fe00 must appear in both tables (it provably reads +0x38 at
0x180141025 and writes +0x3c at 0x180141140). If the scan does not find those two
exact addresses, the scan is broken and its silence means nothing.
Also: FUN_180141660 in full, plus every string literal it references, for the
"does the miss degrade other screens" question.
Absence discipline: this scan enumerates operands, not immediates, so it is
immune to the ==/!=/switch/ladder trap; but it is scoped to CardsDLL only and
says nothing about FIFA17.exe.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q2_out.txt"
try:
lines = []
def P(*a):
lines.append(" ".join(str(x) for x in a))
# ---------------------------------------------------------------- 1. operand scan
from ghidra.program.model.lang import OperandType
hits38, hits3c = {}, {}
n_ins = 0
it = listing.getInstructions(True)
while it.hasNext():
ins = it.next()
n_ins += 1
txt = str(ins)
if "0x38]" not in txt and "0x3c]" not in txt:
continue
a = int(ins.getAddress().getOffset())
f = fm.getFunctionContaining(ins.getAddress())
key = (f.getName() if f else "?",
int(f.getEntryPoint().getOffset()) if f else 0)
if "0x38]" in txt:
hits38.setdefault(key, []).append((a, txt))
if "0x3c]" in txt:
hits3c.setdefault(key, []).append((a, txt))
P("instructions scanned: %d" % n_ins)
P("functions touching a +0x38 operand: %d" % len(hits38))
P("functions touching a +0x3c operand: %d" % len(hits3c))
P("")
P("=== CONTROL ===")
ctl = [t for k, v in hits38.items() for t in v if t[0] == 0x180141025]
P("read of +0x38 at 0x180141025 found: %s" % (ctl or "NO -- SCAN BROKEN"))
ctl2 = [t for k, v in hits3c.items() for t in v if t[0] == 0x180141140]
P("write of +0x3c at 0x180141140 found: %s" % (ctl2 or "NO -- SCAN BROKEN"))
both = sorted(set(hits38) & set(hits3c), key=lambda k: k[1])
P("")
P("=== FUNCTIONS TOUCHING BOTH +0x38 AND +0x3c (%d) ===" % len(both))
for k in both:
P(" %s @ %#x" % k)
for a, t in sorted(hits38[k]):
P(" 38 %#x %s" % (a, t))
for a, t in sorted(hits3c[k]):
P(" 3c %#x %s" % (a, t))
only3c = sorted(set(hits3c) - set(hits38), key=lambda k: k[1])
P("")
P("=== FUNCTIONS TOUCHING +0x3c ONLY (%d) ===" % len(only3c))
for k in only3c:
P(" %s @ %#x" % k)
for a, t in sorted(hits3c[k]):
P(" %#x %s" % (a, t))
# ---------------------------------------------------------------- 2. tiny accessors
P("")
P("=== TINY ACCESSOR BYTE PATTERNS IN .text ===")
pats = {
"mov eax,[rcx+0x38]; ret": b"\x8b\x41\x38\xc3",
"mov eax,[rcx+0x3c]; ret": b"\x8b\x41\x3c\xc3",
"mov eax,[rcx+0x38]": b"\x8b\x41\x38",
"mov eax,[rcx+0x3c]": b"\x8b\x41\x3c",
}
for name, pat in pats.items():
hs = find_all(pat, blocks=(".text",))
P(" %-26s %d hits" % (name, len(hs)))
for h in hs[:40]:
f = fm.getFunctionContaining(addr(h))
P(" %#x in %s" % (h, f.getName() if f else "?"))
# ---------------------------------------------------------------- 3. FUN_180141660
src = dec(0x180141660)
P("")
P("=== FUN_180141660 FULL DECOMPILE, len=%d ===" % len(src))
P(src)
P("")
P("=== STRING LITERALS REFERENCED BY FUN_180141660 ===")
f = func(0x180141660)
seen = set()
for ad in f.getBody().getAddresses(True):
ins = listing.getInstructionAt(ad)
if ins is None:
continue
for r in ins.getReferencesFrom():
t = int(r.getToAddress().getOffset())
if t in seen or not (0x1801E5000 <= t <= 0x180290000):
continue
seen.add(t)
try:
s = rd_str(t, 80)
except Exception:
continue
if s and all(32 <= ord(c) < 127 for c in s) and len(s) >= 3:
P(" %#x from %#x %r" % (t, int(ad.getOffset()), s))
with open(OUT, "w") as fh:
fh.write("\n".join(lines))
print("wrote %s (%d lines)" % (OUT, len(lines)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,132 @@
"""Q: who READS the parsed item record's discard fields, item+0x38 (the wire
discardValue) and item+0x3c (the client's own fcc_discardcoins result)?
WHY q2's CONTROL FAILED, and why that was the control's fault: inside
FUN_18013fe00 the item is a STACK STRUCT at RBP+0x160, so the guard reads
[RBP+0x198] and the store writes [RBP+0x19c]. A scan for the displacements 0x38
and 0x3c can never see them. The control was invalid, not the scan.
NEW METHOD -- FINGERPRINT THE STRUCT, NOT THE OFFSET. The item record has several
displacements that are rare in general code: +0x146 (preferredPosition, u16),
+0x148 (nation), +0x154 (leagueId), +0x94 (teamid), +0xb4 (rating). Any function
that dereferences a pointer at two or more of those is handling an item record.
Collect the displacement set per function from the instruction text, select the
item handlers, and then report their +0x38 / +0x3c usage.
SECOND TEST, independent of the fingerprint: find every place in .text where a
dword is READ at [reg+0x38] and, within 0x40 bytes and off the SAME base
register, a dword is READ at [reg+0x3c]. That is the shape of a "server value
else computed value" selector. H1 (consumer reads +0x38 only) predicts no such
selector on an item; H2 predicts one.
CONTROL for this run: the fingerprint must select FUN_18013fe00 itself when the
frame register RBP is allowed, because that function demonstrably touches
RBP+0x2a6 (0x146+0x160), RBP+0x2a8, RBP+0x2b4 and RBP+0x214. I print the
frame-relative fingerprint hits separately for exactly that reason.
"""
import re
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q3_out.txt"
RE_MEM = re.compile(r"\[(R[A-Z0-9]+) \+ (-?0x[0-9a-f]+)\]")
try:
lines = []
def P(*a):
lines.append(" ".join(str(x) for x in a))
ITEM_MARKS = (0x146, 0x148, 0x154, 0x94, 0xb4, 0x8c)
FRAME_MARKS = tuple(m + 0x160 for m in ITEM_MARKS)
per_fn = {} # entry -> {'name', 'disp': {d: [(addr, base, txt)]}}
reads38 = [] # (addr, base, fnentry, txt)
reads3c = []
n_ins = 0
it = listing.getInstructions(True)
while it.hasNext():
ins = it.next()
n_ins += 1
txt = str(ins)
if "[" not in txt:
continue
ms = RE_MEM.findall(txt)
if not ms:
continue
a = int(ins.getAddress().getOffset())
f = fm.getFunctionContaining(ins.getAddress())
ent = int(f.getEntryPoint().getOffset()) if f else 0
rec = per_fn.setdefault(ent, {"name": f.getName() if f else "?", "disp": {}})
for base, dtxt in ms:
d = int(dtxt, 16)
rec["disp"].setdefault(d, []).append((a, base, txt))
if d == 0x38 and txt.startswith("MOV E") and "dword ptr [" + base in txt:
reads38.append((a, base, ent, txt))
if d == 0x3c and txt.startswith("MOV E") and "dword ptr [" + base in txt:
reads3c.append((a, base, ent, txt))
P("instructions scanned: %d ; functions with memory operands: %d" % (n_ins, len(per_fn)))
# ---- CONTROL: the frame-relative fingerprint must select FUN_18013fe00
P("")
P("=== CONTROL: frame-relative item fingerprint (marks + 0x160) ===")
ctl = []
for ent, rec in per_fn.items():
got = [m for m in FRAME_MARKS if m in rec["disp"]]
if len(got) >= 3:
ctl.append((ent, rec["name"], [hex(g) for g in got]))
for ent, nm, got in sorted(ctl):
P(" %-18s %#x marks %s %s" % (nm, ent, got,
"<== FUN_18013fe00" if ent == 0x18013FE00 else ""))
P(" control %s" % ("PASS" if any(e == 0x18013FE00 for e, _, _ in ctl)
else "FAIL -- fingerprint cannot see the known item handler"))
# ---- pointer-relative fingerprint: the real search
P("")
P("=== ITEM HANDLERS BY POINTER-RELATIVE FINGERPRINT (>=2 of %s) ==="
% [hex(m) for m in ITEM_MARKS])
cands = []
for ent, rec in per_fn.items():
got = []
for m in ITEM_MARKS:
for (a, base, txt) in rec["disp"].get(m, []):
if base not in ("RSP", "RBP"):
got.append(m)
break
if len(got) >= 2:
cands.append((ent, rec["name"], got))
P("candidates: %d" % len(cands))
for ent, nm, got in sorted(cands):
rec = per_fn[ent]
h38 = [(a, b, t) for (a, b, t) in rec["disp"].get(0x38, []) if b not in ("RSP", "RBP")]
h3c = [(a, b, t) for (a, b, t) in rec["disp"].get(0x3c, []) if b not in ("RSP", "RBP")]
P("")
P(" %-18s %#x marks %s +0x38:%d +0x3c:%d"
% (nm, ent, [hex(g) for g in got], len(h38), len(h3c)))
for a, b, t in h38:
P(" 38 %#x %s" % (a, t))
for a, b, t in h3c:
P(" 3c %#x %s" % (a, t))
# ---- selector shape
P("")
P("=== SELECTOR SHAPE: dword read [reg+0x38] then dword read [SAME reg+0x3c] within 0x40 ===")
idx3c = {}
for a, base, ent, txt in reads3c:
idx3c.setdefault(base, []).append((a, ent, txt))
nsel = 0
for a, base, ent, txt in reads38:
for a2, ent2, txt2 in idx3c.get(base, []):
if 0 < a2 - a <= 0x40:
nsel += 1
nm = per_fn.get(ent, {}).get("name", "?")
P(" %s @ %#x : %#x %s -> %#x %s" % (nm, ent, a, txt, a2, txt2))
P(" selectors found: %d" % nsel)
P(" (reads at +0x38: %d, reads at +0x3c: %d, over the whole .text)"
% (len(reads38), len(reads3c)))
with open(OUT, "w") as fh:
fh.write("\n".join(lines))
print("wrote %s (%d lines)" % (OUT, len(lines)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,51 @@
"""Q: identify the CONSUMER of the item record's discard fields.
The parsed stack struct is local_188 (RBP+0x160) and it is handed to
`FUN_18011a830()->vtbl[0xa08](mgr, existingItem, &parsed)`. The heap records our
live probe walked have the SAME layout (discardValue at +0x38, client-computed at
+0x3c, both confirmed live), so a consumer reads [reg+0x38] / [reg+0x3c].
H1: no consumer reads +0x3c off an item; the display reads +0x38 only.
H2: some consumer reads +0x38 and falls back to +0x3c.
q3 found 40 "read 0x38 then read 0x3c off the same base" sites DLL-wide, three of
which sit in functions carrying an item fingerprint. Decompile those and decide.
CONTROL: FUN_18013fe00 is a known item handler and FUN_1800d8330 a known
cardsubtypeid mapper; both must decompile to something recognisable. Also resolve
FUN_18011a830's vtable slot 0xa08, which is the known sink, as a positive check
that vtable resolution works here at all.
Absence discipline: any "no consumer reads +0x3c" statement below is scoped to
CardsDLL's .text only. FIFA17.exe is Denuvo-packed and is NOT searched, so a
consumer living there cannot be excluded by this query.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q4_out.txt"
try:
lines = []
def P(*a):
lines.append(" ".join(str(x) for x in a))
P("=== SINK: FUN_18011a830 and vtable slot 0xa08 ===")
src = dec(0x18011A830)
P("FUN_18011a830 decompile, len=%d" % len(src))
P(src)
for tgt in (0x1800AA440, 0x18007C5F0, 0x18007BF00, 0x1800D7920):
s = dec(tgt)
P("")
P("=" * 100)
P("=== FUN_%x FULL DECOMPILE, len=%d ===" % (tgt, len(s)))
P(s)
P("--- callers of %#x ---" % tgt)
for frm, typ, fn, ent in xrefs_to(tgt):
P(" %#x %-12s %s @ %#x" % (frm, typ, fn, ent))
with open(OUT, "w") as fh:
fh.write("\n".join(lines))
print("wrote %s (%d lines)" % (OUT, len(lines)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,85 @@
"""Q: which value does the UI actually receive for the quick-sell price?
LEAD: the UI layer is bound by NAME. q4 showed calls of the shape
(**(code **)(*view + 0x48))(view, "LATEST_GAMES_WON", value)
so every number the client renders is pushed through a named setter. If the
quick-sell price is pushed under a name, finding that name finds the reader, and
the reader tells us whether it reads item+0x38 (the wire discardValue) or
item+0x3c (the client's own fcc_discardcoins result).
METHOD: enumerate every printable string in .rdata/.data whose text contains
DISCARD / discard / QUICK / Quick / SELL / Sell / COIN / Coin, print it with its
xrefs and the containing function, then decompile the functions that push a
discard-ish name.
CONTROL: the search must find the strings we already know exist -- the SQL
fragments "fcc_discardcoins" (0x1802231f0) and "price" (0x1802231e4) both
contain the target substrings, and the known UI name "LATEST_GAMES_WON" must
show up under COIN?? no -- it must show up in a separate positive check that the
string enumerator sees UI names at all. Both checks are printed explicitly. If
either fails the enumerator is broken and its silence means nothing.
"""
import re
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q5_out.txt"
try:
lines = []
def P(*a):
lines.append(" ".join(str(x) for x in a))
NEEDLES = ("DISCARD", "discard", "Discard", "QUICK", "Quick", "quick",
"SELL", "Sell", "sell", "COIN", "Coin", "coin")
# gather printable C strings out of .rdata/.data
blocks = {}
for b in mem.getBlocks():
if b.getName() in (".rdata", ".data") and b.isInitialized():
s = int(b.getStart().getOffset())
n = int(b.getEnd().getOffset()) - s + 1
blocks[b.getName()] = (s, n)
P("blocks: %s" % {k: (hex(v[0]), v[1]) for k, v in blocks.items()})
RE_STR = re.compile(rb"[ -~]{4,120}\x00")
found = []
for bn, (s, n) in blocks.items():
off = 0
CH = 1 << 20
while off < n:
ln = min(CH, n - off)
data = read_bytes(s + off, ln)
for m in RE_STR.finditer(data):
txt = m.group()[:-1].decode("ascii")
if any(x in txt for x in NEEDLES):
found.append((s + off + m.start(), bn, txt))
off += ln - 130 if ln == CH else ln
# dedupe
seen = set()
found = [f for f in found if not (f[0] in seen or seen.add(f[0]))]
P("strings matching %s: %d" % (list(NEEDLES), len(found)))
P("")
P("=== CONTROL 1: the two known SQL literals must be in the hit list ===")
hits = {a for a, _, _ in found}
P(" 0x1802231f0 'fcc_discardcoins' present: %s" % (0x1802231F0 in hits))
P(" 0x1802231e4 'price' present (should be False, no needle): %s" % (0x1802231E4 in hits))
P("")
P("=== CONTROL 2: the enumerator sees UI names -- LATEST_GAMES_WON ===")
la = find_all(b"LATEST_GAMES_WON\x00", blocks=(".rdata", ".data"))
P(" LATEST_GAMES_WON found at %s" % [hex(x) for x in la])
P("")
P("=== HITS WITH XREFS ===")
for a, bn, txt in sorted(found):
xs = xrefs_to(a)
fns = sorted({(fn, ent) for _, _, fn, ent in xs if ent})
P("%#x [%s] %-46r xrefs=%d %s"
% (a, bn, txt, len(xs), ", ".join("%s@%#x" % f for f in fns[:8])))
with open(OUT, "w") as fh:
fh.write("\n".join(lines))
print("wrote %s (%d lines)" % (OUT, len(lines)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,80 @@
"""Q: FUN_1800eb850 pushes BOTH "DISCARD_CREDITS" (3 refs) and
"CALCULATED_DISCARD_CREDITS" (1 ref) to the UI. Which item offset feeds each?
H1 predicts DISCARD_CREDITS <- item+0x38 (the wire discardValue) and
CALCULATED_DISCARD_CREDITS <- item+0x3c (the client's fcc_discardcoins result),
two independent UI properties with no native fallback between them. If so, which
one the card tile shows is decided in the Flash/Scaleform asset, not in native
code, and the "Quick Sell 0" observation means the tile binds DISCARD_CREDITS.
METHOD: full decompile plus the raw instruction stream around every reference to
the two names, so the register feeding the third argument is visible rather than
inferred from decompiler variable naming. Also the callers of FUN_1800eb850, and
the same treatment for the sibling names DISCARD / COINS_AWARDED.
CONTROL: FUN_18013fe00's own two sites are the reference semantics: the guard
reads [RBP+0x198] (= item+0x38) and the store writes [RBP+0x19c] (= item+0x3c).
Any offsets this query reports must be interpretable against a struct base held
in a register; I print the full function so the base can be traced, rather than
quoting a fragment.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q6_out.txt"
try:
lines = []
def P(*a):
lines.append(" ".join(str(x) for x in a))
for nm, a in (("DISCARD_CREDITS", 0x1802161C8),
("CALCULATED_DISCARD_CREDITS", 0x1802161D8),
("DISCARD", 0x1801F4D28)):
P("=== xrefs to %r (%#x) ===" % (nm, a))
for frm, typ, fn, ent in xrefs_to(a):
P(" %#x %-12s %s @ %#x" % (frm, typ, fn, ent))
P("")
f = func(0x1800EB850)
b = f.getBody()
lo = int(b.getMinAddress().getOffset())
hi = int(b.getMaxAddress().getOffset())
P("FUN_1800eb850 body [%#x..%#x] size=%d" % (lo, hi, int(b.getNumAddresses())))
src = dec(0x1800EB850)
P("")
P("=== FUN_1800eb850 FULL DECOMPILE, len=%d ===" % len(src))
P(src)
P("")
P("=== FUN_1800eb850 FULL DISASSEMBLY ===")
p = lo
while p <= hi:
ins = listing.getInstructionAt(addr(p))
if ins is None:
P("%#x <none>" % p)
p += 1
continue
extra = ""
for r in ins.getReferencesFrom():
t = int(r.getToAddress().getOffset())
if 0x1801E5000 <= t <= 0x180290000:
try:
s = rd_str(t, 60)
except Exception:
s = ""
if s:
extra = " ; %r" % s
P("%#x %s%s" % (p, ins, extra))
p += ins.getLength()
P("")
P("=== CALLERS OF FUN_1800eb850 ===")
for frm, typ, fn, ent in xrefs_to(0x1800EB850):
P(" %#x %-12s %s @ %#x" % (frm, typ, fn, ent))
with open(OUT, "w") as fh:
fh.write("\n".join(lines))
print("wrote %s (%d lines)" % (OUT, len(lines)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,69 @@
"""Q: which struct offsets do the two UI getters read?
FUN_1800eb850 pushes:
"DISCARD_CREDITS" <- FUN_1801a8620(handle)
"CALCULATED_DISCARD_CREDITS" <- FUN_1801a8090(handle)
"CARD_LEVEL" <- FUN_1801a80c0(handle)
"CARD_RARITY" <- FUN_1801a8880 || FUN_1801a88c0
H1 predicts FUN_1801a8620 reads +0x38 and FUN_1801a8090 reads +0x3c, i.e. the
wire value and the client's own computation are exposed to the UI as two
SEPARATE named properties with no native fallback between them.
BONUS CONTROL, and it is a strong one: the live probe showed item+0x54 holding
1/2/3 exactly tracking rating (3 if >=75, 2 if >=65, else 1) on all 22 resident
records, which contradicts the existing field map's "itemType 3=player 2=staff".
If FUN_1801a80c0 ("CARD_LEVEL") reads +0x54, that independently settles it as
the fcc_discardcoins `level`.
Also print the callers of FUN_1800eb850 to name the screen.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q7_out.txt"
TARGETS = [0x1801A8620, 0x1801A8090, 0x1801A80C0, 0x1801A8880, 0x1801A88C0,
0x1801A8850, 0x1801A8810, 0x1801A7140, 0x1801A78F0]
try:
lines = []
def P(*a):
lines.append(" ".join(str(x) for x in a))
for t in TARGETS:
f = func(t)
if f is None:
P("%#x NO FUNCTION" % t)
continue
b = f.getBody()
lo, hi = int(b.getMinAddress().getOffset()), int(b.getMaxAddress().getOffset())
P("=" * 92)
P("FUN_%x body [%#x..%#x] size=%d" % (t, lo, hi, int(b.getNumAddresses())))
P("--- disassembly (full) ---")
p = lo
while p <= hi:
ins = listing.getInstructionAt(addr(p))
if ins is None:
P(" %#x <none>" % p)
p += 1
continue
P(" %#x %s" % (p, ins))
p += ins.getLength()
s = dec(t)
P("--- decompile, len=%d ---" % len(s))
P(s)
P("")
P("=" * 92)
P("=== CALLERS OF FUN_1800eb850 ===")
for frm, typ, fn, ent in xrefs_to(0x1800EB850):
P(" %#x %-12s %s @ %#x" % (frm, typ, fn, ent))
if ent:
for f2, t2, n2, e2 in xrefs_to(ent):
P(" <- %#x %s @ %#x" % (f2, n2, e2))
with open(OUT, "w") as fh:
fh.write("\n".join(lines))
print("wrote %s (%d lines)" % (OUT, len(lines)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,89 @@
"""Final batch.
Q-A How many places consume each getter?
FUN_1801a8620 = item+0x38 (wire discardValue, UI name DISCARD_CREDITS)
FUN_1801a8090 = item+0x3c (client fcc_discardcoins result, UI name
CALCULATED_DISCARD_CREDITS)
If +0x3c has exactly ONE consumer and it is the UI property push, then no
native code ever falls back from the wire value to the computed one, and
which number the tile shows is a Flash-asset decision. That is H1.
Q-B What is FUN_1800eb850 registered as? Its only xrefs are DATA slots at
0x180216130 / 0x1802658a0 / 0x1802f85ec, so it sits in a dispatch table.
Dump the qwords either side and resolve any string pointers to name it.
Q-C item+0x34 feeds the UI property "BOUGHT_FOR" and is NOT on the field map.
Print the deserializer sites that write +0x194 (= 0x34 + 0x160, the frame
form inside FUN_18013fe00) so the wire atom can be identified later.
CONTROL: FUN_1801a8620 and FUN_1801a8090 are 8-byte leaf functions whose bodies
were printed in q7, so their identity is not in doubt; the xref counts below are
the only new claim. A getter with zero xrefs would mean the xref index is stale,
which is checkable against FUN_1801a80c0 (CARD_LEVEL), known to be called from
FUN_1800eb850.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q8_out.txt"
try:
lines = []
def P(*a):
lines.append(" ".join(str(x) for x in a))
P("=== Q-A getter consumers ===")
for nm, a in (("+0x38 DISCARD_CREDITS getter FUN_1801a8620", 0x1801A8620),
("+0x3c CALCULATED getter FUN_1801a8090", 0x1801A8090),
("+0x54 CARD_LEVEL getter FUN_1801a80c0", 0x1801A80C0),
("+0x58 rare==1 getter FUN_1801a88c0", 0x1801A88C0),
("+0x58 rare==0xc getter FUN_1801a8880", 0x1801A8880)):
xs = xrefs_to(a)
P("%-44s %d xrefs" % (nm, len(xs)))
for frm, typ, fn, ent in xs:
P(" %#x %-12s %s @ %#x" % (frm, typ, fn, ent))
P("")
P("=== Q-B dispatch-table context around the FUN_1800eb850 slots ===")
for slot in (0x180216130, 0x1802658A0, 0x1802F85EC):
P("-- slot %#x --" % slot)
for off in range(-0x40, 0x48, 8):
a = slot + off
try:
v = qword(a)
except Exception:
P(" %#x <unreadable>" % a)
continue
tag = ""
f = fm.getFunctionAt(addr(v)) if 0x180000000 <= v < 0x181000000 else None
if f:
tag = "-> FUNC %s" % f.getName()
elif 0x1801E5000 <= v <= 0x180290000:
try:
s = rd_str(v, 60)
except Exception:
s = ""
if s and all(32 <= ord(c) < 127 for c in s):
tag = "-> %r" % s
P(" %#x %016x %s%s" % (a, v, tag, " <== the slot" if off == 0 else ""))
P("")
P("=== Q-C writes to the parsed item's +0x34 (frame form RBP+0x194) ===")
f = func(0x18013FE00)
lo = int(f.getBody().getMinAddress().getOffset())
hi = int(f.getBody().getMaxAddress().getOffset())
p = lo
while p <= hi:
ins = listing.getInstructionAt(addr(p))
if ins is None:
p += 1
continue
t = str(ins)
if "0x194]" in t or "0x190]" in t or "0x198]" in t or "0x19c]" in t:
P(" %#x %s" % (p, t))
p += ins.getLength()
with open(OUT, "w") as fh:
fh.write("\n".join(lines))
print("wrote %s (%d lines)" % (OUT, len(lines)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,41 @@
"""Side finding follow-up: the parsed item's +0x34 is pushed to the UI as
"BOUGHT_FOR" (FUN_1800eb850, from local_38+0x34) and it is NOT on the field map.
It is written once in the deserializer, at 0x180140885 (MOV [RBP+0x194],EAX).
Q: which wire atom writes it? Print the instruction stream from 0x180140780 to
0x1801408c0 so the dispatch arm and its atom immediate are visible, in whatever
form the compiler chose (cmp / sub-ladder / switch), rather than grepping for
one form.
CONTROL: the same window must also show the neighbouring known write
0x180140e43 -> [RBP+0x190] (item+0x30) or, failing that, at least one arm whose
atom is already in docs/fut_atoms.tsv, so that the arm-reading method is shown
to work on a known case in the same function.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/q9_out.txt"
try:
lines = []
def P(*a):
lines.append(" ".join(str(x) for x in a))
for lo, hi, tag in ((0x180140760, 0x1801408D0, "around the +0x34 (BOUGHT_FOR) write"),
(0x180140DC0, 0x180140E60, "around the +0x30 write, as a control")):
P("=== %s : %#x..%#x ===" % (tag, lo, hi))
p = lo
while p < hi:
ins = listing.getInstructionAt(addr(p))
if ins is None:
p += 1
continue
P(" %#x %s" % (p, ins))
p += ins.getLength()
P("")
with open(OUT, "w") as fh:
fh.write("\n".join(lines))
print("wrote %s (%d lines)" % (OUT, len(lines)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,51 @@
"""Q1: WHERE DOES THE DISPLAYED RATING COME FROM?
HYPOTHESIS: the card face is painted from an "attribute publisher" that reads the
0x180-stride parsed item record and pushes named ATTRIB_* keys to the UI layer.
CARD_SYSTEM.md already names one such function, FUN_1800e5940, which reads
record+0xe3 and publishes ATTRIB_CONTRACT_NEGOTIATION. If that function also
publishes a rating key, and it reads it from the record (offset 0xb4 per the live
field map) rather than re-querying the local players table, then the displayed
rating is OURS.
CONTROL (same syntactic form): the publisher must also emit the six attribute keys
from +0x98..+0xac. Those are KNOWN-OURS -- CARD_SYSTEM.md records invented
attributes rendering on screen (SILVA 72/44/58/60/62/61). If my read of the
publisher shows the attributes coming from somewhere OTHER than the record, my
read of the publisher is wrong, not the doc, and every rating conclusion in this
file is void.
Also enumerate every ATTRIB_* literal in .rdata with its xrefs, which is the raw
material for the Q2 "ours vs client's" table.
"""
import traceback
try:
print("=== A: every ATTRIB_ literal in .rdata ===")
hits = find_all(b"ATTRIB_")
print("n=%d" % len(hits))
seen = {}
for h in hits:
s = rd_str(h, 96)
seen.setdefault(s, []).append(h)
for s in sorted(seen):
print(" %-46s %s" % (s, " ".join("%#x" % a for a in seen[s])))
print()
print("=== B: xrefs to each ATTRIB_ literal ===")
for s in sorted(seen):
for a in seen[s]:
xs = xrefs_to(a)
if not xs:
continue
print(" %-46s @%#x" % (s, a))
for frm, typ, fn, ent in xs:
print(" from %#x %-12s %s(%#x)" % (frm, typ, fn, ent))
print()
print("=== C: FUN_1800e5940 in full ===")
src = dec(0x1800E5940)
print("len=%d" % len(src))
print(src)
except Exception:
traceback.print_exc()
@@ -0,0 +1,72 @@
"""Q2: the card-accessor family and the OTHER card publishers.
FUN_1800e5940 is the MANAGER card publisher (gated on CARD_TYPE, emits
ManagerCardBio / TACTICAL_KNOWLEDGE / ATTRIB_TEAM_TALKS). It reads every value it
publishes through a family of tiny accessors at 0x1801a8xxx taking a 4-qword stack
wrapper, and pushes them to a UI sink via param_3 vtable slots +0x08 (bool),
+0x10 (int), +0x20 (string).
HYPOTHESIS: those accessors are thin field reads off the 0x180-stride parsed item
record, so FUN_1801a87f0 (OVERALL_RATING) reads record+0xb4, which is where atom
0x274 rating is stored. If so the DISPLAYED rating is OUR wire value.
CONTROL, same syntactic form: FUN_1801a8540 is published as LEAGUE_ID. LEAGUE_ID
is KNOWN to be client-derived -- FUN_180135890 "always recomputes leagueid +0x154"
on a DB hit. And FUN_1801a8480 is CONTRACT_REMAINING, known-ours (+0x8c, we send
contract 7 and the manager card printed "CONTRACT 7"). So the accessor family must
show BOTH kinds reading the SAME record: that proves the accessors are record
reads and that "ours vs theirs" is decided upstream in the merge, not here. If the
league accessor instead performs a DB query while the rating one does a field read,
that is the opposite answer and it is equally visible.
Also: enumerate every function in [0x1801a7000,0x1801a9000) with its decompiled
body, which is the whole accessor table, and find the sibling publishers by xref
on the UI key literals.
"""
import traceback
try:
print("=== A: UI key literals and their xrefs (finds sibling publishers) ===")
keys = [b"OVERALL_RATING", b"CARD_LEVEL", b"CARD_RARITY", b"CARD_TYPE",
b"ASSET_ID", b"LEAGUE_ID", b"TEAM_ID", b"NATIONALITY",
b"CONTRACT_REMAINING", b"FIRST_NAME", b"LAST_NAME", b"KNOWN_AS",
b"PREFERRED_POSITION", b"POSITION", b"FITNESS", b"PLAYSTYLE",
b"PLAY_STYLE", b"CHEMISTRY", b"ATTRIBUTE", b"ATTR", b"MORALE",
b"TRAINING", b"DISCARD", b"QUICK_SELL", b"UNTRADEABLE",
b"RESOURCE_ID", b"DEFINITION_ID", b"CARD_ASSET", b"ITEM_STATE",
b"ITEM_TYPE", b"SUBTYPE", b"RARE", b"PHOTO", b"HEADSHOT", b"IS_"]
for k in keys:
for a in find_all(k):
s = rd_str(a, 64)
if not s.startswith(k.decode()):
continue
xs = xrefs_to(a)
if not xs:
continue
print(" %-34s @%#x" % (s, a))
for frm, typ, fn, ent in xs:
print(" from %#x %-10s %s(%#x)" % (frm, typ, fn, ent))
print()
print("=== B: every function in [0x1801a7000,0x1801a9000) decompiled ===")
it = fm.getFunctions(addr(0x1801A7000), True)
n = 0
while it.hasNext():
f = it.next()
e = int(f.getEntryPoint().getOffset())
if e >= 0x1801A9000:
break
n += 1
src = dec(e)
body = " ".join(src.split())
print("--- %s @%#x len=%d" % (f.getName(), e, len(src)))
print(" %s" % (body[:900]))
print("total functions in range: %d" % n)
print()
print("=== C: FUN_1801a78f0 (builds the wrapper) in full ===")
s = dec(0x1801A78F0)
print("len=%d" % len(s))
print(s)
except Exception:
traceback.print_exc()
@@ -0,0 +1,44 @@
"""Q3: the PLAYER card publisher, the attribute publisher, and the merge.
FUN_1800e6e20 emits OVERALL_RATING, CARD_LEVEL, CARD_RARITY, LEAGUE_ID, TEAM_ID,
LAST_NAME, PREFERRED_POSITION(+_ID), PLAY_STYLE, NATIONALITY(+ABBR/ABBR15/ASSET_ID),
CONTRACT_REMAINING and ASSET_ID -- that is the player card face. Get it in full and
map each published key to the accessor and therefore to a record offset.
FUN_1800e65d0 / FUN_1800e96f0 emit ATTRIBUTE_NAME / _ABBR / _VALUE: the six bars.
FUN_1800ea400 and FUN_1800e86f0 and FUN_1800eb850 are the other publishers.
HYPOTHESIS: every one of these is a pure field read off the same 0x180 record, so
the "ours vs the client's" split is decided ONLY in the merge FUN_180141660 /
FUN_180135890, never at draw time.
CONTROL: FUN_1800e6e20 must publish LAST_NAME from record+0xc8 (an inline char
array the merge WRITES from the local DB) and OVERALL_RATING from record+0xb4 (a
field the merge is documented never to touch). Both in the same function, same
call form. If they do not both appear as record reads, my model of the publisher
is wrong.
Also dump FUN_180135890 (players merge, the Q4 miss path) and FUN_180141660 in
full, plus the CARD_TYPE getter FUN_18003b800 and FUN_18003cbf0.
"""
import traceback
FUNCS = [
("FUN_1800e6e20 PLAYER card publisher", 0x1800E6E20),
("FUN_1800e65d0 attribute publisher A", 0x1800E65D0),
("FUN_1800e96f0 attribute publisher B", 0x1800E96F0),
("FUN_1800ea400 publisher", 0x1800EA400),
("FUN_1800e86f0 publisher", 0x1800E86F0),
("FUN_1800eb850 publisher", 0x1800EB850),
("FUN_18015fa80 publisher", 0x18015FA80),
("FUN_1800e80f0 publisher", 0x1800E80F0),
]
try:
for label, ea in FUNCS:
src = dec(ea)
print("=" * 70)
print("### %s @%#x len=%d" % (label, ea, len(src)))
print(src)
except Exception:
traceback.print_exc()
@@ -0,0 +1,59 @@
"""Q4: the deserializer's atom->offset table, the merge, and the blank-card path.
Three questions, one batch.
(a) THE WIRE SIDE. FUN_18013fe00 is the shared ITEM element deserializer. Print it
IN FULL (never truncated -- see the absence trap) so the atom dispatch can be
enumerated in every form: == 0xNN, != 0xNN, switch case labels and running-sum
ladders. Atoms of interest: 0x274 rating, 0x287 resourceId, 0x23 assetId,
0x6b cardassetid, 0x6c cardsubtypeid, 0x23f playStyle, 0x24a preferredPosition,
0x31 attributeList, 0x271 rareflag, 0x172 itemState, 0x173 itemType.
HYPOTHESIS: rating 0x274 -> record+0xb4, and playStyle 0x23f has NO arm (the
live sweep found no 0xfa anywhere in a record although we send playStyle 250,
and the player publisher reads PLAY_STYLE from +0x88).
CONTROL: the same enumeration must FIND 0x274 and 0x287, which are known to be
parsed. An enumeration that finds neither is a broken enumeration, not an
absent atom.
(b) THE MERGE. FUN_180141660 (dispatch on record+0x4c) and FUN_180135890 (players
branch). Q4 asks what exactly the client failed to find for a blank card and
what the minimum viable identity is.
(c) THE ACCESSORS the player publisher uses, in full and untruncated, so each
published UI key gets an exact record offset.
"""
import traceback
ACC = [0x1801A8010, 0x1801A8020, 0x1801A8090, 0x1801A80A0, 0x1801A80C0,
0x1801A8100, 0x1801A8110, 0x1801A8120, 0x1801A8130, 0x1801A8140,
0x1801A8460, 0x1801A8480, 0x1801A8490, 0x1801A84A0, 0x1801A8550,
0x1801A8590, 0x1801A85C0, 0x1801A85D0, 0x1801A8600, 0x1801A8620,
0x1801A8640, 0x1801A8660, 0x1801A86A0, 0x1801A86B0, 0x1801A87F0,
0x1801A8890, 0x1801A88D0, 0x1801A8950, 0x1801A8940]
try:
print("=== C: accessors in full ===")
for a in ACC:
s = dec(a)
print("--- %#x len=%d" % (a, len(s)))
print(s)
print()
print("=== B: merge dispatch FUN_180141660 ===")
s = dec(0x180141660)
print("len=%d" % len(s))
print(s)
print()
print("=== B2: players merge FUN_180135890 ===")
s = dec(0x180135890)
print("len=%d" % len(s))
print(s)
print()
print("=== A: item deserializer FUN_18013fe00 IN FULL ===")
s = dec(0x18013FE00, 600)
print("len=%d" % len(s))
print(s)
except Exception:
traceback.print_exc()
@@ -0,0 +1,77 @@
"""Q5: resourceId decomposition, the value mappers, and who reads assetId (+0x20).
Established so far, and the reason for each question here:
* FUN_180141660's TAIL, reached on EVERY family including the no-merge default,
does record+0x54 = 3 if record+0xb4 >= 0x4b else 2 if >= 0x41 else 1. So +0x54
is the bronze/silver/gold CARD LEVEL derived from rating, not itemType.
* The players merge queries `players` by record+0x18 & 0xffffff and, on 0 rows,
OVERWRITES rating=0x32, attrs=1, teamid=0x78d, nation=0xe, position=2, name=" ".
* The item deser routes atom 0x1d1 nation to +0x148 for family 1 and +0xde for
family 2 and NOWHERE otherwise, and atom 0x18a leagueId always to +0xe0.
Remaining:
(a) FUN_180166ca0(resourceId, &out_byte, buf, &out_dword) -- the resourceId
decomposition. out_dword lands at record+0x18 and out_byte at record+0x24,
and +0x24 gates the "p%d.dds" FUTPlayerHeads photo registration. This decides
Q3: what resourceId actually has to look like.
CONTROL: DAT_7364642e642570 must read as "p%d.dds"; if it does not, my read of
the photo path is wrong.
(b) FUN_180136480 playStyle mapper, FUN_180166810 preferredPosition mapper,
FUN_180166660 itemState mapper, FUN_1801666f0 injuryType mapper.
(c) WHO READS record+0x20 (assetId)? Accessors FUN_1801a8020 and FUN_1801a8990
read it. Enumerate their callers. HYPOTHESIS: only the staff/manager card
art path, which the merge itself fills from the local DB, so a wire assetId
is dead for every family.
CONTROL: the same enumeration run on FUN_1801a87f0 (+0xb4 rating) must return
the publishers we already found. An enumeration that returns nothing for
rating is broken.
(d) FUN_1800d84e0 -- the value written to record+0x30.
(e) FUN_1801356c0 manager merge, for the family table.
"""
import traceback
try:
print("=== CONTROL: the format string at 0x1801eaf90..0x1801eafa8 region ===")
for a in (0x1801EAF98,):
print(" %#x -> %r" % (a, rd_str(a, 32)))
hits = find_all(b"p%d.dds")
print(" 'p%%d.dds' occurrences: %s" % " ".join("%#x" % h for h in hits))
for h in find_all(b"FUTPlayerHeads"):
print(" FUTPlayerHeads @%#x" % h)
print()
print("=== (a) FUN_180166ca0 resourceId decomposition ===")
print(dec(0x180166CA0))
print("=== (b) mappers ===")
for lbl, a in (("playStyle FUN_180136480", 0x180136480),
("preferredPosition FUN_180166810", 0x180166810),
("itemState FUN_180166660", 0x180166660),
("injuryType FUN_1801666f0", 0x1801666F0)):
s = dec(a)
print("--- %s len=%d" % (lbl, len(s)))
print(s)
print("=== (c) callers of the accessors ===")
for lbl, a in (("+0x20 assetId FUN_1801a8020", 0x1801A8020),
("+0x20/+0x4c FUN_1801a8990", 0x1801A8990),
("+0x1c cardassetid FUN_1801a8010", 0x1801A8010),
("+0x18 resourceId FUN_1801a80a0", 0x1801A80A0),
("CONTROL +0xb4 rating FUN_1801a87f0", 0x1801A87F0),
("+0x54 level FUN_1801a80c0", 0x1801A80C0),
("+0x54 FUN_1801a8870", 0x1801A8870),
("+0x54 FUN_1801a88f0", 0x1801A88F0)):
print("--- %s" % lbl)
for frm, typ, fn, ent in xrefs_to(a):
print(" %#x %-10s %s(%#x)" % (frm, typ, fn, ent))
print()
print("=== (d) FUN_1800d84e0 (record+0x30) ===")
print(dec(0x1800D84E0))
print("=== (e) FUN_1801356c0 manager merge ===")
s = dec(0x1801356C0)
print("len=%d" % len(s))
print(s)
except Exception:
traceback.print_exc()
@@ -0,0 +1,46 @@
"""Q6: loose ends.
(a) _DAT_1801f66a0 -- the qword the deser uses to initialise {cardsubtypeid(+0x50),
+0x54}. If +0x54 is the card LEVEL and no atom writes it, its pre-merge value
is whatever this constant carries.
(b) Does ANY writer of record+0x54 exist besides FUN_180141660's tail? Enumerate
every function that references the level accessors and, more directly, look for
the constant 0x4b/0x41 rating-tier ladder elsewhere.
(c) FUN_18003b800 -- the CARD_TYPE getter whose value 0x12 gates the manager
publisher. What is the enum?
(d) FUN_1801a7dd0 / FUN_1801a78f0 -- how the publisher's wrapper gets its record
pointer, to confirm wrapper+0x18 IS the 0x180 item record and not a copy.
CONTROL: FUN_1801a7100 already visibly does param_1[3] = *(param_2+0x10), i.e.
wrapper+0x18 = arg+0x10; if FUN_1801a7dd0 disagrees my offsets are off.
"""
import traceback
try:
print("=== (a) _DAT_1801f66a0 ===")
q = qword(0x1801F66A0)
print(" qword %#018x -> low dword %#x (+0x50 init) high dword %#x (+0x54 init)"
% (q, q & 0xFFFFFFFF, q >> 32))
print()
print("=== (b) other rating-tier ladders: functions comparing a byte to 0x4b ===")
for lbl, a in (("FUN_180141660 tail", 0x180141660),
("FUN_1801a80c0 accessor", 0x1801A80C0)):
print(" known: %s" % lbl)
print(" callers of FUN_1800d8330 (family map), for context:")
for frm, typ, fn, ent in xrefs_to(0x1800D8330):
print(" %#x %-10s %s(%#x)" % (frm, typ, fn, ent))
print()
print("=== (c) FUN_18003b800 CARD_TYPE getter ===")
print(dec(0x18003B800))
print("=== FUN_18003b9d0 and FUN_18003cbf0 (manager publisher gates) ===")
print(dec(0x18003B9D0))
print(dec(0x18003CBF0))
print("=== (d) wrapper construction ===")
for a in (0x1801A7DD0, 0x1801A78F0, 0x1801A7100):
s = dec(a)
print("--- %#x len=%d" % (a, len(s)))
print(s)
except Exception:
traceback.print_exc()
@@ -0,0 +1,37 @@
"""Q7: completeness check on the headline claim.
CLAIM: the displayed rating is record+0xb4 and nothing else.
The evidence so far is that the literal OVERALL_RATING has exactly 4 xrefs and all 4
functions call FUN_1801a87f0 (+0xb4). That is only airtight if OVERALL_RATING is the
ONLY key under which a rating reaches the UI. Enumerate every .rdata literal that
contains "RATING" or "OVR" and report its xrefs, so a second rating key cannot hide.
CONTROL: the enumeration must re-find OVERALL_RATING with its 4 known xrefs.
"""
import traceback
try:
seen = {}
for pat in (b"RATING", b"OVR", b"Rating"):
for a in find_all(pat):
# walk back to the start of the C string
s = a
while s > a - 64:
try:
if mem.getByte(addr(s - 1)) & 0xFF == 0:
break
except Exception:
break
s -= 1
txt = rd_str(s, 96)
if txt:
seen.setdefault(txt, set()).add(s)
for txt in sorted(seen):
for a in sorted(seen[txt]):
xs = xrefs_to(a)
if not xs:
continue
print("%-44s @%#x" % (txt[:44], a))
for frm, typ, fn, ent in xs:
print(" %#x %-10s %s(%#x)" % (frm, typ, fn, ent))
except Exception:
traceback.print_exc()
@@ -0,0 +1,16 @@
"""Q8: the four publishers of the bare "RATING" key -- do any bypass record+0xb4?
If one of them reads a rating from the local DB instead of the item record, the
headline claim ("the displayed rating is ours") needs qualifying by screen.
CONTROL: FUN_18003ded0 also publishes FIRST_NAME/LAST_NAME, so it is card-shaped and
is the most likely counterexample. Print all four in full.
"""
import traceback
try:
for a in (0x18003DED0, 0x180053AA0, 0x1800F0970, 0x18005F910):
s = dec(a)
print("=" * 60)
print("### %#x len=%d" % (a, len(s)))
print(s)
except Exception:
traceback.print_exc()
@@ -0,0 +1,19 @@
"""Q9: why is record+0xe0 zero although atom 0x18a leagueId writes local_a8?
The struct-offset formula (offset = 0x188 - localnum) is validated empirically on
NINE fields (contract +0x8c, rating +0xb4, discardValue +0x38, cardsubtypeid +0x50,
teamid +0x94, nation +0x148, owners +0x48, cardassetid +0x1c, assetId +0x20), yet a
live read shows +0xe0 == 0 on all 22 records while we send leagueId 13..353.
Candidate: FUN_180134cb0, called at the tail of FUN_180135890, clears the manager
block for family 1.
CONTROL: whatever it does must NOT clear +0xb4/+0x94/+0x148, which the live read
shows surviving.
"""
import traceback
try:
for a in (0x180134CB0,):
s = dec(a)
print("### %#x len=%d" % (a, len(s)))
print(s)
except Exception:
traceback.print_exc()
@@ -0,0 +1,50 @@
"""q_cd_route_1 -- the club route family.
HYPOTHESIS
The 125-row action table at 0x1802caa20 binds each client-originated request to a
URL base out of the 48-row base table at 0x18021df80 (16-byte rows: char* template,
char* symbolic name). Exactly four rows carry base index 3 = "ut/%s/club":
ClubSearch (fn 0x180123a60), ClubStats (0x180123a70), StaffStats (0x1801247e0),
ConsumablesSearch (0x180123a80). If that is right, those four factories are the
COMPLETE set of club-route request builders and everything the client can emit on
/club is one of them.
CONTROL
Decompile a NON-club factory from the same table in the same pass (PurchasedItems
0x180124260, base 26 = ut/%s/purchased) and confirm it resolves to a different
request class with a different suffix builder. If the four club factories and the
control all decompile to the same shape, the shape is real; if the control comes
back empty while the targets do too, the method is broken, not the answer.
Second control: the /stats/%s, /stats/staff and /consumables/%s literals were found
by RIP-relative displacement scan of .text on the ON-DISK PE, at 0x18012f5b5,
0x18012b086 and 0x18013090e. Those three functions must turn out to be the URL
builders of three of the four classes. If they are not, the base-index reading of
column 1 is wrong.
"""
import traceback
try:
TARGETS = [
("ClubSearch.factory", 0x180123a60),
("ClubStats.factory", 0x180123a70),
("StaffStats.factory", 0x1801247e0),
("ConsumablesSearch.factory", 0x180123a80),
("CONTROL PurchasedItems.factory", 0x180124260),
("uses /stats/%s", 0x18012f5b5),
("uses /stats/staff", 0x18012b086),
("uses /consumables/%s", 0x18013090e),
("uses both enum tables", 0x180166306),
]
for label, ea in TARGETS:
f = func(ea)
print("=" * 78)
print("### %s @ %s -> %s" % (label, hex(ea), f.getName() if f else "NO FUNC"))
if f is None:
continue
print(" entry %s" % f.getEntryPoint())
src = dec(f)
print(" len(src) = %d" % len(src))
print(src)
except Exception:
traceback.print_exc()
@@ -0,0 +1,79 @@
"""q_cd_route_2 -- the ClubSearch (FutStickerBookSearch) query-string builder.
HYPOTHESIS
The club item list is the "StickerBookSearch" request (RS4 literal at 0x180221e48).
Its literal block holds "%s%s=%s" 0x180221e78, "%s%s=%d" 0x180221e80, "%s%s="
0x180221e94, "2017", "desc", "asc", so the query string is assembled key by key with
a separator+name+value printf, and the key names come out of the atom reverse
lookup FUN_180180cd0 exactly as they do in the /stats/%s builder FUN_18012f4f0
(already decompiled: 6 cases, atoms 0x87 club / 0x389 year / 0xbd country /
0x189 league / 0x1d7 newcards / 0xa5 consumables).
A RIP-displacement scan of .text on the on-disk PE puts every use of those three
printf formats in 0x18012deb0..0x18012e5bd, one contiguous region, plus two strays
at 0x180169357/0x1801693ac and one at 0x180163a1c.
CONTROL
FUN_18012f4f0 (/stats/%s) decompiled cleanly in q_cd_route_1 through the same
helpers, so a NO FUNC or an empty body here is a fact about this address, not about
the harness. Also included: the two enum reverse-lookups FUN_180166300 (table
0x180229ab0) and the sibling at 0x180166340 (table 0x180229c30), and their callers,
because those tables are the candidate ?type= vocabularies and a table with no
caller inside a URL builder proves nothing about the wire.
Absence discipline: the atom-id switch in a builder is a `switch` with case labels,
so it is enumerated by reading the decompile in full, not by grepping "== 0x".
"""
import traceback
try:
def show(label, ea, full=True):
f = func(ea)
print("=" * 78)
print("### %s @ %s -> %s" % (label, hex(ea), f.getName() if f else "NO FUNC"))
if f is None:
return None
print(" entry %s body %s" % (f.getEntryPoint(), f.getBody()))
src = dec(f)
print(" len(src) = %d" % len(src))
if full:
print(src)
return f
seen = set()
for ea in (0x18012deb0, 0x18012df5c, 0x18012e087, 0x18012e19f, 0x18012e202,
0x18012e256, 0x18012e2d0, 0x18012e322, 0x18012e5bd,
0x18012dfb0, 0x18012e003, 0x18012e36d, 0x18012e3b8, 0x18012e403,
0x18012e44e, 0x18012e499, 0x18012e0db):
f = fm.getFunctionContaining(addr(ea))
if f is None:
print("### query-fmt use @ %s -> NO FUNC" % hex(ea))
continue
k = int(f.getEntryPoint().getOffset())
if k in seen:
continue
seen.add(k)
show("query-fmt user (via %s)" % hex(ea), k)
for ea in (0x180169357, 0x180163a1c):
f = fm.getFunctionContaining(addr(ea))
if f is None:
print("### stray fmt use @ %s -> NO FUNC" % hex(ea))
continue
k = int(f.getEntryPoint().getOffset())
if k not in seen:
seen.add(k)
show("stray query-fmt user (via %s)" % hex(ea), k)
show("enum revlookup table 0x180229ab0", 0x180166300)
show("enum revlookup table 0x180229c30", 0x180166340)
for ea in (0x180166300, 0x180166340):
f = fm.getFunctionContaining(addr(ea))
if f is None:
continue
print("--- callers of %s:" % hex(ea))
for c in sorted(set(int(x.getEntryPoint().getOffset()) for x in f.getCallingFunctions(mon))):
print(" %s %s" % (hex(c), fm.getFunctionAt(addr(c)).getName()))
show("StaffStats suffix (/stats/staff user)", 0x18012b086)
except Exception:
traceback.print_exc()
@@ -0,0 +1,51 @@
"""q_cd_route_3 -- the ?type= vocabulary itself, and the remaining club value maps.
HYPOTHESIS
FUN_18012ddf0 (the ClubSearch / FutStickerBookSearch query builder, decompiled in
q_cd_route_2) writes the type= parameter from FUN_18012ec50(code), where code is
*(req+0x10) after a 3-case remap of *(req+0x14). FUN_18012ec50 is therefore the
COMPLETE code->string map for club?type=, and enumerating its cases (switch labels
AND == AND != AND any sub/dec ladder, read from a full-length decompile) closes the
vocabulary. The one branch that bypasses it is code 0xf, which picks
badge/kit/stadium/ball/equippables from *(req+0x30).
CONTROLS
* FUN_18012ec50 is decompiled alongside three sibling value maps used by the same
builder for other parameters -- FUN_1800d8b50 (position=), FUN_180166620
(formation=), FUN_18012ee20 (state=). If FUN_18012ec50 came back as a stub while
those three came back as real tables, the stub is a fact; if all four are stubs
the method is at fault.
* Both enum reverse-lookups over the two candidate tables are re-printed with their
callers, so "table 0x180229c30 is/is not the type= vocabulary" is decided by a
caller edge and not by the table's contents looking plausible.
* len(src) is printed for every function and every body is printed in full, so no
absence claim here rests on a truncated decompile.
"""
import traceback
try:
def show(label, ea):
f = fm.getFunctionContaining(addr(ea)) if ea else None
print("=" * 78)
print("### %s @ %s -> %s" % (label, hex(ea), f.getName() if f else "NO FUNC"))
if f is None:
return
print(" entry %s body %s" % (f.getEntryPoint(), f.getBody()))
src = dec(f)
print(" len(src) = %d" % len(src))
print(src)
try:
cs = sorted(set(int(x.getEntryPoint().getOffset())
for x in f.getCallingFunctions(mon)))
print(" callers: %s" % ", ".join(hex(c) for c in cs))
except Exception as e:
print(" callers: <%s>" % e)
show("type= value map FUN_18012ec50", 0x18012ec50)
show("position= value map FUN_1800d8b50", 0x1800d8b50)
show("formation= value map FUN_180166620", 0x180166620)
show("state= value map FUN_18012ee20", 0x18012ee20)
show("enum revlookup 0x180229ab0", 0x180166300)
show("enum revlookup 0x180229c30", 0x180166340)
except Exception:
traceback.print_exc()
@@ -0,0 +1,54 @@
"""q_cd_route_4 -- close the bound: how base+suffix are actually composed.
HYPOTHESIS
Column 1 of each 48-byte row of the 125-row action table at 0x1802caa20 is an index
into the 48-row URL-base table at 0x18021df80, and the URL is base-template then the
class's own suffix/query builder. Exactly four rows carry index 3 = "ut/%s/club".
If that is right, the club route family is closed by the table.
KNOWN SOFT SPOT, the reason for this query: no row carries index 43 = "ut/v2/%s/store",
yet ut/v2/%s/store is a live-proven route. So either the base index can be overridden
at runtime, or the base table is indexed from somewhere else as well. Until that is
settled the "exactly four" bound is a strong default, not an absolute.
CONTROL
The one RIP-relative reference to the action table found by scanning .text of the
on-disk PE is at 0x180123f46. Decompiling its container should show the row layout
being read (name, base index, upper name, flag, factory) -- if the field it reads at
+0x08 is NOT used as a table index, the base-index reading is wrong and every
"exactly four" statement in this dimension has to be withdrawn.
Second target: 0x18012ea74 is the only reference to the literal "club" at
0x180221e40, which sits inside the FutStickerBookSearch literal block. Printing it
says whether the club-search class composes its own base (which would make the base
table irrelevant for it) or uses the literal for something else entirely.
Everything is printed with len(src) and in full; no absence claim rests on a
truncated body.
"""
import traceback
try:
def show(label, ea):
f = fm.getFunctionContaining(addr(ea))
print("=" * 78)
print("### %s @ %s -> %s" % (label, hex(ea), f.getName() if f else "NO FUNC"))
if f is None:
return
print(" entry %s body %s" % (f.getEntryPoint(), f.getBody()))
src = dec(f)
print(" len(src) = %d" % len(src))
print(src)
try:
cs = sorted(set(int(x.getEntryPoint().getOffset())
for x in f.getCallingFunctions(mon)))
print(" callers: %s" % ", ".join(hex(c) for c in cs))
except Exception as e:
print(" callers: <%s>" % e)
show("action-table reader (only ref to 0x1802caa20)", 0x180123f46)
show("only ref to \"club\" literal 0x180221e40", 0x18012ea74)
show("URL append helper used by every suffix builder", 0x180008020)
show("StaffStats suffix thunk (raw addr 0x18012b080)", 0x18012b080)
except Exception:
traceback.print_exc()
@@ -0,0 +1,44 @@
"""q_cd_route_5 -- is column 1 of the action row really the URL-base index?
HYPOTHESIS
Row N of the action table 0x1802caa20 (48-byte rows) has column 1 = index into the
16-byte-stride (template, symbolic name) table at 0x18021df80, and the four club
actions carry index 3. RIP-displacement scan of .text on the on-disk PE finds only
four references anywhere near that table: 0x180123713 (-24), 0x180123790 (-64),
0x18012437e (+8) and 0x1801db62c (-64). Decompiling their containers should show
one of them doing table[i*2] / table[i*2+1] with i coming from the action row.
CONTROL / FALSIFIER
ut/v2/%s/store (index 43) is a LIVE-PROVEN route and NO action row carries index 43.
So if these functions show the index arriving only from the action row, the club
bound is exact for every action in the table but the store proves some other path
exists, and the bound must be stated with that caveat. If instead they show a
runtime override (a per-request base field, or a second table), the "exactly four
club actions" claim is a default and not a closure, and this query is what says so.
Every body is printed in full with len(src).
"""
import traceback
try:
def show(label, ea):
f = fm.getFunctionContaining(addr(ea))
print("=" * 78)
print("### %s @ %s -> %s" % (label, hex(ea), f.getName() if f else "NO FUNC"))
if f is None:
return
print(" entry %s body %s" % (f.getEntryPoint(), f.getBody()))
src = dec(f)
print(" len(src) = %d" % len(src))
print(src)
try:
cs = sorted(set(int(x.getEntryPoint().getOffset())
for x in f.getCallingFunctions(mon)))
print(" callers: %s" % ", ".join(hex(c) for c in cs))
except Exception as e:
print(" callers: <%s>" % e)
for ea in (0x180123713, 0x180123790, 0x18012437e, 0x1801db62c):
show("base-table ref", ea)
except Exception:
traceback.print_exc()
@@ -0,0 +1,196 @@
"""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()
@@ -0,0 +1,21 @@
"""D3 Q10 (control): is FUN_1800e2a40 really vtable slot +0x40 of the object that
FUN_18003e370 calls? The whole action-flag attribution rests on that. Find every
.rdata/.data table containing the pointer and print the slot index and neighbours;
slot +0x40 = index 8. CONTROL in the same run: FUN_18013fe00 is a known vtable
member? no -- instead use FUN_1801a7260, whose two DATA xrefs we already saw, and
check the pointer search finds them."""
import struct, traceback
try:
for tag, va in (("actionflags_1800e2a40", 0x1800e2a40),
("gate_1801a7260(CONTROL: 2 DATA xrefs expected)", 0x1801a7260)):
hits = find_all(struct.pack("<Q", va), blocks=(".rdata", ".data"))
print("=== %s %#x : %d pointer slots" % (tag, va, len(hits)))
for h in hits:
print(" at %#x" % h)
for k in range(-10, 4):
try: t = qword(h + k*8)
except Exception: continue
nm = fname(t) if 0x180000000 <= t < 0x181000000 else ""
print(" slot %+#5x %#x %s" % (k*8, t, nm))
except Exception:
traceback.print_exc()
@@ -0,0 +1,53 @@
"""D3 Q11: fix the failed control from Q10.
Q10's control was INVALID BY CONSTRUCTION, not a scan failure: xrefs_to(0x1801a7260)
reported two DATA references, but those live at 0x180284468 / 0x180300d1c which are
4-byte RVA entries (RVA of the function is 0x1a7260) in the exception/unwind tables,
so an 8-byte-pointer search can never find them. Re-do the control properly and then
answer the real question: what is the slot INDEX of FUN_1800e2a40 in the table that
contains it? The action-flag attribution needs index 8 (= vtable +0x40).
CONTROL (valid this time): the same 8-byte pointer search must find FUN_18013fe00,
which we know sits nowhere in a vtable, AND must find the item vtable 0x1801eaac0's
own three slots. Concretely: read 0x1801eaac0 and confirm its slots are .text
addresses -- an 8-byte-pointer table we have already relied on.
"""
import struct, traceback
try:
print("### the 4-byte RVA check that explains the Q10 control failure")
for a in (0x180284468, 0x180300d1c):
try:
v = dword(a)
print(" %#x -> dword %#x (RVA of 0x1801a7260 is 0x1a7260)" % (a, v))
except Exception as e:
print(" %#x unreadable %s" % (a, e))
print()
print("### CONTROL: the known item vtable 0x1801eaac0")
for k in range(4):
t = qword(0x1801eaac0 + k*8)
print(" slot %+#4x %#x %s" % (k*8, t, fname(t) if 0x180000000 <= t < 0x181000000 else ""))
print()
print("### walk back from 0x180215b50 to the start of its pointer table")
a = 0x180215b50
start = a
while True:
p = a - 8
try:
t = qword(p)
except Exception:
break
if not (0x180001000 <= t < 0x1801d0000):
break
start = p
a = p
if 0x180215b50 - start > 0x800:
break
print(" table start %#x, FUN_1800e2a40 is at %#x -> slot %#x (index %d)"
% (start, 0x180215b50, 0x180215b50 - start, (0x180215b50 - start)//8))
for off in range(0, 0x180215b50 - start + 0x40, 8):
t = qword(start + off)
print(" %+#6x %#x %s" % (off, t, fname(t) if 0x180000000 <= t < 0x181000000 else ""))
except Exception:
traceback.print_exc()
@@ -0,0 +1,22 @@
"""D3 Q12: close the last gap -- is FUN_1800e2a40 the thing FUN_18003e370 calls?
Its only 8-byte pointer slot is at +0xd0 of one table, not +0x40, so either the call
goes through a thunk or the attribution is wrong. Print all callers, and print the
service constructor FUN_180018bd0 so the interface can be named.
CONTROL: the semantic anchor is independent of the vtable -- byte[2] of the output
array is computed as (isPlayer && squadHasRoom && !alreadyInSquad), which can only be
TO_ACTIVE_SQUAD, the 3rd of the eight names. If callers show a thunk, both lines of
evidence agree; if not, the semantic anchor still stands alone."""
import traceback
try:
print("### callers of FUN_1800e2a40")
for c in callers(0x1800e2a40):
print(" ", c)
for frm, typ, fn, ent in xrefs_to(0x1800e2a40):
print(" xref %#x %s in %s (%#x)" % (frm, typ, fn, ent))
print()
print("### FUN_180018bd0 (the service FUN_18003e370 asks for)")
print(dec(0x180018bd0))
print("### FUN_18003cbf0 (CARD_ID extraction, 2nd arg to slot 0x40)")
print(dec(0x18003cbf0))
except Exception:
traceback.print_exc()
@@ -0,0 +1,167 @@
"""D3 Q2: the itemState string<->enum functions, and who READS the state slots.
ESTABLISHED BEFORE THIS QUERY (q_cd_state_1 + live read, both controls passed):
the deser's stack struct base is local_188 (anchored by local_118 = &PTR_LAB_1801eaac0
landing at record+0x70, the known item vtable), so record_off = 0x188 - N.
itemState (atom 0x172) -> FUN_180166660(str) -> record +0x5c (live: 1 == "free")
owners (atom 0x207) -> record +0x48 u8 (live: 1)
untradeable(atom 0x361)-> record +0x49 u8 = (untradeable == FALSE), a TRADEABLE flag
(live: 0 on all 22)
the enum table is at 0x180229cc0 (NOT 0x180229d20, which is mid-table):
invalid 0, free 1, WAITING_FOR_GAME 2, inGame 2, forSale 5, offered 6,
activeBadge 0x64, activeHomeKit 0x65, activeAwayKit 0x66, activeBall 0x67,
activeStadium 0x68, active 0xff, terminator {NULL, 0xffffffff}
HYPOTHESIS: FUN_180166660 walks that table and returns a default for no-match; there is
a matching enum->string writer used when the client SENDS itemState back; and the UI
gating for "list on transfer market" / "quick sell" reads +0x49 and/or +0x5c.
CONTROL for the reader scan (same syntactic form as the target -- a memory operand with
a small displacement): the scan must also find the KNOWN readers of +0x38/+0x3c
(discardValue) and of +0x70 (the vtable). If displacement 0x38 or 0x70 comes back with
zero hits the scan is broken and every absence below is void.
OUTPUT: full decompiles (len printed), full table walk, full grouped reader 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)
STATE_VALUES = {0, 1, 2, 5, 6, 0x64, 0x65, 0x66, 0x67, 0x68, 0xff}
def dump(tag, va):
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)
print(src)
with open(OUT + "q2_%s_%x.c" % (tag, va), "w") as fh:
fh.write(src)
return src
try:
print("###### A. string->enum helpers used by the item deser")
for tag, va in (("itemState_0x172", 0x180166660),
("injuryType_0x168", 0x1801666f0),
("prefPos_0x24a", 0x180166810),
("resourceId_split_0x287", 0x180166ca0),
("playStyle_0x23f", 0x180136480)):
dump(tag, va)
print()
print("###### B. every xref into the itemState table block 0x180229cc0..0x180229d80")
for a in range(0x180229cc0, 0x180229d90, 8):
xs = xrefs_to(a)
for frm, typ, fn, ent in xs:
print(" table%+#5x %#x xref from %#x %s in %s (%#x)"
% (a - 0x180229cc0, a, frm, typ, fn, ent))
print()
print("###### C. xrefs to each itemState string literal (finds the enum->string writer)")
for name in (b"invalid\x00", b"free\x00", b"WAITING_FOR_GAME\x00", b"inGame\x00",
b"forSale\x00", b"offered\x00", b"activeBadge\x00", b"activeHomeKit\x00",
b"activeAwayKit\x00", b"activeBall\x00", b"activeStadium\x00",
b"tradepile\x00", b"club\x00", b"trade\x00", b"pile\x00"):
hits = find_all(name)
print(" %-20s occurrences=%d" % (name.decode().strip("\x00"), len(hits)))
for h in hits:
for frm, typ, fn, ent in xrefs_to(h):
print(" @%#x xref %#x %s in %s (%#x)" % (h, frm, typ, fn, ent))
print()
print("###### D. callers of FUN_180166660 (every itemState parse site)")
try:
for c in callers(0x180166660):
print(" caller", c)
except Exception as e:
print(" callers() failed:", e)
print()
print("###### E. reader census: instructions with memory displacement 0x49 / 0x5c")
print(" CONTROLS in the same scan: 0x38 and 0x70")
want = {0x49: [], 0x5c: [], 0x38: [], 0x70: []}
total = 0
it = listing.getInstructions(True)
while it.hasNext():
ins = it.next()
total += 1
try:
n = ins.getNumOperands()
except Exception:
continue
for i in range(n):
try:
objs = ins.getOpObjects(i)
except Exception:
continue
if len(objs) < 2:
continue
has_reg = any(hasattr(o, "getName") for o in objs)
if not has_reg:
continue
for o in objs:
v = None
try:
v = int(o.getValue())
except Exception:
continue
if v in want:
f = fm.getFunctionContaining(ins.getAddress())
want[v].append((int(ins.getAddress().getOffset()),
f.getName() if f else "?",
int(f.getEntryPoint().getOffset()) if f else 0,
str(ins)))
print(" scanned %d instructions" % total)
for d in (0x38, 0x70, 0x49, 0x5c):
print(" disp %#04x -> %d hits (CONTROL)" % (d, len(want[d]))
if d in (0x38, 0x70) else " disp %#04x -> %d hits" % (d, len(want[d])))
print()
print("###### F. functions touching [reg+0x49], grouped (this displacement is rare)")
from collections import defaultdict
g = defaultdict(list)
for a, fn, ent, txt in want[0x49]:
g[(ent, fn)].append((a, txt))
for (ent, fn), lst in sorted(g.items()):
print(" %s (%#x) n=%d" % (fn, ent, len(lst)))
for a, txt in lst:
print(" %#x %s" % (a, txt))
print()
print("###### G. functions touching [reg+0x5c] that ALSO contain an itemState value")
g2 = defaultdict(list)
for a, fn, ent, txt in want[0x5c]:
g2[(ent, fn)].append((a, txt))
print(" total distinct functions touching +0x5c: %d" % len(g2))
for (ent, fn), lst in sorted(g2.items()):
if not ent:
continue
f = func(ent)
if f is None:
continue
imms = set()
it2 = listing.getInstructions(f.getBody(), True)
while it2.hasNext():
ins = it2.next()
for i in range(ins.getNumOperands()):
for o in ins.getOpObjects(i):
try:
imms.add(int(o.getValue()))
except Exception:
pass
hit = imms & {0x64, 0x65, 0x66, 0x67, 0x68, 0xff, 5, 6}
if 0x64 in imms or 0x67 in imms or 0x68 in imms:
print(" *** %s (%#x) n=%d state-ish immediates=%s"
% (fn, ent, len(lst), sorted(hex(x) for x in hit)))
for a, txt in lst:
print(" %#x %s" % (a, txt))
except Exception:
traceback.print_exc()
@@ -0,0 +1,170 @@
"""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()
@@ -0,0 +1,139 @@
"""D3 Q4: who CONSUMES the state slots, and the transfer/quick-sell vocabulary.
The q3 publisher hunt had a control that came back empty for a reason I can state:
FUN_18013fe00 builds the record on the STACK (RSP-relative), and I had excluded
RSP/RBP bases, so the deser cannot appear. That control was therefore uninformative,
not passed. Here the fingerprint is rebuilt around offsets that are only meaningful on
a fully built item record and that a consumer must reach through a register:
0x3c discardValue-computed, 0xb4 rating, 0x146 preferredPosition, 0x148 nation,
0x154 leagueId, 0x94 teamid, 0x5c itemState, 0x49 tradeable-flag, 0x60 pile
CONTROL for this scan: it must rediscover functions that touch 0xb4 AND 0x146 AND
0x148 together, because the UI certainly draws rating, position and nation from the
same object. Zero such functions => the scan is broken and nothing below counts.
Also decompiled in full:
FUN_1800515e0 touches all five of {0x38,0x3c,0x49,0x5c,0x60} -- candidate item
copy-constructor, which if true independently re-derives the layout
FUN_180130d10 the untradeableCount deser that WRITES [reg+0x49]
FUN_180128600 / FUN_180128e30 the only two deserializers that parse `pile` 0x226
FUN_18013e410 tradeId 0x331 + tradeState 0x335
FUN_180148b70 the second `untradeable` 0x361 arm
FUN_180138e10 itemLoans 0x16f + duplicateItemLoans 0xed
And a literal hunt for the transfer-market / quick-sell vocabulary with xrefs.
"""
import traceback, os
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)
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 + "q4_%s_%x.c" % (tag, va), "w") as fh:
fh.write(src)
return src
try:
print("###### A. item-record consumer fingerprint over the whole .text")
disp_of = defaultdict(set)
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 or 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:
ff = fm.getFunctionContaining(ins.getAddress())
if ff is None:
break
f = int(ff.getEntryPoint().getOffset())
disp_of[f].add(v)
print(" scanned %d instructions" % tot)
CTRL = {0xb4, 0x146, 0x148}
ctrl_fns = [e for e, d in disp_of.items() if CTRL <= d]
print(" CONTROL {0xb4,0x146,0x148} all present in %d functions: %s"
% (len(ctrl_fns), [("%s(%#x)" % (fname(e), e)) for e in sorted(ctrl_fns)][:20]))
ITEM = {0x3c, 0xb4, 0x146, 0x148, 0x154, 0x94, 0x5c, 0x49, 0x60, 0x38, 0x50, 0x58}
scored = sorted(((len(ITEM & d), e, sorted(ITEM & d)) for e, d in disp_of.items()),
reverse=True)
print(" top item-record consumers by fingerprint overlap:")
for k, e, s in scored[:40]:
if k < 5:
break
print(" %2d/%d %#x %-20s %s" % (k, len(ITEM), e, fname(e), [hex(x) for x in s]))
print(" of those, the ones that ALSO touch 0x5c or 0x49:")
for k, e, s in scored[:200]:
if k < 4:
break
if 0x5c in s or 0x49 in s:
print(" %2d/%d %#x %-20s %s" % (k, len(ITEM), e, fname(e), [hex(x) for x in s]))
print()
print("###### B. full decompiles")
for tag, va in (("copyctor_cand", 0x1800515e0),
("untradeableCount_deser", 0x180130d10),
("pile_deser_a", 0x180128600),
("pile_deser_b", 0x180128e30),
("tradeId_tradeState_deser", 0x18013e410),
("untradeable_2nd", 0x180148b70),
("itemLoans_deser", 0x180138e10)):
dump(tag, va)
print()
print("###### C. transfer / sell / list vocabulary in .rdata, with xrefs")
NEEDLES = [b"TRANSFER", b"Transfer", b"transfer", b"QUICK_SELL", b"QuickSell",
b"quickSell", b"DISCARD", b"Discard", b"TRADEABLE", b"tradeable",
b"Tradeable", b"UNTRADEABLE", b"LIST_ON", b"tradepile", b"TRADE_PILE",
b"canBeSold", b"isTradeable", b"AUCTION", b"auctionhouse"]
seen = set()
for nd in NEEDLES:
hits = find_all(nd, blocks=(".rdata", ".data"))
print(" needle %-14s hits=%d" % (nd.decode(), len(hits)))
for h in hits[:80]:
# back up to the start of the C string
st = h
for _ in range(96):
try:
if mem.getByte(addr(st - 1)) == 0:
break
except Exception:
break
st -= 1
if st in seen:
continue
seen.add(st)
s = rd_str(st, 120)
xs = xrefs_to(st)
fns = sorted({(fn, ent) for _, _, fn, ent in xs if ent})
print(" %#x %-58r xrefs=%d %s" % (st, s, len(xs),
["%s(%#x)" % (n, e) for n, e in fns][:6]))
except Exception:
traceback.print_exc()
@@ -0,0 +1,142 @@
"""D3 Q5: the UI view-model accessors over the item record, and the enum tables for
pile / tradeState / bidState.
WHAT LED HERE. FUN_1801a8940 is a nine-byte getter `return *(*(this+0x18) + 0x49)` and
FUN_1801a89f0 is `return *(*(this+0x18) + 0x48)`: there is a wrapper class holding the
item record at +0x18 and exposing its fields one accessor at a time. FUN_1801a7260 is a
PREDICATE in the same region that reads item+0x49, item+0x4c, item+0x50 and item+0x145
and returns 0 or 1 -- exactly the shape of a "is this menu entry enabled" test, which is
the open live question (Place on Transfer List / List on Transfer Market greyed out).
HYPOTHESIS: enumerating every small function of the form `*(*(param_1+0x18) + N)` gives
the COMPLETE list of item fields the UI can see, and the predicates in the same region
give the gating rules. If itemState (+0x5c) has an accessor, the lifecycle table is
reachable; if it has none, itemState reaches the UI some other way and I must say so.
CONTROL: the accessor enumeration must find offsets we already know the UI displays --
+0x38/+0x3c (discardValue, confirmed on screen today) and +0xb4 (rating). If those come
back with no accessor the enumeration is looking at the wrong class and proves nothing.
Also: pile string->enum FUN_180142650 (used by the PUT /item verdict deser),
tradeState FUN_180166bd0, bidState FUN_180166380, with their tables walked.
"""
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)
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 + "q5_%s_%x.c" % (tag, va), "w") as fh:
fh.write(src)
return src
def walk_table(base, tag, lo=-0x100, hi=0x200):
print(" --- table %s at %#x" % (tag, base))
for off in range(lo, hi, 0x10):
a = base + off
try:
p = qword(a)
q = qword(a + 8)
except Exception:
continue
s = rd_str(p, 64) if 0x180000000 <= p < 0x181000000 else ""
print(" %#x (%+#5x) p=%#014x q=%#010x %r" % (a, off, p, q, s))
try:
print("###### A. accessor enumeration over the whole DLL")
print(" every function <= 0x40 bytes whose decompile is a single deref of")
print(" *(param_1 + 0x18) or *(param_1 + 0x10) plus a constant offset")
pat18 = re.compile(r"\(param_1 \+ 0x18\) \+ (0x[0-9a-f]+|\d+)\)")
pat18b = re.compile(r"\*\(longlong \*\)\(param_1 \+ 0x18\)\)")
pat10 = re.compile(r"\(param_1 \+ 0x10\) \+ (0x[0-9a-f]+|\d+)\)")
acc18 = defaultdict(list)
acc10 = defaultdict(list)
n_small = 0
fit = fm.getFunctions(True)
while fit.hasNext():
f = fit.next()
sz = int(f.getBody().getNumAddresses())
if sz > 0x40:
continue
n_small += 1
ent = int(f.getEntryPoint().getOffset())
try:
src = dec(ent, 30)
except Exception:
continue
for m in pat18.finditer(src):
acc18[int(m.group(1), 0)].append((ent, f.getName(), sz))
for m in pat10.finditer(src):
acc10[int(m.group(1), 0)].append((ent, f.getName(), sz))
if pat18b.search(src) and "+ 0x18" in src and not pat18.search(src):
acc18[0].append((ent, f.getName(), sz))
print(" scanned %d small functions" % n_small)
print(" --- accessors on *(this+0x18) + N (N = item record offset)")
for off in sorted(acc18):
for ent, nm, sz in acc18[off]:
print(" +%#-6x %s (%#x) size=%d" % (off, nm, ent, sz))
print(" CONTROL: accessors exist for +0x38/+0x3c/+0xb4? %s"
% {hex(k): len(acc18.get(k, [])) for k in (0x38, 0x3c, 0xb4)})
print(" itemState +0x5c accessor count: %d" % len(acc18.get(0x5c, [])))
print(" --- accessors on *(this+0x10) + N")
for off in sorted(acc10):
for ent, nm, sz in acc10[off]:
print(" +%#-6x %s (%#x) size=%d" % (off, nm, ent, sz))
print()
print("###### B. the gate predicate and its neighbours")
for tag, va in (("gate_1801a7260", 0x1801a7260), ("helper_1801a8900", 0x1801a8900),
("get49_1801a8940", 0x1801a8940), ("get48_1801a89f0", 0x1801a89f0),
("f5c_1801a5a30", 0x1801a5a30), ("f5c_1801a5a50", 0x1801a5a50),
("f5c_1801a5aa0", 0x1801a5aa0), ("f5c_1801a5ac0", 0x1801a5ac0),
("f5c_1801a5ae0", 0x1801a5ae0), ("f5c_1801a7040", 0x1801a7040)):
dump(tag, va)
print()
print("###### C. who references the gate predicate (vtable slot or direct call)")
for va in (0x1801a7260, 0x1801a8940, 0x1801a89f0):
print(" --- xrefs to %#x" % va)
for frm, typ, fn, ent in xrefs_to(va):
print(" %#x %s in %s (%#x)" % (frm, typ, fn, ent))
# vtable membership: any .rdata qword equal to va
import struct
hits = find_all(struct.pack("<Q", va), blocks=(".rdata", ".data"))
for h in hits:
print(" IN TABLE at %#x ; neighbours:" % h)
for k in range(-3, 6):
try:
t = qword(h + k * 8)
except Exception:
continue
print(" %+2d %#x %s" % (k, t, fname(t) if 0x180000000 <= t < 0x181000000 else ""))
print()
print("###### D. pile / tradeState / bidState string->enum")
for tag, va in (("pile_enum_180142650", 0x180142650),
("tradeState_180166bd0", 0x180166bd0),
("bidState_180166380", 0x180166380)):
src = dump(tag, va)
m = re.search(r"PTR_[A-Za-z_0-9]*_(1[0-9a-f]{8})", src or "")
if m:
walk_table(int(m.group(1), 16), tag, 0, 0x120)
else:
m2 = re.search(r"DAT_(1[0-9a-f]{8})", src or "")
if m2:
walk_table(int(m2.group(1), 16) - 8, tag, 0, 0x120)
except Exception:
traceback.print_exc()
@@ -0,0 +1,129 @@
"""D3 Q6: the item ACTION vocabulary (TO_TRADE_PILE / DISCARD ...) and every consumer
of the tradeable flag; plus a precise search for code that compares item+0x5c against
an itemState value.
WHAT LED HERE. 'TO_TRADE_PILE' (0x1801f4d48) and 'DISCARD' (0x1801f4d28) are adjacent
in .rdata and BOTH are referenced by the single function FUN_18003e370, which also
manipulates a byte at +0x49 -- the tradeable flag's offset. That is the action-menu
builder, i.e. the thing that greys entries out.
CONTROL for the string window: the window must also contain other action names we can
recognise as menu entries (not random data); if the neighbourhood is unreadable garbage
the window is wrong and the identification is not made.
CONTROL for the itemState-value scan: the same look-ahead machinery, pointed at
displacement 0x3c with the constant 0, must rediscover the KNOWN discard guard
`if ((int)local_150 == 0)`-style tests. If the look-ahead finds nothing anywhere the
scan is broken and its silence about +0x5c means nothing.
"""
import traceback, os
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)
STATES = {1, 2, 5, 6, 0x64, 0x65, 0x66, 0x67, 0x68, 0xff, 0xffffffff}
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 + "q6_%s_%x.c" % (tag, va), "w") as fh:
fh.write(src)
return src
try:
print("###### A. the action-name neighbourhood in .rdata")
a = 0x1801f4a00
while a < 0x1801f5200:
s = rd_str(a, 120)
if s and all(32 <= ord(c) < 127 for c in s):
xs = xrefs_to(a)
fns = sorted({(fn, e) for _, _, fn, e in xs if e})
print(" %#x %-46r xrefs=%d %s" % (a, s, len(xs),
["%s(%#x)" % (n, e) for n, e in fns][:5]))
a += len(s) + 1
else:
a += 1
print()
print("###### B. the action-menu builder and the tradeable-flag consumers")
for tag, va in (("actionmenu_18003e370", 0x18003e370),
("call_get49_1800bc580", 0x1800bc580),
("call_gate_1800e2a40", 0x1800e2a40),
("call_get48_1800d0600", 0x1800d0600),
("call_get48_1800e4fd0", 0x1800e4fd0)):
dump(tag, va)
print()
print("###### C. precise scan: [reg+DISP] loaded then compared to a constant")
print(" targets DISP=0x5c (itemState) and DISP=0x49 (tradeable)")
print(" CONTROL DISP=0x3c and DISP=0x4c, which we know are compared")
res = defaultdict(list)
for DISP in (0x5c, 0x49, 0x3c, 0x4c):
it = listing.getInstructions(True)
window = []
while it.hasNext():
ins = it.next()
window.append(ins)
if len(window) > 12:
window.pop(0)
s0 = str(window[0])
if ("+ %#x]" % DISP) not in s0:
continue
if "RSP" in s0 or "RBP" in s0:
continue
mn = window[0].getMnemonicString().upper()
if mn == "CMP":
# direct compare with immediate
try:
v = int(window[0].getOpObjects(1)[0].getValue())
except Exception:
v = None
f = fm.getFunctionContaining(window[0].getAddress())
res[DISP].append((int(window[0].getAddress().getOffset()),
f.getName() if f else "?", "DIRECT", v, s0))
continue
if mn not in ("MOV", "MOVZX", "MOVSX", "MOVSXD"):
continue
try:
dst = str(window[0].getOpObjects(0)[0])
except Exception:
continue
for nxt in window[1:]:
sn = str(nxt)
mn2 = nxt.getMnemonicString().upper()
if mn2 in ("CMP", "SUB", "TEST") and dst.replace("R", "E") in sn.replace("R", "E"):
try:
v = int(nxt.getOpObjects(1)[0].getValue())
except Exception:
v = None
f = fm.getFunctionContaining(window[0].getAddress())
res[DISP].append((int(window[0].getAddress().getOffset()),
f.getName() if f else "?", mn2, v, s0 + " ; " + sn))
break
print(" DISP %#04x -> %d load/compare pairs" % (DISP, len(res[DISP])))
for DISP in (0x3c, 0x4c, 0x49, 0x5c):
tag = "CONTROL" if DISP in (0x3c, 0x4c) else "TARGET"
print(" --- %s DISP %#04x, constants seen: %s"
% (tag, DISP, sorted({v for _, _, _, v, _ in res[DISP] if v is not None})[:40]))
print()
print(" --- every DISP 0x5c compare whose constant is an itemState value")
for a, fn, kind, v, txt in res[0x5c]:
if v in STATES:
print(" %#x %-20s %s %s | %s" % (a, fn, kind, hex(v), txt))
print()
print(" --- every DISP 0x49 compare")
for a, fn, kind, v, txt in res[0x49]:
print(" %#x %-20s %s %s | %s" % (a, fn, kind, v if v is None else hex(v), txt))
except Exception:
traceback.print_exc()
@@ -0,0 +1,97 @@
"""D3 Q7: the eight card-action flags, and the code that switches on itemState.
ESTABLISHED. FUN_18003e370 publishes eight per-card booleans to Flash under the names
DISCARD, MODIFY, TO_ACTIVE_SQUAD, TO_TRADE_PILE, TO_STICKER_BOOK, MAY_BE_REMOVED,
QUICK_SEARCH, DREAM_REPLACE, filled by FUN_1800e2a40 in that byte order:
[0] FUN_1801a71c0 [1] FUN_1801a7210 [2] inline (isPlayer && squad room && not in
squad) [3] FUN_1801a7260 [4] FUN_1801a7180 [5] constant 1 [6] FUN_1801a7320
[7] FUN_1801a71e0
and a precise, controlled scan (control displacements 0x3c and 0x4c returned 25 and 37
load/compare pairs) showed that item+0x49 is compared in EXACTLY TWO places in the
whole DLL -- the getter FUN_1801a8940 and the TO_TRADE_PILE predicate FUN_1801a7260 --
while item+0x5c is compared against 0x64..0x68 in six functions.
POLARITY IS THE OPEN QUESTION. Byte [2] is computed inline as
isPlayer && squadHasRoom && !alreadyInSquad -> 1
which can only be an ENABLE flag, so 1 = action offered. Under that reading
FUN_1801a7260 returns 1 (offered) whenever item+0x49 is 0, which is the opposite of
what "tradeable" should do. Either the flag array is a DISABLE mask, or the service
call at vtable+0x270 inverts the sense. This query decompiles all eight predicates and
the service so the polarity is READ, not assumed.
CONTROL: FUN_1801a7250 is already known to be the +0x4c (card family) accessor and byte
[2] uses it as "is a player". If the decompile of FUN_1801a7250 is not a +0x4c read the
whole byte-order attribution is wrong and nothing here counts.
"""
import traceback, os
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/cards/"
os.makedirs(OUT, exist_ok=True)
def dump(tag, va, full=6000):
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" % (tag, va, f.getName(), len(src)))
print("=" * 78)
with open(OUT + "q7_%s_%x.c" % (tag, va), "w") as fh:
fh.write(src)
if len(src) <= full:
print(src, " [PRINTED IN FULL]")
else:
print(" [TOO LONG TO PRINT IN FULL -- written to q7_%s_%x.c; printing every"
" line mentioning 0x5c plus 6 lines of context]" % (tag, va))
lines = src.split("\n")
keep = set()
for i, l in enumerate(lines):
if "0x5c" in l or "0x49" in l or "0x48" in l:
for j in range(max(0, i - 6), min(len(lines), i + 7)):
keep.add(j)
prev = -2
for i in sorted(keep):
if i != prev + 1:
print(" ...")
print(" %4d %s" % (i, lines[i]))
prev = i
return src
try:
print("###### A. the eight card-action predicates, in Flash byte order")
for tag, va in (("a0_DISCARD_1801a71c0", 0x1801a71c0),
("a1_MODIFY_1801a7210", 0x1801a7210),
("a2_family_acc_1801a7250", 0x1801a7250),
("a3_TO_TRADE_PILE_1801a7260", 0x1801a7260),
("a4_TO_STICKER_BOOK_1801a7180", 0x1801a7180),
("a6_QUICK_SEARCH_1801a7320", 0x1801a7320),
("a7_DREAM_REPLACE_1801a71e0", 0x1801a71e0),
("view_init_1801a78f0", 0x1801a78f0),
("view_isnull_1801a8850", 0x1801a8850),
("view_4c_1801a8110", 0x1801a8110),
("helper_1801aa190", 0x1801aa190)):
dump(tag, va)
print()
print("###### B. the itemState (0x5c) switches")
for tag, va in (("s_180084720", 0x180084720), ("s_180094220", 0x180094220),
("s_180043880", 0x180043880), ("s_180094ae0", 0x180094ae0),
("s_180113870", 0x180113870), ("s_1801c3480", 0x1801c3480),
("s_1801b3640", 0x1801b3640), ("s_18011dc50", 0x18011dc50),
("s_180051cd0", 0x180051cd0)):
dump(tag, va)
print()
print("###### C. the CardInventoryAdapter registration (what else it publishes)")
dump("adapter_18003ec30", 0x18003ec30)
dump("listpanel_18003e550", 0x18003e550)
print()
print("###### D. the service behind FUN_180009c80 -- what is vtable slot 0x270?")
dump("svc_ctor_180009c80", 0x180009c80)
dump("svc_ctor_180009b60", 0x180009b60)
except Exception:
traceback.print_exc()
@@ -0,0 +1,123 @@
"""D3 Q8: close out the lifecycle table -- is forSale(5)/offered(6) ever tested, and
what is the item-record pile (+0x60) vocabulary?
ESTABLISHED SO FAR. itemState lands at item+0x5c; 0x64..0x68 publish the Flash boolean
IS_ACTIVE (three independent publishers agree, one of them as the range test
`state - 100 < 5`); the equip path FUN_180113870 writes 1 back into +0x5c when it
unequips and 0x67 when it equips; the squad code FUN_1801b3640 accepts state 1 and
state 2. The earlier precise scan reported the constants compared against [reg+0x5c]
anywhere in the DLL as {-1,0,1,2,3,100,101,102,103,104} -- 5 and 6 absent -- but that
population mixes several unrelated structs, so this query re-runs it printing EVERY hit
with its function, and adds the `SUB/DEC ladder` and `switch jump table` forms that a
plain compare scan cannot see.
CONTROL: the same three forms, pointed at [reg+0x60], must rediscover the values we
have already measured live in the item record (1 for club items, 6 for purchased) and
the value 4 we just read in FUN_1801c3480. If 1/4/6 do not come out of the scan it is
not seeing item-record pile tests and its silence proves nothing.
"""
import traceback, os
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)
def dump(tag, va, full=7000):
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" % (tag, va, f.getName(), len(src)))
print("=" * 78)
with open(OUT + "q8_%s_%x.c" % (tag, va), "w") as fh:
fh.write(src)
print(src if len(src) <= full else " [long; written to file]")
return src
try:
for DISP in (0x5c, 0x60):
print()
print("###### displacement %#x : every compare / ladder / jump-table dispatch" % DISP)
it = listing.getInstructions(True)
window = []
seen = []
while it.hasNext():
ins = it.next()
window.append(ins)
if len(window) > 16:
window.pop(0)
s0 = str(window[0])
if ("+ %#x]" % DISP) not in s0 or "RSP" in s0 or "RBP" in s0:
continue
mn = window[0].getMnemonicString().upper()
f = fm.getFunctionContaining(window[0].getAddress())
fn = f.getName() if f else "?"
a0 = int(window[0].getAddress().getOffset())
if mn == "CMP":
try:
v = int(window[0].getOpObjects(1)[0].getValue())
except Exception:
v = None
seen.append((a0, fn, "CMP", v, s0))
continue
if mn not in ("MOV", "MOVZX", "MOVSX", "MOVSXD"):
continue
try:
dst = str(window[0].getOpObjects(0)[0])
except Exception:
continue
key = dst.replace("R", "E")
run = 0
for nxt in window[1:]:
sn = str(nxt)
m2 = nxt.getMnemonicString().upper()
if key not in sn.replace("R", "E"):
continue
if m2 in ("CMP", "TEST"):
try:
v = int(nxt.getOpObjects(1)[0].getValue())
except Exception:
v = None
seen.append((a0, fn, m2, v, s0 + " ; " + sn))
break
if m2 in ("SUB", "DEC", "ADD", "INC"):
try:
d = 1 if m2 == "DEC" else (-1 if m2 == "INC"
else int(nxt.getOpObjects(1)[0].getValue()))
except Exception:
break
run += d if m2 in ("SUB", "DEC") else -d
seen.append((a0, fn, "LADDER@%d" % run, run, s0 + " ; " + sn))
continue
if m2 == "JMP":
seen.append((a0, fn, "JUMPTABLE", None, s0 + " ; " + sn))
break
print(" %d hits" % len(seen))
by_fn = defaultdict(list)
for a0, fn, kind, v, txt in seen:
by_fn[fn].append((a0, kind, v, txt))
for fn in sorted(by_fn):
vals = sorted({v for _, _, v, _ in by_fn[fn] if v is not None})
print(" %-22s n=%-3d constants=%s" % (fn, len(by_fn[fn]), vals))
allv = sorted({v for _, _, _, v, _ in seen if v is not None})
print(" ALL CONSTANTS for disp %#x: %s" % (DISP, allv))
if DISP == 0x60:
print(" CONTROL -- are the live-measured pile values 1, 4 and 6 present? %s"
% {k: (k in allv) for k in (1, 4, 6)})
if DISP == 0x5c:
print(" are forSale(5) and offered(6) present? %s"
% {k: (k in allv) for k in (5, 6)})
for a0, fn, kind, v, txt in seen:
if v in (5, 6, 3, 0xff, -1):
print(" %#x %-22s %s %s | %s" % (a0, fn, kind, v, txt))
print()
print("###### the remaining itemState readers")
for tag, va in (("s_18011dc50", 0x18011dc50), ("s_180094ae0", 0x180094ae0)):
dump(tag, va)
except Exception:
traceback.print_exc()
@@ -0,0 +1,18 @@
"""D3 Q9 (tail): is the itemState string match case-sensitive, and what is the
FUN_18011dc50 `state == 1` test? CONTROL: FUN_180008190 is also used for the
literal "dds" and "highest"/"outbid"/"buyNow", so whatever it is, it is a plain
string compare; the only open point is case folding."""
import traceback
try:
for tag, va in (("strcmp_180008190", 0x180008190), ("s_18011dc50", 0x18011dc50)):
f = func(va)
src = dec(va)
print("=" * 78); print("%s %#x %s len=%d" % (tag, va, f.getName() if f else "?", len(src)))
print("=" * 78); print(src)
print("### raw disasm of FUN_180008190")
f = func(0x180008190)
it = listing.getInstructions(f.getBody(), True)
while it.hasNext():
i = it.next(); print(" %#x %s" % (int(i.getAddress().getOffset()), i))
except Exception:
traceback.print_exc()