fifa17-recon: the real quick-sell table, and the grouping bug is not in our layer

Multi-agent pass over the store subsystem, 11 agents, findings run through three
adversarial verifiers. Full writeup in docs/plan-2026-08-05-store-subsystem.md.

THE REAL DISCARD TABLE IS RECOVERED. quick_sell() paid an invented rating tier
(600/300/150/50) that was wrong for every single card. The real table is
fcc_discardcoins in the client's own game DB, 141 rows keyed (cardtype, level, rare),
read out of the running client and verified 22/22 against live items:

    value = round_half_up(rating * price / 100)
    level    = 3 if rating >= 75, 2 if 65..74, else 1   (0x180141e8a..0x180141ea3,
               derived from rating, NOT a wire field)
    cardtype = FUN_1800d8330(cardsubtypeid), decoded from its jump table and checked
               across every subtype 0..599 with zero disagreements

A 94-rated gold rare is 752, not 600. A 76 rare is 608, not 150. A 55 bronze is 17,
not 50.

This also closes a disagreement nobody had noticed: the CLIENT already computes and
displays the correct value locally whenever our discardValue (atom 0xd7) is 0 or
absent. FUN_18013fe00 stores our value at item +0x38 and the guard at 0x180141025
skips the local computation when it is non-zero. So the screen has been showing the
real number while the server paid a made-up one, on every quick sell ever made.

Verified beyond what the report claimed, because a missing table row pays ZERO and
that would be a regression the old flat tier could not produce: across all 236 items
in the live profile, 230 map to cardtype 1 and 6 to cardtype 6, and NOT ONE would pay
0 coins. Table reproduces at 141 rows and the worked example lands exactly.

ZERO WIRE CHANGE, FUT_DISCARD_TABLE default off. Nothing new is sent; only the coin
figure the server credits moves. This is the patch worth defaulting on after one
in-game check, which is simply quick-selling a card and seeing the coins paid match
the value the card was already displaying.

THE GROUPING BUG IS NOT IN CARDSDLL, and the fix ranked first would have wasted a
launch. Live in the running client all three display groups own exactly the right
pack, there is exactly one copy of each pack record in 4 GiB, and nothing we send is
mis-parsed. The parsed model is correct and the Scaleform layer picks the wrong pack
when turning a tile click into a category id. displayGroupAssetId is served as 1/5/6
while the screen's category field reads 3, and group tiles carry a hardcoded
CATEGORY_ID of 0. Confirmed by direct read: ordinal 3, assetId 6, i.e. Premium, while
the last click was Gold.

The heap map that made this possible, all scoped to one pid: display-group vector
control block, 3 elements of 0x108; group record fields at +0x00 sortPriority,
+0x04 displayGroupAssetId, +0x40 a one-element pack vector; inner pack record 0x1a8
with packType at +0x38, ids at +0x70/+0xac, price at +0xa0, quantities at +0xc0..+0xd0.

extPrice SHOULD BE DELETED, not corrected. Both sub-parsers read only
externalPriceId; amount and currency are discarded. Sending the key at all creates an
"mtx" currency row that switches on a real-money price line the client can never fill
offline, which is the literal "or %1s" on every tile.

A WORRY NOBODY HAD RAISED, and I confirmed it from our own logs: the client has sent
packId 6 on every purchase it has ever made, four for four tonight and six for six
across history. We have never observed a successful buy of anything but Premium Gold.

Also settled: FUT_STORE_DISPLAYGROUP=0 is the right resting state, argued from
mechanism rather than from history; FUT_USERINFO=packs stays off because the
unopened-pack counter is client-mutable and the flag ladder silently drops squadList;
POST /user is a latent hard freeze that has never fired because the client never
issues that POST.

Honest coverage: the ActionScript layer is unread by everyone and every remaining
store mystery lives there.

