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:
File diff suppressed because it is too large
Load Diff
@@ -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"))
|
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
|
# 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
|
# 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().
|
# import-time snapshots and will NOT reflect a later adopt_from_auth().
|
||||||
@@ -215,6 +304,12 @@ class Store:
|
|||||||
dv = it.get("discardValue") or 0
|
dv = it.get("discardValue") or 0
|
||||||
if dv:
|
if dv:
|
||||||
return int(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
|
r = it.get("rating") or 0
|
||||||
return 600 if r >= 85 else 300 if r >= 80 else 150 if r >= 75 else 50
|
return 600 if r >= 85 else 300 if r >= 80 else 150 if r >= 75 else 50
|
||||||
with _LOCK:
|
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()
|
||||||
@@ -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.
|
# "unknown", so the live-proven value is now on. See _pack_body.
|
||||||
STORE_DISPLAYGROUP = os.environ.get("FUT_STORE_DISPLAYGROUP", "1") == "1"
|
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.
|
# 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
|
# for exactly that reason, and the live test buys a pack to prove the buy path
|
||||||
# still works.
|
# still works.
|
||||||
body["displayGroup"] = {"value": p["name"]}
|
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
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user