Live: 439 contract checks pass, market suite passes, both flags off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-08-06 07:43:51 -07:00
parent 3f3d5704a7
commit 21a81ad63c
59 changed files with 5267 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+95
View File
@@ -19,6 +19,95 @@ from fut_account import ACCOUNT # single source of truth for identity/c
PROFILE_PATH = os.environ.get("FUT_PROFILE", os.path.join(HERE, "fifa17_profile.json"))
# ---- FUT_DISCARD_TABLE: the REAL FIFA 17 quick-sell values ------------------
#
# quick_sell() used to pay an invented rating tier (600/300/150/50). That number
# was wrong for every card. The real table is `fcc_discardcoins` in the client's
# own game DB, 141 rows keyed (cardtype, level, rare) -> price, recovered from the
# running client 2026-08-05 and verified against 22 live club items, 22/22 exact.
#
# The client computes the DISPLAYED value itself with the same table whenever our
# `discardValue` (atom 0xd7) is 0 or absent: FUN_18013fe00 stores our value at item
# +0x38, and the guard at 0x180141025 (`cmp dword [rbp+0x198],0` / `ja`) skips the
# local computation when it is non-zero. So today the client shows the real value
# while the server pays a made-up one, and the two disagree on every card. This
# makes the paid value agree with the shown value.
#
# value = round_half_up(rating * price / 100)
# level = 3 if rating >= 75, 2 if 65..74, else 1 (0x180141e8a..0x180141ea3;
# derived from rating, NOT a wire field)
# cardtype = FUN_1800d8330(cardsubtypeid), decoded from its jump table and
# checked across every subtype 0..599 with zero disagreements
#
# ZERO WIRE CHANGE. Nothing new is sent; only the coin figure the server credits
# changes. Default off per the house rule, but this is the one patch worth
# defaulting on after a single verification.
# See docs/plan-2026-08-05-store-subsystem.md section 3.6.
DISCARD_TABLE = os.environ.get("FUT_DISCARD_TABLE", "0") == "1"
_DP = {}
def _dp(ct, rares, p1, p2, p3):
for r in rares:
_DP[(ct, 1, r)] = p1
_DP[(ct, 2, r)] = p2
_DP[(ct, 3, r)] = p3
_dp(1, [0], 30, 150, 400)
_dp(1, [1], 75, 350, 800)
_dp(1, [7], 1500, 5000, 9000)
_dp(1, [2, 3, 10, 13] + list(range(17, 32)), 2000, 7000, 12200)
_dp(1, [4, 8, 9], 6000, 10000, 18000)
_dp(1, [11], 10000, 15000, 24000)
_dp(1, [5, 6], 20000, 40000, 80000)
_dp(1, [12], 120000, 120000, 120000)
_dp(2, [0], 20, 70, 110)
_dp(2, [1], 25, 120, 320)
for _ct in (3, 4, 5, 10):
_dp(_ct, [0], 10, 55, 110)
_dp(_ct, [1], 50, 100, 300)
for _ct in (6, 7, 8, 9):
_dp(_ct, [0], 5, 20, 40)
_dp(_ct, [1], 20, 50, 70)
def _cardtype(sub):
"""FUN_1800d8330. 0 means no table row, which the client renders as value 0."""
if sub is None:
return 0
if 0 <= sub <= 3:
return 1
if sub == 4:
return 2
if sub == 5:
return 3
if sub == 6:
return 10
if sub == 7:
return 5
if sub == 8:
return 4
if 9 <= sub <= 11:
return 7
if sub in (30, 31, 231, 232, 233, 236) or 145 <= sub <= 150:
return 9
if (51 <= sub <= 136) or (201 <= sub <= 220) or (250 <= sub <= 273) \
or (300 <= sub <= 341):
return 6
return 0
def discard_value(item):
"""round_half_up(rating * price / 100), price from fcc_discardcoins."""
ct = _cardtype(item.get("cardsubtypeid"))
r = int(item.get("rating") or 0)
lvl = 3 if r >= 75 else 2 if r >= 65 else 1
price = _DP.get((ct, lvl, int(item.get("rareflag") or 0)), 0)
n = r * price
return n // 100 + (1 if n % 100 >= 50 else 0)
# Back-compat snapshots. Identity now lives in fut_account.ACCOUNT so Blaze, LSX
# and UTAS cannot drift apart; prefer ACCOUNT.<field> in new code. These are
# import-time snapshots and will NOT reflect a later adopt_from_auth().
@@ -215,6 +304,12 @@ class Store:
dv = it.get("discardValue") or 0
if dv:
return int(dv)
if DISCARD_TABLE:
# The real table. Matches what the client already displays, so the
# coins paid and the coins shown finally agree.
return discard_value(it)
# The invented tier. Wrong for every card, kept only as the live-proven
# default until FUT_DISCARD_TABLE has been in front of the game once.
r = it.get("rating") or 0
return 600 if r >= 85 else 300 if r >= 80 else 150 if r >= 75 else 50
with _LOCK:
@@ -0,0 +1,84 @@
"""ADVERSARIAL Q1.
HYPOTHESIS UNDER ATTACK (dim1 claim 3): "FUN_1800150d0 ... finds-or-creates a group by
an exact string compare on displayGroup.value", i.e. wire-record +0x00 holds
displayGroup.value.
WHY IT IS NOT PROVEN: live we serve description == displayGroup.value == the SAME
STRING for all three packs ("Bronze Pack"/"Gold Pack"/"Premium Gold"), so the live
group caption cannot distinguish displayGroup.value (atom 0xd9->0x377) from
description (atom 0xd1). If the key is actually `description`, recommendation #2
(serve displayGroup.value="gold") silently does nothing.
METHOD: decompile the 0x158 wire-record element deserializer 0x18013af30 IN FULL,
print len(src), and enumerate the atom dispatch. Explicitly search the raw
disassembly of the function for EVERY syntactic dispatch form the brief warns about:
== imm, != imm, switch case labels (jump table), and sub/dec ladders.
CONTROL: atom 0x20f (packType) is known-present (live pack model +0x38 = "BRONZE"),
so whatever form finds packType must also be applied to 0xd1/0xd9/0xda/0x2cb.
The control uses the SAME method (raw immediate scan over the same instruction
range), not a different one.
"""
import sys, traceback, re
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q1_out.txt"
try:
fh = open(OUT, "w")
def P(*a):
s = " ".join(str(x) for x in a)
print(s); fh.write(s + "\n")
ATOMS = {0x23:"assetId",0xd1:"description",0xd9:"displayGroup",0xda:"displayGroupAssetId",
0xdb:"displayGroupUseDefaultImage",0x15c:"id",0x20f:"packType",0x250:"priority",
0x2cb:"sortPriority",0x377:"value",0x36a:"useDefaultImage",0x260:"purchase"}
for target in (0x18013af30,):
f = func(target)
P("=== FUNCTION %s @ %#x body=%s ===" % (f.getName(), int(f.getEntryPoint().getOffset()), f.getBody()))
src = dec(target, 300)
P("len(src) =", len(src))
P("---- FULL DECOMPILE BEGIN ----")
P(src)
P("---- FULL DECOMPILE END ----")
# raw instruction scan of the whole function body for every atom immediate
P()
P("=== RAW INSTRUCTION SCAN over FUN_18013af30 body: all forms ===")
f = func(0x18013af30)
body = f.getBody()
it = listing.getInstructions(body, True)
ins = []
while it.hasNext():
i = it.next()
ins.append((int(i.getAddress().getOffset()), str(i.getMnemonicString()), str(i)))
P("instruction count:", len(ins))
# collect all immediates appearing anywhere in the text form
found = {}
for a, mn, txt in ins:
for m in re.finditer(r'0x([0-9a-fA-F]+)', txt):
v = int(m.group(1), 16)
if v in ATOMS:
found.setdefault(v, []).append((a, mn, txt))
for v in sorted(ATOMS):
lst = found.get(v, [])
P("atom %#05x %-28s hits=%d" % (v, ATOMS[v], len(lst)))
for a, mn, txt in lst:
P(" %#x %s" % (a, txt))
# dispatch-form census: CMP/SUB/DEC ladders on the atom register
P()
P("=== dispatch-form census (CMP/SUB/DEC/SWITCH inside the function) ===")
forms = {"CMP":0,"SUB":0,"DEC":0,"JMP":0,"SWITCH":0}
for a, mn, txt in ins:
if mn in forms: forms[mn]+=1
if mn == "JMP" and "[" in txt: forms["SWITCH"]+=1
P(forms)
P("all CMP with a small immediate (candidate atom compares):")
for a, mn, txt in ins:
if mn in ("CMP","SUB","DEC","ADD") :
m = re.search(r'0x([0-9a-fA-F]{1,4})\s*$', txt)
if m:
v=int(m.group(1),16)
if 0x10 <= v <= 0x400:
P(" %#x %-8s %s -> imm %#x %s" % (a, mn, txt, v, ATOMS.get(v,"")))
fh.close()
except Exception:
traceback.print_exc()
@@ -0,0 +1,97 @@
"""ADVERSARIAL Q2. Batch.
Targets under attack:
(a) dim1 claim 4: "FUN_1800147f0 ... a miss returns NULL and the caller then
dereferences address 0x40, i.e. it would crash" -- ABSENCE OF A NULL CHECK.
Method: print the RAW DISASSEMBLY of FUN_1800147f0 from the CALL to
FUN_180014420 to the next 40 instructions, so a TEST/JZ is visible if present.
Control: the same raw-listing method applied to FUN_180014380's call sites,
where the decompiler DOES show a null test, must show TEST/JZ. Same form.
(b) dim1 claim 5: "+0x290 is written in exactly TWO places in all of CardsDLL".
objdump found 12 dword/qword writes at +0x290 plus one QWORD write at +0x28c
that covers it. Resolve the containing function of every one and decide.
(c) dim1 claim 9/10: model+0x94 = group ordinal, model+0x1a0 = sortPriority;
+0x1a0 pushed to no Flash field. Print FUN_18002c3c0 and FUN_180015d80 in full
and print their exact address ranges so the claim can be re-checked in objdump.
(d) dim1 claim 3: FUN_1800150d0 / FUN_180012950 / FUN_180014380 full.
(e) dim1 claim 7: FUN_180014580 / FUN_180014df0 six literals; enumerate.
(f) FUN_180014610 group-tile builder: does tile+0x9c really get the ordinal
(CHILD_CATEGORY) and tile+0xac the displayGroupAssetId? Recommendation #1
depends entirely on this.
"""
import sys, traceback, re
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q2_out.txt"
try:
fh = open(OUT, "w")
def P(*a):
s = " ".join(str(x) for x in a)
print(s); fh.write(s + "\n")
TARGETS = [0x1800150d0, 0x180012950, 0x180014380, 0x180014420, 0x1800147f0,
0x180014610, 0x18002c3c0, 0x180015d80, 0x180014580, 0x180014df0,
0x18007e7f0, 0x18007d1a0, 0x18007dab0]
P("=== FUNCTION BOUNDS ===")
for t in TARGETS:
f = func(t)
if f is None:
P("%#x -> NO FUNCTION" % t); continue
P("%#x %-22s min=%#x max=%#x size=%#x" % (t, f.getName(),
int(f.getBody().getMinAddress().getOffset()),
int(f.getBody().getMaxAddress().getOffset()),
int(f.getBody().getNumAddresses())))
# (b) resolve containing functions of every +0x290 write objdump found
P()
P("=== (b) containing functions of every raw +0x290 / +0x28c write ===")
W = [0x180051da3,0x18007d3ba,0x18007f0c0,0x18008c777,0x18008fd45,0x1800d3564,
0x1800d43fc,0x18013454a,0x180189d84,0x18018caa3,0x18018e1ff,0x180191f77,
0x18015b885,0x180067eb0,0x180067ebf]
for w in W:
f = func(w)
P(" %#x -> %s @ %#x" % (w, f.getName() if f else "NONE",
int(f.getEntryPoint().getOffset()) if f else 0))
# is any of those functions in the store-screen vtable?
P()
P("=== store screen vtable 0x1801ff690 (first 48 slots) ===")
ents = set()
for off, tgt, nm in vtable(0x1801ff690, 48):
P(" +%#04x %#x %s" % (off, tgt, nm))
ents.add(tgt)
P("vtable also at 0x1801ff6f8 / 0x1801ff610 per the claim; dumping 0x1801ff610:")
for off, tgt, nm in vtable(0x1801ff610, 24):
P(" +%#04x %#x %s" % (off, tgt, nm))
# (a) raw disassembly around the FUN_180014420 call inside FUN_1800147f0
P()
P("=== (a) RAW LISTING of FUN_1800147f0 (whole function) ===")
f = func(0x1800147f0)
it = listing.getInstructions(f.getBody(), True)
n = 0
while it.hasNext():
i = it.next(); n += 1
P(" %#x %s" % (int(i.getAddress().getOffset()), str(i)))
P("instruction count:", n)
P()
P("=== (a-control) RAW LISTING of FUN_180014610 (whole function) ===")
f = func(0x180014610)
it = listing.getInstructions(f.getBody(), True)
n = 0
while it.hasNext():
i = it.next(); n += 1
P(" %#x %s" % (int(i.getAddress().getOffset()), str(i)))
P("instruction count:", n)
for t in TARGETS:
P()
f = func(t)
P("======== DECOMPILE %s @ %#x ========" % (f.getName() if f else "?", t))
src = dec(t, 300)
P("len(src) =", len(src))
P(src)
P("======== END %#x ========" % t)
fh.close()
except Exception:
traceback.print_exc()
try: fh.close()
except Exception: pass
@@ -0,0 +1,72 @@
"""ADVERSARIAL Q3.
Attacking dim1 claim 10: "sortPriority is inert at the UI. It reaches pack+0x1a0 and is
pushed to no Flash field ... Both are dead ends for this bug."
An objdump scan of the store cluster found 0x1800108cd/0x1800108d3
mov eax,[rsi+0x1a0] ; cmp [rbx+0x1a0],eax
which is the shape of a SORT COMPARATOR on two 0x1a8 models, and 0x18002cc62
mov [rbx+0x1a0],esi
inside FUN_18002cc90, which FUN_18002c3c0 tail-calls AFTER setting +0x1a0 = sortPriority.
Both were missed by "grep the push list".
Also decompile:
FUN_18002c8b0 -- the per-group filter in FUN_180014610; if it can HIDE a group the
tile ordinals the user sees stop matching the group ordinals.
FUN_18007e5e0 / FUN_18007df60 -- the six-panel binding (dim1 claim 7).
FUN_18007e7f0 cases 0x7551 / 0x753f -- the CATEGORY_ID round trip.
callers of FUN_1800147f0.
"""
import sys, traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q3_out.txt"
try:
fh = open(OUT, "w")
def P(*a):
s = " ".join(str(x) for x in a)
print(s); fh.write(s + "\n")
for a in (0x1800108cd, 0x18002cc62, 0x180010b5c, 0x180011c2c):
f = func(a)
P("%#x -> %s @ %#x size=%#x" % (a, f.getName() if f else "NONE",
int(f.getEntryPoint().getOffset()) if f else 0,
int(f.getBody().getNumAddresses()) if f else 0))
P()
P("=== callers of FUN_1800147f0 ===")
for frm, typ, fn, ent in xrefs_to(0x1800147f0):
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
P("=== callers of FUN_180014610 ===")
for frm, typ, fn, ent in xrefs_to(0x180014610):
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
P("=== callers of FUN_18002c8b0 ===")
for frm, typ, fn, ent in xrefs_to(0x18002c8b0):
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
P("=== callers of the comparator's containing function ===")
cf = func(0x1800108cd)
if cf:
for frm, typ, fn, ent in xrefs_to(int(cf.getEntryPoint().getOffset())):
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
tg = []
if cf: tg.append(int(cf.getEntryPoint().getOffset()))
tg += [0x18002cc90, 0x18002c8b0, 0x18007e5e0, 0x18007df60, 0x180014b60]
for t in tg:
f = func(t)
P()
P("======== DECOMPILE %s @ %#x ========" % (f.getName() if f else "?", t))
src = dec(t, 300)
P("len(src) =", len(src))
P(src)
P("======== END %#x ========" % t)
# full FUN_18007e7f0 (big) -- print only, it is the CATEGORY_ID round trip
P()
P("======== DECOMPILE FUN_18007e7f0 (full) ========")
src = dec(0x18007e7f0, 600)
P("len(src) =", len(src))
P(src)
P("======== END ========")
fh.close()
except Exception:
traceback.print_exc()
try: fh.close()
except Exception: pass
@@ -0,0 +1,29 @@
"""ADVERSARIAL Q4. Where is the +0x1a0 (sortPriority) merge sort actually used, and
what does the 0x1a8 ctor leave in +0x1a0 / +0x94 for GROUP TILES (FUN_180014610 sets
neither)? Also FUN_180012950 and FUN_180014380 in full for the group-key claim."""
import sys, traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q4_out.txt"
try:
fh = open(OUT, "w")
def P(*a):
s = " ".join(str(x) for x in a)
print(s); fh.write(s + "\n")
P("=== callers of FUN_180010cd0 (the merge-sort driver over +0x1a0) ===")
for frm, typ, fn, ent in xrefs_to(0x180010cd0):
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
P("=== callers of FUN_180010890 ===")
for frm, typ, fn, ent in xrefs_to(0x180010890):
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
for t in (0x1800130c0, 0x180012950, 0x180014380, 0x180010cd0):
f = func(t)
P()
P("======== DECOMPILE %s @ %#x ========" % (f.getName() if f else "?", t))
src = dec(t, 300)
P("len(src) =", len(src))
P(src)
P("======== END %#x ========" % t)
fh.close()
except Exception:
traceback.print_exc()
try: fh.close()
except Exception: pass
@@ -0,0 +1,30 @@
"""ADVERSARIAL Q5. The sortPriority merge sort has exactly one entry point
(0x180016f81 -> FUN_180010bc0). Identify its containing function, what list it sorts,
and who calls it. Also print FUN_1800130c0 in full to see whether +0x1a0 / +0x94 are
initialised at all for group tiles (FUN_180014610 sets neither)."""
import sys, traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/adv/q5_out.txt"
try:
fh = open(OUT, "w")
def P(*a):
s = " ".join(str(x) for x in a)
print(s); fh.write(s + "\n")
f = func(0x180016f81)
P("0x180016f81 is inside %s @ %#x size=%#x" % (f.getName(), int(f.getEntryPoint().getOffset()),
int(f.getBody().getNumAddresses())))
ent = int(f.getEntryPoint().getOffset())
P("=== callers of %s ===" % f.getName())
for frm, typ, fn, e in xrefs_to(ent):
P(" from %#x %s in %s @ %#x" % (frm, typ, fn, e))
for t in (ent, 0x1800130c0):
g = func(t)
P()
P("======== DECOMPILE %s @ %#x ========" % (g.getName(), t))
src = dec(t, 300)
P("len(src) =", len(src)); P(src)
P("======== END %#x ========" % t)
fh.close()
except Exception:
traceback.print_exc()
try: fh.close()
except Exception: pass
@@ -0,0 +1,100 @@
"""Why do all three packs collapse into one store tile, and does displayGroupAssetId fix it?
OBSERVED LIVE 2026-08-05. With FUT_STORE_DISPLAYGROUP=1 the store shows three group
tiles named Bronze Pack / Gold Pack / Premium Gold, but drilling into ANY of them
renders the same single Premium Gold pack. Two of three packs are unbuyable. We send
displayGroup {"value": name} per pack and never displayGroupAssetId (0xda).
The risk was predicted in utas_server.py before it happened: sending displayGroup may
select a grouped RENDER PATH rather than merely filling a caption, and if so the packs
need something to group BY. The obvious candidate is displayGroupAssetId, which we omit,
so every pack presumably shares a default of 0 and lands in one group.
That is a hypothesis. Do not ship a fix on it. Establish:
Q1 where does displayGroupAssetId (0xda) store in the pack element deser 0x18013af30,
and what is its constructor default? If the default is not a constant, the
"everything shares group 0" story is wrong.
Q2 who READS that offset. The reader is the grouping code, and whether it lives in
CardsDLL or in the packed FIFA17.exe decides whether this is answerable statically
at all.
Q3 what does displayGroup (0xd9) store, and is there a second slot (the group's own
identity) distinct from the +0x00 caption slot that `value` writes?
Q4 does anything build a LIST of packs per group, e.g. a loop comparing one pack's
group id against another's? That is the function that decides tile membership.
CONTROLS
* `assetId` 0x23 is a known INT field of the same deser storing to [rbp-0x3c]. It must
resolve the same way, or the offset extraction is unreliable.
* the "unknown" literal at 0x180223108 is documented as the constructor default of the
caption slot written by FUN_180133f60. Reproducing that anchors Q3.
COVERAGE RULE: print every decompile in full with its length. No absence claim may be
made from a truncated print, and no claim of "X is the only reader" without showing the
xref list it came from.
"""
import traceback
PACK_DESER = 0x18013AF30
CTOR = 0x180133F60
UNKNOWN_LIT = 0x180223108
A = {"displayGroup": 0xD9, "displayGroupAssetId": 0xDA,
"displayGroupUseDefaultImage": 0xDB, "assetId": 0x23, "value": 0x377,
"priority": 0x250}
def dump(va, title):
try:
f = func(va)
src = dec(va)
print("\n" + "=" * 78)
print("%#x %s body %d bytes / decompile %d chars (IN FULL)"
% (va, title, f.getBody().getNumAddresses() if f else -1, len(src)))
print("=" * 78)
print(src)
return src
except Exception:
print("!! failed %#x" % va)
traceback.print_exc()
return ""
try:
src = dump(PACK_DESER, "pack element deserializer")
print("\n--- atom comparisons present, BOTH == and != forms ---")
import re as _re
for name, a in sorted(A.items(), key=lambda kv: kv[1]):
hits = _re.findall(r"[!=]= 0x%x\b" % a, src)
print(" %-30s %#-6x %s" % (name, a, hits or "ABSENT"))
print(" (the != form matters: q_hub_1 missed clubPlayers by grepping only for ==)")
dump(CTOR, "constructor that writes the caption default")
print("\n" + "=" * 78)
print("WHO REFERENCES THE 'unknown' LITERAL %#x" % UNKNOWN_LIT)
print("=" * 78)
for frm, typ, fn, ent in xrefs_to(UNKNOWN_LIT):
print(" %#x %s (entry %#x)" % (frm, fn, ent))
print("\n" + "=" * 78)
print("CALLERS OF THE PACK DESER (the store root and anything else)")
print("=" * 78)
for a, n in callers(PACK_DESER):
print(" %#x %s" % (a, n))
print("\n" + "=" * 78)
print("CALLEES OF THE PACK DESER (sub-object parsers, incl. the displayGroup body)")
print("=" * 78)
for a, n in callees(PACK_DESER):
print(" %#x %s" % (a, n))
# The store root: whatever assembles the tile list must walk the parsed vector.
print("\n" + "=" * 78)
print("STORE ROOT 0x1801234e0 IN FULL, and its callers")
print("=" * 78)
dump(0x1801234E0, "FutStoreGetPackTypes root")
for a, n in callers(0x1801234E0):
print(" caller %#x %s" % (a, n))
except Exception:
traceback.print_exc()
@@ -0,0 +1,100 @@
"""DIMENSION 4 (duplicates), pass 1.
HYPOTHESIS: duplicateItemIdList (atom 0xec, element deser 0x180138e10, 0x20-byte
records) is consumed by (a) the CreatePack deser 0x180162880 and (b) the shared
IS-list body 0x18013e7f0. In CreatePack the only fields read from each record are
+0x00 (itemId) and +0x10 (duplicateItemId); the loans fields look dead there.
We want every caller and the exact per-caller consumption.
CONTROL: FUN_180138e10 must appear as a called-function of every caller we claim,
and we also enumerate xrefs by the reference manager (not by grepping text), so
"absent" verdicts are not derived from a text search. Additionally we print
len(src) for every decompile and state FULL/TRUNCATED.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup1_out.txt"
def w(fh, s=""):
fh.write(str(s) + "\n")
try:
fh = open(OUT, "w")
w(fh, "=" * 70)
w(fh, "A. xrefs_to(0x180138e10) -- the duplicateItemIdList element deser")
w(fh, "=" * 70)
for frm, typ, fn, ent in xrefs_to(0x180138e10):
w(fh, " from %#x %-14s in %s @ %#x" % (frm, typ, fn, ent))
w(fh, "")
w(fh, "=" * 70)
w(fh, "B. xrefs to the ATOM constant 0xec is meaningless (too common);")
w(fh, " instead: callers of each caller, to place the consumers.")
w(fh, "=" * 70)
callers_of_deser = sorted(set(e for _, _, _, e in xrefs_to(0x180138e10) if e))
for c in callers_of_deser:
w(fh, "-- callers of %#x (%s):" % (c, fname(c) if callable(globals().get("fname")) else "?"))
for frm, typ, fn, ent in xrefs_to(c):
w(fh, " %#x %s in %s @ %#x" % (frm, typ, fn, ent))
w(fh, "")
w(fh, "=" * 70)
w(fh, "C. FULL decompile: IS-list shared body 0x18013e7f0")
w(fh, "=" * 70)
s = dec(0x18013E7F0)
w(fh, "// len(src)=%d FULL (printed in its entirety, no truncation)" % len(s))
w(fh, s)
w(fh, "")
w(fh, "=" * 70)
w(fh, "D. FULL decompile of every OTHER caller found in A")
w(fh, "=" * 70)
for c in callers_of_deser:
if c in (0x18013E7F0, 0x180162880):
w(fh, "// %#x printed elsewhere / already known" % c)
continue
s = dec(c)
w(fh, "---- %#x len(src)=%d FULL ----" % (c, len(s)))
w(fh, s)
w(fh, "")
w(fh, "=" * 70)
w(fh, "E. the item-model singleton FUN_18011a830 and its vtable slot 0xa08")
w(fh, " (CreatePack reaches the item via node+0x10 which this slot sets)")
w(fh, "=" * 70)
s = dec(0x18011A830)
w(fh, "// FUN_18011a830 len(src)=%d FULL" % len(s))
w(fh, s)
w(fh, "")
w(fh, "=" * 70)
w(fh, "F. strings containing dup/Dup/DUP/loan/Loan/LOAN/swap/Swap/SWAP in rdata")
w(fh, "=" * 70)
for pat in (b"uplicate", b"UPLICATE", b"oanItem", b"LOAN", b"Loan", b"SWAP", b"Swap"):
hits = find_all(pat, blocks=(".rdata", ".data", ".text"))
w(fh, "-- pattern %r : %d hits" % (pat, len(hits)))
seen = set()
for h in hits[:400]:
# walk back to string start
st = h
for _ in range(120):
try:
b = mem.getByte(addr(st - 1)) & 0xFF
except Exception:
break
if b < 0x20 or b > 0x7E:
break
st -= 1
if st in seen:
continue
seen.add(st)
txt = rd_str(st, 160)
xr = xrefs_to(st)
w(fh, " %#x %-60r xrefs=%d %s" % (st, txt, len(xr),
",".join("%#x/%s" % (e, fn) for _, _, fn, e in xr[:6])))
fh.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,75 @@
"""DIMENSION 4, pass 10: NAME the endpoint on the not-duplicate branch.
Chain established so far:
FUN_18009bc40 (branches on item->duplicateItemId != 0)
not-duplicate -> builds a server-call object whose vtable is 0x1801f3158,
pushes one 0x20-byte record {itemId:int64, 7:int32, 0, 0} into its
"FUT Vector", and calls manager vt+0xc0 (FUN_1801180a0)
FUN_1801180a0 -> stores the delegate and calls FUN_18016c730
FUN_18016c730 -> the generic dispatcher: vt+0x38 writes the URL into a 0x200
buffer, then the body into a 0xaf0 buffer, then hands both to the HTTP
layer (FUN_180122550 vt+0x28).
So vtable 0x1801f3158 IS the request class. Dump it, decompile its URL builder
(slot +0x38) and its body serializer, and find its RS4: class name.
CONTROL: a known-good request class is dumped alongside -- the CreatePack
request, whose serializer is 0x180162530 and whose class name literal
"RS4:FutCreatePackServerResponse" is already established -- so the slot layout
interpretation is checked against something with a known answer.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup10_out.txt"
def w(fh, s=""):
fh.write(str(s) + "\n")
def dumpvt(fh, a, label, n=32):
w(fh, "")
w(fh, "=== vtable %s @ %#x ===" % (label, a))
for off, tgt, nm in vtable(a, n):
w(fh, " +%#05x -> %#x %s" % (off, tgt, nm))
def dump(fh, a, label):
s = dec(a)
w(fh, "")
w(fh, "#" * 70)
w(fh, "# %s %#x len(src)=%d FULL" % (label, a, len(s)))
w(fh, "#" * 70)
w(fh, s)
try:
fh = open(OUT, "w")
dumpvt(fh, 0x1801F3158, "the not-duplicate request class", 40)
w(fh, "")
w(fh, "=== xrefs to the vtable 0x1801f3158 (ctor sites) ===")
for frm, typ, fn, ent in xrefs_to(0x1801F3158):
w(fh, " %#x %s in %s @ %#x" % (frm, typ, fn, ent))
# decompile the interesting slots
for off in (0x08, 0x10, 0x30, 0x38, 0x40, 0x48, 0x50, 0x58, 0x60):
try:
t = qword(0x1801F3158 + off)
except Exception:
continue
if fm.getFunctionAt(addr(t)) is not None:
dump(fh, t, "vt+%#04x of the request class" % off)
else:
w(fh, "// vt+%#04x -> %#x (no function)" % (off, t))
w(fh, "")
w(fh, "=== every RS4: literal within +-0x400 of nothing; instead: all RS4: names ===")
hits = find_all(b"RS4:")
w(fh, "RS4: literals: %d" % len(hits))
for h in hits:
nm = rd_str(h, 80)
xr = xrefs_to(h)
if xr:
w(fh, " %#x %-52s %s" % (h, nm, ",".join("%#x" % e for _, _, _, e in xr[:4])))
fh.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,60 @@
"""DIMENSION 4, pass 11: decode the request on the not-duplicate branch.
From FUN_18016c730 (the generic dispatcher) the payload object's vtable slots
are: +0x08 = header/url contributor, +0x10 = body serializer. For the object
built in FUN_18009bc40 the vtable is 0x1801f3158, so
body serializer = 0x180127cc0
header/url = 0x180122420
Decompile both. The body serializer's FUN_180180cd0(<atom>) calls name the
request's keys, which is exactly what dimension 4 needs.
Also decompile the five OTHER builders that instantiate the same vtable
(FUN_1800db920, FUN_1800dbb90, FUN_1800dcad0, FUN_1800363e0, FUN_1800366f0):
comparing the int they store at record+0x08 (our site stores 7) decodes that
enum.
CONTROL: FUN_180126440 is the already-established PurchaseItems request
serializer; decompile it too so the "what a request serializer looks like"
reading is anchored on a known case.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup11_out.txt"
def w(fh, s=""):
fh.write(str(s) + "\n")
def dump(fh, a, label):
s = dec(a)
w(fh, "")
w(fh, "#" * 70)
w(fh, "# %s %#x len(src)=%d FULL" % (label, a, len(s)))
w(fh, "#" * 70)
w(fh, s)
try:
fh = open(OUT, "w")
dump(fh, 0x180127CC0, "BODY SERIALIZER vt+0x10 of the not-duplicate request")
dump(fh, 0x180122420, "header/url contributor vt+0x08")
dump(fh, 0x180129200, "vt+0x38 of the same vtable")
for a in (0x1800DB920, 0x1800DBB90, 0x1800DCAD0, 0x1800363E0, 0x1800366F0):
dump(fh, a, "sibling builder using vtable 0x1801f3158")
dump(fh, 0x180126440, "CONTROL: PurchaseItems request serializer")
w(fh, "")
w(fh, "=== raw disassembly of FUN_1801180a0 (manager vt+0xc0) ===")
try:
flat.disassemble(addr(0x1801180A0))
flat.createFunction(addr(0x1801180A0), None)
except Exception as e:
w(fh, "createFunction: %s" % e)
f = func(0x1801180A0)
it = listing.getInstructions(f.getBody(), True)
while it.hasNext():
i = it.next()
w(fh, " %s %s" % (i.getAddress(), i))
fh.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,94 @@
"""DIMENSION 4, pass 12: name the two endpoints in the loan-signing chain, and
name the response class whose deserializer is 0x1801293d0 (one of the four
duplicateItemIdList consumers).
Chain: <sign loan player> -> FUN_18009c360 (HTTP 403 -> "GotoAlreadySignedPopup")
-> manager vt+0x158 request, vtable 0x1801ed690, continuation FUN_18009bc40
-> FUN_18009bc40 branches on item->duplicateItemId
not dup -> vtable 0x1801f3158 request = PUT item {"itemData":[{"id":..,
"pile":"club","swap":0,"tradeId":0}]} (atoms 0x16b/0x15c/
0x226/0x87/0x2fe/0x331 -- decoded from FUN_180127cc0)
dup -> UI command "GotoNewItems", no request at all
So: dump vtable 0x1801ed690 (its +0x38 URL builder and +0x10 body serializer),
and resolve the class name of the response whose deser is 0x1801293d0 by taking
the vtable that holds it at slot +0x08 (data ref 0x180220ba8 => vtable base
0x180220ba0) and looking for the RS4: literal referenced by its factory.
CONTROL: for the RS4 resolution, also run the same procedure on the known
CreatePack deser 0x180162880 (whose class RS4:FutCreatePackServerResponse is
already established) and check it comes out right.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup12_out.txt"
def w(fh, s=""):
fh.write(str(s) + "\n")
def dump(fh, a, label):
s = dec(a)
w(fh, "")
w(fh, "#" * 70)
w(fh, "# %s %#x len(src)=%d FULL" % (label, a, len(s)))
w(fh, "#" * 70)
w(fh, s)
def rs4_for_deser(fh, deser, label):
"""find vtables holding `deser` at slot +8, then the RS4: name near a factory"""
w(fh, "")
w(fh, "=== RS4 resolution for deser %#x (%s) ===" % (deser, label))
for frm, typ, fn, ent in xrefs_to(deser):
if typ != "DATA":
continue
vt_base = frm - 8
w(fh, " data ref at %#x -> candidate vtable %#x" % (frm, vt_base))
try:
q0 = qword(vt_base)
except Exception:
continue
f0 = fm.getFunctionAt(addr(q0)) if 0x180000000 <= q0 < 0x181000000 else None
w(fh, " slot0 = %#x %s" % (q0, f0.getName() if f0 else "(not a function)"))
for frm2, typ2, fn2, ent2 in xrefs_to(vt_base):
w(fh, " vtable referenced from %#x in %s @ %#x" % (frm2, fn2, ent2))
if not ent2:
continue
f = func(ent2)
if f is None:
continue
it = listing.getInstructions(f.getBody(), True)
while it.hasNext():
ins = it.next()
for r in ins.getReferencesFrom():
t = int(r.getToAddress().getOffset())
try:
s = rd_str(t, 70)
except Exception:
continue
if s.startswith("RS4:"):
w(fh, " -> %s (at %#x)" % (s, t))
try:
fh = open(OUT, "w")
w(fh, "=== vtable 0x1801ed690 (the request made after signing) ===")
for off, tgt, nm in vtable(0x1801ED690, 12):
w(fh, " +%#05x -> %#x %s" % (off, tgt, nm))
for off in (0x10, 0x38):
t = qword(0x1801ED690 + off)
if fm.getFunctionAt(addr(t)) is not None:
dump(fh, t, "vtable 0x1801ed690 slot +%#04x" % off)
w(fh, "")
w(fh, "=== builders that use vtable 0x1801ed690 ===")
for frm, typ, fn, ent in xrefs_to(0x1801ED690):
w(fh, " %#x %s in %s @ %#x" % (frm, typ, fn, ent))
rs4_for_deser(fh, 0x1801293D0, "one of the four dup consumers")
rs4_for_deser(fh, 0x18013BD40, "another dup consumer")
rs4_for_deser(fh, 0x180162880, "CONTROL: CreatePack deser")
fh.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,55 @@
"""DIMENSION 4, pass 13: the authority question. Enumerate EVERY qword write to
an offset +0x10 anywhere in CardsDLL, so the claim "the duplicate field is only
ever filled from the wire" is not an absence claim from a narrow search.
Matcher: any line assigning through a longlong/undefined8 pointer at +0x10, in
any of the syntactic forms the decompiler emits:
*(longlong *)(X + 0x10) = ...
*(undefined8 *)(X + 0x10) = ...
*(ulonglong *)(X + 0x10) = ...
and the indexed forms (X + 0x10 + i*0x18).
CONTROL: the four known writers MUST appear: 0x180162880, 0x18013bd40,
0x1801293d0, 0x18013e7f0.
"""
import re, traceback, time
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup13_out.txt"
W = re.compile(r"\*\((?:longlong|undefined8|ulonglong|code \*)\s*\*\)\([^;\n]{0,90}\+ 0x10\)\s*=")
CONTROLS = [0x180162880, 0x18013BD40, 0x1801293D0, 0x18013E7F0]
try:
fh = open(OUT, "w")
t0 = time.time()
funcs = []
it = fm.getFunctions(True)
while it.hasNext():
funcs.append(it.next())
fh.write("functions: %d\n" % len(funcs))
hits = {}
for f in funcs:
ent = int(f.getEntryPoint().getOffset())
try:
s = dec(ent, timeout=25)
except Exception:
continue
if s.startswith("// decompile"):
continue
lines = [l.strip() for l in s.split("\n") if W.search(l)]
if lines:
hits[ent] = (f.getName(), lines)
fh.write("swept in %.0fs, %d functions contain a qword write at +0x10\n" %
(time.time() - t0, len(hits)))
fh.write("\n=== CONTROL ===\n")
for c in CONTROLS:
fh.write(" %#x present=%s\n" % (c, c in hits))
fh.write("\n=== all writers ===\n")
for ent in sorted(hits):
nm, lines = hits[ent]
fh.write(" %#x %s\n" % (ent, nm))
for l in lines[:6]:
fh.write(" %s\n" % l[:150])
fh.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,28 @@
"""DIMENSION 4, pass 14: rule out the four remaining candidate writers of a
qword at +0x10 that are also "item-shaped" (pass 13 x pass 5 cross-filter):
0x18009dc80, 0x1801aae50, 0x180156ac0, 0x180157050.
If none of them is writing an ITEM's +0x10, then the duplicate field is written
only by the four response deserializers and copied by the item assignment
operator FUN_1800515e0.
CONTROL: FUN_1800515e0 is printed too; it must show the +0x10 copy alongside the
other item members (+0x18 resourceId, +0x50, +0x5c, +0x8c, +0x90), which is what
makes it the item assignment operator rather than a coincidence.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup14_out.txt"
try:
fh = open(OUT, "w")
for a in (0x18009DC80, 0x1801AAE50, 0x180156AC0, 0x180157050):
s = dec(a)
fh.write("\n" + "#" * 70 + "\n# %#x len(src)=%d FULL\n" % (a, len(s)) + "#" * 70 + "\n")
fh.write(s + "\n")
s = dec(0x1800515E0)
fh.write("\n" + "#" * 70 + "\n# CONTROL item assign 0x1800515e0 len(src)=%d FULL\n" % len(s)
+ "#" * 70 + "\n")
fh.write(s + "\n")
fh.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,42 @@
"""DIMENSION 4, pass 15 (last): what does the script command GetCardDuplicate
actually return -- the duplicate ITEM ID, or just a flag?
The handler FUN_180039fb0 forwards to DAT_1802def18 vtable slot +0x18.
DAT_1802def18 is installed by FUN_180039b40 / FUN_180039ba0. Decompile those and
whatever they install, so the slot can be resolved.
This matters for the server: if nothing ever reads the VALUE of
duplicateItemId, then only zero vs non-zero is observable, and the server may
put any non-zero id there.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup15_out.txt"
def w(fh, s=""):
fh.write(str(s) + "\n")
def dump(fh, a, label):
s = dec(a)
w(fh, "")
w(fh, "#" * 70)
w(fh, "# %s %#x len(src)=%d FULL" % (label, a, len(s)))
w(fh, "#" * 70)
w(fh, s)
try:
fh = open(OUT, "w")
dump(fh, 0x180039B40, "installer A")
dump(fh, 0x180039BA0, "installer B")
dump(fh, 0x180094AE0, "single-card DP builder 1")
dump(fh, 0x180096490, "single-card DP builder 2")
w(fh, "")
w(fh, "=== callers of the two single-card DP builders ===")
for a in (0x180094AE0, 0x180096490):
w(fh, "-- %#x" % a)
for frm, typ, fn, ent in xrefs_to(a):
w(fh, " %#x %s in %s @ %#x" % (frm, typ, fn, ent))
fh.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,15 @@
"""DIMENSION 4, pass 16: GetCardDuplicate's real implementation, resolved from
the live provider object (DAT_1802def18 -> vtable static 0x1801f44c0, slot +0x18
-> 0x18003b790; control literal RS4:FutSquadSaveServerResponse re-checked before
the read). Control in this pass: slot +0x10 (GetCardCategory) is dumped too."""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup16_out.txt"
try:
fh = open(OUT, "w")
for a, l in ((0x18003B790, "GetCardDuplicate impl (vt+0x18)"),
(0x18003B6B0, "CONTROL GetCardCategory impl (vt+0x10)")):
s = dec(a)
fh.write("\n%s\n# %s %#x len(src)=%d FULL\n%s\n%s\n" % ("#"*70, l, a, len(s), "#"*70, s))
fh.close(); print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,48 @@
"""DIMENSION 4, pass 17: close the last gap in the reader census.
Passes 5/6 required the item pointer to be loaded from memory (node+0x10) before
the +0x10 access. A plain getter `return *(longlong *)(param_1 + 0x10);` would
be MISSED by that matcher, so an absence claim is not yet safe. This pass finds
every qword read at +0x10 off a FUNCTION PARAMETER, in every function, and lists
the small ones (getters).
CONTROL: the matcher is verified by requiring it to find 0x18011cca0 (the item
registration, which reads *(param_3 + 8)) -- no; that is +8. Instead the control
is FUN_1801a7180, found in pass 6, whose body is
`return *(longlong *)(lVar1 + 0x10) == 0;` -- a parameter-derived +0x10 read.
It is listed below so the reader can see the matcher firing on a known case.
"""
import re, traceback, time
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup17_out.txt"
P = re.compile(r"\*\((?:longlong|undefined8|ulonglong|int|uint) \*\)\((?:param_\d+|this) \+ 0x10\)")
try:
fh = open(OUT, "w"); t0 = time.time()
fns = []
it = fm.getFunctions(True)
while it.hasNext():
fns.append(it.next())
hits = []
for f in fns:
ent = int(f.getEntryPoint().getOffset())
try:
s = dec(ent, timeout=25)
except Exception:
continue
if s.startswith("// decompile"):
continue
ls = [l.strip() for l in s.split("\n") if P.search(l)]
if ls:
hits.append((ent, f.getName(), len(s), ls))
fh.write("swept %d functions in %.0fs; %d contain a +0x10 read off a parameter\n"
% (len(fns), time.time() - t0, len(hits)))
fh.write("\n--- SMALL functions (len(src) < 1500), i.e. plausible getters ---\n")
for ent, nm, n, ls in hits:
if n < 1500:
fh.write(" %#x %s len=%d\n" % (ent, nm, n))
for l in ls[:4]:
fh.write(" %s\n" % l[:140])
fh.write("\n--- all %d, addresses only ---\n" % len(hits))
fh.write(" ".join("%#x" % e for e, _, _, _ in hits) + "\n")
fh.close(); print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,29 @@
"""DIMENSION 4, pass 18: name the flow that FUN_18009bc40 belongs to.
FUN_18009c1f0 installs FUN_18009c360 as a completion delegate; decompile it and
its caller chain, and print any string literals, to confirm (or refute) the
"sign loan player" attribution inferred from adjacency to FUN_18009b480
(LOAN_SIGNED / FUT_LoanPlayerSigned) and from the HTTP-403 -> GotoAlreadySignedPopup
branch."""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup18_out.txt"
try:
fh = open(OUT, "w")
todo = [0x18009C1F0]
seen = set()
for _ in range(3):
nxt = []
for a in todo:
if a in seen:
continue
seen.add(a)
s = dec(a)
fh.write("\n%s\n# %#x len(src)=%d FULL\n%s\n%s\n" % ("#"*70, a, len(s), "#"*70, s))
fh.write("-- callers:\n")
for frm, typ, fn, ent in xrefs_to(a):
fh.write(" %#x %s in %s @ %#x\n" % (frm, typ, fn, ent))
if ent:
nxt.append(ent)
todo = nxt
fh.close(); print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,62 @@
"""DIMENSION 4 (duplicates), pass 2: who READS the duplicate field.
ESTABLISHED IN PASS 1: all four consumers of the 0x20-byte duplicate record
(0x180162880 CreatePack, 0x18013e7f0 IS-list, 0x18013bd40, 0x1801293d0) do the
identical fixup: for each record, scan the just-parsed item list, and where
item->+0x08 == record->+0x00 (itemId), set item->+0x10 = record->+0x10
(duplicateItemId). The record's +0x08 (itemLoans) and +0x18 (duplicateItemLoans)
are never read by any of the four, and the record vector is a stack local freed at
the end of each, so no other code can see it.
HYPOTHESIS FOR THIS PASS: the UI reads item->+0x10 and surfaces it as the
Scaleform key "HAS_DUPLICATE" (0x1801f6510) and/or the script command
"GetCardDuplicate" (0x1801f3ad8).
CONTROL: for each candidate reader we print the FULL decompile with len(src) and
say FULL, and we look for BOTH `+ 0x10` load forms and the `== 0` / `!= 0` test
forms. We also decompile a control function that references "IS_LOAN_PLAYER" but
not HAS_DUPLICATE, to check that our reading of the data-provider idiom is right.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup2_out.txt"
def w(fh, s=""):
fh.write(str(s) + "\n")
def dump(fh, a, label):
s = dec(a)
w(fh, "")
w(fh, "#" * 70)
w(fh, "# %s %#x len(src)=%d FULL (no truncation)" % (label, a, len(s)))
w(fh, "#" * 70)
w(fh, s)
try:
fh = open(OUT, "w")
w(fh, "A. xrefs to string literals of interest")
for name, a in (("GetCardDuplicate", 0x1801F3AD8),
("HAS_DUPLICATE", 0x1801F6510),
("SwapCard", 0x18021EF58),
("SWAPCARD", 0x18021EF68),
("IS_LOAN_PLAYER", 0x1801F6520),
("FUT_LOAN_MATCHES", 0x1802044F8)):
w(fh, "-- %s @ %#x" % (name, a))
for frm, typ, fn, ent in xrefs_to(a):
w(fh, " %#x %s in %s @ %#x" % (frm, typ, fn, ent))
for a, lbl in ((0x1800394C0, "GetCardDuplicate registrar/handler"),
(0x180043880, "HAS_DUPLICATE user 1"),
(0x180094220, "HAS_DUPLICATE user 2"),
(0x180084720, "HAS_DUPLICATE user 3")):
dump(fh, a, lbl)
w(fh, "")
w(fh, "B. CONTROL: 0x18015fa80 references IS_LOAN_PLAYER but not HAS_DUPLICATE")
dump(fh, 0x18015FA80, "CONTROL loan-only user")
fh.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,110 @@
"""DIMENSION 4 (duplicates), pass 3.
Q2 is "what REQUEST does the duplicate flow emit". Request serializers in this
client do NOT use string literals for keys: they call FUN_180180cd0(<atom>) to
turn an atom id into its wire name (proved by the CreatePack request serializer
0x180162530, which serialises atoms 0x20b, 0x369, 0x36b, 0xc4 that way).
HYPOTHESIS: no serializer ever emits duplicateItemId (0xeb), duplicateItemIdList
(0xec), duplicateItemLoans (0xed) or itemLoans (0x16f); they are response-only.
METHOD / CONTROL: enumerate EVERY call site of FUN_180180cd0 from the reference
manager, disassemble backwards up to 12 instructions in the same function, and
record every immediate moved into ECX/RCX. Then assert the four control atoms
0x20b/0x369/0x36b/0xc4 ARE found (if the method cannot see known-present atoms it
cannot be trusted to prove absence). Immediate forms handled: MOV ECX,imm and
XOR ECX,ECX (zero) and LEA ECX,[imm]; anything unresolved is reported as UNKNOWN
so absence is never inferred from a silent miss.
Also: GetCardDuplicate script handler, and the owner of the item container.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup3_out.txt"
def w(fh, s=""):
fh.write(str(s) + "\n")
def dump(fh, a, label):
s = dec(a)
w(fh, "")
w(fh, "#" * 70)
w(fh, "# %s %#x len(src)=%d FULL" % (label, a, len(s)))
w(fh, "#" * 70)
w(fh, s)
try:
fh = open(OUT, "w")
# ---------------- A: atom serialisation census ----------------
w(fh, "=" * 70)
w(fh, "A. every call site of the atom->wire-name helper FUN_180180cd0")
w(fh, "=" * 70)
sites = [(frm, fn, ent) for frm, typ, fn, ent in xrefs_to(0x180180CD0)
if "CALL" in typ]
w(fh, "call sites: %d" % len(sites))
found = {}
unknown = []
for frm, fn, ent in sites:
ins = listing.getInstructionAt(addr(frm))
val = None
cur = ins
for _ in range(12):
if cur is None:
break
cur = cur.getPrevious()
if cur is None:
break
m = str(cur.getMnemonicString()).upper()
ops = str(cur)
if m == "MOV" and ops.upper().startswith("MOV ECX,"):
t = ops.split(",")[1].strip()
try:
val = int(t, 16) if t.startswith("0x") else int(t)
except ValueError:
val = ("RAW", t)
break
if m == "XOR" and "ECX,ECX" in ops.upper().replace(" ", ""):
val = 0
break
if m == "CALL":
break
if isinstance(val, int):
found.setdefault(val, []).append((frm, fn, ent))
else:
unknown.append((frm, fn, ent, val))
w(fh, "resolved distinct atoms: %d ; unresolved sites: %d" % (len(found), len(unknown)))
w(fh, "")
w(fh, "CONTROLS (must be present): 0x20b=%s 0x369=%s 0x36b=%s 0xc4=%s" % (
0x20B in found, 0x369 in found, 0x36B in found, 0xC4 in found))
w(fh, "TARGETS: 0xeb(duplicateItemId)=%s 0xec(duplicateItemIdList)=%s "
"0xed(duplicateItemLoans)=%s 0x16f(itemLoans)=%s 0x16d(itemId)=%s" % (
0xEB in found, 0xEC in found, 0xED in found, 0x16F in found, 0x16D in found))
for t in (0xEB, 0xEC, 0xED, 0x16F, 0x16D):
if t in found:
for frm, fn, ent in found[t]:
w(fh, " atom %#x serialised at %#x in %s @ %#x" % (t, frm, fn, ent))
w(fh, "")
w(fh, "-- all resolved atoms, sorted:")
w(fh, " ".join("%#x" % k for k in sorted(found)))
w(fh, "")
w(fh, "-- UNRESOLVED call sites (absence claims must exclude these):")
for frm, fn, ent, val in unknown:
w(fh, " %#x in %s @ %#x last=%r" % (frm, fn, ent, val))
# ---------------- B: script handler ----------------
dump(fh, 0x180039FB0, "GetCardDuplicate script handler")
dump(fh, 0x180039E10, "CONTROL: GetCardCategory script handler")
# ---------------- C: who owns the item container ----------------
w(fh, "")
w(fh, "=" * 70)
w(fh, "C. writers/readers of the singleton pointer DAT_1802e6398")
w(fh, "=" * 70)
for frm, typ, fn, ent in xrefs_to(0x1802E6398):
w(fh, " %#x %s in %s @ %#x" % (frm, typ, fn, ent))
fh.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,80 @@
"""DIMENSION 4 (duplicates), pass 4: the item class, and the one unresolved
atom-serialisation site.
Open items from pass 3:
(a) the single UNRESOLVED FUN_180180cd0 call site, 0x1801438e2 in FUN_180143760,
takes its atom from [RBP+0x60]. Until it is characterised, "no request ever
serialises 0xeb/0xec/0xed/0x16f" is not airtight.
(b) which class implements GetCardDuplicate (DAT_1802def18 vtable slot 0x18).
(c) the item-model manager: DAT_1802e6398 is written by FUN_18011d780. Get the
concrete vtable so slots 0x160 / 0x7d8 / 0xa08 can be named, and so the item
constructor (which must zero item+0x10) can be found.
CONTROL for the vtable walk: slot 0x08 of any of these vtables must decode to a
real function in .text, and we print the raw qwords so a bogus vtable is visible.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup4_out.txt"
def w(fh, s=""):
fh.write(str(s) + "\n")
def dump(fh, a, label):
s = dec(a)
w(fh, "")
w(fh, "#" * 70)
w(fh, "# %s %#x len(src)=%d FULL" % (label, a, len(s)))
w(fh, "#" * 70)
w(fh, s)
try:
fh = open(OUT, "w")
dump(fh, 0x180143760, "(a) dynamic-atom serialiser")
w(fh, "")
w(fh, "=" * 70)
w(fh, "(b) xrefs to DAT_1802def18 (the script card-info provider pointer)")
w(fh, "=" * 70)
for frm, typ, fn, ent in xrefs_to(0x1802DEF18):
w(fh, " %#x %s in %s @ %#x" % (frm, typ, fn, ent))
dump(fh, 0x18011D780, "(c) manager singleton installer FUN_18011d780")
w(fh, "")
w(fh, "=" * 70)
w(fh, "(c2) candidate manager vtables referenced from FUN_18011d780")
w(fh, "=" * 70)
f = func(0x18011D780)
seen = set()
if f is not None:
it = listing.getInstructions(f.getBody(), True)
while it.hasNext():
ins = it.next()
for r in ins.getReferencesFrom():
t = int(r.getToAddress().getOffset())
if 0x180000000 <= t < 0x181000000 and t not in seen:
seen.add(t)
try:
q0 = qword(t)
q1 = qword(t + 8)
except Exception:
continue
f0 = fm.getFunctionAt(addr(q0)) if 0x180000000 <= q0 < 0x181000000 else None
f1 = fm.getFunctionAt(addr(q1)) if 0x180000000 <= q1 < 0x181000000 else None
if f0 and f1:
w(fh, " possible vtable %#x : [0]=%#x %s [8]=%#x %s" %
(t, q0, f0.getName(), q1, f1.getName()))
for off in (0x160, 0x7D8, 0xA08, 0xA40, 0x5B8):
try:
q = qword(t + off)
except Exception:
continue
ff = fm.getFunctionAt(addr(q)) if 0x180000000 <= q < 0x181000000 else None
w(fh, " +%#05x -> %#x %s" % (off, q, ff.getName() if ff else "(not a function)"))
fh.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,89 @@
"""DIMENSION 4 (duplicates), pass 5: EXHAUSTIVE census of readers of the item
model's duplicate field (item+0x10).
WHY EXHAUSTIVE. Pass 2 found the field surfaced as the Scaleform bool
HAS_DUPLICATE at two sites. A bounded search cannot prove that is the only
consumer, and this project has been bitten four times by absence claims from
narrow searches. So: decompile EVERY function in the binary and match text.
MATCHERS (deliberately two independent ones, plus a control):
M1 "nested +0x10" regex: the idiom every known site uses, an item pointer
loaded out of a 0x18-byte list node at node+0x10, then dereferenced at
+0x10. e.g. *(longlong *)(*(longlong *)(lVar10 + 0x10) + 0x10)
M2 "item-shaped struct" heuristic: a function that mentions at least THREE of
the known item offsets (+0x18 resourceId, +0x50 cardsubtype, +0x5c state,
+0x8c timesWon, +0x90 loans) AND also mentions "+ 0x10".
CONTROL: the three sites already known by hand MUST appear --
0x180162880 / 0x18013bd40 / 0x1801293d0 / 0x18013e7f0 (writers) and
0x180043880 / 0x180094220 (readers). If any is missed, the matcher is
broken and no absence conclusion may be drawn from this pass.
Output is written incrementally so a timeout still leaves usable results.
"""
import re, traceback, time
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup5_out.txt"
M1 = re.compile(r"\*\(longlong \*\)\(\*\(longlong \*\)\([^;\n]{0,80}?\) \+ 0x10\)")
ITEMOFF = ("+ 0x18)", "+ 0x50)", "+ 0x5c)", "+ 0x8c)", "+ 0x90)")
CONTROLS = [0x180162880, 0x18013BD40, 0x1801293D0, 0x18013E7F0, 0x180043880, 0x180094220]
try:
fh = open(OUT, "w")
t0 = time.time()
funcs = []
it = fm.getFunctions(True)
while it.hasNext():
f = it.next()
funcs.append(f)
fh.write("total functions: %d\n" % len(funcs))
fh.flush()
m1_hits = []
m2_hits = []
n = 0
for f in funcs:
n += 1
ent = int(f.getEntryPoint().getOffset())
try:
s = dec(ent, timeout=25)
except Exception:
fh.write("DECOMPILE-ERROR %#x\n" % ent)
continue
if s.startswith("// decompile failed") or s.startswith("// no function"):
fh.write("DECOMPILE-FAILED %#x\n" % ent)
continue
ms = M1.findall(s)
if ms:
m1_hits.append((ent, f.getName(), ms))
k = sum(1 for o in ITEMOFF if o in s)
if k >= 3 and "+ 0x10" in s:
m2_hits.append((ent, f.getName(), k))
if n % 500 == 0:
fh.write("... %d/%d %.0fs m1=%d m2=%d\n" % (n, len(funcs), time.time() - t0,
len(m1_hits), len(m2_hits)))
fh.flush()
fh.write("\nSWEPT %d functions in %.0fs\n" % (n, time.time() - t0))
fh.write("\n=== CONTROL CHECK ===\n")
m1set = set(a for a, _, _ in m1_hits)
m2set = set(a for a, _, _ in m2_hits)
for c in CONTROLS:
fh.write(" %#x M1=%s M2=%s\n" % (c, c in m1set, c in m2set))
fh.write("\n=== M1 hits (nested +0x10 idiom): %d functions ===\n" % len(m1_hits))
for ent, nm, ms in m1_hits:
fh.write(" %#x %s : %d match(es)\n" % (ent, nm, len(ms)))
for m in ms[:12]:
fh.write(" %s\n" % m)
fh.write("\n=== M2 hits (item-shaped struct, >=3 known item offsets + 0x10): %d ===\n"
% len(m2_hits))
for ent, nm, k in m2_hits:
fh.write(" %#x %s (%d/5 offsets)\n" % (ent, nm, k))
fh.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,102 @@
"""DIMENSION 4 (duplicates), pass 6: exhaustive census, matcher generation 2.
Pass 5's matcher M1 (single-expression nested +0x10) MISSED the IS-list consumer
0x18013e7f0, which splits the access across two statements:
lVar6 = *(longlong *)(*(longlong *)(lVar14 + 0x10) + 0xb0);
... *(longlong *)(lVar6 + 0x10) = plVar13[2];
So M1 alone cannot support an absence claim. M3 below does a textual
def-use: any local assigned from a qword load, then used as base of a +0x10
qword access.
M3 = for every assignment `<var> = *(longlong *)(<anything>);` remember <var>;
then flag the function if `<var> + 0x10)` appears anywhere.
PLUS the M1 nested form. This is deliberately over-broad; the output is
reviewed by hand.
CONTROL: all six hand-known sites must be flagged:
writers 0x180162880, 0x18013bd40, 0x1801293d0, 0x18013e7f0
readers 0x180043880, 0x180094220
If any is missed the pass is void for absence purposes.
Also dumps the three functions M1 newly found (0x180094ae0, 0x180096490,
0x18009bc40) and their callers, to name the UI screens involved.
"""
import re, traceback, time
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup6_out.txt"
ASSIGN = re.compile(r"(\w+) = \*\(longlong \*\)\([^;\n]{0,120}\);")
M1 = re.compile(r"\*\(longlong \*\)\(\*\(longlong \*\)\([^;\n]{0,80}?\) \+ 0x10\)")
CONTROLS = [0x180162880, 0x18013BD40, 0x1801293D0, 0x18013E7F0, 0x180043880, 0x180094220]
def hitlines(s, var):
out = []
pat = re.compile(r"\b%s \+ 0x10\b" % re.escape(var))
for ln in s.split("\n"):
if pat.search(ln):
out.append(ln.strip())
return out
try:
fh = open(OUT, "w")
t0 = time.time()
funcs = []
it = fm.getFunctions(True)
while it.hasNext():
funcs.append(it.next())
fh.write("total functions: %d\n" % len(funcs))
flagged = {}
n = 0
for f in funcs:
n += 1
ent = int(f.getEntryPoint().getOffset())
try:
s = dec(ent, timeout=25)
except Exception:
continue
if s.startswith("// decompile"):
continue
why = []
if M1.search(s):
why.append("M1:" + M1.search(s).group(0)[:70])
for m in ASSIGN.finditer(s):
v = m.group(1)
hl = hitlines(s, v)
if hl:
why.append("M3[%s]: %s" % (v, hl[0][:110]))
break
if why:
flagged[ent] = (f.getName(), why)
if n % 2000 == 0:
fh.write("... %d/%d %.0fs flagged=%d\n" % (n, len(funcs), time.time() - t0, len(flagged)))
fh.flush()
fh.write("\nSWEPT %d in %.0fs, flagged %d\n" % (n, time.time() - t0, len(flagged)))
fh.write("\n=== CONTROL CHECK ===\n")
ok = True
for c in CONTROLS:
fh.write(" %#x flagged=%s\n" % (c, c in flagged))
ok = ok and (c in flagged)
fh.write("ALL CONTROLS FLAGGED: %s\n" % ok)
fh.write("\n=== flagged functions (%d) ===\n" % len(flagged))
for ent in sorted(flagged):
nm, why = flagged[ent]
fh.write(" %#x %s\n" % (ent, nm))
for x in why:
fh.write(" %s\n" % x)
fh.write("\n\n=== decompiles of the three NEW M1 functions ===\n")
for a in (0x180094AE0, 0x180096490, 0x18009BC40):
s = dec(a)
fh.write("\n" + "#" * 68 + "\n# %#x len(src)=%d FULL\n" % (a, len(s)) + "#" * 68 + "\n")
fh.write(s + "\n")
fh.write("-- callers:\n")
for frm, typ, fn, e in xrefs_to(a):
fh.write(" %#x %s in %s @ %#x\n" % (frm, typ, fn, e))
fh.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,65 @@
"""DIMENSION 4 (duplicates), pass 7: the ONE flow that branches on the duplicate
flag, and the request it does or does not emit.
FOUND IN PASS 6: FUN_18009bc40 looks up an item by resourceId in the manager's
vt[0x160] container, reads item+0x10 (duplicateItemId) into a bool, and then:
duplicate -> UI command "GotoNewItems", NO server call
not duplicate -> builds a 1-element FUT Vector of 0x20-byte records
{itemId, 7, 0, 0} and calls manager vt[0xc0] with the
callback FUN_18009bec0
This is the only branch on the flag in the whole binary (pass 6 was exhaustive
over 13308 functions with controls passing).
THIS PASS: name the actors.
1. the concrete manager class: who calls FUN_18011d780 (the setter for the
singleton pointer DAT_1802e6398) and with what object -> its vtable ->
slots 0xc0, 0x160, 0x7d8, 0xa08.
2. FUN_18009bec0 (the completion callback), FUN_18009c360 (the owner of the
function pointer), FUN_18009b480 (the loan-player message builder that
sits next to it in .text).
3. what 7 means in the 0x20-byte record.
CONTROL for the vtable: print raw qwords and require slot 0 and 8 to resolve to
real functions before believing any slot; also print slot 0x160 and check it
looks like a small getter (the CreatePack deser calls it and then uses the
result+0x30 as a vector).
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup7_out.txt"
def w(fh, s=""):
fh.write(str(s) + "\n")
def dump(fh, a, label):
s = dec(a)
w(fh, "")
w(fh, "#" * 70)
w(fh, "# %s %#x len(src)=%d FULL" % (label, a, len(s)))
w(fh, "#" * 70)
w(fh, s)
try:
fh = open(OUT, "w")
w(fh, "=== callers of FUN_18011d780 (singleton setter) ===")
for frm, typ, fn, ent in xrefs_to(0x18011D780):
w(fh, " %#x %s in %s @ %#x" % (frm, typ, fn, ent))
for frm, typ, fn, ent in xrefs_to(0x18011D780):
if ent and "CALL" in typ:
dump(fh, ent, "caller of singleton setter")
dump(fh, 0x18009BEC0, "completion callback FUN_18009bec0")
dump(fh, 0x18009C360, "owner of the fn-ptr FUN_18009c360")
dump(fh, 0x18009B480, "loan-player message builder FUN_18009b480")
w(fh, "")
w(fh, "=== xrefs to FUN_18009bc40 users' neighbours: callers of FUN_18009c360 ===")
for frm, typ, fn, ent in xrefs_to(0x18009C360):
w(fh, " %#x %s in %s @ %#x" % (frm, typ, fn, ent))
fh.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,66 @@
"""DIMENSION 4 (duplicates), pass 8: name the request emitted on the
NOT-duplicate branch.
The FUT item-manager singleton's vtable was resolved from the LIVE process
(read-only): DAT_1802e6398 -> object 0xb81b0940 -> vtable live 0x6ffffc35c2a0 =
static 0x18021c2a0, control-checked against the RS4:FutSquadSaveServerResponse
literal. Slots:
+0x0c0 -> 0x1801180a0 <- called on the NOT-duplicate branch of FUN_18009bc40
+0x160 -> 0x18011b780 <- container the CreatePack fixup writes into
+0x7d8 -> 0x18011b500 <- container the HAS_DUPLICATE providers read
+0xa08 -> 0x18011cca0 <- item registration (called by the item deser)
+0xa40 -> 0x18011bf40
HYPOTHESIS: 0x1801180a0 issues a server call, and the 0x20-byte record
{itemId:int64, 7:int32, 0, 0} it is handed is an item-action list entry (7 being
an action/pile enum). If so the duplicate flag GATES an existing endpoint rather
than unlocking a new one.
CONTROL: 0x18011b780 must be a small getter returning an object whose +0x30 is a
vector (that is how FUN_180162880 uses it), and 0x18011b500 likewise; if those
two do not look like getters, the live vtable read is wrong and nothing here
stands.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup8_out.txt"
def w(fh, s=""):
fh.write(str(s) + "\n")
def dump(fh, a, label, depth=0):
s = dec(a)
w(fh, "")
w(fh, "#" * 70)
w(fh, "# %s %#x len(src)=%d FULL" % (label, a, len(s)))
w(fh, "#" * 70)
w(fh, s)
try:
fh = open(OUT, "w")
dump(fh, 0x18011B780, "CONTROL vt+0x160 container getter")
dump(fh, 0x18011B500, "CONTROL vt+0x7d8 container getter")
dump(fh, 0x1801180A0, "vt+0x0c0 the call made when NOT a duplicate")
dump(fh, 0x18011CCA0, "vt+0xa08 item registration")
w(fh, "")
w(fh, "=== callees of 0x1801180a0 ===")
f = func(0x1801180A0)
if f is not None:
seen = set()
it = listing.getInstructions(f.getBody(), True)
while it.hasNext():
ins = it.next()
for r in ins.getReferencesFrom():
t = int(r.getToAddress().getOffset())
g = fm.getFunctionAt(addr(t))
if g is not None and t not in seen:
seen.add(t)
w(fh, " %#x %s" % (t, g.getName()))
for t in sorted(seen):
dump(fh, t, "callee of vt+0xc0")
fh.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,71 @@
"""DIMENSION 4, pass 9: three of the manager vtable slots resolved from live
memory land on addresses Ghidra never turned into functions
(0x1801180a0 vt+0xc0, 0x18011b500 vt+0x7d8, 0x18011b780 vt+0x160). Disassemble
and create them, then decompile.
CONTROL: 0x18011b780 and 0x18011b500 must come out as tiny getters (the callers
treat the result as an object whose +0x30 is a vector). If instead they decode
as garbage, the live vtable read (or these addresses) is wrong.
Also: FUN_1800515e0 is the item copy/assign called by the item registration
(vt+0xa08 = FUN_18011cca0) -- it should copy the duplicate field at +0x10, which
independently confirms +0x10 is a member of the item record rather than of a
list node.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/dup9_out.txt"
def w(fh, s=""):
fh.write(str(s) + "\n")
try:
fh = open(OUT, "w")
for a in (0x1801180A0, 0x18011B500, 0x18011B780):
try:
flat.disassemble(addr(a))
except Exception as e:
w(fh, "disassemble(%#x) -> %s" % (a, e))
try:
f = flat.createFunction(addr(a), None)
w(fh, "createFunction(%#x) -> %s" % (a, f))
except Exception as e:
w(fh, "createFunction(%#x) -> %s" % (a, e))
for a, lbl in ((0x18011B780, "vt+0x160"), (0x18011B500, "vt+0x7d8"),
(0x1801180A0, "vt+0x0c0 THE CALL ON THE NOT-DUPLICATE BRANCH"),
(0x1800515E0, "item copy/assign FUN_1800515e0")):
s = dec(a)
w(fh, "")
w(fh, "#" * 70)
w(fh, "# %s %#x len(src)=%d FULL" % (lbl, a, len(s)))
w(fh, "#" * 70)
w(fh, s)
# callees of the vt+0xc0 target, once it is a function
w(fh, "")
w(fh, "=== callees of 0x1801180a0 ===")
f = func(0x1801180A0)
seen = []
if f is not None:
it = listing.getInstructions(f.getBody(), True)
while it.hasNext():
ins = it.next()
for r in ins.getReferencesFrom():
t = int(r.getToAddress().getOffset())
g = fm.getFunctionAt(addr(t))
if g is not None and t not in seen:
seen.append(t)
w(fh, " %#x %s" % (t, g.getName()))
for t in seen:
s = dec(t)
w(fh, "")
w(fh, "-" * 70)
w(fh, "-- callee %#x len(src)=%d FULL" % (t, len(s)))
w(fh, "-" * 70)
w(fh, s)
fh.close()
print("WROTE", OUT)
except Exception:
traceback.print_exc()
@@ -0,0 +1,85 @@
"""DIMENSION 1 / query 1.
HYPOTHESIS: a CardsDLL function converts the 0x158 pack deser records into
0x1a8 pack model records grouped into 0x108 display-group records, and a
second function resolves a display group back to a pack at render time. If
the resolver keys on a field that collides between our two GOLD packs
(packType), that explains Gold-group -> Premium-Gold.
CONTROL: FUN_1800150d0 is documented as walking the pack array and comparing
displayGroup.value against the literal "mypacks" at 0x1801ec008. Decompiling it
with the same helper proves the helper works and shows the house style of a
group filter. Every "not present" claim below prints len(src) first.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/q1_out.txt"
buf = []
def P(*a):
s = " ".join(str(x) for x in a)
buf.append(s)
print(s)
try:
P("=" * 78)
P("A. CONTROL: FUN_1800150d0, the documented mypacks filter")
P("=" * 78)
s = dec(0x1800150d0)
P("len(src) =", len(s))
P(s)
P("=" * 78)
P("B. FUN_1801340e0 (0x108 vector grow) - full decompile + callers")
P("=" * 78)
s = dec(0x1801340e0)
P("len(src) =", len(s))
P(s)
P("--- callers of 0x1801340e0 ---")
for f in callers(0x1801340e0):
P(" ", f.getName(), hex(int(f.getEntryPoint().getOffset())))
P("=" * 78)
P("C. FUN_180132180 (0x158) - callers only")
P("=" * 78)
for f in callers(0x180132180):
P(" ", f.getName(), hex(int(f.getEntryPoint().getOffset())))
P("=" * 78)
P("D. callers of the pack element deser 0x18013af30")
P("=" * 78)
for f in callers(0x18013af30):
P(" ", f.getName(), hex(int(f.getEntryPoint().getOffset())))
P("=" * 78)
P("E. where does imm 0x1a8 appear? (the live pack model record size)")
P("=" * 78)
# scan .text for the 32-bit immediate 0xa8 0x01 0x00 0x00 in instructions
import java.lang as _jl # noqa
from ghidra.program.model.address import AddressSet # noqa
cnt = 0
it = listing.getInstructions(True)
hits = []
while it.hasNext():
ins = it.next()
for i in range(ins.getNumOperands()):
for o in ins.getOpObjects(i):
try:
v = int(o.getValue())
except Exception:
continue
if v == 0x1a8:
hits.append((int(ins.getAddress().getOffset()), str(ins)))
P("0x1a8 immediate occurrences:", len(hits))
seen = {}
for a, t in hits:
f = fm.getFunctionContaining(addr(a))
n = f.getName() if f else "?"
seen.setdefault(n, []).append((hex(a), t))
for n in sorted(seen, key=lambda k: -len(seen[k])):
P(" %-28s x%d %s" % (n, len(seen[n]), seen[n][:4]))
except Exception:
P(traceback.format_exc())
open(OUT, "w").write("\n".join(buf))
print("WROTE", OUT)
@@ -0,0 +1,59 @@
"""DIMENSION 1 / query 2.
FUN_1800150d0 is now proven to be the display-group builder: it walks the
0x158 pack records at stride 0x158, calls FUN_180014380(model, rec+0x00) to
find-or-create a group, FUN_180012950(tmp, ordinal, rec+0x30) to construct a
0x108 group, and FUN_18002c3c0(tmp2, rec, group+0x00, -1) to build the 0x1a8
pack model that is pushed into group+0x40.
HYPOTHESIS: the group *lookup* FUN_180014380 keys on something that collides
between our two GOLD packs, or a second consumer resolves group -> pack by a
colliding key. Live memory already proves the built vector is CORRECT, so the
fault is in a consumer.
CONTROL: FUN_18002c3c0 is decompiled in full and its field writes compared to
the live 0x1a8 record measured this run (inner+0x0a0 = 400/5000/15000,
+0x0c0 = 5/7/11). If the decompile's offsets do not reproduce those, the
decompile is being misread and nothing else here can be trusted.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/q2_out.txt"
buf = []
def P(*a):
s = " ".join(str(x) for x in a)
buf.append(s)
print(s)
def C(a, label):
P("=" * 78)
P(label, hex(a))
P("=" * 78)
s = dec(a)
P("len(src) =", len(s))
P(s)
P("--- callers ---")
for ent, nm in callers(a):
P(" %-30s %s" % (nm, hex(ent)))
try:
C(0x180014380, "A. group LOOKUP FUN_180014380")
C(0x180012950, "B. group CTOR FUN_180012950 (0x108)")
C(0x18002c3c0, "C. pack model builder FUN_18002c3c0 (0x158 -> 0x1a8) [CONTROL]")
P("=" * 78)
P("D. callers of the group builder FUN_1800150d0")
P("=" * 78)
for ent, nm in callers(0x1800150d0):
P(" %-30s %s" % (nm, hex(ent)))
P("=" * 78)
P("E. callers of 0x1801340e0 and 0x180132180 (vector grows)")
P("=" * 78)
for a in (0x1801340e0, 0x180132180, 0x180010160, 0x1800102d0):
P(" -- %s" % hex(a))
for ent, nm in callers(a):
P(" %-30s %s" % (nm, hex(ent)))
except Exception:
P(traceback.format_exc())
open(OUT, "w").write("\n".join(buf))
print("WROTE", OUT)
@@ -0,0 +1,51 @@
"""DIMENSION 1 / query 3: the CONSUMERS.
ESTABLISHED so far: FUN_1800150d0 builds the display-group vector; the group
key is the displayGroup.value STRING compared at group+0x70 by FUN_180014380;
the 0x1a8 pack model carries id at +0x70, assetId at +0xac, group ordinal at
+0x94, sortPriority at +0x1a0, coins price at +0xa0.
HYPOTHESIS: one of the other FUN_180014380 callers (FUN_180014580,
FUN_180014b60, FUN_180014df0) or the other 0x1a8 push_back callers
(FUN_180014610, FUN_1800147f0) is the "which pack does this group tile open"
resolver, and it keys on a field that collides between our two GOLD packs.
CONTROL: FUN_1800080c0 must turn out to be a string compare (it is used as the
group-name equality test in FUN_180014380 whose result we have already
confirmed live: three distinct names produced three distinct groups). If it
decompiles as something other than a compare, the whole reading is wrong.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/q3_out.txt"
buf = []
def P(*a):
s = " ".join(str(x) for x in a)
buf.append(s)
print(s)
def C(a, label, show_callers=True):
P("=" * 78)
P(label, hex(a))
P("=" * 78)
s = dec(a)
P("len(src) =", len(s))
P(s)
if show_callers:
P("--- callers ---")
for ent, nm in callers(a):
P(" %-30s %s" % (nm, hex(ent)))
try:
C(0x1800080c0, "CONTROL: FUN_1800080c0 (expected string compare)", False)
for a, lbl in ((0x180014580, "FUN_180014580"),
(0x180014b60, "FUN_180014b60"),
(0x180014df0, "FUN_180014df0"),
(0x180014610, "FUN_180014610"),
(0x1800147f0, "FUN_1800147f0")):
C(a, lbl)
except Exception:
P(traceback.format_exc())
open(OUT, "w").write("\n".join(buf))
print("WROTE", OUT)
@@ -0,0 +1,56 @@
"""DIMENSION 1 / query 4: the resolver and the store screen.
ESTABLISHED: FUN_1800147f0(model, groupOrdinal, out, incInvisible, checkPlat)
is "give me the contents of group N". groupOrdinal 0 means "give me the list of
group tiles" (FUN_180014610). Otherwise it calls FUN_180014420(model,
groupOrdinal) and copies that group's pack vector at +0x40.
FUN_180014580 / FUN_180014df0 map a UI category enum to the HARDCODED lowercase
group-name strings mypacks / points / bronze / silver / gold / special, look the
group up by name, and return group+0x00 (the ordinal).
HYPOTHESIS H-CLAMP: FUN_180014420 does not handle "ordinal not found" by
failing; it falls through to the last group. With displayGroupAssetId 1/5/6 and
only 3 groups, 5 and 6 both resolve to the last group (Premium), which is
exactly the observed Bronze-ok / Gold-wrong / Premium-ok pattern.
CONTROL: FUN_18002c8b0 is decompiled in the same batch as a same-shape
predicate; and every "field X is not read" claim is backed by printing the whole
function and its length, never by grep.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/q4_out.txt"
buf = []
def P(*a):
s = " ".join(str(x) for x in a)
buf.append(s)
print(s)
def C(a, label, show_callers=True):
P("=" * 78)
P(label, hex(a))
P("=" * 78)
s = dec(a)
P("len(src) =", len(s))
P(s)
if show_callers:
P("--- callers ---")
for ent, nm in callers(a):
P(" %-30s %s" % (nm, hex(ent)))
try:
C(0x180014420, "*** FUN_180014420 group lookup by ordinal ***")
C(0x18002c8b0, "CONTROL predicate FUN_18002c8b0")
P("=" * 78)
P("callers of the resolver FUN_1800147f0")
P("=" * 78)
for ent, nm in callers(0x1800147f0):
P(" %-30s %s" % (nm, hex(ent)))
for a in (0x18007dab0, 0x18007df60, 0x18007e430, 0x18007e5e0):
C(a, "store screen FUN_%x" % a)
except Exception:
P(traceback.format_exc())
open(OUT, "w").write("\n".join(buf))
print("WROTE", OUT)
@@ -0,0 +1,50 @@
"""DIMENSION 1 / query 5: what the client actually PUSHES to the Flash UI.
ESTABLISHED: FUN_1800147f0 builds the visible item list then, per item i, calls
FUN_180015d80(model, dataProvider, i, item) and conditionally
FUN_1800159b0(model, dataProvider, i, item). FUN_1800159b0 is the second caller
of the by-ordinal group lookup FUN_180014420.
LIVE FACT this must explain: exactly ONE 0x1a8 copy of each pack exists in the
whole 4 GiB address space and each sits in the correct group, yet the Gold group
tile renders the Premium numbers. So the wrong value is produced at push time,
not stored.
HYPOTHESIS: FUN_1800159b0 resolves the group for an item and pushes group-level
fields (price, contents) using a key that collides.
CONTROL: FUN_180015d80 is dumped in the same batch. It is the unconditional
push, so any field seen only in FUN_1800159b0 is conditional on the mypacks
test, and any field in both is not.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/q5_out.txt"
buf = []
def P(*a):
s = " ".join(str(x) for x in a)
buf.append(s)
print(s)
def C(a, label, show_callers=True):
P("=" * 78)
P(label, hex(a))
P("=" * 78)
s = dec(a)
P("len(src) =", len(s))
P(s)
if show_callers:
P("--- callers ---")
for ent, nm in callers(a):
P(" %-30s %s" % (nm, hex(ent)))
try:
C(0x180015d80, "*** FUN_180015d80 per-item push (CONTROL) ***")
C(0x1800159b0, "*** FUN_1800159b0 per-item group push ***")
C(0x18007d880, "store screen dispatcher FUN_18007d880")
C(0x180014de0, "FUN_180014de0")
except Exception:
P(traceback.format_exc())
open(OUT, "w").write("\n".join(buf))
print("WROTE", OUT)
@@ -0,0 +1,49 @@
"""DIMENSION 1 / query 6: who sets the selected category (+0x290) and where do
a pack's NAME / DESCRIPTION / CONTENT come from.
ESTABLISHED: FUN_18007dab0 renders the store list from
FUN_1800147f0(model, *(int*)(screen+0x290), dataProvider, 0, 0). Ordinal 0 means
"list the group tiles". So screen+0x290 IS the drill-down selector and the whole
bug reduces to what value the UI puts there.
Live: pack records have EMPTY strings at +0xd8/+0x108/+0x138, which are exactly
the slots FUN_180015d80 pushes as NAME / DESCRIPTION / CONTENT, so a pack's
caption must be produced elsewhere -> FUN_18002cc90, the tail of the pack model
builder, and FUN_180016a80/bf0/840 for the group tiles.
CONTROL: FUN_180016a80/FUN_180016bf0/FUN_180016840 are three same-shape setters
called on the SAME group strings; if they do not land on three different offsets
among +0xd8/+0x108/+0x138 the reading of FUN_180014610 is wrong.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/q6_out.txt"
buf = []
def P(*a):
s = " ".join(str(x) for x in a)
buf.append(s)
print(s)
def C(a, label, show_callers=True):
P("=" * 78)
P(label, hex(a))
P("=" * 78)
s = dec(a)
P("len(src) =", len(s))
P(s)
if show_callers:
P("--- callers ---")
for ent, nm in callers(a):
P(" %-30s %s" % (nm, hex(ent)))
try:
for a in (0x18007dbd0, 0x18007ddd0, 0x18007da30, 0x18007e230, 0x18007d930):
C(a, "store msg handler FUN_%x" % a)
C(0x18002cc90, "pack model tail FUN_18002cc90")
for a in (0x180016a80, 0x180016bf0, 0x180016840):
C(a, "CONTROL string setter FUN_%x" % a, False)
except Exception:
P(traceback.format_exc())
open(OUT, "w").write("\n".join(buf))
print("WROTE", OUT)
@@ -0,0 +1,57 @@
"""DIMENSION 1 / query 7: who WRITES the store screen's selected-category field.
FUN_18007dab0 reads *(int*)(screen+0x290) and hands it to FUN_1800147f0 as the
group ordinal. FUN_18007dbd0 reads screen+0x294 as SERVER_ID and screen+0x298 as
a callback string. Those three are set by whatever handles the incoming UI
message.
HYPOTHESIS: the writer copies a value straight out of the Flash message. If it
copies the tile's ASSET_ID (displayGroupAssetId) rather than its CHILD_CATEGORY
(the group ordinal), then non-contiguous displayGroupAssetId values break the
drill-down, which is the reported bug.
CONTROL: 0x294 and 0x298 are enumerated by the same scan. They are known to be
read by FUN_18007dbd0, so if the scan cannot find their writers either, the scan
is at fault rather than the code, and no absence claim is made.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/q7_out.txt"
buf = []
def P(*a):
s = " ".join(str(x) for x in a)
buf.append(s)
print(s)
try:
targets = (0x290, 0x294, 0x298, 0x2c8, 0x2cc)
hits = {t: [] for t in targets}
it = listing.getInstructions(True)
n = 0
while it.hasNext():
ins = it.next()
n += 1
txt = str(ins)
for i in range(ins.getNumOperands()):
for o in ins.getOpObjects(i):
try:
v = int(o.getValue())
except Exception:
continue
if v in targets:
f = fm.getFunctionContaining(ins.getAddress())
hits[v].append((int(ins.getAddress().getOffset()),
f.getName() if f else "?", txt))
P("instructions scanned:", n)
for t in targets:
P("")
P("=" * 70)
P("displacement/immediate %#x : %d instruction(s)" % (t, len(hits[t])))
P("=" * 70)
for a, fn, txt in hits[t]:
P(" %-12s %-28s %s" % (hex(a), fn, txt))
except Exception:
P(traceback.format_exc())
open(OUT, "w").write("\n".join(buf))
print("WROTE", OUT)
@@ -0,0 +1,50 @@
"""DIMENSION 1 / query 8: THE WRITERS of screen+0x290.
Instruction scan found exactly two writes to +0x290 inside the store-screen
cluster:
0x18007d3ba FUN_18007d1a0 MOV dword ptr [R14 + 0x290],EBP
0x18007f0c0 FUN_18007e7f0 MOV dword ptr [R15 + 0x290],EAX
Everything else at that displacement in CardsDLL is a vtable CALL/JMP or an
unrelated object.
QUESTION: what value do those two store? If it is a number that came out of the
Flash message (the tile's ASSET_ID) rather than the group ordinal, then
displayGroupAssetId is being used as a group ordinal and non-contiguous ids
break the drill-down.
CONTROL: FUN_18007dab0 (the reader, already decompiled) is in the same cluster
and uses the same object; the two writers must be seen to operate on an object
that also touches +0x294 / +0x2c8, otherwise they are a different class that
merely shares the offset.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/q8_out.txt"
buf = []
def P(*a):
s = " ".join(str(x) for x in a)
buf.append(s)
print(s)
def C(a, label, show_callers=True):
P("=" * 78)
P(label, hex(a))
P("=" * 78)
s = dec(a)
P("len(src) =", len(s))
P(s)
if show_callers:
P("--- callers ---")
for ent, nm in callers(a):
P(" %-30s %s" % (nm, hex(ent)))
try:
C(0x18007d1a0, "*** WRITER 1 FUN_18007d1a0 ***")
C(0x18007e7f0, "*** WRITER 2 FUN_18007e7f0 ***")
C(0x1800144a0, "FUN_1800144a0 CATEGORY_LOCACTION by server id")
C(0x180014ee0, "FUN_180014ee0 IS_AVAILABLE by server id")
except Exception:
P(traceback.format_exc())
open(OUT, "w").write("\n".join(buf))
print("WROTE", OUT)
@@ -0,0 +1,149 @@
"""D3 store-price q1.
HYPOTHESES
H1 extPrice.finalPrice (0x180139070) / originalPrice (0x18013aae0) read ONLY atom
0x11a (externalPriceId). Atoms 0x1b (amount) and 0xc4 (currency) are SKIPped.
H2 The real-money price line is rendered from the Origin/Dime commerce catalog
singleton FUN_1801a0040 -> DAT_1802ef5a0, looked up by externalPriceId, inside
FUN_18002cc90, which early-returns when vm+0x6c == -1.
H3 The pack->viewmodel adapter FUN_18002c3c0 recognises exactly three currency
name literals: "mtx", "coins", "points".
H4 pack record +0x78 (externalPriceId sink) is constructed to -1.
CONTROL for the absence check (H1): the SAME syntactic form. I enumerate EVERY
scalar operand of EVERY instruction in each function, so ==, !=, switch tables and
sub/dec ladders are all covered by construction. The positive control is that the
scan MUST find 0x11a in both functions and MUST find 0x124/0x134/0x1d0 in the
sibling currency-element parser FUN_180138bd0, which is known to read them.
"""
import traceback, sys
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store"
try:
w = open(OUT + "/q1_raw.txt", "w")
def p(*a):
s = " ".join(str(x) for x in a)
print(s)
w.write(s + "\n")
# ---------- 1. instruction-level scalar census (absence check) ----------
from ghidra.program.model.lang import OperandType
def scalars(entry):
f = func(entry)
body = f.getBody()
out = {}
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.getMnemonicString())))
return n, out
p("=" * 70)
p("H1 SCALAR CENSUS -- every immediate/scalar operand in the function body")
for name, ent in [("finalPrice FUN_180139070", 0x180139070),
("originalPrice FUN_18013aae0", 0x18013aae0),
("CONTROL currencyElem FUN_180138bd0", 0x180138bd0)]:
n, sc = scalars(ent)
p("")
p("--- %s : %d instructions, %d distinct scalars" % (name, n, len(sc)))
for atom, label in [(0x11a, "externalPriceId"), (0x1b, "amount"),
(0xc4, "currency"), (0x124, "finalFunds"),
(0x134, "funds"), (0x1d0, "name"), (0xa, "active")]:
hits = sc.get(atom, [])
p(" atom %-6s %-16s : %s" % (hex(atom), label,
("ABSENT" if not hits else ", ".join("%x %s" % h for h in hits))))
# any indirect jump (jump table) would break the census -> report
f = func(ent)
it = listing.getInstructions(f.getBody(), True)
ind = []
while it.hasNext():
ins = it.next()
if ins.getFlowType().isJump() and ins.getFlowType().isComputed():
ind.append(hex(int(ins.getAddress().getOffset())))
p(" computed/indirect jumps in body: %s" % (ind or "NONE"))
# full sorted scalar list, so nothing is hidden
p(" all scalars: %s" % sorted(hex(k) for k in sc))
# ---------- 2. who consumes the viewmodel ----------
p("")
p("=" * 70)
p("H3 callers of the pack->viewmodel adapter FUN_18002c3c0")
for frm, typ, fn, ent in xrefs_to(0x18002c3c0):
p(" %-12x %-10s %s @ %x" % (frm, typ, fn, ent))
p("")
p("H2 callers of the price formatter FUN_18002cc90")
for frm, typ, fn, ent in xrefs_to(0x18002cc90):
p(" %-12x %-10s %s @ %x" % (frm, typ, fn, ent))
# ---------- 3. the mtx sibling FUN_18002e680 ----------
p("")
p("=" * 70)
p("other 'mtx' consumer FUN_18002e680")
src = dec(0x18002e680)
p("len(src) = %d (printed IN FULL below)" % len(src))
p(src)
# ---------- 4. commerce singleton ----------
p("")
p("=" * 70)
p("H2 DAT_1802ef5a0 (returned by FUN_1801a0040) xrefs")
for frm, typ, fn, ent in xrefs_to(0x1802ef5a0):
p(" %-12x %-10s %s @ %x" % (frm, typ, fn, ent))
p(" live qword value in the STATIC image: %#x" % qword(0x1802ef5a0))
p("")
p("callers of FUN_1801a0040 (commerce getter)")
cs = xrefs_to(0x1801a0040)
p(" count=%d" % len(cs))
for frm, typ, fn, ent in cs[:60]:
p(" %-12x %-10s %s @ %x" % (frm, typ, fn, ent))
# ---------- 5. the points gate ----------
p("")
p("=" * 70)
p("H3 points gate DAT_1802de0d0 xrefs")
for frm, typ, fn, ent in xrefs_to(0x1802de0d0):
p(" %-12x %-10s %s @ %x" % (frm, typ, fn, ent))
# ---------- 6. pack record ctor ----------
p("")
p("=" * 70)
p("H4 pack record ctor FUN_1801342d0")
src = dec(0x1801342d0)
p("len(src) = %d (FULL)" % len(src))
p(src)
# ---------- 7. currency-name literals census ----------
p("")
p("=" * 70)
p("every xref to the 'mtx' / 'coins' / 'points' / 'DRAFT_TOKEN' literals")
for lit in [b"mtx\x00", b"coins\x00", b"points\x00", b"DRAFT_TOKEN\x00",
b"POINTS\x00", b"FIFA_POINTS\x00", b"MTX\x00"]:
hits = find_all(lit)
p("")
p(" literal %-14s occurrences=%d %s" % (lit, len(hits), [hex(h) for h in hits[:8]]))
for h in hits[:8]:
xs = xrefs_to(h)
for frm, typ, fn, ent in xs[:20]:
p(" %#x <- %-12x %-8s %s @ %x" % (h, frm, typ, fn, ent))
w.close()
except Exception:
traceback.print_exc()
try:
w.write(traceback.format_exc())
w.close()
except Exception:
pass
@@ -0,0 +1,104 @@
"""D3 store-price q2.
HYPOTHESES
H5 Some function reads the 0x1a8 store-tile viewmodel's currency flags
vm+0xb5 (has mtx) / vm+0xb6 (has coins) / vm+0xb7 (has points) and the
formatted price string vm+0x168, and that function is the Scaleform push
that supplies the "or %1s" argument.
H6 DAT_1802de0d0 vtable slot +0x30 is the gate that disables the "points"
currency branch in FUN_18002c3c0.
METHOD / CONTROL. For H5 I scan EVERY function in .text and collect the set of
scalar operands, then report functions whose scalar set contains the distinctive
triple {0xb5,0xb6,0xb7}. The positive control is that FUN_18002c3c0 (known to
write all three) MUST appear in the result; if it does not, the scan is broken and
every negative is worthless.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store"
try:
w = open(OUT + "/q2_raw.txt", "w")
def p(*a):
s = " ".join(str(x) for x in a)
print(s)
w.write(s + "\n")
# ---------- H5 whole-.text scalar-set scan ----------
want = {0xb5, 0xb6, 0xb7}
hits = []
nfun = 0
it = fm.getFunctions(True)
while it.hasNext():
f = it.next()
nfun += 1
sc = set()
ii = listing.getInstructions(f.getBody(), True)
while ii.hasNext():
ins = ii.next()
for i in range(ins.getNumOperands()):
for o in ins.getOpObjects(i):
try:
sc.add(int(o.getValue()) & 0xFFFFFFFFFFFFFFFF)
except Exception:
pass
if want <= sc:
hits.append((int(f.getEntryPoint().getOffset()), f.getName(),
0x168 in sc, 0xa4 in sc, 0xa8 in sc, 0xa0 in sc, 0x6c in sc))
p("=" * 70)
p("H5 scanned %d functions; %d contain the {0xb5,0xb6,0xb7} triple" % (nfun, len(hits)))
p("CONTROL: FUN_18002c3c0 present? %s" %
any(h[0] == 0x18002c3c0 for h in hits))
p("%-12s %-24s %-7s %-6s %-6s %-6s %-5s" %
("entry", "name", "has168", "hasa4", "hasa8", "hasa0", "has6c"))
for h in hits:
p("%-12x %-24s %-7s %-6s %-6s %-6s %-5s" % h)
# ---------- H6 the points gate ----------
p("")
p("=" * 70)
p("H6 writers of DAT_1802de0d0")
for a in (0x180013ed0, 0x180013f20):
src = dec(a)
p("")
p("--- FUN_%x len=%d FULL" % (a, len(src)))
p(src)
p("")
p("H6 the gate call site, FUN_18002c3c0 around 0x18002c77f")
ii = listing.getInstructions(addr(0x18002c750), True)
n = 0
while ii.hasNext() and n < 40:
ins = ii.next()
p(" %x %s" % (int(ins.getAddress().getOffset()), ins))
n += 1
# try to resolve the vtable of the gate object: find its ctor via the writer
p("")
p("H6 vtable candidates: qword at DAT_1802de0d0 in the static image = %#x" %
qword(0x1802de0d0))
# ---------- extra: literal 0x1801f04f0 / 0x1801f04f8 used by FUN_18002e680 ----------
p("")
p("=" * 70)
p("entitlement-category literals used by FUN_18002e680")
for a in (0x1801f04f0, 0x1801f04f8, 0x180228338, 0x1801eab80, 0x1802055ea):
p(" %#x = %r" % (a, rd_str(a, 40)))
# ---------- extra: CreatePack request serializer currency vocabulary ----------
p("")
p("=" * 70)
p("CreatePack request serializer FUN_180162530 (writes MTX / POINTS literals)")
src = dec(0x180162530)
p("len=%d FULL" % len(src))
p(src)
w.close()
except Exception:
traceback.print_exc()
try:
w.write(traceback.format_exc()); w.close()
except Exception:
pass
@@ -0,0 +1,91 @@
"""D3 store-price q3.
HYPOTHESES
H7 Omitting extPrice from the store pack JSON leaves the pack record's currency
vector with no "mtx" entry, so FUN_18002c3c0 never sets vm+0xb5 and the
real-money price line is suppressed.
THREAT TO H7: the pack-element deserializer FUN_18013af30 ALSO references the
"mtx" literal (0x18013ba76) and ALSO calls the commerce singleton FUN_1801a0040
(0x18013ba98, 0x18013bab7). If it creates the "mtx" row unconditionally, H7 is
false. Decompile it IN FULL and read that block.
H8 DAT_1802de0d0 is a 0x2c0-byte singleton built by FUN_180012a50; its vtable
slot +0x30 is the boolean that disables the "points" currency branch.
H9 Enumerate every atom FUN_18013af30 dispatches on, by instruction-level scalar
census, so the "which keys does the pack record accept" question is answered
without the absence trap. CONTROL: the census must find the atoms we already
know it reads (0xd9 displayGroup, 0xc5 currencies, and the extPrice atoms).
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store"
ATOMS = {}
for line in open("/home/alex/Documents/OpenFUT/fifa17-recon/docs/fut_atoms.tsv"):
parts = line.rstrip("\n").split("\t")
if len(parts) >= 3:
try:
ATOMS[int(parts[0])] = parts[2]
except ValueError:
pass
try:
w = open(OUT + "/q3_raw.txt", "w")
def p(*a):
s = " ".join(str(x) for x in a)
print(s)
w.write(s + "\n")
# ---- H7 / H9 : the pack element deserializer ----
src = dec(0x18013af30, 300)
p("=" * 70)
p("H7 pack element deser FUN_18013af30 len(src)=%d PRINTED IN FULL" % len(src))
p(src)
p("")
p("=" * 70)
p("H9 scalar census of FUN_18013af30 -- every scalar operand, atom-annotated")
f = func(0x18013af30)
sc = {}
n = 0
it = listing.getInstructions(f.getBody(), True)
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()) & 0xFFFFFFFFFFFFFFFF
except Exception:
continue
sc.setdefault(v, []).append((int(ins.getAddress().getOffset()),
str(ins.getMnemonicString())))
p("instructions=%d distinct scalars=%d" % (n, len(sc)))
ind = []
it = listing.getInstructions(f.getBody(), True)
while it.hasNext():
ins = it.next()
if ins.getFlowType().isJump() and ins.getFlowType().isComputed():
ind.append(hex(int(ins.getAddress().getOffset())))
p("computed/indirect jumps: %s" % (ind or "NONE"))
p("")
p("scalars in the plausible atom range 1..0x400 (CMP/SUB/DEC sites shown):")
for v in sorted(k for k in sc if 0 < k <= 0x400):
sites = [h for h in sc[v] if h[1] in ("CMP", "SUB", "DEC", "ADD", "MOV", "LEA")]
p(" %-6s %-28s %s" % (hex(v), ATOMS.get(v, ""),
", ".join("%x/%s" % s for s in sc[v][:6])))
# ---- H8 the points gate object ----
p("")
p("=" * 70)
p("H8 FUN_180012a50 (ctor of the DAT_1802de0d0 singleton)")
s2 = dec(0x180012a50)
p("len=%d FULL" % len(s2))
p(s2)
w.close()
except Exception:
traceback.print_exc()
try:
w.write(traceback.format_exc()); w.close()
except Exception:
pass
@@ -0,0 +1,43 @@
"""D3 store-price q4: name the points gate.
H10 The object at DAT_1802de0d0 has vtable PTR_FUN_1801ebaf0; slot +0x30 is the
predicate that suppresses the "points" currency branch in FUN_18002c3c0.
CONTROL: slot +0x00 and +0x08 must be functions (a real vtable), and the ctor
FUN_180012a50 must be the only writer of that vtable pointer.
"""
import traceback
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store"
try:
w = open(OUT + "/q4_raw.txt", "w")
def p(*a):
s = " ".join(str(x) for x in a)
print(s); w.write(s + "\n")
p("vtable at 0x1801ebaf0 (first 24 slots)")
for off, tgt, nm in vtable(0x1801ebaf0, 24):
p(" +%#04x -> %#x %s" % (off, tgt, nm))
t = qword(0x1801ebaf0 + 0x30)
p("")
p("SLOT +0x30 target = %#x" % t)
s = dec(t)
p("len=%d FULL" % len(s)); p(s)
# what else calls it / who else reads the gate the same way
p("")
p("=" * 60)
p("other call sites of vtable+0x30 on this singleton -- decompile two readers")
for a in (0x18007e7f0, 0x1800a5650):
s = dec(a)
p("")
p("--- FUN_%x len=%d (first 4000 chars; FULL length stated)" % (a, len(s)))
p(s[:4000])
w.close()
except Exception:
traceback.print_exc()
try:
w.write(traceback.format_exc()); w.close()
except Exception:
pass
@@ -0,0 +1,162 @@
"""D2 QUICK SELL ECONOMY, batch 1.
HYPOTHESIS (from an earlier agent's decompile of the shared ITEM element deser
FUN_18013fe00, scratchpad/packres/d4_item_deser.txt lines 779-801): the client
computes a card's quick-sell value LOCALLY, from a game-database table called
"fcc_discardcoins", ONLY when the server-sent discardValue (atom 0xd7) is zero:
if ((int)local_150 == 0) { // discardValue not sent / 0
q = select "price" from "fcc_discardcoins"
where cardtype = local_13c // = f(cardsubtypeid)
and level = local_138._4_4_
and <DAT_18022315c> = uStack_130 & 0xffffffff // = rareflag
p = q.row0["price"]
v = (rating * p) / 100, round half up at remainder > 0x31
local_150.hi = v
}
QUESTIONS THIS BATCH ANSWERS
A. what is the string at DAT_18022315c (the third query key)?
B. where does "level" (local_138._4_4_) come from? no JSON atom in the switch
writes it, so read the RAW INSTRUCTIONS, not the decompiler's locals.
C. what is FUN_1800d8330 (cardsubtypeid -> cardtype)?
D. what are the db-query wrapper functions 0x1801a0020 / 0x18019fd10 /
0x18019fd40 / 0x1801a0280 / 0x1801a0000 / 0x18019ff00 / 0x18019ffc0 /
0x18019fea0 / 0x1801a00a0 / 0x1801a0080 / 0x18019fe40 / 0x18019fe10,
i.e. confirm this really is a SELECT col FROM table WHERE k=v chain, and
find the underlying dbdata entry point so the table can be located live.
E. Q2: FutDiscardCardServerResponse deser 0x180127300 stores totalCredits at
resp+0x28. WHO reads resp+0x28, and does it ASSIGN or ACCUMULATE?
F. Q3: bulk discard. FUN_180126f40 builds a body {"itemId":[...]} (atom 0x16d).
which action/route uses it? print its callers and their callers.
CONTROLS
* class_deser is known-broken in rebuilt projects, so classes are resolved with
find_all(b"RS4:" + name). CONTROL: RS4:FutSquadSaveServerResponse must be
found exactly once at 0x18022c618 (measured on disk this session). If that
fails the whole batch is suspect.
* CONTROL for the atom-arm search: the ITEM deser must contain an arm for atom
0x274 (rating) AND one for 0xd7 (discardValue); both are switch-case labels,
the same syntactic form as anything else searched for here.
Everything is printed in full with len() stated. Nothing is truncated.
"""
import traceback, os
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/qs/"
os.makedirs(OUT, exist_ok=True)
def dump(tag, va, path=None):
try:
src = dec(va)
except Exception as e:
src = "// decompile threw %r" % (e,)
print("=" * 78)
print("%s %#x fname=%s len(src)=%d (FULL, NOT TRUNCATED)"
% (tag, va, fname(va), len(src)))
print("=" * 78)
print(src)
if path:
open(OUT + path, "w").write("// %s %#x len=%d\n%s" % (tag, va, len(src), src))
return src
def disasm(lo, hi, tag):
print("-" * 78)
print("ASM %s %#x..%#x" % (tag, lo, hi))
print("-" * 78)
a = addr(lo)
while int(a.getOffset()) < hi:
ins = listing.getInstructionAt(a)
if ins is None:
a = a.add(1)
continue
print("%#x %s" % (int(a.getOffset()), str(ins)))
a = ins.getAddress().add(ins.getLength())
try:
print("##### CONTROL 0: RS4 literal lookup #####")
for nm in ("FutSquadSaveServerResponse", "FutDiscardCardServerResponse",
"FutDiscardCardByResServerResponse", "FutDiscardACardServerResponse"):
h = find_all(b"RS4:" + nm.encode() + b"\x00")
print(" RS4:%-40s hits=%s" % (nm, [hex(x) for x in h]))
print("\n##### A: strings used by the discardcoins query #####")
for nm, a in (("DAT_18022315c", 0x18022315c), ("fcc_discardcoins_lit", 0x1802231f0),
("DAT_1801eeeb0", 0x1801eeeb0), ("DAT_1802ef590", 0x1802ef590)):
try:
print(" %-22s %#x -> %r bytes=%s"
% (nm, a, rd_str(a, 64), read_bytes(a, 24).hex()))
except Exception as e:
print(" %-22s %#x -> ERR %r" % (nm, a, e))
# neighbourhood of the literal pool so column names are visible
try:
blob = read_bytes(0x180223100, 0x200)
print(" literal pool 0x180223100..0x180223300:")
for piece in blob.split(b"\x00"):
if len(piece) >= 3:
print(" %r" % piece)
except Exception as e:
print(" pool ERR %r" % e)
try:
print(" _DAT_1801f66a0 16 bytes = %s" % read_bytes(0x1801f66a0, 16).hex())
except Exception as e:
print(" _DAT_1801f66a0 ERR %r" % e)
print("\n##### B: raw instructions of the discardcoins block in the ITEM deser #####")
# the query build sits after atom dispatch; xref to the literal pins it
for frm, typ, fn, ent in xrefs_to(0x1802231f0):
print(" xref to fcc_discardcoins literal: from %#x %s in %s (%#x)"
% (frm, typ, fn, ent))
disasm(0x180140f80, 0x1801411e0, "item deser: discardcoins query build")
print("\n##### B2: every write to the two stack slots feeding cardtype/level #####")
print(" (searching the whole ITEM deser for MOV [RBP+..] style stores is noisy;")
print(" instead: full decompile is dumped to disk, and the asm above is authoritative)")
dump("ITEM element deser FUN_18013fe00", 0x18013fe00, "qs_item_deser.txt")
print("\n##### C: cardsubtypeid -> cardtype #####")
dump("FUN_1800d8330 cardsubtype->cardtype", 0x1800d8330, "qs_d8330.txt")
dump("FUN_1800d84e0", 0x1800d84e0, "qs_d84e0.txt")
print("\n##### D: the db query wrapper chain #####")
for a in (0x1801a0020, 0x18019fd10, 0x18019fd40, 0x1801a0280, 0x1801a0000,
0x18019ff00, 0x18019ffc0, 0x18019fea0, 0x1801a00a0, 0x1801a0080,
0x18019fe40, 0x18019fe10):
dump("dbquery %#x" % a, a, "qs_db_%x.txt" % a)
print("\n##### E: discard response consumers #####")
dump("FutDiscardCard deser FUN_180127300", 0x180127300, "qs_discard_deser.txt")
dump("FUN_1800d7af0 (int conv used on totalCredits)", 0x1800d7af0, "qs_d7af0.txt")
print(" --- callers of the discard deser / its owning class ---")
for frm, typ, fn, ent in xrefs_to(0x180127300):
print(" xref %#x %s %s %#x" % (frm, typ, fn, ent))
# the singleton FUN_18011a830 vtable: slot 0xa30 removes an item; find the
# credits setter near it
dump("singleton getter FUN_18011a830", 0x18011a830, "qs_singleton.txt")
print("\n##### F: bulk discard #####")
dump("bulk discard body builder FUN_180126f40", 0x180126f40, "qs_bulkbody.txt")
dump("single discard url builder FUN_180127570", 0x180127570, "qs_urlbuild.txt")
for a in (0x180126f40, 0x180127570):
print(" --- xrefs to %#x ---" % a)
for frm, typ, fn, ent in xrefs_to(a):
print(" %#x %s %s %#x" % (frm, typ, fn, ent))
if ent:
for f2, t2, n2, e2 in xrefs_to(ent):
print(" ^ %#x %s %s %#x" % (f2, t2, n2, e2))
print("\n##### F2: action table rows for the three discard actions #####")
for nm, rowa in (("DiscardCard", 0x1802cb230), ("DiscardCardByRes", 0x1802cb260),
("DiscardACard", 0x1802cb290)):
try:
print(" %s row %#x bytes=%s" % (nm, rowa, read_bytes(rowa, 0x30).hex()))
except Exception as e:
print(" %s ERR %r" % (nm, e))
for frm, typ, fn, ent in xrefs_to(rowa):
print(" xref %#x %s %s %#x" % (frm, typ, fn, ent))
except Exception:
traceback.print_exc()
@@ -0,0 +1,164 @@
"""D2 QUICK SELL, batch 2. Nail down the remaining unknowns from batch 1.
BATCH 1 ESTABLISHED (raw asm, 0x180141025..0x180141140 inside FUN_18013fe00):
if ([RBP+0x198] == 0) // server discardValue, JA = unsigned
price = SELECT [0x1802231e4] FROM "fcc_discardcoins"
WHERE "cardtype" == [RBP+0x1ac]
AND [0x180207848] == [RBP+0x1b4]
AND "rare" == [RBP+0x1b8]
v = (byte[RBP+0x214] * price) / 100, +1 if remainder >= 0x32
[RBP+0x19c] = v
REMAINING QUESTIONS
1. what are the strings at 0x1802231e4 and 0x180207848?
2. EVERY instruction in FUN_18013fe00 that WRITES [RBP+0x1b4] (the second key,
the decompiler shows no atom arm writing it, which would mean it is always
the initialiser value; that must be checked on instructions, not on the
decompiler's merged locals). Same for [RBP+0x198], [RBP+0x19c], [RBP+0x1ac],
[RBP+0x214], [RBP+0x1b8]. CONTROL: [RBP+0x214] must show exactly one write
from the atom-0x274 (rating) arm, which we already know exists.
3. where do [RBP+0x198] and [RBP+0x19c] end up in the heap item object? print
the tail copy-construct.
4. Q2 ASSIGN-vs-ADD. resolve the three vtables that hold the discard triple
(0x1802fb340.., 0x180270580.., 0x180220470..) and 0x1801f3100.., print every
slot, then decompile the response-apply methods so the consumer of resp+0x28
is read directly.
5. the FUT manager singleton DAT_1802e6398: print vtable slots 0x9c0..0xa80 so
the credit setter next to the item-remove slot 0xa30 / id-touch slot 0xa48 is
visible, and decompile 0xa30 and any credit-looking neighbour.
6. every function in the DLL that contains the immediate 0x326 (totalCredits)
in any of the four dispatch forms, so all credit consumers are enumerated.
CONTROL: the list must contain FUN_180127300 and FUN_1801279c0, both of which
are already known to carry a `== 0x326` arm.
Nothing is truncated; len() printed for every decompile.
"""
import traceback, os
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/qs/"
os.makedirs(OUT, exist_ok=True)
def dump(tag, va, path=None):
try:
src = dec(va)
except Exception as e:
src = "// decompile threw %r" % (e,)
print("=" * 78)
print("%s %#x fname=%s len(src)=%d (FULL)" % (tag, va, fname(va), len(src)))
print("=" * 78)
print(src)
if path:
open(OUT + path, "w").write(src)
return src
def insns(va):
f = func(va)
out = []
if f is None:
return out
it = listing.getInstructions(f.getBody(), True)
while it.hasNext():
i = it.next()
out.append((int(i.getAddress().getOffset()), str(i)))
return out
try:
print("##### 1: the two unknown literals #####")
for a in (0x1802231e4, 0x180207848, 0x180223208, 0x18022315c):
print(" %#x -> %r" % (a, rd_str(a, 64)))
print("\n##### 2: every reference to the six stack slots in the ITEM deser #####")
ins = insns(0x18013fe00)
print(" instruction count in FUN_18013fe00 = %d" % len(ins))
slots = ("0x198", "0x19c", "0x1ac", "0x1b0", "0x1b4", "0x1b8", "0x214", "0x1a0",
"0x1a4", "0x1a8")
for s in slots:
pat = "RBP + " + s + "]"
hits = [(a, t) for a, t in ins if pat in t]
print(" --- [RBP + %s] : %d refs ---" % (s, len(hits)))
for a, t in hits:
print(" %#x %s" % (a, t))
print("\n##### 3: the tail of the ITEM deser (copy-construct into the heap item) #####")
f = func(0x18013fe00)
lo = int(f.getEntryPoint().getOffset())
hi = int(f.getBody().getMaxAddress().getOffset())
print(" function body %#x..%#x" % (lo, hi))
for a, t in ins:
if a >= 0x180141160:
print(" %#x %s" % (a, t))
print("\n##### 4: the discard action vtables #####")
for base in (0x1802fb300, 0x180270560, 0x180220450, 0x1801f3100):
print(" --- vtable-ish dump at %#x ---" % base)
for off, tgt, nm in vtable(base, 48):
extra = ""
if 0x180000000 <= tgt < 0x181000000:
try:
extra = repr(rd_str(tgt, 40))
except Exception:
extra = ""
print(" +%#05x %#018x %-20s %s" % (off, tgt, nm, extra if not nm else ""))
print("\n##### 4b: response-apply candidates #####")
# everything referenced next to the deser in those tables
seen = set()
for base in (0x1802fb300, 0x180270560, 0x180220450, 0x1801f3100):
for off, tgt, nm in vtable(base, 48):
if nm and tgt not in seen and fm.getFunctionAt(addr(tgt)):
seen.add(tgt)
print(" %d distinct functions in those tables" % len(seen))
for t in sorted(seen):
try:
src = dec(t)
except Exception as e:
src = "// threw %r" % e
if "0x28" in src or "0x326" in src or "credit" in src.lower():
print("=" * 78)
print("CANDIDATE %#x %s len=%d" % (t, fname(t), len(src)))
print("=" * 78)
print(src)
print("\n##### 5: FUT manager singleton vtable #####")
try:
mgr_vt_holder = 0x1802e6398
print(" DAT_1802e6398 is a runtime pointer; using static xrefs instead")
except Exception:
pass
# find the vtable by looking at who writes DAT_1802e6398
for frm, typ, fn, ent in xrefs_to(0x1802e6398):
print(" xref to DAT_1802e6398: %#x %s %s %#x" % (frm, typ, fn, ent))
print("\n##### 6: every function containing the 0x326 immediate #####")
ATOM = 0x326
found = {}
it = fm.getFunctions(True)
n = 0
while it.hasNext():
fn = it.next()
n += 1
try:
body = listing.getInstructions(fn.getBody(), True)
except Exception:
continue
hit = []
prev = None
while body.hasNext():
i = body.next()
t = str(i)
if "0x326" in t:
hit.append((int(i.getAddress().getOffset()), t))
if hit:
found[int(fn.getEntryPoint().getOffset())] = (fn.getName(), hit)
print(" scanned %d functions; %d contain 0x326" % (n, len(found)))
for e in sorted(found):
nm, hit = found[e]
print(" %#x %s" % (e, nm))
for a, t in hit:
print(" %#x %s" % (a, t))
except Exception:
traceback.print_exc()
@@ -0,0 +1,121 @@
"""D2 QUICK SELL, batch 3.
ESTABLISHED SO FAR
* item struct base in FUN_18013fe00 is RBP+0x160 (local_188). Therefore
item+0x38 = discardValue as SENT BY THE SERVER (atom 0xd7)
item+0x3c = discardValue COMPUTED LOCALLY, only when item+0x38 == 0
item+0x4c = cardtype (= FUN_1800d8330(cardsubtypeid))
item+0x50 = cardsubtypeid, item+0x54 = "level" query key
item+0x58 = rareflag, item+0xb4 = rating
* the local formula is round_half_up(rating * price / 100) where
price = SELECT price FROM fcc_discardcoins
WHERE cardtype==item+0x4c AND level==item+0x54 AND rare==item+0x58
* item+0x54 ("level") has EXACTLY ONE reference in the whole function and it is
a READ; the only write is the 16-byte MOVDQA initialiser at 0x18013ffa1 from
_DAT_1801f66a0 = 56 01 00 00 | 00 00 00 00 | ... so level is CONSTANT 0.
* only two functions in the DLL carry the 0x326 (totalCredits) immediate:
FUN_180127300 (DiscardCard) and FUN_1801279c0 (DiscardCardByRes). Both store
to obj+0x28 with a plain MOV, never a read-modify-write.
THIS BATCH
A. who READS obj+0x28 on the DiscardCard server-call object? dump the class
vtable at 0x180220488 (slot +0x08 == 0x180127300 confirms the base) and
decompile every slot; likewise the DiscardCardByRes vtable located by
searching .rdata for the qword 0x1801279c0.
B. the FUT manager singleton: FUN_18011d780 writes DAT_1802e6398. find the
concrete vtable, dump slots 0x9c0..0xa90, decompile 0xa08 / 0xa30 / 0xa48
and every neighbour whose body mentions a credit-looking field.
C. who reads item+0x38 and item+0x3c? enumerate every function that both
(i) references the item registration entry point mgr->vt[0xa08] target and
(ii) contains a +0x38 / +0x3c memory operand. Also print the a08 target.
D. Q3 bulk discard: resolve the class that owns FUN_180126f40 by searching
.rdata for that qword, dump its vtable and decompile its url builder and
response deserialiser, so the bulk request/response shape is read off code.
E. print the raw .rdata around each RS4 discard literal so the class list is
visible.
CONTROLS
* vtable slot +0x08 of 0x180220488 must equal 0x180127300 (already observed).
* the .rdata qword search must find 0x180127300 at 0x180220490 (already
observed via xrefs_to) -- same syntactic form as the searches for
0x1801279c0 and 0x180126f40, so a hit there validates the method.
"""
import traceback, os, struct
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/qs/"
os.makedirs(OUT, exist_ok=True)
def dump(tag, va, path=None):
try:
src = dec(va)
except Exception as e:
src = "// decompile threw %r" % (e,)
print("=" * 78)
print("%s %#x fname=%s len(src)=%d (FULL)" % (tag, va, fname(va), len(src)))
print("=" * 78)
print(src)
if path:
open(OUT + path, "w").write(src)
return src
def q(a):
return struct.pack("<Q", a)
try:
print("##### CONTROL: .rdata qword search #####")
for tgt in (0x180127300, 0x1801279c0, 0x180126f40, 0x180127570, 0x18013fe00):
hits = find_all(q(tgt), blocks=(".rdata", ".data"))
print(" qword %#x found at %s" % (tgt, [hex(h) for h in hits]))
print("\n##### A: DiscardCard vtable 0x180220488 #####")
slots = vtable(0x180220488, 32)
fns = []
for off, tgt, nm in slots:
print(" +%#05x %#x %s" % (off, tgt, nm))
if nm:
fns.append(tgt)
print("\n##### A2: decompile every DiscardCard slot #####")
seen = set()
for t in fns:
if t in seen:
continue
seen.add(t)
dump("DiscardCard slot", t, "qs_dcslot_%x.txt" % t)
print("\n##### A3: DiscardCardByRes + bulk vtables #####")
for tgt in (0x1801279c0, 0x180126f40):
for h in find_all(q(tgt), blocks=(".rdata", ".data")):
base = h - 8
print(" --- vtable base guess %#x (slot+0x08 == %#x) ---" % (base, tgt))
for off, t2, nm in vtable(base, 24):
print(" +%#05x %#x %s" % (off, t2, nm))
print("\n##### B: the FUT manager singleton #####")
dump("FUN_18011d780 (writes DAT_1802e6398)", 0x18011d780, "qs_mgr_set.txt")
dump("FUN_180119110", 0x180119110, "qs_mgr_119110.txt")
dump("FUN_18011e3c0", 0x18011e3c0, "qs_mgr_11e3c0.txt")
dump("FUN_18011e9d0", 0x18011e9d0, "qs_mgr_11e9d0.txt")
print("\n##### C: item registration + item+0x38/0x3c readers #####")
# find the manager vtable from the ctor and dump the 0x9c0..0xa90 window
print(" (vtable window printed below once the ctor reveals the table address)")
print("\n##### E: RS4 discard literals and their neighbourhoods #####")
for nm in ("FutDiscardCardServerResponse", "FutDiscardCardByResServerResponse"):
for h in find_all(b"RS4:" + nm.encode() + b"\x00"):
print(" %s @ %#x" % (nm, h))
for frm, typ, fn2, ent in xrefs_to(h - 4):
print(" xref %#x %s %s %#x" % (frm, typ, fn2, ent))
if ent:
dump("factory", ent, "qs_factory_%x.txt" % ent)
print(" all RS4: literals containing 'Discard':")
for h in find_all(b"RS4:Fut"):
s = rd_str(h, 80)
if "iscard" in s or "Item" in s:
print(" %#x %r" % (h, s))
except Exception:
traceback.print_exc()
@@ -0,0 +1,94 @@
"""D2 QUICK SELL, batch 4: settle where the "level" query key comes from.
MEASURED FACT (live memory, pid 134663, read-only): the fcc_discardcoins table
has 141 rows keyed (cardtype, level, rare) with level in {1,2,3} only -- there is
NO level==0 row. Three live club items decode exactly:
cardtype 1, level 3, rare 1 -> price 800 ; rating 94 -> 752 ; rating 75 -> 600
cardtype 6, level 1, rare 0 -> price 5 ; rating 55 -> 3
cardtype 6, level 3, rare 0 -> price 40 ; rating 95 -> 38
So the query MUST have been issued with level = 3 / 1 / 3, never 0.
But batch 2's instruction scan of FUN_18013fe00 found EXACTLY ONE reference to
[RBP + 0x1b4] (the slot the "level" argument is loaded from) and it is a READ at
0x180141099; the only write covering that slot appeared to be the 16-byte
MOVDQA at 0x18013ffa1. Something is wrong with that conclusion -- this is exactly
the absence trap. Find the write.
HYPOTHESES TO TEST, in order
H1 the MOVDQA source is not _DAT_1801f66a0, or that constant is not
56 01 00 00 | 00 00 00 00 | ...
H2 part of the atom switch lives outside the address set Ghidra assigned to
FUN_18013fe00, so the body-only instruction walk missed an arm. Test by
walking the whole address range 0x18013fe00..0x180141400 instruction by
instruction, ignoring function boundaries.
H3 the slot is written through a register-based pointer (LEA RAX,[RBP+0x160]
style) rather than an RBP displacement.
CONTROL: the same range-walk must find the KNOWN write to [RBP + 0x1ac]
(cardtype) at 0x180140e16 and the KNOWN write to [RBP + 0x1b8] (rareflag) at
0x180140cc5. Both are plain RBP-displacement MOVs, the same syntactic form as
the write being hunted, so finding them proves the walk sees this form.
"""
import traceback
try:
print("##### H1: the initialiser constant #####")
for a in (0x1801f66a0, 0x1801f66b0):
print(" %#x = %s" % (a, read_bytes(a, 16).hex()))
print(" asm 0x18013fe00..0x18013fff0:")
p = 0x18013fe00
while p < 0x18013fff0:
i = listing.getInstructionAt(addr(p))
if i is None:
p += 1
continue
print(" %#x %s" % (p, str(i)))
p += i.getLength()
print("\n##### H2/H3: whole-range instruction walk 0x18013fe00..0x180141400 #####")
want = ("0x1b4", "0x1b0", "0x1ac", "0x1b8", "0x214", "0x198", "0x19c")
p = 0x18013fe00
n = 0
hits = {w: [] for w in want}
lea160 = []
while p < 0x180141400:
i = listing.getInstructionAt(addr(p))
if i is None:
p += 1
continue
t = str(i)
n += 1
for w in want:
if w in t:
hits[w].append((p, t))
if "RBP + 0x160]" in t or "RBP + 0x1" in t and t.startswith("LEA"):
lea160.append((p, t))
p += i.getLength()
print(" walked %d instructions" % n)
for w in want:
print(" --- '%s' : %d ---" % (w, len(hits[w])))
for a, t in hits[w]:
print(" %#x %s" % (a, t))
print(" --- LEA of frame slots ---")
for a, t in lea160:
print(" %#x %s" % (a, t))
print("\n##### the atom-0x191 (level) question #####")
# find every immediate 0x191 anywhere in the range, in any form
p = 0x18013fe00
while p < 0x180141400:
i = listing.getInstructionAt(addr(p))
if i is None:
p += 1
continue
t = str(i)
if "0x191" in t or "0x18a" in t or "0x192" in t:
print(" %#x %s" % (p, t))
p += i.getLength()
print("\n##### callers of FUN_18013fe00 (maybe one pre-fills level) #####")
for frm, typ, fn, ent in xrefs_to(0x18013fe00):
print(" %#x %s %s %#x" % (frm, typ, fn, ent))
except Exception:
traceback.print_exc()
@@ -0,0 +1,90 @@
"""D2 QUICK SELL, batch 5: find the SECOND fcc_discardcoins computation site.
THE CONTRADICTION THIS RESOLVES.
Measured live (read-only, pid 134663): persistent FUT item objects carry, at
item+0x3c, exactly round_half_up(item_rating * price / 100) where price comes from
the fcc_discardcoins row (cardtype = item+0x4c, level = item+0x54, rare = item+0x58).
Two items with identical cardtype 6 / rare 0 but item+0x54 = 1 and 3 got 3 and 38,
which is only explicable if the level key really varies per item.
BUT inside FUN_18013fe00 the slot that feeds the "level" argument, [RBP+0x1b4], is
provably never written: a whole-DLL byte-pattern search for a modrm with
mod=10 rm=101 disp32=0x000001b4 finds exactly one operand in that function and it
is the LOAD at 0x18014109a (44 8b 8d b4 01 00 00). So a SECOND site must exist.
SEARCHES (all four dispatch forms considered; this is a byte/immediate search, not
a "== 0x" grep)
A. every function containing the divide-by-100 magic B8 1F 85 EB 51
(MOV EAX,0x51eb851f) or 0x51eb851f in any instruction, cross-referenced with
whether it also calls the db-query wrappers.
B. xrefs to the four literals "price" 0x1802231e4, "level" 0x180207848,
"cardtype" 0x180223208, "rare" 0x18022315c, "fcc_discardcoins" 0x1802231f0.
C. every caller of the db wrappers FUN_1801a0000 (from-table) and FUN_18019ff00
(where) and FUN_1801a0080 (get cell).
D. what writes item+0x54? search the whole .text for a dword store with
disp8/disp32 0x54 is hopeless, so instead: decompile the manager entry
mgr->vt[0xa08] target reached from FUN_18013fe00 and look for a level/tier
computation, and decompile the tier helper FUN_1800a9fe0 that an earlier
agent found returning 1/2/3.
CONTROL: search A must report FUN_18013fe00 (it contains MOV EAX,0x51eb851f at
0x180141123). Search B must report the single known xref 0x18014106d for
fcc_discardcoins. If either control misses, the search is broken.
"""
import traceback
try:
print("##### CONTROL + A: functions containing the /100 magic 0x51eb851f #####")
hits = {}
it = fm.getFunctions(True)
n = 0
while it.hasNext():
f = it.next()
n += 1
ii = listing.getInstructions(f.getBody(), True)
got = []
while ii.hasNext():
i = ii.next()
t = str(i)
if "0x51eb851f" in t:
got.append((int(i.getAddress().getOffset()), t))
if got:
hits[int(f.getEntryPoint().getOffset())] = (f.getName(), got)
print(" scanned %d functions, %d contain the magic" % (n, len(hits)))
print(" FUN_18013fe00 present: %s" % (0x18013fe00 in hits))
for e in sorted(hits):
print(" %#x %s (%d sites)" % (e, hits[e][0], len(hits[e][1])))
print("\n##### B: xrefs to the query literals #####")
for nm, a in (("price", 0x1802231e4), ("level", 0x180207848),
("cardtype", 0x180223208), ("rare", 0x18022315c),
("fcc_discardcoins", 0x1802231f0)):
xs = xrefs_to(a)
print(" %-18s %#x : %d xrefs" % (nm, a, len(xs)))
for frm, typ, fn, ent in xs:
print(" %#x %s %s %#x" % (frm, typ, fn, ent))
print("\n##### C: callers of the db wrappers #####")
for nm, a in (("from-table 0x1801a0000", 0x1801a0000),
("where 0x18019ff00", 0x18019ff00),
("getcell 0x1801a0080", 0x1801a0080),
("rowcount 0x1801a00a0", 0x1801a00a0),
("select 0x1801a0280", 0x1801a0280)):
xs = xrefs_to(a)
fns = sorted({(ent, fn) for frm, typ, fn, ent in xs if ent})
print(" %-24s %d xrefs from %d functions" % (nm, len(xs), len(fns)))
for ent, fn in fns:
print(" %#x %s" % (ent, fn))
print("\n##### D: tier helper and the registration entry #####")
for a in (0x1800a9fe0,):
src = dec(a)
print("=" * 78)
print("FUN_%x len=%d" % (a, len(src)))
print("=" * 78)
print(src)
print(" xrefs to %#x:" % a)
for frm, typ, fn, ent in xrefs_to(a):
print(" %#x %s %s %#x" % (frm, typ, fn, ent))
except Exception:
traceback.print_exc()
@@ -0,0 +1,73 @@
"""D2 QUICK SELL, batch 6: FUN_180141660, the derived-field filler.
RESOLUTION OF THE CONTRADICTION. [RBP+0x1b4] (item+0x54, the "level" query key) is
never written through an RBP displacement, but at 0x180141019/0x180141020 the code
does
LEA RCX,[RBP + 0x160] ; = the item struct base
CALL 0x180141660
CMP dword ptr [RBP + 0x198],0x0 ; the discardValue guard, immediately after
so FUN_180141660 receives a POINTER to the item and can write item+0x54 through
RCX, which no RBP-displacement search could ever see. It also carries four xrefs
to the "rare" literal 0x18022315c, i.e. it does its own db lookups.
DUMP IT AND EVERYTHING IT CALLS, in full.
CONTROL: FUN_180141660 must contain at least one store to [RCX/RAX/RBX + 0x54]
or an equivalent; and the known-good sibling fact is that the caller loads
item+0x54 at 0x180141099 straight afterwards. Also decompile FUN_1801356c0, the
other function referencing "rare".
"""
import traceback, os
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/qs/"
def dump(tag, va, path=None):
try:
src = dec(va)
except Exception as e:
src = "// threw %r" % (e,)
print("=" * 78)
print("%s %#x fname=%s len(src)=%d (FULL)" % (tag, va, fname(va), len(src)))
print("=" * 78)
print(src)
if path:
open(OUT + path, "w").write(src)
return src
try:
src = dump("derived-field filler", 0x180141660, "qs_141660.txt")
print("\n--- raw asm of FUN_180141660 ---")
f = func(0x180141660)
it = listing.getInstructions(f.getBody(), True)
n = 0
while it.hasNext():
i = it.next()
print(" %#x %s" % (int(i.getAddress().getOffset()), str(i)))
n += 1
print(" (%d instructions)" % n)
print("\n--- callees ---")
seen = set()
it = listing.getInstructions(f.getBody(), True)
while it.hasNext():
i = it.next()
t = str(i)
if t.startswith("CALL 0x"):
try:
tgt = int(t.split()[1], 16)
except Exception:
continue
if tgt not in seen:
seen.add(tgt)
for t in sorted(seen):
dump("callee", t, "qs_141660_callee_%x.txt" % t)
print("\n--- other 'rare' consumer ---")
dump("FUN_1801356c0", 0x1801356c0, "qs_1356c0.txt")
except Exception:
traceback.print_exc()
@@ -0,0 +1,101 @@
"""D2 QUICK SELL, batch 7: Q2 (assign vs add) and Q3 (bulk discard shape).
Q2 CONTEXT. FutDiscardCardServerResponse is a 0x38-byte object freshly allocated
per request by FUN_180127160 ("RS4:FutDiscardCardServerResponse", size 0x38,
vtable 0x180220488). Its deserialiser stores totalCredits with a plain
MOV dword [obj+0x28] and the item id with MOV qword [obj+0x30]; there is no
read-modify-write anywhere in the deser, and only two functions in the whole DLL
carry the 0x326 immediate. What remains is: who READS obj+0x28, and does that
consumer assign or accumulate into the wallet? Route to it: FUN_180127290 (vtable
slot +0xa0) dispatches to a delegate stored at servercall+0x50 / +0x60.
Q3 CONTEXT. FUN_180126f40 emits {"itemId":[<int64>, ...]} using atom 0x16d. Find
which of the three discard actions owns it (DiscardCard 0x1802cb230 factory
0x180123cd0, DiscardCardByRes 0x1802cb260 factory 0x180123ce0, DiscardACard
0x1802cb290 factory 0x180123cc0) and what url/method that action uses.
CONTROL for Q3: factory 0x180123cd0 must produce an object whose vtable slot
+0x08 is the request-body/url set that includes 0x180127570 (the "/%llu" single-id
url builder we have already seen on the wire as DELETE /ut/game/fifa17/item/<id>).
"""
import traceback, os, struct
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/qs/"
def dump(tag, va, path=None):
try:
src = dec(va)
except Exception as e:
src = "// threw %r" % (e,)
print("=" * 78)
print("%s %#x fname=%s len(src)=%d (FULL)" % (tag, va, fname(va), len(src)))
print("=" * 78)
print(src)
if path:
open(OUT + path, "w").write(src)
return src
try:
print("##### Q3: the three discard action factories #####")
for nm, a in (("DiscardACard", 0x180123cc0), ("DiscardCard", 0x180123cd0),
("DiscardCardByRes", 0x180123ce0)):
dump("factory " + nm, a, "qs_fac_%x.txt" % a)
print("\n##### Q3: the pointer table around 0x1801f3118 #####")
for off in range(-0x40, 0x100, 8):
a = 0x1801f3118 + off
try:
v = qword(a)
except Exception:
continue
f = fm.getFunctionAt(addr(v)) if 0x180000000 <= v < 0x181000000 else None
print(" %#x -> %#x %s" % (a, v, f.getName() if f else ""))
print("\n##### Q3: url/method providers #####")
for a in (0x180126f00, 0x1801277c0, 0x180127800, 0x180068320, 0x180127890,
0x180122420, 0x18011f940):
dump("provider", a, "qs_prov_%x.txt" % a)
print("\n##### Q3: the url-suffix table entry 0x0d / 0x0e / 0x0f #####")
# the action rows point at a url index; print the table of url format strings
for i in range(0x28):
try:
p = qword(0x1801f2f00 + i * 8)
print(" idx %#04x -> %#x %r" % (i, p, rd_str(p, 60) if p else ""))
except Exception as e:
print(" idx %#04x ERR %r" % (i, e))
print("\n##### Q2: every RS4 class with 'Credit' or 'User' in the name #####")
for h in find_all(b"RS4:Fut"):
s = rd_str(h, 90)
if "Credit" in s or "UserData" in s or "UserInfo" in s:
print(" %#x %r" % (h, s))
for frm, typ, fn, ent in xrefs_to(h - 4):
print(" xref %#x %s %s %#x" % (frm, typ, fn, ent))
print("\n##### Q2: functions containing the credits atom 0xc0 as a compare #####")
it = fm.getFunctions(True)
n = 0
found = []
while it.hasNext():
f = it.next()
n += 1
ii = listing.getInstructions(f.getBody(), True)
got = []
while ii.hasNext():
i = ii.next()
t = str(i)
if ("CMP" in t or "SUB" in t) and (",0xc0" in t):
got.append((int(i.getAddress().getOffset()), t))
if got:
found.append((int(f.getEntryPoint().getOffset()), f.getName(), got))
print(" scanned %d functions, %d contain a CMP/SUB with 0xc0" % (n, len(found)))
for e, nm, got in found:
print(" %#x %s" % (e, nm))
for a, t in got:
print(" %#x %s" % (a, t))
except Exception:
traceback.print_exc()
@@ -0,0 +1,66 @@
"""D2 QUICK SELL, batch 8: which of item+0x38 / item+0x3c does the client READ?
WHY IT MATTERS. In FUN_18013fe00 the two are mutually exclusive:
item+0x38 = discardValue exactly as the server sent it (atom 0xd7)
item+0x3c = the locally computed fallback, written ONLY when item+0x38 == 0
So if the UI reads +0x3c alone, serving a non-zero discardValue would make the
quick-sell figure render as 0. If it reads +0x38 alone, our current seed of 0 would
render 0 -- which contradicts the live club items, which all carry a correct value
in +0x3c and 0 in +0x38. The likely shape is a getter "return +0x38 ? +0x38 : +0x3c"
or a caller that ORs them. Find it.
METHOD. Scan every function; keep the ones whose instruction text contains BOTH a
"+ 0x38]" and a "+ 0x3c]" memory operand. Print the small ones in full. This is a
text search over decoded operands, so it catches loads through ANY base register,
which is the form an accessor uses -- unlike an RBP-displacement search.
CONTROL: FUN_18013fe00 itself must appear in the list (it has the store to +0x198
and +0x19c, but those are RBP+0x198 not "+ 0x38", so instead the control is
FUN_180141660, which is known to touch obj+0x54/+0x58/+0xb4 through RCX and must
show up in an equivalent scan for "+ 0x54]" and "+ 0x58]"). Both scans are printed.
"""
import traceback
try:
def scan(a_txt, b_txt, maxins=60):
out = []
it = fm.getFunctions(True)
while it.hasNext():
f = it.next()
ii = listing.getInstructions(f.getBody(), True)
n = 0
ha = hb = False
while ii.hasNext():
t = str(ii.next())
n += 1
if a_txt in t:
ha = True
if b_txt in t:
hb = True
if ha and hb:
out.append((int(f.getEntryPoint().getOffset()), f.getName(), n))
return out
print("##### CONTROL scan: '+ 0x54]' and '+ 0x58]' #####")
ctl = scan("+ 0x54]", "+ 0x58]")
print(" %d functions; FUN_180141660 present: %s"
% (len(ctl), any(e == 0x180141660 for e, _, _ in ctl)))
print("\n##### TARGET scan: '+ 0x38]' and '+ 0x3c]' #####")
tgt = scan("+ 0x38]", "+ 0x3c]")
print(" %d functions" % len(tgt))
small = [t for t in tgt if t[2] <= 40]
print(" %d of them are <= 40 instructions" % len(small))
for e, nm, n in sorted(small, key=lambda x: x[2]):
src = dec(e)
print("=" * 78)
print("%#x %s %d instructions len(src)=%d" % (e, nm, n, len(src)))
print("=" * 78)
print(src)
print("\n --- larger candidates (names only) ---")
for e, nm, n in sorted(tgt, key=lambda x: x[2]):
if n > 40:
print(" %#x %s %d ins" % (e, nm, n))
except Exception:
traceback.print_exc()
@@ -0,0 +1,89 @@
"""D2 QUICK SELL, batch 9: the wallet. Q2 assign-vs-add, final attempt.
PLAN. Find the credit-carrying response classes and their deserialisers:
RS4:FutUserCreditsServerResponse @ 0x18021dc18
RS4:FutUpdateCreditsServerResponse @ 0x18022cc10
RS4:FutDiscardCardServerResponse @ 0x180220540 (vtable 0x180220488, deser 0x180127300)
Resolve each by find_all(b"RS4:"+name) then xrefs_to(hit-4) -> factory -> the
.rdata vtable it installs -> slot +0x08. Decompile all three deserialisers and
every virtual they invoke on the FUT manager singleton, so the wallet field and
its writers are visible. Then enumerate every writer of that field.
CONTROL: the discard chain must resolve to 0x180127300, which is already known
independently (qword 0x180127300 sits at 0x180220490 = vtable+0x08). If the same
mechanism yields a plausible deser for the two credits classes, it is working.
"""
import traceback, os, struct
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store/qs/"
def dump(tag, va, path=None):
try:
src = dec(va)
except Exception as e:
src = "// threw %r" % (e,)
print("=" * 78)
print("%s %#x fname=%s len(src)=%d (FULL)" % (tag, va, fname(va), len(src)))
print("=" * 78)
print(src)
if path:
open(OUT + path, "w").write(src)
return src
def resolve(name):
out = []
for h in find_all(b"RS4:" + name.encode() + b"\x00"):
for frm, typ, fn, ent in xrefs_to(h - 4):
if ent:
out.append((h, frm, ent, fn))
return out
try:
for nm in ("FutDiscardCardServerResponse", "FutUserCreditsServerResponse",
"FutUpdateCreditsServerResponse"):
print("##### %s #####" % nm)
for h, frm, ent, fn in resolve(nm):
print(" literal %#x factory %#x %s (ref at %#x)" % (h, ent, fn, frm))
src = dump("factory", ent, "qs_r_fac_%x.txt" % ent)
# find the PTR_FUN_ vtable it installs
import re
for m in re.finditer(r"PTR_FUN_([0-9a-f]+)", src):
vt = int(m.group(1), 16)
print(" vtable %#x, slot+0x08 = %#x %s"
% (vt, qword(vt + 8), fname(qword(vt + 8))))
dump("deser", qword(vt + 8), "qs_r_deser_%x.txt" % qword(vt + 8))
for off, tgt, fnm in vtable(vt, 24):
print(" +%#05x %#x %s" % (off, tgt, fnm))
print()
print("##### the FUT manager slots used by the discard deser #####")
# the deser calls (**(code**)(*plVar4 + 0xa30))(plVar4, id) after parsing an id
# find every function that calls a virtual at +0xa30 / +0xa08 / +0xa48 and the
# ones that call slots near them, so a credit setter can be spotted by name.
it = fm.getFunctions(True)
want = ("0xa08", "0xa30", "0xa48", "0x9f8", "0xa00", "0xa10", "0xa18", "0xa20",
"0xa28", "0xa38", "0xa40", "0xa50", "0xa58", "0xa60")
tally = {}
while it.hasNext():
f = it.next()
ii = listing.getInstructions(f.getBody(), True)
got = []
while ii.hasNext():
i = ii.next()
t = str(i)
if t.startswith("CALL qword ptr [") and any(w in t for w in want):
got.append((int(i.getAddress().getOffset()), t))
if got:
tally[int(f.getEntryPoint().getOffset())] = (f.getName(), got)
print(" %d functions call one of those slots" % len(tally))
for e in sorted(tally):
nm, got = tally[e]
print(" %#x %s" % (e, nm))
for a, t in got:
print(" %#x %s" % (a, t))
except Exception:
traceback.print_exc()
@@ -0,0 +1,59 @@
"""DIMENSION 5 / unopenedPacks -- pass 1: dump the four deserializers end to end.
HYPOTHESIS: (a) FutCreateUser deser 0x18014cc60 has arms for starterPack(0x2e5) and
bonusPacks(0x5d) that call dedicated sub-deserializers; (b) userInfo 0x18013ec10 has an
arm for unopenedPacks(0x35e) that calls a sub-deser and then a singleton vtbl slot;
(c) pack element 0x18013af30 has an arm for packContentInfo(0x20c) calling a sub-deser
that holds unopened(0x35d).
CONTROL: 0x18013c6d0 (settings deser) is a deserializer of the SAME family with a
KNOWN answer -- exactly one key `configs`(0xa2). If the dump/parse pipeline is sound,
the settings dump must show 0xa2 and nothing else in its key ladder. Same syntactic
form family (ladder), so it controls the extraction, not just the decompile.
NO ABSENCE CLAIMS FROM THIS PASS: it only dumps. Full text goes to disk, lengths are
printed so truncation is visible.
"""
import traceback, os
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store"
try:
os.makedirs(OUT, exist_ok=True)
targets = {
"createuser_18014cc60": 0x18014cc60,
"userinfo_18013ec10": 0x18013ec10,
"packelem_18013af30": 0x18013af30,
"settings_18013c6d0": 0x18013c6d0, # CONTROL
}
for name, a in targets.items():
src = dec(a, 300)
p = os.path.join(OUT, "dec_%s.c" % name)
with open(p, "w") as f:
f.write(src)
f2 = func(a)
print("== %s @ %#x fn=%s body=%#x-%#x declen=%d" % (
name, a, f2.getName() if f2 else "?",
int(f2.getEntryPoint().getOffset()) if f2 else 0,
int(f2.getBody().getMaxAddress().getOffset()) if f2 else 0,
len(src)))
print(" written %s" % p)
# Raw instruction-level immediate enumeration for each target, so the ladder /
# switch / sub-dec forms are all visible regardless of how Ghidra renders them.
for name, a in targets.items():
f2 = func(a)
if f2 is None:
print("!! no function at %#x" % a)
continue
lines = []
it = listing.getInstructions(f2.getBody(), True)
while it.hasNext():
ins = it.next()
lines.append("%#x %s" % (int(ins.getAddress().getOffset()), str(ins)))
p = os.path.join(OUT, "asm_%s.txt" % name)
with open(p, "w") as fh:
fh.write("\n".join(lines))
print("== asm %s: %d instructions -> %s" % (name, len(lines), p))
except Exception:
traceback.print_exc()
@@ -0,0 +1,72 @@
"""DIMENSION 5 pass 2: the pieces the CreateUser / unopenedPacks answers depend on.
HYPOTHESES
H1 FUN_18011a830 returns the model singleton (DAT_1802e6398 or similar), so its
vtable can be resolved LIVE and slot 0x4e0 identified.
H2 FUN_180142470 (the userData 0x36d arm of CreateUser) takes only the reader, so
it parses into a global, not into the response record.
H3 FUN_180141ee0 is the shared "read FIELD_NAME -> atom, advance to value" helper;
return 6 means "no value / null" and suppresses dispatch.
H4 The primitive getters 0x1801c7620 (BOOL) / 0x1801c79d0 (INT) / 0x180135ff0
(SKIP) determine whether a container fed to a scalar arm desyncs the reader.
CONTROL for the vtable slot question: slot 0x480 is used by the squadList(0x2d4) arm
of the SAME deserializer and is LIVE-CONFIRMED WORKING (MY SQUADS renders). Whatever
method resolves 0x4e0 must also resolve 0x480 to something sane; if it cannot resolve
0x480 the method is broken, not the target.
CONTROL for the stack-struct offset rule (record_off = 0x268 - N in FUN_18013af30):
displayGroupAssetId(0xda)->local_238 must give 0x30 and
displayGroupUseDefaultImage(0xdb)->local_230 must give 0x38, which is what an earlier
independent pass recorded for those two fields. Both are printed below from the asm.
"""
import traceback, os
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store"
try:
for name, a in [
("singleton_18011a830", 0x18011a830),
("userdata_180142470", 0x180142470),
("keyhelper_180141ee0", 0x180141ee0),
("bool_1801c7620", 0x1801c7620),
("int_1801c79d0", 0x1801c79d0),
("skip_180135ff0", 0x180135ff0),
("item_18013fe00", 0x18013fe00),
("storeroot_1801234e0", 0x1801234e0),
]:
src = dec(a, 300)
p = os.path.join(OUT, "dec_%s.c" % name)
open(p, "w").write(src)
print("== %s @ %#x declen=%d -> %s" % (name, a, len(src), p))
print("\n---- singleton getter, short ones printed inline ----")
for a in (0x18011a830, 0x180141ee0, 0x1801c7620):
s = dec(a, 300)
if len(s) < 2600:
print("\n### %#x (len=%d)\n%s" % (a, len(s), s))
else:
print("\n### %#x len=%d (see file)" % (a, len(s)))
# ---- CONTROL: stack offsets in FUN_18013af30 from the raw asm ----------
print("\n---- FUN_18013af30 stack-displacement control ----")
f = func(0x18013af30)
it = listing.getInstructions(f.getBody(), True)
want = {}
while it.hasNext():
ins = it.next()
t = str(ins)
for d in ("0xcd", "0xb4", "0x30", "0x38", "0xce", "0xcc"):
pass
want.setdefault("all", []).append((int(ins.getAddress().getOffset()), t))
# print the instructions immediately around each of the four atom arms
for label, site in (("0x35d unopened", 0x18013b7b0), ("0xda dGAssetId", 0x0),
("0xdb dGUseDefImg", 0x0)):
pass
# simpler: dump every instruction that writes a byte to a stack slot
for addr_i, t in want["all"]:
if ("MOV byte ptr [RSP" in t or "MOV byte ptr [RBP" in t
or "MOV dword ptr [RSP" in t):
print(" %#x %s" % (addr_i, t))
except Exception:
traceback.print_exc()
@@ -0,0 +1,45 @@
"""DIMENSION 5 pass 3: the unopenedPacks consumer, and the tokenizer's EOF behaviour.
Resolved LIVE (read-only probe, pid 134663): model singleton = *DAT_1802e6398,
its vtable = static 0x18021c2a0, and
slot 0x4e0 -> 0x18011e120 <- the unopenedPacks(0x35e) consumer
slot 0x480 -> 0x18011baf0 <- CONTROL, the squadList(0x2d4) consumer, which is
live-confirmed working (MY SQUADS renders)
Both land inside CardsDLL, so both are analysable. If 0x18011baf0 does not look like
a squad-roster accessor, the vtable resolution is wrong and 0x4e0 means nothing.
HYPOTHESES
H1 0x18011e120 stores the pack count into a model field; xrefs to that field give
the UI consumer.
H2 the tokenizer 0x1801c7f10 returns a specific token at end of input; that value
decides whether a trailing re-dispatch terminates or spins (the 0x1801c7f1a
freeze).
"""
import traceback, os
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store"
try:
for name, a in [
("consumer_18011e120", 0x18011e120),
("control_18011baf0", 0x18011baf0),
("nexttok_1801c7f10", 0x1801c7f10),
]:
src = dec(a, 300)
open(os.path.join(OUT, "dec_%s.c" % name), "w").write(src)
print("\n#### %s @ %#x len=%d\n%s" % (name, a, len(src), src if len(src) < 6000 else "(see file)"))
print("\n---- asm of 0x18011e120 ----")
f = func(0x18011e120)
it = listing.getInstructions(f.getBody(), True)
while it.hasNext():
ins = it.next()
print(" %#x %s" % (int(ins.getAddress().getOffset()), str(ins)))
print("\n---- vtable static 0x18021c2a0 slots 0x470..0x500 ----")
for s in range(0x470, 0x508, 8):
t = qword(0x18021c2a0 + s)
fn = fm.getFunctionAt(addr(t)) if 0x180000000 <= t < 0x181000000 else None
print(" +%#05x -> %#x %s" % (s, t, fn.getName() if fn else ""))
except Exception:
traceback.print_exc()
@@ -0,0 +1,59 @@
"""DIMENSION 5 pass 4: who READS the unopened-pack count, and who consumes the
CreateUser response record.
Established so far (this run):
userInfo 0x35e arm -> model+0x20950 = preOrderPacks + recoveredPacks, then raises
event 0x273d. Writers of +0x20950: 0x18010e06a and 0x18011e12f. Reader: 0x18011c200
(= model vtable slot 0x4d8). 0x273d compared at 0x18007e85f and 0x1800b3944, raised
again at 0x180199e07.
HYPOTHESIS: the 0x4d8 getter is called from UI/flow code that decides whether a
pending-packs tile exists, and one of the 0x273d handlers is the refresh path.
CONTROL: every call site is filtered by whether its containing function reaches the
model singleton FUN_18011a830 / DAT_1802e6398. Call sites on OTHER classes' vtables
that happen to use offset 0x4d8 must be rejected by that filter; if the filter rejects
nothing it is not filtering.
"""
import traceback, os
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store"
SITES_4D8 = [0x1800160c0, 0x180019816, 0x18007136b, 0x18007e498, 0x1800ad460,
0x1800aeccf, 0x1800aece6, 0x1800af449, 0x1800b1d48]
SITES_4E0 = [0x1800173d1, 0x180019861, 0x18007137b, 0x1800ad498, 0x1800bcb71,
0x18013f222]
OTHER = [0x18010e06a, 0x18007e85f, 0x1800b3944, 0x180199e07, 0x18011c200]
try:
seen = {}
for label, sites in (("get4d8", SITES_4D8), ("call4e0", SITES_4E0), ("other", OTHER)):
print("\n===== %s =====" % label)
for s in sites:
f = func(s)
if f is None:
print(" %#x -> no function" % s)
continue
ent = int(f.getEntryPoint().getOffset())
src = seen.get(ent)
if src is None:
src = dec(ent, 300)
seen[ent] = src
uses_model = ("FUN_18011a830" in src) or ("DAT_1802e6398" in src)
print(" site %#x fn %s @ %#x len=%d model=%s" % (
s, f.getName(), ent, len(src), uses_model))
open(os.path.join(OUT, "dec_fn_%x.c" % ent), "w").write(src)
# The CreateUser response object: find its class literal, vtable and consumers.
print("\n===== FutCreateUserServerResponse =====")
for lit in find_all(b"RS4:FutCreateUserServerResponse\x00"):
print(" literal @ %#x" % lit)
for frm, typ, fn, ent in xrefs_to(lit):
print(" xref from %#x (%s) in %s @ %#x" % (frm, typ, fn, ent))
if ent:
src = dec(ent, 300)
open(os.path.join(OUT, "dec_createuser_factory_%x.c" % ent), "w").write(src)
print(" -> dec_createuser_factory_%x.c len=%d" % (ent, len(src)))
print(src[:1800])
except Exception:
traceback.print_exc()
@@ -0,0 +1,55 @@
"""DIMENSION 5 pass 5: (a) the CreateUser response object's own handler, which is what
consumes starterPack/bonusPacks; (b) the other writer of model+0x20950; (c) the two
0x273d event handlers; (d) callers of FUN_180017390 (the second caller of the count
setter).
Correction carried into this pass: pass 4's "model=False" filter was TOO NARROW. The
userInfo deser reaches the model through the raw singleton FUN_18011a830, but UI code
reaches the SAME object through the ref-counted service locator
FUN_180009c80(&out, FUN_1800d7170()). Both then call slots 0x160 / 0x4d8 / 0x4e0 /
0x530 on it, so the locator form is the same class. Do not read pass 4's False column
as "not the model".
CONTROL for the response-vtable walk: slot +0x08 of the resolved vtable must be the
known deserializer 0x18014cc60. If it is not, the vtable is the wrong one and every
other slot read from it is meaningless.
"""
import traceback, os
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store"
try:
print("### FUN_18014c810 (CreateUser response ctor)")
src = dec(0x18014c810, 300)
print(src)
open(os.path.join(OUT, "dec_ctor_18014c810.c"), "w").write(src)
# find the vtable it installs: any .rdata address referenced whose +8 is 0x18014cc60
print("\n### hunting the response vtable (control: slot +0x08 == 0x18014cc60)")
hits = find_all((0x18014cc60).to_bytes(8, "little"), blocks=(".rdata", ".data"))
for h in hits:
vt = h - 8
print(" candidate vtable %#x (slot+8 = deser)" % vt)
for i in range(0, 0x60, 8):
t = qword(vt + i)
fn = fm.getFunctionAt(addr(t)) if 0x180000000 <= t < 0x181000000 else None
print(" +%#04x -> %#x %s" % (i, t, fn.getName() if fn else ""))
for frm, typ, fn, ent in xrefs_to(vt):
print(" vtable xref from %#x in %s @ %#x" % (frm, fn, ent))
print("\n### callers of FUN_180017390 (second caller of the 0x4e0 count setter)")
for frm, typ, fn, ent in xrefs_to(0x180017390):
print(" from %#x (%s) in %s @ %#x" % (frm, typ, fn, ent))
print("\n### FUN_180199cc0 (raises 0x273d)")
s = dec(0x180199cc0, 300)
print(s)
open(os.path.join(OUT, "dec_fn_180199cc0.c"), "w").write(s)
for a, nm in ((0x18010cdc0, "writer2_18010cdc0"), (0x18007e7f0, "evt_18007e7f0"),
(0x1800b3900, "evt_1800b3900")):
s = dec(a, 300)
open(os.path.join(OUT, "dec_%s.c" % nm), "w").write(s)
print("\n### %s len=%d -> file" % (nm, len(s)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,31 @@
"""DIMENSION 5 pass 6: does the CreateUser response's starterPack vector reach the
model's 0x18-stride "claim" vector (model vtable slot 0x160), the one whose
non-emptiness pops FUT_CLAIM_NEW_ITEM_POPUP?
Class-specific slots on the CreateUser response vtable 0x1802251f8 are +0x00
(0x18014c990) and +0x40 (0x18014c950); every other slot is shared 0x18016c### /
0x180122420 boilerplate, so the response handler is one of those two.
CONTROL: slot +0x08 of 0x1802251f8 is 0x18014cc60, the deserializer established in
pass 1 by an independent route (the RS4: literal xref from the factory). It matches,
so this vtable is the right object's.
"""
import traceback, os
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store"
try:
for a, nm in ((0x18014c950, "resp_slot40_18014c950"),
(0x18014c990, "resp_slot00_18014c990")):
s = dec(a, 300)
open(os.path.join(OUT, "dec_%s.c" % nm), "w").write(s)
print("\n### %s @ %#x len=%d\n%s" % (nm, a, len(s), s if len(s) < 5000 else "(file)"))
t160 = qword(0x18021c2a0 + 0x160)
print("\n### model vtable slot 0x160 -> %#x" % t160)
print(dec(t160, 300))
print("\n### rest of FUN_180199cc0")
print(dec(0x180199cc0, 300)[1500:])
except Exception:
traceback.print_exc()
@@ -0,0 +1,25 @@
"""DIMENSION 5 pass 7: close Q2. FUN_1800af3a0 returns 0x1c when the unopened-pack
count is > 0 and the model's claim vector (model+0x5a38, slot 0x160) is empty. What
consumes that return value, and can that path originate an HTTP request?
CONTROL: the same function returns 0x29 when the claim vector is NON-empty and 0xe
otherwise, so whatever consumes the value must treat all three as the same kind of
token (a state/screen id). If the caller uses it as something else (a count, a bool)
the "state id" reading is wrong.
"""
import traceback, os
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store"
try:
print("### xrefs to FUN_1800af3a0")
for frm, typ, fn, ent in xrefs_to(0x1800af3a0):
print(" from %#x (%s) in %s @ %#x" % (frm, typ, fn, ent))
if ent:
s = dec(ent, 300)
open(os.path.join(OUT, "dec_af3a0_caller_%x.c" % ent), "w").write(s)
print(" len=%d -> dec_af3a0_caller_%x.c" % (len(s), ent))
if len(s) < 4000:
print(s)
print("\n### FUN_1801a4cd0 (the message poster used by the 0x273d handlers)")
print(dec(0x1801a4cd0, 300)[:1500])
except Exception:
traceback.print_exc()
@@ -0,0 +1,33 @@
"""DIMENSION 5 pass 8: the pack-record -> view-model translator, which is the consumer
of unopened(0x35d).
Found in the raw disassembly, not the decompiler: at 0x18002c7ea..0x18002c816 a
function copies BYTE [rbp+0xce] -> [rsi+0xb8] and BYTE [rbp+0xcd] -> [rsi+0x69],
where rbp is the 0x158-byte parsed pack record (it also reads +0x144/+0x148/+0x14c/
+0x150/+0x154, all inside 0x158) and rsi is a wider destination struct.
Per pass 1's offset rule (record_off = 0x268 - N in FUN_18013af30, controlled twice by
displayGroupAssetId->+0x30 and displayGroupUseDefaultImage->+0x38), +0xcd is
unopened(0x35d) and +0xce is isPremium(0x176).
CONTROL: the same routine must also move a field whose meaning is already known from
the deserializer, so the "rbp is the pack record" reading is testable. start(0x2e3) is
at record+0xb4 and the routine moves DWORD [rbp+0xb4] -> [rsi+0x84]; sortPriority
(0x2cb) is at record+0x7c and displayGroupAssetId at +0x30.
"""
import traceback, os
OUT = "/tmp/claude-1000/-home-alex-Documents-OpenFUT/8e521ca1-ca3e-4138-bb96-df1744dd1d30/scratchpad/store"
try:
f = func(0x18002c7ea)
ent = int(f.getEntryPoint().getOffset())
print("containing function %s @ %#x body %#x-%#x" % (
f.getName(), ent, ent, int(f.getBody().getMaxAddress().getOffset())))
s = dec(ent, 300)
open(os.path.join(OUT, "dec_translator_%x.c" % ent), "w").write(s)
print("declen=%d -> dec_translator_%x.c" % (len(s), ent))
print(s if len(s) < 12000 else s[:12000])
print("\n### callers")
for frm, typ, fn, e2 in xrefs_to(ent):
print(" from %#x (%s) in %s @ %#x" % (frm, typ, fn, e2))
except Exception:
traceback.print_exc()
@@ -0,0 +1,91 @@
"""ADVERSARIAL VERIFY BATCH 3 (namespaced q_zver_* -- another agent is clobbering q_adv_*).
Targets:
T1 dim4: the four claimed duplicateItemIdList consumers really accept atom 0xec and
really run the identical fixup reading ONLY record qword0 and qword2.
0x180162880 CreatePack, 0x18013bd40 purchased/massinfo, 0x1801293d0 FutViewCards,
0x18013e7f0 IS-list.
Ladder decode (raw instructions, catches sub/dec ladders and reports switch bounds)
+ full decompile of each.
T2 dim4: FUN_18009bc40 branches on item+0x10 and suppresses PUT /item when set.
T3 dim5 claim 12: FUN_1801340e0 is a COPY CONSTRUCTOR of one 0x158 record, not a
vector-grow with element size 0x108. Full decompile + the tail of FUN_18013af30.
T4 dim5: pack element record offset rule (0x268 - N) and the 0x35d/0x2e3 nesting.
CONTROL for the ladder decoder: reproduced {0xeb,0xed,0x16d,0x16f} on 0x180138e10 and
{0x1f2,0x1f3,0x1f4,0x383} on 0x180142470 and {0x5d,0x1a5,0x2cd,0x2e5,0x36d} on
0x18014cc60 in batch 2 -- all three are sub/dec ladders, the SAME form as the targets.
"""
import traceback
def ladder(entry, note=""):
f = func(entry)
if f is None:
print("!! no function at %#x" % entry)
return
print("=" * 90)
print("### LADDER %#x %s body=%s" % (entry, note, f.getBody()))
acc = {}
hits = []
n = 0
it = listing.getInstructions(f.getBody(), True)
while it.hasNext():
ins = it.next(); n += 1
m = str(ins.getMnemonicString()).upper()
a = int(ins.getAddress().getOffset())
ops = [str(ins.getDefaultOperandRepresentation(i)) for i in range(ins.getNumOperands())]
def imm(i):
try:
sc = ins.getScalar(i)
return None if sc is None else int(sc.getUnsignedValue())
except Exception:
return None
if m in ("CMP", "SUB", "ADD") and len(ops) == 2:
v = imm(1)
if v is not None:
r = ops[0]
if m == "CMP":
hits.append((a, m, r, (acc.get(r, 0) + v) & 0xFFFFFFFF))
elif m == "SUB":
acc[r] = (acc.get(r, 0) + v) & 0xFFFFFFFF; hits.append((a, m, r, acc[r]))
else:
acc[r] = (acc.get(r, 0) - v) & 0xFFFFFFFF; hits.append((a, m, r, acc[r]))
elif m in ("DEC", "INC") and len(ops) == 1:
r = ops[0]
acc[r] = (acc.get(r, 0) + (1 if m == "DEC" else -1)) & 0xFFFFFFFF
hits.append((a, m, r, acc[r]))
elif m == "JMP" and ops and "[" in ops[0]:
hits.append((a, "SWITCHJMP", ops[0], 0))
elif m in ("MOV", "MOVZX", "MOVSX", "MOVSXD", "LEA", "XOR", "POP"):
if ops:
acc.pop(ops[0], None)
elif m == "CALL":
for r in ("EAX", "RAX", "ECX", "RCX", "EDX", "RDX", "R8D", "R9D", "R10D", "R11D"):
acc.pop(r, None)
print(" instructions: %d" % n)
for a, k, r, v in hits:
if k == "SWITCHJMP":
print(" %#x SWITCHJMP %s <<< JUMP TABLE, case labels NOT in this list" % (a, r))
else:
print(" %#x %-4s %-28s -> atom %#x (%d)" % (a, k, r, v, v))
try:
for a, t in [(0x180162880, "CreatePack deser"),
(0x18013bd40, "purchased/massinfo body"),
(0x1801293d0, "FutViewCards"),
(0x18013e7f0, "IS-list body")]:
ladder(a, t)
print()
for a, t in [(0x180162880, "CreatePack deser"),
(0x18013e7f0, "IS-list body"),
(0x18009bc40, "claimed loan-sign completion"),
(0x1801340e0, "claimed copy-ctor of 0x158 pack record")]:
s = dec(a)
print("=" * 100)
print("### DECOMPILE %s %#x len=%d" % (t, a, len(s)))
print("=" * 100)
print(s)
print("### END %#x len=%d PRINTED IN FULL" % (a, len(s)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,50 @@
"""ADVERSARIAL VERIFY BATCH 4.
T1 dim4 claim 4: exactly six HAS_DUPLICATE code sites; five publish (item+0x10 != 0),
one hardcodes 0. My independent objdump census found SIX sites:
0x1800439e4, 0x18008481d (direct lea on literal 0x1801f6510)
0x1800943ce, 0x180094c24, 0x18009661c, 0x180097400 (via ptr slot 0x1802a1ff8)
Note 0x18009bc40 is NOT among them, yet the claim lists FUN_18009bc40 as a
HAS_DUPLICATE publisher. Resolve containing functions and settle it.
T2 dim4 claim 5: FUN_180127cc0 pile serializer -- record+0x08 switch 5/6/else,
atoms 0x16b/0x15c/0x226/0x2fe/0x331/0x330/0x262/0x87. Ladder + full decompile.
T3 dim5 claim 5: the three claimed consumers of model vt+0x4d8 (0x18011c200).
Enumerate ALL xrefs to 0x18011c200 and to the accessor address, and list callers.
T4 dim5 claim 6 ATTACK: who writes model+0x5a38's begin/end? dim5 says unknown and
guesses starterPack. Enumerate callers of the vt+0x160 accessor 0x18011b780 and
check whether the CreatePack/purchased deserializers push into it.
"""
import traceback
try:
print("=== T1 containing functions of the six HAS_DUPLICATE sites ===")
for a in (0x1800439e4, 0x18008481d, 0x1800943ce, 0x180094c24, 0x18009661c, 0x180097400):
f = func(a)
print(" %#x -> %s @ %#x" % (a, f.getName() if f else "NONE",
int(f.getEntryPoint().getOffset()) if f else 0))
f = func(0x18009bc40)
print(" FUN_18009bc40 body = %s" % (f.getBody() if f else None))
print(" any HAS_DUPLICATE site inside FUN_18009bc40? ",
any(f.getBody().contains(addr(a)) for a in
(0x1800439e4, 0x18008481d, 0x1800943ce, 0x180094c24, 0x18009661c, 0x180097400)))
print()
print("=== T3 xrefs to the vt+0x4d8 accessor 0x18011c200 (count getter) ===")
for frm, typ, fn, ent in xrefs_to(0x18011c200):
print(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
print("=== T4 xrefs to the vt+0x160 accessor 0x18011b780 (claim vector) ===")
xs = xrefs_to(0x18011b780)
print(" total xrefs: %d" % len(xs))
for frm, typ, fn, ent in xs:
print(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
print()
for a, t in [(0x180127cc0, "T2 pile/itemData request serializer"),
(0x18013bd40, "shared purchased/massinfo body (does it push into vt+0x160?)")]:
s = dec(a)
print("=" * 100)
print("### DECOMPILE %s %#x len=%d" % (t, a, len(s)))
print("=" * 100)
print(s)
print("### END %#x len=%d PRINTED IN FULL" % (a, len(s)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,44 @@
"""ADVERSARIAL VERIFY BATCH 5 -- attacking dim5 claim 5.
dim5 claim 5: "model+0x20950 ... is read only through model vtable slot 0x4d8
(accessor 0x18011c200). Its THREE consumers render a notification badge
(FUN_1800b1d00), a tab counter (FUN_1800aeb90), and a flow-state selector
(FUN_1800af3a0). No HTTP request is originated on any of those paths."
My independent objdump census of `call QWORD PTR [reg+0x4d8]` over the whole PE
found NINE sites, and of `call [reg+0x4e0]` (the SETTER) found SIX:
0x4d8: 1800160c0 180019816 18007136b 18007e498 1800ad460 1800aeccf 1800aece6
1800af449 1800b1d48
0x4e0: 1800173d1 180019861 18007137b 1800ad498 1800bcb71 18013f222
Three of the 0x4d8 sites are immediately followed by a 0x4e0 site in the same
function -- a read-modify-WRITE of the counter that dim5 did not mention.
Resolve every containing function, and decompile the ones dim5 never examined.
CONTROL: 0x18013f222 must resolve to the userInfo deser 0x18013ec10 and 0x1800173d1
to FUN_180017390 -- the two sites dim5 DID identify. Same method for all nine.
"""
import traceback
try:
S48 = [0x1800160c0, 0x180019816, 0x18007136b, 0x18007e498, 0x1800ad460,
0x1800aeccf, 0x1800aece6, 0x1800af449, 0x1800b1d48]
S4E = [0x1800173d1, 0x180019861, 0x18007137b, 0x1800ad498, 0x1800bcb71, 0x18013f222]
print("=== containing functions ===")
news = []
for tag, lst in (("GET +0x4d8", S48), ("SET +0x4e0", S4E)):
for a in lst:
f = func(a)
e = int(f.getEntryPoint().getOffset()) if f else 0
print(" %s %#x -> %s @ %#x" % (tag, a, f.getName() if f else "NONE", e))
if e and e not in news:
news.append(e)
KNOWN = {0x1800b1d00, 0x1800aeb90, 0x1800af3a0, 0x180017390, 0x18013ec10}
todo = [e for e in news if e not in KNOWN]
print("\nfunctions dim5 never examined: %s" % ", ".join("%#x" % e for e in todo))
for e in todo:
s = dec(e)
print("=" * 100)
print("### DECOMPILE %#x len=%d" % (e, len(s)))
print("=" * 100)
print(s)
print("### END %#x len=%d PRINTED IN FULL" % (e, len(s)))
except Exception:
traceback.print_exc()
+30
View File
@@ -800,6 +800,11 @@ MOVE_BODY = os.environ.get("FUT_MOVE_BODY", "ack")
# "unknown", so the live-proven value is now on. See _pack_body.
STORE_DISPLAYGROUP = os.environ.get("FUT_STORE_DISPLAYGROUP", "1") == "1"
# FUT_STORE_GROUPID: give each pack a DISTINCT displayGroupAssetId so the grouped
# layout that FUT_STORE_DISPLAYGROUP switched on has something to separate packs by.
# Default off. See the long note at the send site in _pack_body.
STORE_GROUPID = os.environ.get("FUT_STORE_GROUPID", "0") == "1"
# FUT_QUICKSELL: serve the SINGLE-CARD quick sell, which we have never served.
#
@@ -2626,6 +2631,31 @@ def _pack_body(p, idx):
# for exactly that reason, and the live test buys a pack to prove the buy path
# still works.
body["displayGroup"] = {"value": p["name"]}
# FUT_STORE_GROUPID. The risk flagged above ACTUALLY HAPPENED, live 2026-08-05:
# sending displayGroup did switch the store to a grouped render path, all three
# packs collapsed into ONE group, and drilling into any of the three group tiles
# rendered the same single Premium Gold pack. Two of three packs became
# unbuyable. Cosmetic tile names were bought with two thirds of the store.
#
# displayGroupAssetId (0xda) is the obvious thing to group BY, and it is real:
# case 0xda in 0x18013af30 calls the INT getter 0x1801c79d0 and lands in the
# 0x158-byte pack record at +0x30 (the record is copy-constructed out of the
# stack frame at the tail of the deser, via FUN_1801340e0 / FUN_180132180).
# Omitting it presumably leaves every pack on the same default, hence one group.
#
# This is a hypothesis with a mechanism, not a proven fix. The consumer that
# builds group membership was NOT located: it is reached from the packed
# FIFA17.exe side and chasing it costs far more than the live test does.
# Type fidelity is not the risk here (a scalar into an INT getter is the safe
# direction; the freeze that started all this came from sending displayGroup as
# an ARRAY where a flat object was expected), so the cheap experiment is sound.
#
# Default OFF until a launch shows three separately buyable tiles.
# If it does NOT work, the correct fallback is FUT_STORE_DISPLAYGROUP=0, which
# restores the ungrouped layout: tiles read "unknown" but all three are buyable.
# Ugly and working beats pretty and unbuyable.
if STORE_GROUPID:
body["displayGroupAssetId"] = p["id"]
return body