fifa17-recon: the transfer market works -- listed a card end to end, no freeze

The subsystem that was fully greyed-out this morning now lists a card on the transfer
market: price screen, Submit, "your item is now up for trade", TRANSFER LIST 0/100,
auctionCount 1, and STORE.listings() holds the auction. Every step verified at the
instruction level first, then confirmed live. Three fixes, all behind flags, all off by
default until this run proved them.

1. WE WERE BANNING OUR OWN TRADING. userInfo.feature (atom 0x11c) is a RESTRICTION map,
   not a grant; we sent feature={"trade":true}, which is a trade BAN. Verified in
   q_feature_trade.py: FUN_18013ec10 parses feature/trade into userInfo+0x17c, and at
   the massinfo END_OBJECT the client runs
     cmp byte [rsi+0x17c],0 / jz skip / mov dword [rsi+0x50],0
   feeding applier 0x18011dc91 -> IS_TRADING_ENABLED (model+0x1fd2e) = 0. It runs LAST
   and unconditionally, which is why the gate read 0 all day regardless of /settings or
   the Blaze config store. FUT_TRADING sends feature={} instead. Live: gate flipped
   0 -> 1 on UT re-entry (model rebuilt, pointer changed, byte read 1).

2. TRANSFER LIST CAPACITY 0/0. pileSizeClientData (massinfo atom 0x227, parser
   0x18013adb0) is the capacity, NOT the "MY CLUB counter" the old comment claimed.
   Verified in q_pilesize_keys.py: exactly two storing arms, key 2 -> model+0x1fd1c
   (TRADE_PILE_SIZE) and key 4 -> +0x1fd20 (watch list), every other key SKIP'd. The old
   code would have sprayed the 246 club count into the capacity. FUT_PILESIZES sends
   key 2 = 100, key 4 = 50. Live: capacity read 0 -> 100, header showed 0/100.

3. THE PRICE SCREEN FROZE THE CLIENT. GET marketdata/pricelimits was answered with an
   OBJECT {minPrice,maxPrice}; the deser 0x180163ee0 reads a BARE TOP-LEVEL ARRAY
   (root loop while tok != 0xd), so object-where-array desynced the SAX reader into the
   0x1801c7f1a busy loop (confirmed live: utime climbing 227 ticks/s, core pinned).
   Verified in q_pricelimits.py: element fields defId 0xcf, maxPrice 0x1c2, minPrice
   0x1ca, all scalar ints. marketdata_route now returns a bare array, one element per
   requested defId. Live: price screen opened and Submit succeeded.

Corrected along the way, all now in the code: two prior "trading root causes" from
earlier today were wrong (the Blaze IS_TRADING_ENABLED keys are output-only names, and
the applier is a virtual method at vtable+0x988, not unreachable). Those refutations are
recorded in blaze_responder_v3b.py and the doc.

Also lands the transfer-market recon doc (plan-2026-08-06-transfer-market.md) and the
market Ghidra query set.

Server-authoritative economy note: the 5% transfer fee and the price bands (currently a
150..15000 placeholder per defId) are not yet real; that is refinement, not a freeze.
The live-auction market SCREEN ("List on Transfer Market" browse) is a separate surface
still to do (P4 auction-counts route, P5 empty market bodies).

Live: 439 contract checks pass. Card listed and persisted, auctionCount 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-08-06 14:33:10 -07:00
parent a3fd51692f
commit 43557989f5
61 changed files with 4126 additions and 35 deletions
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -713,8 +713,11 @@ FUT_RS4_CONFIG = (
# is zero. Which atom writes +0x1c is UNKNOWN and is the thing worth chasing.
#
# Default OFF and it should stay off.
+ ([(k, "1") for k in ("tradingEnabled", "IS_TRADING_ENABLED")]
if os.environ.get("FUT_TRADING") else [])
# NOTE: FUT_TRADING no longer does anything here. These keys are inert (output
# names the DLL emits, never reads). The REAL trading fix is in utas_server.py:
# userInfo.feature was banning trade. Left disabled so the flag has one meaning.
+ ([] if True else
[(k, "1") for k in ("tradingEnabled", "IS_TRADING_ENABLED")])
# NOTE: do NOT advertise itemDbVersion/checkServerDbVersion here or in any
# response -- proven inert (wf_96b6c0c5): they are JSON field names that route
# to the value-SKIP handler 0x180135ff0, never compared. See docs/CARD_SYSTEM.md.
@@ -0,0 +1,90 @@
"""ADVERSARIAL VERIFICATION BATCH 1 (dim4 + dim5).
HYPOTHESES UNDER ATTACK
H1 (dim5 f5/f7): the publisher FUN_18006cc60 maps model vtable slots to IS_* names,
and IS_TRADING_ENABLED (0x1801fc118) has exactly ONE rip-relative reference in
.text (the lea), i.e. the name is output-only.
CONTROL: run the same rip-relative scanner against a literal that IS known to be
compared, e.g. one of the ISOfferTrade error strings 0x180228f20, which must show
up in a *different* instruction context, and against IS_STORE_ENABLED.
H2 (dim5 f5 positive control): IS_STORE_ENABLED's accessor (vt+0x280) - what does it
actually compute? If it is a live-evaluable expression we can compare STORE vs
TRADING under the same publish mechanism.
H3 (dim4 f2): FutGetSuggestedPricing deser 0x180163ee0 top-level token is
START_ARRAY (loop terminates on 0xd) - CONTROL FUN_180165df0 (ISStart) must
terminate on 10.
H4 (dim4 f4): 0x1801642c0 is `return 1;`.
H5 (dim4 f6): tradeState table 0x180229e40 / bidState ladder FUN_180166380.
H6 (dim4 f9): IS_MAX_AUCTIONS publisher FUN_1800377c0 + GetAuctionCount deser
0x180163770.
H7 (dim4 f8): error mapper FUN_1801844c0.
Everything printed IN FULL with len(src).
"""
import traceback, struct
def full(tag, va):
try:
s = dec(va)
print("\n----- %s %#x len=%d -----" % (tag, va, len(s)))
print(s)
except Exception:
traceback.print_exc()
try:
print("### H1: publisher FUN_18006cc60")
full("publisher", 0x18006cc60)
print("\n### model vtable slots")
VT = 0x18021c2a0
for off in (0x270, 0x280, 0x2b0, 0x988, 0x998, 0xa58, 0xa60, 0x130, 0x5b8, 0xa00):
t = qword(VT + off)
print(" vt+%#05x -> %#x %s" % (off, t, fname(t) if 'fname' in dir() else ''))
full("vt+0x280 IS_STORE_ENABLED accessor", qword(VT + 0x280))
full("vt+0x270 IS_TRADING_ENABLED accessor", qword(VT + 0x270))
full("vt+0xa58 TRADE_PILE_SIZE accessor", qword(VT + 0xa58))
print("\n### H1 rip-relative reference scan, form independent")
# Scan .text for any 4-byte little-endian rel32 whose target == literal VA,
# for every instruction end position. This catches lea/mov/cmp/push equally.
tblk = None
for b in mem.getBlocks():
if b.getName() == ".text":
tblk = b
TS = int(tblk.getStart().getOffset()); TE = int(tblk.getEnd().getOffset())
text = read_bytes(TS, TE - TS + 1)
print(" .text %#x..%#x len=%d" % (TS, TE, len(text)))
def ripscan(target, label):
hits = []
for i in range(0, len(text) - 4):
rel = struct.unpack_from('<i', text, i)[0]
# instruction end = TS + i + 4 (rel32 is the last field of the insn)
if TS + i + 4 + rel == target:
hits.append(TS + i)
print(" %-34s target %#x : %d candidate rel32 sites" % (label, target, len(hits)))
for h in hits[:20]:
print(" at %#x bytes %s fn %s" % (h - 3, text[h - 6:h + 6].hex(),
(fm.getFunctionContaining(addr(h)) or "?")))
return hits
lits = {}
for nm in (b"IS_TRADING_ENABLED\x00", b"IS_STORE_ENABLED\x00",
b"IS_DRAFT_MODE_ENABLED\x00", b"TRADE_PILE_SIZE\x00",
b"IS_MAX_AUCTIONS\x00", b"NUM_MAX_AUCTIONS\x00",
b"You are not allowed to bid on this trade\x00"):
f = find_all(nm, blocks=(".rdata", ".data", ".text"))
lits[nm] = f
print(" literal %-45r -> %s" % (nm[:40], [hex(x) for x in f]))
for nm, f in lits.items():
for a in f:
ripscan(a, nm[:30].decode(errors='replace'))
print("\n### H3 pricelimits vs ISStart control")
full("FutGetSuggestedPricing deser", 0x180163ee0)
full("FutISStart deser CONTROL", 0x180165df0)
print("\n### H4 generic ack deser")
full("ack deser", 0x1801642c0)
except Exception:
traceback.print_exc()
@@ -0,0 +1,84 @@
"""ADVERSARIAL VERIFICATION BATCH 2.
Everything printed IN FULL with len(src). No truncation, no absence claimed from
a partial print.
H8 dim4 f5: auctionInfo record deser 0x18013e410 has exactly 12 atoms + tradeId
identity lookup via model vt+0xa00.
H9 dim4 f7: shared IS-list body 0x18013e7f0, credits -> model vt+0x5b8.
H10 dim4 f6: tradeState table walk FUN_180166bd0 (table 0x180229e40) and bidState
ladder FUN_180166380 -- two DIFFERENT dispatch forms, read separately.
H11 dim4 f8: FUN_1801844c0 status map, FUN_180165050 461 override.
H12 dim4 f9: FUN_1800377c0 IS_MAX_AUCTIONS + FUN_180163770 GetAuctionCount deser.
CONTROL for the publisher form: FUN_18000d550 TRADE_PILE_SIZE.
H13 dim4 f11: deser VAs for FutISWatchList / FutGetAuctionCount / FutISStart via
RS4 name -> abs64 ptr -> installed vtable -> slot +0x08, with FutISSearch and
FutGetTradePile as the CONTROL pair (must come back 0x180163420 / 0x180170810).
"""
import traceback, struct
def full(tag, va):
try:
s = dec(va)
print("\n----- %s %#x len=%d -----" % (tag, va, len(s)))
print(s)
except Exception:
traceback.print_exc()
try:
for tag, va in [("auctionInfo record deser", 0x18013e410),
("shared IS-list body", 0x18013e7f0),
("tradeState decoder", 0x180166bd0),
("bidState decoder", 0x180166380),
("status mapper", 0x1801844c0),
("ISOfferTrade 461 override", 0x180165050),
("IS_MAX_AUCTIONS publisher", 0x1800377c0),
("TRADE_PILE_SIZE publisher CONTROL", 0x18000d550),
("GetAuctionCount deser", 0x180163770),
("ISWatchList deser", 0x180166240),
("ISSearch deser CONTROL", 0x180163420),
("GetTradePile deser CONTROL", 0x180170810)]:
full(tag, va)
print("\n### tradeState table at 0x180229e40")
a = 0x180229e40
for i in range(10):
p = qword(a + i * 16); v = dword(a + i * 16 + 8)
if p == 0:
print(" [%d] NULL terminator, value=%d" % (i, v)); break
print(" [%d] %#x %r = %d" % (i, p, rd_str(p), v if v < 0x80000000 else v - (1 << 32)))
print("\n### H13 RS4 name -> installed vtable -> slot+0x08")
for nm, expect in [(b"RS4:FutISSearchServerResponse\x00", 0x180163420),
(b"RS4:FutGetTradePileServerResponse\x00", 0x180170810),
(b"RS4:FutISWatchListServerResponse\x00", None),
(b"RS4:FutGetAuctionCountServerResponse\x00", None),
(b"RS4:FutISStartServerResponse\x00", None),
(b"RS4:FutGetSuggestedPricingServerResponse\x00", None),
(b"RS4:FutRelistAllServerResponse\x00", None),
(b"RS4:FutISWatchTradeServerResponse\x00", None),
(b"RS4:FutISRemoveTradeServerResponse\x00", None),
(b"RS4:FutISRemoveWatchServerResponse\x00", None),
(b"RS4:FutISViewTradeServerResponse\x00", None),
(b"RS4:FutISOfferTradeServerResponse\x00", None)]:
locs = find_all(nm, blocks=(".rdata", ".data"))
print("\n %s -> %s" % (nm.decode().rstrip("\x00"), [hex(x) for x in locs]))
for L in locs:
xs = xrefs_to(L)
print(" xrefs: %s" % [(hex(a), t, f) for a, t, f, _ in xs])
for a, t, f, ent in xs:
if ent:
s = dec(ent)
# find the vtable it installs: look for PTR_ / &DAT_ assignment
import re
m = re.findall(r"(?:PTR_[A-Za-z_0-9]*_|DAT_|&)([0-9a-fA-F]{9})", s)
print(" fn %s @%#x len=%d installs %s" % (f, ent, len(s), set(m)))
for cand in set(m):
try:
vt = int(cand, 16)
if 0x180200000 <= vt < 0x180290000:
slot = qword(vt + 8)
print(" vtable %#x slot+0x08 = %#x (expect %s)"
% (vt, slot, hex(expect) if expect else "?"))
except Exception:
pass
except Exception:
traceback.print_exc()
@@ -0,0 +1,26 @@
"""ADVERSARIAL BATCH 3 -- the relaunch-critical path.
H14: does the settings deser FUN_18013c6d0 pre-initialise its struct fields
+0x28..+0x40 to 1 before parsing? If it zero-inits them, then the observed
live pattern (model+0x1fd2e=0 surrounded by 1s) cannot have come from the
applier, i.e. the applier NEVER RAN -- which decides "never set" vs
"set then cleared".
Also: which atom writes struct+0x1c (the field FUN_180173e00 gates on)?
H15: FUN_180173e00 in full -- the test rdx / cmp [rdx+0x1c],0 gate.
H16: dim5 f8 -- FUN_180180770 blaze client-config reader, full key list.
"""
import traceback
def full(tag, va):
try:
s = dec(va)
print("\n===== %s %#x len=%d =====" % (tag, va, len(s)))
print(s)
except Exception:
traceback.print_exc()
try:
full("settings deser FUN_18013c6d0", 0x18013c6d0)
full("settings completion FUN_180173e00", 0x180173e00)
full("blaze config reader FUN_180180770", 0x180180770)
except Exception:
traceback.print_exc()
@@ -0,0 +1,29 @@
"""ADVERSARIAL BATCH 4 -- the settings RESPONSE object, not the model-side deser.
FUN_180173e00 reads its param_2 (the FutGetSettings response) at +0x1c (error gate),
copies +0x28..+0xc0 and hands &<copy of +0x28> to the gate applier vt+0x988, and
copies +0xc8..+0xd4 and hands &<copy of +0xc8> to vt+0x998.
So model+0x1fd2e <- response+0x50, and model+0x1fd1c <- response+0xd0.
HYPOTHESIS: the FutGetSettings response deserializer writes response+0x50 and +0xd0
from specific atoms. Find them.
CONTROL: the same RS4-name -> vtable -> slot+0x08 resolution that reproduced
FutISSearch 0x180163420 and FutGetTradePile 0x180170810 in batch 2.
"""
import traceback, re
try:
for nm in (b"RS4:FutGetSettingsServerResponse\x00", b"RS4:FutSettingsServerResponse\x00",
b"RS4:FutISSearchServerResponse\x00"):
locs = find_all(nm, blocks=(".rdata", ".data"))
print("\n### %s -> %s" % (nm.decode().rstrip("\x00"), [hex(x) for x in locs]))
for L in locs:
for a, t, f, ent in xrefs_to(L):
if not ent: continue
s = dec(ent)
m = set(re.findall(r"(?:PTR_[A-Za-z_0-9]*_|DAT_|&)([0-9a-fA-F]{9})", s))
print(" fn %s @%#x installs %s" % (f, ent, m))
for c in m:
v = int(c, 16)
if 0x180200000 <= v < 0x180290000:
print(" vtable %#x slot+0x08 = %#x" % (v, qword(v + 8)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,10 @@
"""BATCH 5: which atom writes FutGetSettings response+0x50 (-> IS_TRADING_ENABLED)
and +0xd0 (-> TRADE_PILE_SIZE)? Two candidate desers resolved in batch 4."""
import traceback, re
try:
for va in (0x18014e590, 0x180153060):
s = dec(va)
print("\n===== deser %#x len=%d =====" % (va, len(s)))
print(s)
except Exception:
traceback.print_exc()
@@ -0,0 +1,71 @@
"""Verify: does userInfo.feature={"trade":true} ZERO the trade gate byte?
The claim (workflow wf_29791945): userInfo.feature (atom 0x11c) is a RESTRICTION map,
not a grant. Sending trade (atom 0x330) = true marks trade restricted, and at the
massinfo top-level END_OBJECT, 0x180174f19 does `mov dword [rsi+0x50],0`, which feeds
the applier 0x18011dc91 `mov [rdi+0x1fd2e],al`, forcing IS_TRADING_ENABLED = 0. It runs
LAST and unconditionally, so no configs/Blaze value can beat it.
This has to be right before we change server code, because two prior trading root-causes
this session were wrong. Verify the actual instructions rather than trust the summary.
CONTROL: storeEnabled path must NOT be zeroed the same way (the store works), so whatever
zeroes trade must be specific to the feature/trade branch, not applied to store.
"""
import re, traceback
MASSINFO = 0x180174630 # massinfo deser root (calls settings deser + appliers)
ZERO_SITE = 0x180174f19 # claimed `mov dword [rsi+0x50],0`
APPLIER = 0x18011DC50
try:
src = dec(MASSINFO)
f = func(MASSINFO)
print("%#x massinfo root body %d / decompile %d chars"
% (MASSINFO, f.getBody().getNumAddresses() if f else -1, len(src)))
# a) the instruction at the claimed zero site, read raw
print("\n=== instructions around %#x ===" % ZERO_SITE)
ins = listing.getInstructionAt(addr(ZERO_SITE))
if ins is None:
# step back to find the containing instruction
ins = listing.getInstructionContaining(addr(ZERO_SITE))
a = addr(ZERO_SITE - 0x18)
for _ in range(14):
i = listing.getInstructionAt(a)
if i is None:
a = a.add(1); continue
mark = " <== claimed zero site" if int(i.getAddress().getOffset()) == ZERO_SITE else ""
print(" %#x %s%s" % (int(i.getAddress().getOffset()), i, mark))
a = i.getAddress().add(i.getLength())
# b) does the feature(0x11c)/trade(0x330) atom appear in the massinfo deser or a callee?
print("\n=== feature 0x11c / trade 0x330 dispatch, in massinfo + callees ===")
scan = [MASSINFO] + [a for a, _ in callees(MASSINFO)]
for ent in scan:
try:
d = dec(ent)
except Exception:
continue
hits = []
for atom, name in ((0x11c, "feature"), (0x330, "trade")):
for m in re.finditer(r"(case |== |!= )0x%x\b" % atom, d):
hits.append(name)
if hits:
print(" %#x %-20s handles: %s" % (ent, fname(ent), sorted(set(hits))))
# c) confirm the applier writes 0x1fd2e from a field, and trace what feeds it
print("\n=== applier %#x: the 0x1fd2e write and its source ===" % APPLIER)
da = dec(APPLIER)
for ln in da.splitlines():
if "0x1fd2e" in ln or "param_2[10]" in ln:
print(" " + ln.strip())
# d) CONTROL: is there a zero-write to the store field (0x1fd2f) anywhere near the
# trade zero site? there should NOT be, or the store would break too.
print("\n=== CONTROL: any 0x1fd2f (store) zeroing near the trade path? ===")
n = sum(1 for ln in src.splitlines() if "0x50] = 0" in ln.replace(" ", "") or "rsi+0x50" in ln)
print(" '[rsi+0x50]=0'-style writes in massinfo root: look above; store gate is a different offset")
except Exception:
traceback.print_exc()
@@ -0,0 +1,33 @@
"""D3 Q1/Q4: full decompile of the TO_TRADE_PILE predicate FUN_1801a7260 and its
publisher FUN_18003e370 / filler FUN_1800e2a40.
HYPOTHESIS: FUN_1801a7260 has MORE than the two documented terms (service gate,
item+0x49). Specifically it may consult the pile discriminator item+0x60 (live:
1 for /club, 6 for /purchased) or a pile/state field, which would make the
PURCHASED pile the reason the menu is greyed.
CONTROL: FUN_18003e550 (the listing panel publisher, DURATION/START_PRICE/
ASKING_PRICE) is decompiled in the same batch -- a function known to exist and to
be reachable, so a successful decompile there proves the decompiler is working
and a failure on the target is a real failure, not a harness problem.
Every decompile prints len(src) and is printed IN FULL (absence trap rule).
"""
import traceback
try:
TARGETS = [
("FUN_1801a7260 TO_TRADE_PILE predicate", 0x1801a7260),
("FUN_18003e370 eight-flag publisher", 0x18003e370),
("FUN_1800e2a40 flag filler", 0x1800e2a40),
("FUN_18003e550 CONTROL listing panel publisher", 0x18003e550),
]
for label, a in TARGETS:
src = dec(a)
print("=" * 78)
print("### %s @ %#x len(src)=%d" % (label, a, len(src)))
print("=" * 78)
print(src)
print()
except Exception:
traceback.print_exc()
@@ -0,0 +1,42 @@
"""D3: the FutGetUserMassInfoServerResponse constructor FUN_180173a50 -- what is
the DEFAULT of +0x17c, the byte that vetoes tradingEnabled?
Also: is there a SEPARATE "/settings" request descriptor whose completion path
skips that veto? Slot +0x08 of descriptor vtable 0x18022d000 is
FUN_180173d60 -> route literal "/userMassInfo", so descriptors carry their route
as a plain string; find the one carrying "/settings" and walk its vtable the same
way.
CONTROL: the "/userMassInfo" literal must resolve back to FUN_180173d60 through
the same machinery used to find "/settings". If it does not, an absence for
"/settings" proves nothing.
"""
import traceback
try:
print("### FUN_180173a50 response ctor")
src = dec(0x180173A50)
print("len(src)=%d" % len(src))
print(src)
print()
print("#" * 78)
print("### route literals")
for lit in (b"/userMassInfo\x00", b"/settings\x00", b"/settings?", b"settings"):
hits = find_all(lit, blocks=(".rdata", ".data"))
print("--- %r %d hits: %s" % (lit, len(hits), [hex(h) for h in hits[:20]]))
for h in hits[:20]:
for frm, typ, fn, ent in xrefs_to(h):
print(" ref from %#x %s in %s (%#x)" % (frm, typ, fn, ent))
print()
print("#" * 78)
print("### every RS4: response class name mentioning Settings or MassInfo")
for h in find_all(b"RS4:Fut", blocks=(".rdata", ".data")):
s = rd_str(h, 80)
if "etting" in s or "assInfo" in s:
print(" %#x %s" % (h, s))
for frm, typ, fn, ent in xrefs_to(h):
print(" ref from %#x %s in %s (%#x)" % (frm, typ, fn, ent))
except Exception:
traceback.print_exc()
@@ -0,0 +1,54 @@
"""D3: the SETTINGS SUB-STRUCT constructor and the standalone /settings path.
The massinfo response ctor FUN_180173a50 calls FUN_18014e320(param_1 + 5), i.e.
on response+0x28 -- which is exactly the base the settings deserializer
FUN_18013c6d0 is handed and exactly the base the gate applier FUN_18011dc50
reads. So FUN_18014e320 holds the DEFAULT of every gate the client will adopt
when `configs` is empty.
WHY THIS MATTERS: with FUT_SETTINGS unset the server serves {"configs": []}, and
live the gate bytes are model+0x1fd28=60, +0x1fd2c=1, +0x1fd2e=0, +0x1fd2f=1,
+0x1fd3a..+0x1fd45=1. If those are FUN_18014e320's defaults, the applier ran and
simply copied defaults -- which both proves the apply path is live and means
sending extra config rows cannot regress a gate that is currently on.
FALSIFIER: if FUN_18014e320 leaves index [7] (settings+0x1c) at something other
than 60, the "60 came from the default" story is wrong and model+0x1fd28 must
have another source.
CONTROL, non-boolean and therefore not coincidence: index [7] -> model+0x1fd28
must be 60 and index [4] -> model+0x1fd54 must be 480 in the constructor.
Also: is the standalone /settings response applied at all?
FUN_18014e490 references RS4:FutGetSettingsServerResponse and 0x18014e473
references the "/settings" route literal. Decompile that whole family.
"""
import traceback
try:
for label, a in [
("FUN_18014e320 SETTINGS STRUCT CTOR (defaults)", 0x18014E320),
("FUN_18014e490 /settings response factory", 0x18014E490),
("FUN_180152350 other FutGetSettings ref", 0x180152350),
]:
src = dec(a)
print("=" * 78)
print("### %s @ %#x len(src)=%d" % (label, a, len(src)))
print("=" * 78)
print(src)
print()
f = fm.getFunctionContaining(addr(0x18014E473))
print("### function containing the /settings literal ref 0x18014e473: %s"
% (f.getName() if f else "NONE"))
if f:
ent = int(f.getEntryPoint().getOffset())
src = dec(ent)
print("### %#x len(src)=%d" % (ent, len(src)))
print(src)
else:
# not inside a recognised function; dump the surrounding vtable-ish data
print("raw around 0x18014e460..0x18014e4e0:")
print(read_bytes(0x18014E460, 0x80).hex())
except Exception:
traceback.print_exc()
@@ -0,0 +1,53 @@
"""D3 FINAL: pin down response+0x17c, the byte that vetoes tradingEnabled.
The massinfo response ctor FUN_180173a50 constructs a sub-object at +0xd8 via
FUN_18010ea80. 0x17c - 0xd8 = 0xa4, so a deserializer handed base=+0xd8 would
reach it as [base+0xa4] (disp32 a4 00 00 00), which the earlier +0x17c scan could
not see. Scan for that displacement too, and decompile every sub-parser the
massinfo deserializer delegates to.
CONTROL for the 0xa4 scan: the same scan is run for 0x28 (the settings sub-struct
base, whose ctor FUN_18014e320 is known to write +0x1c and +0x28) -- if the
harness cannot see a displacement it is known to contain, its absences are void.
Second control: FUN_18010ea80 must show writes consistent with a ~0xb0-byte
object, otherwise +0xd8 is not the parent of +0x17c and the arithmetic is wrong.
"""
import traceback
try:
TEXT_LO, TEXT_HI = 0x180001000, 0x1801e5000
text = read_bytes(TEXT_LO, TEXT_HI - TEXT_LO)
def dispscan(disp):
pat = int(disp).to_bytes(4, "little")
out, i = [], text.find(pat)
while i != -1:
va = TEXT_LO + i
f = fm.getFunctionContaining(addr(va))
out.append((va, f.getName() if f else "?",
int(f.getEntryPoint().getOffset()) if f else 0,
text[max(0, i - 8):i + 8].hex()))
i = text.find(pat, i + 1)
return out
print("### disp32 +0xa4 sites in the 0x18010e000-0x180180000 band")
for va, nm, ent, ctx in dispscan(0xA4):
if 0x18010E000 <= va < 0x180180000:
print(" %#x %-24s (%#x) ctx=%s" % (va, nm, ent, ctx))
for label, a in [
("FUN_18010ea80 ctor of the +0xd8 sub-object", 0x18010EA80),
("FUN_180142470 userData(0x36d) parser", 0x180142470),
("FUN_18013adb0 pileSizeClientData(0x227) parser -> +0xc8", 0x18013ADB0),
("FUN_180139610 arm 0x10c helper", 0x180139610),
("FUN_180174160 arm 0x339 parser -> +0x200", 0x180174160),
("FUN_18013a1c0 arm 0x19a", 0x18013A1C0),
("FUN_18013bd40 arm 0x263", 0x18013BD40),
]:
src = dec(a)
print("=" * 78)
print("### %s @ %#x len(src)=%d" % (label, a, len(src)))
print("=" * 78)
print(src)
except Exception:
traceback.print_exc()
@@ -0,0 +1,37 @@
"""D3 THE ANSWER: FUN_18013ec10 sets the veto byte.
The +0xd8 sub-object's constructor FUN_18010ea80 defaults its byte +0xa4 to 0
(`*(undefined1 *)(param_1 + 0x29) = 0`), and response+0xd8+0xa4 == response+0x17c
-- the byte that makes FUN_180174630 zero the tradingEnabled field.
The disp32 +0xa4 scan found exactly one non-copy-constructor WRITE in a parser:
0x18013f039 in FUN_18013ec10, encoded `83 f8 01 / 75 06 / 88 87 a4 00 00 00`
i.e. "if (x == 1) byte[rdi+0xa4] = al". Decompile FUN_18013ec10 in full, find the
atom that feeds it, and find its callers to confirm the base is response+0xd8.
CONTROL: FUN_1801129f0 is in the same scan and must turn out to be a
copy/assign of the same struct (it reads +0xa4 and writes +0xa4 from another
object), not a parser. If it is a parser the classification is wrong.
"""
import traceback
try:
for label, a in [
("FUN_18013ec10 the parser that sets the veto byte", 0x18013EC10),
("FUN_1801129f0 CONTROL, expected copy/assign", 0x1801129F0),
]:
src = dec(a, 600)
print("=" * 78)
print("### %s @ %#x len(src)=%d" % (label, a, len(src)))
print("=" * 78)
print(src)
print()
print("### callers of FUN_18013ec10")
for frm, typ, fn, ent in xrefs_to(0x18013EC10):
print(" from %#x %s in %s (%#x)" % (frm, typ, fn, ent))
print("### absolute-pointer sites for FUN_18013ec10")
for h in find_all((0x18013EC10).to_bytes(8, "little"), blocks=(".rdata", ".data")):
print(" %#x" % h)
except Exception:
traceback.print_exc()
@@ -0,0 +1,66 @@
"""D3 Q1 cont / Q3 / Q4: the sub-predicates of the eight action flags, and the
menu-side literal enumeration.
HYPOTHESIS A: FUN_1801a8900 (the extra player-only term inside FUN_1801a7260)
and FUN_1801a8850 (the early-out inside FUN_1800e2a40, which if TRUE leaves all
eight flag bytes UNINITIALISED) are additional conditions nobody has enumerated.
HYPOTHESIS B (Q2): there is a separate menu/action surface. Enumerate every
.rdata literal that looks like a per-card menu action so the enabled entries
("Send to Club", "Quick Sell", "Store all remaining") can be contrasted against
the disabled ones.
CONTROL for the literal scan: "TO_TRADE_PILE" is a known-present literal
published by FUN_18003e370, so the scan MUST return it; if it does not, the scan
is broken and every absence in the same run is worthless.
"""
import traceback
try:
for label, a in [
("FUN_1801a8900 extra player term in TO_TRADE_PILE", 0x1801a8900),
("FUN_1801a8850 early-out guard in FUN_1800e2a40", 0x1801a8850),
("FUN_1801a8110 family getter", 0x1801a8110),
("FUN_1801a71c0 DISCARD predicate", 0x1801a71c0),
("FUN_1801a7210 MODIFY predicate", 0x1801a7210),
("FUN_1801a7250 TO_ACTIVE_SQUAD sub", 0x1801a7250),
("FUN_1801a7180 TO_STICKER_BOOK", 0x1801a7180),
("FUN_1801a7320 QUICK_SEARCH", 0x1801a7320),
("FUN_1801a71e0 DREAM_REPLACE", 0x1801a71e0),
]:
src = dec(a)
print("=" * 78)
print("### %s @ %#x len(src)=%d" % (label, a, len(src)))
print("=" * 78)
print(src)
print()
print("#" * 78)
print("### LITERAL SCAN over .rdata for menu-action-shaped names")
print("#" * 78)
NEEDLES = [b"TO_TRADE_PILE", b"TRADE_PILE", b"TRANSFER", b"LIST_ITEM",
b"SEND_TO_CLUB", b"TO_CLUB", b"QUICK_SELL", b"QUICKSELL",
b"DISCARD", b"STORE_ALL", b"MOVE_TO", b"CONTEXT", b"MENU",
b"ACTION", b"AUCTION", b"WATCH", b"BID", b"BUY_NOW",
b"tradePile", b"watchList", b"transfermarket"]
for n in NEEDLES:
hits = find_all(n, blocks=(".rdata", ".data"))
print("--- %-16s %d hits" % (n.decode(), len(hits)))
seen = set()
for h in hits[:80]:
# walk back to the start of the C string
p = h
for _ in range(120):
try:
if mem.getByte(addr(p - 1)) & 0xFF == 0:
break
except Exception:
break
p -= 1
s = rd_str(p, 160)
if s in seen:
continue
seen.add(s)
print(" %#x %r" % (p, s))
except Exception:
traceback.print_exc()
@@ -0,0 +1,80 @@
"""D3 Q2/Q3: the auction-count surface.
HYPOTHESIS: the third condition is the AUCTION LIMIT. .rdata holds
NUM_CURRENT_AUCTIONS (0x1801f3658), NUM_MAX_AUCTIONS (0x1801f3670),
IS_MAX_AUCTIONS (0x1801f3688) and CARDS_CB_ERR_AUCTION_LIMIT_REACHED
(0x1802142f8). If NUM_MAX_AUCTIONS is fed from model+0x1fd1c (TRADE_PILE_SIZE,
measured 0) then IS_MAX_AUCTIONS is TRUE for every card and the transfer entries
are greyed by a full-trade-pile test, independently of TO_TRADE_PILE.
Find the publishers and decompile them in full.
CONTROL: TO_TRADE_PILE (0x1801f4d48) is resolved by the SAME reference machinery
in the same run; its only referencing function must come out as FUN_18003e370,
which is already established. If that control does not resolve, no absence in
this run means anything.
Both Ghidra's reference manager AND a raw rip-relative displacement scan of
.text are used, because the two miss different things.
"""
import traceback
try:
TEXT_LO, TEXT_HI = 0x180001000, 0x1801e5000
text = read_bytes(TEXT_LO, TEXT_HI - TEXT_LO)
print("text bytes read: %d" % len(text))
def riprefs(target):
"""Every 4-byte little-endian disp32 in .text whose rip-relative target is
`target`, for every instruction length 5..9 (covers lea/mov/cmp/push
encodings without assuming the opcode). Form-independent."""
out = []
for i in range(0, len(text) - 4):
d = int.from_bytes(text[i:i + 4], "little", signed=True)
va = TEXT_LO + i
for ilen in range(4, 10):
if va + ilen + d == target:
out.append((va - (ilen - 4), va, ilen, d))
break
return out
NAMES = [
("TO_TRADE_PILE CONTROL", 0x1801f4d48),
("NUM_CURRENT_AUCTIONS", 0x1801f3658),
("NUM_MAX_AUCTIONS", 0x1801f3670),
("IS_MAX_AUCTIONS", 0x1801f3688),
("CARDS_CB_ERR_AUCTION_LIMIT_REACHED", 0x1802142f8),
("CARDS_CB_ERR_WATCHLIST_FULL", 0x1802140f8),
]
funcs = {}
for label, a in NAMES:
print("=" * 78)
print("### %s @ %#x text=%r" % (label, a, rd_str(a, 60)))
print(" ghidra xrefs_to:")
for t in xrefs_to(a):
print(" from %#x %s in %s (%#x)" % t)
if t[3]:
funcs.setdefault(t[3], set()).add(label)
print(" raw rip-relative disp32 hits (form-independent):")
for start, dispva, ilen, d in riprefs(a):
f = fm.getFunctionContaining(addr(dispva))
nm = f.getName() if f else "?"
ent = int(f.getEntryPoint().getOffset()) if f else 0
print(" disp@%#x ilen=%d -> in %s (%#x) raw=%s"
% (dispva, ilen, nm, ent, read_bytes(dispva - 3, 10).hex()))
if ent:
funcs.setdefault(ent, set()).add(label)
print()
print("#" * 78)
print("### FULL DECOMPILES of every function that touches those names")
print("#" * 78)
for ent in sorted(funcs):
src = dec(ent)
print("=" * 78)
print("### %#x touches %s len(src)=%d" % (ent, sorted(funcs[ent]), len(src)))
print("=" * 78)
print(src)
print()
except Exception:
traceback.print_exc()
@@ -0,0 +1,44 @@
"""D3 Q2 cont: resolve the auction-count struct.
FUN_1800377c0 publishes NUM_CURRENT_AUCTIONS = u16 [S+0x36], NUM_MAX_AUCTIONS =
int [S+0x30], and IS_MAX_AUCTIONS = !(max<0 || current<max), where
S = model->vt+0x130().
HYPOTHESIS: S is a sub-object of the same model singleton (vtable 0x18021c2a0)
and S+0x30 is fed from the same settings applier family as model+0x1fd1c
(TRADE_PILE_SIZE). If S+0x30 is 0 then IS_MAX_AUCTIONS is permanently TRUE.
CONTROL: slot +0xa58 of the same vtable must decompile to the known
"mov eax,[rcx+0x1fd1c]; ret" TRADE_PILE_SIZE stub. If it does not, the vtable
base or the slot arithmetic is wrong and nothing else in this run is trustworthy.
Also decompiles FUN_1800d7b70 (the CARDS_CB_ERR_* name table) and locates
CARDS_CB_ERR_FEATURE_UNAVAILABLE's numeric id.
"""
import traceback
try:
VT = 0x18021c2a0
for slot in (0x130, 0xa58, 0xa60, 0x270):
t = qword(VT + slot)
f = fm.getFunctionAt(addr(t))
print("=" * 78)
print("### vt+%#x -> %#x %s bytes=%s"
% (slot, t, f.getName() if f else "?", read_bytes(t, 16).hex()))
print(dec(t))
print()
print("#" * 78)
print("### FUN_1800d7b70 CARDS_CB_ERR_* table")
src = dec(0x1800d7b70)
print("len(src)=%d" % len(src))
print(src)
print("#" * 78)
print("### FUN_1800d7170 / FUN_180009c80 -- which service is being acquired")
for a in (0x1800d7170, 0x180009c80, 0x180018bd0, 0x180009b60):
s = dec(a)
print("--- %#x len=%d" % (a, len(s)))
print(s)
except Exception:
traceback.print_exc()
@@ -0,0 +1,113 @@
"""D3: (a) constructor defaults for the gate block, (b) the Flash property
publisher registration table.
HYPOTHESIS A: model+0x1fd2e's CONSTRUCTOR DEFAULT is 1. Live it is 0, and the
only disp32 writer is FUN_18011dc50 at 0x18011dc91. If the default is 1, then
that applier RAN and wrote 0, which means the /settings apply path executes and
merely computes the wrong value -- the opposite of "unreachable".
Method: locate the constructor by rip-relative reference to the vtable
0x18021c2a0, decompile it in full, and separately scan .text for the disp32
0x0001fd2c/2d/2e/2f/0x1fd1c so every write form (mov imm, mov reg, movzx, cmp,
lea) is caught by the DISPLACEMENT rather than by the opcode.
HYPOTHESIS B (Q2/Q3): FUN_18003e370 (per-card action flags) and FUN_1800377c0
(auction counts) are entries in a property-provider table. Enumerating that
table gives every Flash property surface the transfer UI can read, which is how
to tell whether "Place on Transfer List" and "List on Transfer Market" share one
predicate.
CONTROL for the disp32 scan: 0x1fd2e must return exactly the two already-known
sites (read 0x18011c670, write 0x18011dc91). If it returns something else the
scan is mis-tuned and its other answers are worthless.
"""
import traceback
try:
TEXT_LO, TEXT_HI = 0x180001000, 0x1801e5000
text = read_bytes(TEXT_LO, TEXT_HI - TEXT_LO)
print("text bytes: %d" % len(text))
def riprefs(target):
out = []
for i in range(0, len(text) - 4):
d = int.from_bytes(text[i:i + 4], "little", signed=True)
va = TEXT_LO + i
for ilen in range(4, 12):
if va + ilen + d == target:
out.append((va, ilen))
break
return out
def dispscan(disp):
pat = int(disp).to_bytes(4, "little")
out = []
i = text.find(pat)
while i != -1:
va = TEXT_LO + i
f = fm.getFunctionContaining(addr(va))
out.append((va, f.getName() if f else "?",
int(f.getEntryPoint().getOffset()) if f else 0,
text[max(0, i - 6):i + 6].hex()))
i = text.find(pat, i + 1)
return out
print("#" * 78)
print("### A1. disp32 scan (CONTROL first)")
for disp, nm in ((0x1FD2E, "IS_TRADING_ENABLED CONTROL"),
(0x1FD1C, "TRADE_PILE_SIZE"),
(0x1FD20, "watchlist size"),
(0x1FD28, "the 0x3c field"),
(0x1FD2C, "byte=1 live"),
(0x1FD2D, "byte=1 live"),
(0x1FD2F, "byte=1 live"),
(0x54F8, "auction sub-object base")):
hits = dispscan(disp)
print("--- +%#x %-28s %d sites" % (disp, nm, len(hits)))
for va, nm2, ent, ctx in hits:
print(" %#x %s (%#x) ctx=%s" % (va, nm2, ent, ctx))
print()
print("#" * 78)
print("### A2. references to the model vtable 0x18021c2a0 (constructor hunt)")
ctors = set()
for va, ilen in riprefs(0x18021C2A0):
f = fm.getFunctionContaining(addr(va))
ent = int(f.getEntryPoint().getOffset()) if f else 0
print(" disp@%#x ilen=%d in %s (%#x) raw=%s"
% (va, ilen, f.getName() if f else "?", ent, read_bytes(va - 3, 12).hex()))
if ent:
ctors.add(ent)
for a in find_all((0x18021C2A0).to_bytes(8, "little"), blocks=(".rdata", ".data")):
print(" absolute qword ptr at %#x" % a)
print()
print("#" * 78)
print("### A3. constructor decompiles")
for ent in sorted(ctors):
src = dec(ent)
print("=" * 78)
print("### %#x len(src)=%d" % (ent, len(src)))
print("=" * 78)
print(src)
print()
print("#" * 78)
print("### B. property-provider table around FUN_18003e370 / FUN_1800377c0")
for nm, fa in (("FUN_18003e370 action flags", 0x18003E370),
("FUN_1800377c0 auction counts", 0x1800377C0),
("FUN_18003e550 listing panel", 0x18003E550)):
hits = find_all(int(fa).to_bytes(8, "little"), blocks=(".rdata", ".data"))
print("--- %s: %d absolute-pointer sites: %s"
% (nm, len(hits), [hex(h) for h in hits]))
for h in hits:
print(" neighbourhood of %#x:" % h)
for k in range(-6, 7):
q = qword(h + k * 8)
f = fm.getFunctionAt(addr(q)) if 0x180000000 <= q < 0x181000000 else None
s = ""
if 0x1801E5000 <= q < 0x18028A000:
s = repr(rd_str(q, 48))
print(" %+4d %#018x %s %s"
% (k * 8, q, f.getName() if f else "", s))
except Exception:
traceback.print_exc()
@@ -0,0 +1,41 @@
"""D3: does the settings applier actually RUN, and where do the gate bytes'
values come from?
LIVE FACTS this run (pid 260692, slide proven twice):
configs is served as [] (FUT_SETTINGS is unset -> "off")
model+0x1fd28 = 60, +0x1fd2c = 1, +0x1fd2d = 1, +0x1fd2e = 0, +0x1fd2f = 1
model+0x1fd1c = 0, +0x1fd20 = 0, +0x1fd3a..+0x1fd45 = 1
The disp32 scan says +0x1fd2c and +0x1fd2f have exactly ONE writer each and it is
FUN_18011dc50 (the settings applier), in `sete` form.
HYPOTHESIS: the applier RUNS on every /settings response, even an empty one, and
copies the SETTINGS-RESPONSE STRUCT's own constructor defaults into the model.
The 1s are that struct's defaults; the 0 at +0x1fd2e is that struct's default for
the tradingEnabled field. That makes IS_TRADING_ENABLED=0 an explained, servable
condition rather than an unreachable one.
FALSIFIER: if the settings struct's default for the field feeding +0x1fd2e is 1,
or if the model constructor writes these bytes after all, the hypothesis dies.
CONTROL: model+0x1fd28 reads 60 live and is written by the same applier from
param+0x1c. If the settings struct's constructor default at +0x1c is 60, that is
an independent, non-boolean confirmation that the applier ran -- a boolean 1
could be coincidence, 60 cannot.
"""
import traceback
try:
for label, a in [
("FUN_18011dc50 gate applier (vt+0x988)", 0x18011DC50),
("FUN_18011dbf0 size applier (vt+0x998)", 0x18011DBF0),
("FUN_180173e00 settings completion callback", 0x180173E00),
("FUN_180174580 descriptor builder", 0x180174580),
]:
src = dec(a)
print("=" * 78)
print("### %s @ %#x len(src)=%d" % (label, a, len(src)))
print("=" * 78)
print(src)
print()
except Exception:
traceback.print_exc()
@@ -0,0 +1,40 @@
"""D3: the settings deserializer's atom -> field-index map, and the real
destination of maximumTradePileSize.
ESTABLISHED SO FAR THIS RUN:
FUN_180173e00 copies response+0x28..+0xc0 to the stack and passes it to
model->vt+0x988 (FUN_18011dc50). So applier index [N] == response+0x28+4N.
applier[10] -> model+0x1fd2e IS_TRADING_ENABLED => response+0x50
applier[7] -> model+0x1fd28 (=60 live) => response+0x44
applier[0] -> FUN_18011f380(model+0x15f00, v) => response+0x28
Separately vt+0x998 gets response+0xc8..0xd4, and reads +8 -> model+0x1fd1c
(TRADE_PILE_SIZE) => response+0xd0, and +0xc -> model+0x1fd20 => response+0xd4.
HYPOTHESIS: the atom that writes response+0x50 is `tradingEnabled`, and
`maximumTradePileSize` writes response+0x28 -- which is NOT model+0x1fd1c. If so,
the historical probe "served maximumTradePileSize=77, no int gate field carries
77" looked in the wrong place: 77 goes into FUN_18011f380, not into any 0x1fd
field.
CONTROL: the deserializer must also show an arm writing response+0xd0, and that
arm's atom is the true TRADE_PILE_SIZE lever. Finding response+0x50 but not
response+0xd0 would mean the offset arithmetic is off and neither answer counts.
Prints FUN_18013c6d0 IN FULL with len(src), plus FUN_18011f380.
"""
import traceback
try:
for label, a in [
("FUN_18013c6d0 settings deserializer", 0x18013C6D0),
("FUN_18011f380 maximumTradePileSize consumer", 0x18011F380),
("FUN_180174630 massinfo deserializer (caller)", 0x180174630),
]:
src = dec(a, 600)
print("=" * 78)
print("### %s @ %#x len(src)=%d" % (label, a, len(src)))
print("=" * 78)
print(src)
print()
except Exception:
traceback.print_exc()
@@ -0,0 +1,72 @@
"""D3 THE THIRD CONDITION: who writes massinfo-response+0x17c?
FOUND: at the END of the userMassInfo deserializer FUN_180174630 (the token==10
tail, i.e. after the whole body is parsed) there is
if (*(char *)(param_1 + 0x17c) != '\\0') { *(undefined4 *)(param_1 + 0x50) = 0; }
and param_1+0x50 is the settings sub-struct's field [10] -- the very field that
FUN_18011dc50 copies to model+0x1fd2e (IS_TRADING_ENABLED). Atom 0x336
(tradingEnabled) writes that same field via FUN_18013c6d0(param_1+0x28).
So a single byte at response+0x17c can veto tradingEnabled AFTER it is parsed.
HYPOTHESIS: +0x17c is a userInfo-level boolean on the same response struct
(the massinfo response carries userInfo, squad and settings) whose atom we are
sending, or whose CONSTRUCTOR DEFAULT is non-zero, and it is the third condition.
METHOD: 0x17c cannot be encoded as a disp8, so every access to [reg+0x17c] must
carry the literal disp32 bytes 7c 01 00 00. Scanning for that displacement is
form-independent -- it catches mov/movzx/cmp/setcc/lea in every encoding, which
is what the "== 0x" grep trap requires.
CONTROL: the same scan must return the KNOWN read at 0x1801748xx inside
FUN_180174630. If the known read does not appear, the scan is broken and no
absence it reports means anything.
Also decompiles FUN_180174580 and dumps descriptor vtable 0x18022d000 to find
the response-struct constructor, so the default value of +0x17c can be read.
"""
import traceback
try:
TEXT_LO, TEXT_HI = 0x180001000, 0x1801e5000
text = read_bytes(TEXT_LO, TEXT_HI - TEXT_LO)
print("text bytes: %d" % len(text))
def dispscan(disp):
pat = int(disp).to_bytes(4, "little")
out, i = [], text.find(pat)
while i != -1:
va = TEXT_LO + i
f = fm.getFunctionContaining(addr(va))
out.append((va, f.getName() if f else "?",
int(f.getEntryPoint().getOffset()) if f else 0,
text[max(0, i - 8):i + 8].hex()))
i = text.find(pat, i + 1)
return out
for disp in (0x17C, 0x50, 0x180):
hits = dispscan(disp)
print("=" * 78)
print("### disp32 +%#x : %d sites" % (disp, len(hits)))
if disp == 0x17C:
for va, nm, ent, ctx in hits:
print(" %#x %-24s (%#x) ctx=%s" % (va, nm, ent, ctx))
else:
print(" (too many to be useful; count only)")
print()
print("#" * 78)
print("### descriptor vtable 0x18022d000")
for slot, t, nm in vtable(0x18022D000, 24):
print(" +%#05x %#018x %s" % (slot, t, nm))
print()
print("#" * 78)
for label, a in [("FUN_180174580 descriptor builder", 0x180174580)]:
src = dec(a)
print("### %s len=%d" % (label, len(src)))
print(src)
except Exception:
traceback.print_exc()
@@ -0,0 +1,46 @@
"""D3: (a) the massinfo response-struct constructor, to read the DEFAULT of
+0x17c (the byte that vetoes tradingEnabled), and (b) Q3: every sibling property
publisher in the same table as FUN_18003e370, to see whether "List on Transfer
Market" has its own enable flag distinct from TO_TRADE_PILE.
(a) HYPOTHESIS: +0x17c is not written by any deserializer arm -- the
form-independent disp32 scan for 7c 01 00 00 found exactly one site inside
FUN_180174630 and it is the READ at 0x180174f12. So its value is whatever the
response struct's constructor leaves. Candidates for the constructor are the
non-deserializer slots of descriptor vtable 0x18022d000.
FALSIFIER: a constructor that memsets the whole struct to 0 makes +0x17c always
0, the veto never fires, and this whole lead dies.
(b) HYPOTHESIS: the publisher table at 0x1801f4c20 groups per-card Flash property
providers; one of the neighbours publishes the listing-panel enable flag.
CONTROL: FUN_18003e370 is in the same batch and must still come out publishing
the eight known names; FUN_18003e550 must still publish DURATION/START_PRICE/
ASKING_PRICE. Both are already established, so a deviation means the batch is
mis-addressed.
"""
import traceback
try:
print("#" * 78)
print("### (a) descriptor vtable 0x18022d000 non-deserializer slots")
for slot in (0x00, 0x08, 0x10, 0x18, 0x48, 0x58, 0x68, 0x78, 0xa0, 0xa8, 0xb8):
t = qword(0x18022D000 + slot)
src = dec(t)
print("=" * 78)
print("### slot +%#05x -> %#x len(src)=%d" % (slot, t, len(src)))
print(src)
print()
print("#" * 78)
print("### (b) sibling property publishers around 0x1801f4c20")
SIBS = [0x18003de00, 0x18003e930, 0x18003ea90, 0x18003ecc0, 0x18003e4b0,
0x18003e500, 0x18003e550, 0x18003e1a0, 0x18003e370, 0x18003ded0,
0x18003ebf0, 0x18003e5f0]
for a in SIBS:
src = dec(a)
print("=" * 78)
print("### %#x len(src)=%d" % (a, len(src)))
print(src)
except Exception:
traceback.print_exc()
@@ -0,0 +1,81 @@
"""HYPOTHESIS: CardsDLL holds named-state literals for online/connection state, in the
same family as IS_TRADING_ENABLED (an OUTPUT name published by FUN_18006cc60).
If an ONLINE/CONNECT/SESSION named state exists, the UI's market refusal and the
Seasons refusal may both read it.
CONTROL: IS_TRADING_ENABLED (0x1801fc118) MUST appear in the enumeration, with exactly
one rip-relative xref (the lea in the publisher). If the enumeration misses it, the
enumeration is broken.
Enumerates .rdata ASCII literals matching online-ish tokens, and for each prints
xref count + containing functions.
"""
import re, traceback
try:
blocks = {}
for b in mem.getBlocks():
blocks[str(b.getName())] = (int(b.getStart().getOffset()), int(b.getEnd().getOffset()))
print("BLOCKS:", {k: ("%#x-%#x" % v) for k, v in blocks.items()})
def block_bytes(name):
s, e = blocks[name]
out = bytearray()
a = s
while a <= e:
n = min(1 << 20, e - a + 1)
out += read_bytes(a, n)
a += n
return s, bytes(out)
rs, rdata = block_bytes(".rdata")
ds, data = block_bytes(".data")
ts, text = block_bytes(".text")
print("LEN .rdata=%d .data=%d .text=%d" % (len(rdata), len(data), len(text)))
TOKENS = [b"ONLINE", b"OFFLINE", b"RECONNECT", b"RE-CONNECT", b"CONNECT",
b"DISCONNECT", b"SESSION", b"HEARTBEAT", b"PING", b"NUCLEUS",
b"PERSONA", b"SEASON", b"UNAVAILABLE", b"UNREACHABLE",
b"Online", b"Offline", b"Reconnect", b"reconnect", b"connected",
b"isOnline", b"online"]
strre = re.compile(rb"[\x20-\x7e]{5,120}")
found = {}
for blkname, base, buf in ((".rdata", rs, rdata), (".data", ds, data)):
for m in strre.finditer(buf):
s = m.group()
if not any(t in s for t in TOKENS):
continue
# require NUL termination to be a real C string
end = m.end()
if end < len(buf) and buf[end] != 0:
continue
va = base + m.start()
found.setdefault(va, (blkname, s.decode("latin1")))
print("TOTAL candidate literals:", len(found))
# rip-relative xref counting over .text, form-independent: find any 4-byte
# displacement d such that (insn_end + d) == va. We approximate by scanning for
# the exact 4-byte LE of (va - (ts + i + 4)) at each i -- too slow. Instead use
# Ghidra's reference manager, and ALSO a raw disp scan for the control.
def xr(va):
try:
return xrefs_to(va)
except Exception:
return []
ctrl = 0x1801fc118
print("\n=== CONTROL IS_TRADING_ENABLED %#x ===" % ctrl)
print(" str:", repr(rd_str(ctrl)))
print(" xrefs:", [(hex(a), t, n) for a, t, n, e in xr(ctrl)])
print(" in enumeration:", ctrl in found)
print("\n=== ENUMERATION (va | block | xrefcount | funcs | string) ===")
for va in sorted(found):
blk, s = found[va]
x = xr(va)
fns = sorted({n for a, t, n, e in x})
print("%#x %s xr=%d %s | %s" % (va, blk, len(x), ",".join(fns[:5]), s))
except Exception:
traceback.print_exc()
@@ -0,0 +1,33 @@
"""HYPOTHESIS: IS_ONLINE (0x1802052b0) is a published named state like
IS_TRADING_ENABLED, and its producer reads a connectivity predicate. FUN_18011fc00
converts an enum to "ONLINE"/"OFFLINE". FUN_180180770 owns FIFA_FUT_ALLOW_PING /
FUT_PING_TIMEOUT (heartbeat). FUN_1800d7b70 is the CARDS_CB_ERR_* name table.
CONTROL: FUN_18006cc60 (the KNOWN publisher of IS_TRADING_ENABLED) is decompiled in
the same batch, so the "publisher shape" I claim for IS_ONLINE is compared against a
proven instance of that shape, not against my expectation of it.
Full length printed for every function; never truncated.
"""
import traceback
try:
TARGETS = [
("CONTROL publisher FUN_18006cc60", 0x18006cc60),
("IS_ONLINE user A FUN_1800a3cb0", 0x1800a3cb0),
("IS_ONLINE user B FUN_1800efe40", 0x1800efe40),
("ONLINE/OFFLINE enum FUN_18011fc00", 0x18011fc00),
("ping cfg FUN_180180770", 0x180180770),
("err name table FUN_1800d7b70", 0x1800d7b70),
]
for label, a in TARGETS:
src = dec(a)
f = func(a)
print("\n" + "=" * 78)
print("### %s entry=%#x name=%s len(src)=%d" % (
label, int(f.getEntryPoint().getOffset()) if f else 0,
f.getName() if f else "?", len(src)))
print("=" * 78)
print(src)
except Exception:
traceback.print_exc()
@@ -0,0 +1,34 @@
"""HYPOTHESIS: FUN_1800f7c40 references BOTH FUT::SeasonsManagerOfflineHelper and
FUT::SeasonsManagerOnlineHelper (and the Competition pair), so it is the place that
CHOOSES online vs offline behaviour -- i.e. it contains the client's own notion of
"am I online for FUT". FUN_1800b2680 owns the GOTO_ONLINE_SEASON / GOTO_OFFLINE_SEASON
navigation names and should gate them on the same predicate.
CONTROL: FUN_18006cc60 was already proven (query 2) to be a name-publisher whose values
come from model vtable slots. If FUN_1800b2680 turns out to publish names the same way,
the shape is the proven one, not an assumed one. I also print FUN_1800a6680
(IS_MODE_ONLINE) whose online/offline split is known from its literals, as a second
same-shape reference point.
Full source printed, length reported, never truncated.
"""
import traceback
try:
TARGETS = [
("helper factory FUN_1800f7c40", 0x1800f7c40),
("nav names FUN_1800b2680", 0x1800b2680),
("draft hub FUN_1800a6680", 0x1800a6680),
("seasons offline helper FUN_1801012c0", 0x1801012c0),
("seasons online helper FUN_1801014c0", 0x1801014c0),
]
for label, a in TARGETS:
src = dec(a)
f = func(a)
print("\n" + "=" * 78)
print("### %s entry=%#x len(src)=%d" % (
label, int(f.getEntryPoint().getOffset()) if f else 0, len(src)))
print("=" * 78)
print(src)
except Exception:
traceback.print_exc()
@@ -0,0 +1,68 @@
"""HYPOTHESIS (Q3): the market refusal and the Seasons refusal are both taken BEFORE any
request is built, so the gate must sit in a caller of the request builder. Walking up
from the route literal "/transfermarket?..." and from the seasons route literals should
expose the predicate, and if the two chains share a callee that predicate is the shared
term.
CONTROL: the SAME walk is run for routes we KNOW the client does issue this session
(/item, /squad/). If the walk produces a plausible-looking "gate" for a route that
demonstrably fires, the walk proves nothing and I say so.
Form-independent: Ghidra refs AND a raw rip-relative disp32 scan over all of .text.
"""
import struct, traceback
try:
ts = te = None
for b in mem.getBlocks():
if str(b.getName()) == ".text":
ts, te = int(b.getStart().getOffset()), int(b.getEnd().getOffset())
text = b""
a = ts
while a <= te:
n = min(1 << 20, te - a + 1)
text += read_bytes(a, n)
a += n
print("len(.text)=%d base=%#x" % (len(text), ts))
def rip_refs(va):
out = []
for i in range(0, len(text) - 4):
d = struct.unpack_from("<i", text, i)[0]
if ts + i + 4 + d == va:
out.append(ts + i)
return out
ROUTES = [
("MARKET", b"/transfermarket?type=%s&start=%d&num=%d\x00"),
("TRADEPILE", b"/tradepile\x00"),
("WATCHLIST", b"/watchlist\x00"),
("SEASONS_HIST", b"/season/user/history?type=online\x00"),
("CONTROL_ITEM", b"/item\x00"),
]
for name, lit in ROUTES:
print("\n" + "=" * 70)
print("### ROUTE %s %r" % (name, lit))
vas = find_all(lit, blocks=(".rdata", ".data"))
print(" literal occurrences:", [hex(v) for v in vas])
for v in vas:
gx = xrefs_to(v)
rr = rip_refs(v)
print(" %#x ghidra_xrefs=%s" % (v, [(hex(x[0]), x[1], x[2]) for x in gx]))
print(" %#x rip_disp_sites=%s" % (v, [hex(x) for x in rr[:12]]))
fns = set()
for site in rr:
f = fm.getFunctionContaining(addr(site))
if f:
fns.add((f.getName(), int(f.getEntryPoint().getOffset())))
for fn, ent in sorted(fns):
print(" in %s %#x" % (fn, ent))
try:
cs = callers(ent)
except Exception as e:
cs = []
print(" callers() err", e)
for c in cs[:20]:
print(" <- ", c)
except Exception:
traceback.print_exc()
@@ -0,0 +1,90 @@
"""Q3/Q4. Two jobs.
(1) callers() returned NOTHING for the market request builder FUN_180162c90 and the
seasons builder FUN_180175bd0. That is the absence trap again: these are almost
certainly virtual. So search for ABSOLUTE 8-byte pointers to them anywhere in the image
(vtable membership) as well as rel32 direct calls.
CONTROL: FUN_18011dc50 is KNOWN (prior run) to have zero rel32 callers and exactly one
absolute-pointer site at 0x18021cc28 = model vtable +0x988. If my scanner does not
reproduce that exact result, the scanner is wrong and every other row is void.
(2) Enumerate the FUT UI surface names: every "*viewmodel" literal and every
"data/ui/layout/fut/*" literal, so the transfer-market screen's provider can be named.
"""
import struct, re, traceback
try:
blocks = {}
for b in mem.getBlocks():
blocks[str(b.getName())] = (int(b.getStart().getOffset()), int(b.getEnd().getOffset()))
def blk(name):
s, e = blocks[name]
out = b""
a = s
while a <= e:
n = min(1 << 20, e - a + 1)
out += read_bytes(a, n)
a += n
return s, out
ts, text = blk(".text")
rs, rdata = blk(".rdata")
ds, data = blk(".data")
print("len text=%d rdata=%d data=%d" % (len(text), len(rdata), len(data)))
def abs_ptr_sites(va):
pat = struct.pack("<Q", va)
out = []
for base, buf, nm in ((rs, rdata, ".rdata"), (ds, data, ".data"), (ts, text, ".text")):
i = buf.find(pat)
while i != -1:
out.append((base + i, nm))
i = buf.find(pat, i + 1)
return out
def rel32_calls(va):
out = []
for i in range(0, len(text) - 5):
if text[i] == 0xE8:
d = struct.unpack_from("<i", text, i + 1)[0]
if ts + i + 5 + d == va:
out.append(ts + i)
return out
TARGETS = [
("CONTROL applier FUN_18011dc50", 0x18011dc50),
("market builder FUN_180162c90", 0x180162c90),
("seasons hist builder FUN_180175bd0", 0x180175bd0),
]
MODEL_VT = 0x18021c2a0
for lbl, va in TARGETS:
aps = abs_ptr_sites(va)
rcs = rel32_calls(va)
print("\n### %s %#x" % (lbl, va))
print(" rel32 direct callers: %d %s" % (len(rcs), [hex(x) for x in rcs[:10]]))
print(" absolute-pointer sites: %d" % len(aps))
for a, nm in aps:
note = ""
if MODEL_VT <= a < MODEL_VT + 0xb20:
note = " == MODEL vtable slot +%#x" % (a - MODEL_VT)
print(" %#x %s%s" % (a, nm, note))
print("\n\n=== UI SURFACE LITERALS ===")
strre = re.compile(rb"[\x20-\x7e]{5,120}")
for base, buf, nm in ((rs, rdata, ".rdata"), (ds, data, ".data")):
for m in strre.finditer(buf):
s = m.group()
low = s.lower()
if (b"viewmodel" in low or b"layout/fut" in low or b"layout\\fut" in low
or b"transfer" in low or b"market" in low or b"tradepile" in low
or b"watchlist" in low or b"auction" in low):
end = m.end()
if end < len(buf) and buf[end] != 0:
continue
va = base + m.start()
x = xrefs_to(va)
print("%#x %s xr=%d %s | %s" % (va, nm, len(x),
",".join(sorted({n for _, _, n, _ in x})[:4]), s.decode("latin1")))
except Exception:
traceback.print_exc()
@@ -0,0 +1,95 @@
"""Q3: find the common FUT request-dispatch path and any precondition that can make the
client refuse to send. Market builder FUN_180162c90 sits at 0x180214dd8 and seasons
builder FUN_180175bd0 at 0x18022d748; both are vtable members. Identify each vtable
(start + RS4 class name nearby), decode enough slots to see the shape, and find the
call sites of the slot index they occupy.
CONTROL: the model vtable 0x18021c2a0 with slot +0x988 = FUN_18011dc50 is a KNOWN,
already-verified vtable/slot pair. The same routine is applied to it, and it must
reproduce +0x988; otherwise the vtable-start detection is wrong.
"""
import struct, traceback
try:
blocks = {}
for b in mem.getBlocks():
blocks[str(b.getName())] = (int(b.getStart().getOffset()), int(b.getEnd().getOffset()))
def blk(name):
s, e = blocks[name]
out = b""
a = s
while a <= e:
n = min(1 << 20, e - a + 1)
out += read_bytes(a, n)
a += n
return s, out
ts, text = blk(".text")
rs, rdata = blk(".rdata")
def is_code(v):
return 0x180001000 <= v < 0x1801e5000
def vtable_start(ptr_site):
"""Walk back while the preceding qword is also a .text pointer."""
a = ptr_site
while a - 8 >= rs:
v = struct.unpack_from("<Q", rdata, a - 8 - rs)[0]
if not is_code(v):
break
a -= 8
return a
def vtable_len(start):
n = 0
a = start
while a - rs + 8 <= len(rdata):
v = struct.unpack_from("<Q", rdata, a - rs)[0]
if not is_code(v):
break
n += 1
a += 8
return n
def rtti_name_near(start):
"""The qword right before a vtable is usually the RTTI/type descriptor
pointer; also try the EA 'RS4:' literal convention nearby."""
out = []
for back in (8, 16):
a = start - back
if a - rs < 0:
continue
v = struct.unpack_from("<Q", rdata, a - rs)[0]
out.append((back, hex(v)))
return out
CASES = [
("CONTROL model applier", 0x18021cc28, 0x18011dc50),
("market builder", 0x180214dd8, 0x180162c90),
("seasons builder", 0x18022d748, 0x180175bd0),
]
for lbl, site, fn in CASES:
st = vtable_start(site)
ln = vtable_len(st)
print("\n### %s ptr_site=%#x fn=%#x" % (lbl, site, fn))
print(" vtable start=%#x slots=%d slot_of_fn=+%#x" % (st, ln, site - st))
print(" preceding qwords:", rtti_name_near(st))
# print slots
for i in range(min(ln, 40)):
v = struct.unpack_from("<Q", rdata, st - rs + i * 8)[0]
f = fm.getFunctionAt(addr(v))
print(" +%#05x %#x %s" % (i * 8, v, f.getName() if f else ""))
if ln > 40:
print(" ... (%d more)" % (ln - 40))
print("\n\n### decompile market builder FUN_180162c90")
s = dec(0x180162c90)
print("len=%d" % len(s))
print(s)
print("\n\n### decompile seasons builder FUN_180175bd0")
s = dec(0x180175bd0)
print("len=%d" % len(s))
print(s)
except Exception:
traceback.print_exc()
@@ -0,0 +1,99 @@
"""Q1/Q3: trade-pile capacity literals -- who READS them?
HYPOTHESIS: TRADE_PILE_SIZE is an OUTPUT name only (published by FUN_18000d550),
exactly like IS_TRADING_ENABLED, and is therefore NOT a Blaze client-config key
the DLL ever looks up.
CONTROL (same form): IS_TRADING_ENABLED (0x1801fc118) is PROVEN output-only --
exactly one rip-relative reference in .text, a `lea` inside the publisher. If my
scanner reproduces that exact result for IS_TRADING_ENABLED, the scanner is good.
SECOND CONTROL: a literal that IS read/compared somewhere, to prove the scanner
can see a consumer at all. I use the route string "/transfermarket?..." which must
be referenced by a request builder, and the atom-name strings.
METHOD, form-independent: I do NOT grep for `== 0x` or trust Ghidra's xref db.
For every byte offset in .text I read the 4 bytes as a little-endian int32 and
test whether text_base+i+4+disp equals the target. That catches lea/mov/cmp/push
in EVERY rip-relative encoding. Separately I search .rdata/.data for the absolute
8-byte pointer, which catches vtable slots and pointer tables.
"""
import struct, traceback
try:
def sect(name):
for b in mem.getBlocks():
if b.getName() == name:
return int(b.getStart().getOffset()), int(b.getEnd().getOffset()) - int(b.getStart().getOffset()) + 1
return None, None
TB, TS = sect(".text")
print("text base %#x size %#x" % (TB, TS))
TEXT = read_bytes(TB, TS)
print("read text len", len(TEXT))
RB, RS = sect(".rdata")
RDATA = read_bytes(RB, RS)
DB, DS = sect(".data")
DATA = read_bytes(DB, DS)
print("rdata %#x len %d ; data %#x len %d" % (RB, len(RDATA), DB, len(DATA)))
def riprefs(target):
"""all i such that some 4-byte window at TB+i is a rip-disp32 to target"""
out = []
for i in range(0, len(TEXT) - 4):
d = struct.unpack_from("<i", TEXT, i)[0]
if TB + i + 4 + d == target:
out.append(TB + i)
return out
def absrefs(target):
p = struct.pack("<Q", target)
out = []
for base, buf in ((RB, RDATA), (DB, DATA), (TB, TEXT)):
j = buf.find(p)
while j != -1:
out.append(base + j)
j = buf.find(p, j + 1)
return out
def strhits(s):
pat = s.encode() + b"\x00"
return find_all(pat)
NAMES = [
"TRADE_PILE_SIZE",
"GetMaxPileSize",
"NUM_MAX_AUCTIONS",
"IS_MAX_AUCTIONS",
"pileSizeClientData",
"TradePileFull",
"maximumTradePileSize",
"IS_TRADING_ENABLED", # CONTROL: proven output-only
"IS_STORE_ENABLED", # CONTROL: sibling
"WATCH_LIST_SIZE",
"maximumActiveAuctions",
"MAX_ACTIVE_AUCTIONS",
]
for nm in NAMES:
hits = strhits(nm)
print("\n=== %-24s %d string hit(s): %s" % (nm, len(hits), [hex(h) for h in hits]))
for h in hits:
print(" text at %#x = %r" % (h, rd_str(h, 80)))
rr = riprefs(h)
print(" rip-disp32 refs in .text: %d" % len(rr))
for a in rr:
# print 24 bytes starting 8 before, plus containing function
ctx = read_bytes(a - 8, 32)
f = fm.getFunctionContaining(addr(a))
print(" %#x in %s bytes[-8..+24]=%s" % (
a, f.getName() if f else "?", ctx.hex()))
ar = absrefs(h)
if ar:
print(" absolute-pointer refs: %s" % [hex(x) for x in ar])
# also the -4 RS4 style and Ghidra's own view
gx = xrefs_to(h)
print(" ghidra xrefs_to: %s" % [(hex(a), t, fn) for a, t, fn, e in gx])
except Exception:
traceback.print_exc()
@@ -0,0 +1,28 @@
"""FUN_180173da0 is the FutGetUserMassInfoServerResponse factory (it is the only
.text site referencing the RS4 class literal). Decompile it and whatever it calls
to construct the object, and read the initialiser for resp+0x28..+0xd4.
CONTROL: resp+0x80 is friendlySeasonsEnabled (atom 0x133, settings deser param_2[0x16]),
whose downstream byte model+0x1fd3a reads 1 LIVE while we serve {"configs": []}.
ctor sets resp+0x80 = 1 => the applier RUNS and the response carries defaults
ctor zeroes resp+0x80 => the applier does NOT run and 0x1fd3a=1 is a ctor default
Those are mutually exclusive and the live byte is already measured, so this is a
genuine prediction, not a post-hoc fit.
"""
import traceback
try:
for a in (0x180173da0,):
s = dec(a, 300)
print("########## %#x len=%d ##########" % (a, len(s)))
print(s)
except Exception:
traceback.print_exc()
try:
for a, tag in [(0x180173cf0, "descriptor vt+0x18"), (0x180173d60, "descriptor vt+0x08"),
(0x180173cc0, "descriptor vt+0x58")]:
s = dec(a, 300)
print("\n\n########## %#x %s len=%d ##########" % (a, tag, len(s)))
print(s)
except Exception:
traceback.print_exc()
@@ -0,0 +1,12 @@
"""FUN_180173a50 = FutGetUserMassInfoServerResponse ctor (0x300 bytes).
Read the initialisers for resp+0x28..+0xd4.
CONTROL: resp+0x80 friendlySeasonsEnabled -- live model+0x1fd3a reads 1 while we
serve {"configs":[]}. ctor sets 1 => applier RUNS. ctor sets 0 => applier does NOT.
"""
import traceback
try:
s = dec(0x180173a50, 300)
print("########## 0x180173a50 ctor len=%d FULL ##########" % len(s))
print(s)
except Exception:
traceback.print_exc()
@@ -0,0 +1,18 @@
"""FUN_18014e320 initialises the SETTINGS sub-struct at resp+0x28 (ctor calls it as
FUN_18014e320(param_1 + 5), param_1 is undefined8* so +5 == +0x28).
DECISIVE CONTROL: sub-offset 0x58 == resp+0x80 == friendlySeasonsEnabled
(atom 0x133 -> settings deser param_2[0x16] -> S2[0x16] -> model+0x1fd3a).
model+0x1fd3a reads 1 LIVE while we serve {"configs": []}.
init sets +0x58 to 1 => the callback FUN_180173e00 RUNS, and every unsent settings
field simply keeps the response default.
init sets +0x58 to 0 => the callback does NOT run and 0x1fd3a=1 is a model default.
Also read sub-offset 0x28 (== resp+0x50 == tradingEnabled -> model+0x1fd2e, live 0).
"""
import traceback
try:
s = dec(0x18014e320, 300)
print("########## 0x18014e320 settings-struct init len=%d FULL ##########" % len(s))
print(s)
except Exception:
traceback.print_exc()
@@ -0,0 +1,24 @@
"""The kill switch: resp+0x17c. userInfo deser base is resp+0xd8, so the byte is
userInfo_struct + 0xa4. Find (a) its default in the userInfo init FUN_18010ea80 and
(b) which atom writes it in the userInfo deser FUN_18013ec10.
WHY IT MATTERS: the response ctor defaults tradingEnabled (resp+0x50) to 1, yet
model+0x1fd2e reads 0 live while we serve {"configs": []}. The settings deser only
touches resp+0x50 via atom 0x336, which we never send. The ONLY other writer is
if (*(char *)(resp + 0x17c) != 0) *(u32 *)(resp + 0x50) = 0;
at the tail of FUN_180174630. So that branch is taken. Something sets +0x17c.
CONTROL for the offset arithmetic: resp+0x2f4 has ctor default 1 and is read by the
callback as *(u8*)(resp+0x2f4); the outer parser sets it to 0 in case 0x2cf. That is
an independently visible byte field in the same object, confirming that single-byte
fields in this struct are addressed exactly the way I am reading +0x17c.
"""
import traceback
for a, tag in [(0x18010ea80, "userInfo sub-struct init (resp+0xd8)"),
(0x18013ec10, "userInfo deserialiser")]:
try:
s = dec(a, 300)
print("\n\n########## %#x %s len=%d ##########" % (a, tag, len(s)))
print(s)
except Exception:
traceback.print_exc()
@@ -0,0 +1,66 @@
"""Q1/Q2/Q4: decompile every capacity consumer found in q_mk_pile_1.
HYPOTHESIS: the "0/0" pair is model+0x1fd1c (TRADE_PILE_SIZE) and a second count,
and the TradePileFull / IS_MAX_AUCTIONS predicates compare a live count against
that capacity. FUN_1800377c0 reads [rsi+0x30] and [rsi+0x36] -- I need to know
whether rsi is the model (offsets would then be 0x30/0x36, NOT 0x1fd1c, so a
different object) or a view struct fed from the model.
CONTROL: FUN_18006cc60 is the PROVEN publisher shape (lea rdx,name; call [r+0x38]).
If FUN_18000d550 decompiles to the same shape, TRADE_PILE_SIZE is output-only by
the same mechanism, which answers Q3 negatively with the template the brief asked
for.
Also dumps the .data neighbourhood of the two absolute-pointer table entries
(0x1802d3560 maximumTradePileSize, 0x1802d3898 pileSizeClientData) to identify
what kind of table they live in (atom-name table vs clientdata field table).
"""
import struct, traceback
try:
for a, tag in [
(0x18000d550, "publisher of TRADE_PILE_SIZE (Q3 template test)"),
(0x1800377c0, "publisher of NUM_MAX_AUCTIONS / IS_MAX_AUCTIONS"),
(0x180038250, "TradePileFull raiser #1"),
(0x180038450, "TradePileFull raiser #2"),
(0x18011dbf0, "the TRADE_PILE_SIZE applier (writes +0x1fd1c)"),
]:
src = dec(a)
print("\n\n########## %#x %s (len=%d) ##########" % (a, tag, len(src)))
print(src)
except Exception:
traceback.print_exc()
try:
print("\n\n########## .data table neighbourhoods ##########")
for t, nm in [(0x1802d3560, "maximumTradePileSize"), (0x1802d3898, "pileSizeClientData")]:
print("\n--- entry %#x (%s)" % (t, nm))
for off in range(-0x60, 0x61, 8):
p = t + off
try:
v = qword(p)
except Exception:
continue
s = ""
if 0x1801e5000 <= v < 0x18028a000 or 0x18028a000 <= v < 0x1802f0000:
try:
txt = rd_str(v, 60)
if txt and all(32 <= ord(c) < 127 for c in txt):
s = " -> %r" % txt
except Exception:
pass
f = fm.getFunctionAt(addr(v)) if 0x180001000 <= v < 0x1801e5000 else None
if f:
s = " -> FUNC %s" % f.getName()
print(" %+#5x %#018x%s" % (off, v, s))
except Exception:
traceback.print_exc()
try:
print("\n\n########## GetMaxPileSize registration site 0x18003f5b7 ##########")
src = dec(0x18003f120)
print("len=%d" % len(src))
print(src)
except Exception:
traceback.print_exc()
@@ -0,0 +1,46 @@
"""Q1 cont: the UI-facing readers. GetMaxPileSize -> FUN_18003ff60,
GetAuctionTunables -> FUN_18003fa40, and model vt+0x130 (the object whose +0x30
holds NUM_MAX_AUCTIONS).
HYPOTHESIS: GetMaxPileSize is what the transfer-list UI actually calls, and it
resolves to the same model+0x1fd1c that TRADE_PILE_SIZE publishes -- OR to the
separate (vt+0x130)+0x30 auction cap. These are two different numbers and the
brief conflates them.
CONTROL: FUN_18000d550 (already decompiled) is the known-good reader of
model+0x1fd1c via vt+0xa58. If FUN_18003ff60 reaches vt+0xa58 too, they agree.
Also: form-independent disp32 writer scan for the auction-cap field. I search
.text for the raw little-endian 4-byte displacement, which catches mov/movzx/cmp/
lea in EVERY encoding -- the search form that caught the +0x1fd2e writer. For a
small offset like 0x30 a disp32 scan is useless (it would be disp8), so instead I
enumerate every writer of the object returned by vt+0x130 by decompiling its
allocator/deserialiser.
"""
import struct, traceback
try:
for a, tag in [
(0x18003ff60, "GetMaxPileSize script binding"),
(0x18003fa40, "GetAuctionTunables script binding"),
]:
src = dec(a)
print("\n\n########## %#x %s (len=%d) ##########" % (a, tag, len(src)))
print(src)
except Exception:
traceback.print_exc()
try:
VT = 0x18021c2a0
print("\n\n########## model vtable slots of interest ##########")
for slot in (0x08, 0x130, 0x270, 0xa58, 0xa60, 0x988, 0x998, 0xb00):
t = qword(VT + slot)
f = fm.getFunctionAt(addr(t)) if 0x180001000 <= t < 0x1801e5000 else None
stub = read_bytes(t, 16) if f or (0x180001000 <= t < 0x1801e5000) else b""
print(" vt+%#05x -> %#x %s stub=%s" % (slot, t, f.getName() if f else "?", stub.hex()))
if 0x180001000 <= t < 0x1801e5000:
s = dec(t)
print(" ---- decompile (len=%d) ----" % len(s))
print(" " + s.replace("\n", "\n "))
except Exception:
traceback.print_exc()
@@ -0,0 +1,84 @@
"""Q1/Q2/Q4 decisive: which ATOM writes the struct field the capacity applier reads,
and is pileSizeClientData consumed anywhere.
FUN_18011dbf0(model, S) does model+0x1fd1c = S[+0x08] and model+0x1fd20 = S[+0x0c].
So I need the settings/response struct S and which atom arm writes S+0x08.
HYPOTHESIS: atom 0x1c0 maximumTradePileSize writes S+0x08 and 0x1bf
maxAuctionsAllowed (or a watchlist atom) writes S+0x0c.
ABSENCE-TRAP GUARD: a jump-table switch never contains the case value as an
immediate, so a literal scan for 0x1c0 would produce a FALSE ABSENCE. I therefore
do BOTH: (a) full decompile of the deserialiser and its callers, printed in FULL
with len(), and (b) a raw immediate scan in every common encoding. A disagreement
between the two is itself the finding.
CONTROL: atom 0x361 (untradeable) and 0x336 are PROVEN to have arms in the settings
range switch. Whatever form I find them in is the form I must search for 0x1c0.
"""
import struct, traceback
CONSTS = {0x1bf: "maxAuctionsAllowed", 0x1c0: "maximumTradePileSize",
0x227: "pileSizeClientData", 0x333: "tradePile", 0x381: "watchlist",
0x361: "CONTROL untradeable", 0x336: "CONTROL"}
try:
for a, tag in [
(0x180173e00, "settings completion callback -- calls BOTH appliers"),
(0x180174580, "builds the request descriptor"),
]:
src = dec(a)
print("\n\n########## %#x %s (len=%d) FULL ##########" % (a, tag, len(src)))
print(src)
except Exception:
traceback.print_exc()
try:
print("\n\n########## caller of vt+0x988 at 0x18011e21a ##########")
f = fm.getFunctionContaining(addr(0x18011e21a))
print("containing:", f.getName() if f else "?", hex(int(f.getEntryPoint().getOffset())) if f else "")
if f:
s = dec(int(f.getEntryPoint().getOffset()))
print("len=%d" % len(s)); print(s)
except Exception:
traceback.print_exc()
try:
print("\n\n########## raw immediate scan for the atom constants ##########")
def sect(name):
for b in mem.getBlocks():
if b.getName() == name:
return int(b.getStart().getOffset()), int(b.getEnd().getOffset()) - int(b.getStart().getOffset()) + 1
TB, TS = sect(".text")
TEXT = read_bytes(TB, TS)
print("text len", len(TEXT))
for v, nm in sorted(CONSTS.items()):
pats = {
"cmp eax,imm32 (3d)": b"\x3d" + struct.pack("<I", v),
"cmp r/m32,imm32 (81 f8..ff)": None,
"sub eax,imm32 (2d)": b"\x2d" + struct.pack("<I", v),
"mov r32,imm32 (b8..bf)": None,
"cmp r/m16,imm16 (66 81 f9)": b"\x66\x81\xf9" + struct.pack("<H", v),
"cmp r/m16,imm16 (66 3d)": b"\x66\x3d" + struct.pack("<H", v),
"bare imm32 le": struct.pack("<I", v),
"bare imm16 le": struct.pack("<H", v),
}
print("\n--- %#05x %s" % (v, nm))
for label, pat in pats.items():
if pat is None:
continue
hits = []
j = TEXT.find(pat)
while j != -1 and len(hits) < 40:
hits.append(TB + j)
j = TEXT.find(pat, j + 1)
if hits:
fns = {}
for h in hits:
fn = fm.getFunctionContaining(addr(h))
fns.setdefault(fn.getName() if fn else "?", []).append(hex(h))
print(" %-30s %3d hit(s)" % (label, len(hits)))
for k, vv in sorted(fns.items()):
print(" %-22s %s" % (k, vv[:8]))
except Exception:
traceback.print_exc()
@@ -0,0 +1,31 @@
"""Q1 decisive: the settings deserialiser's switch, read as a SWITCH not as immediates.
WHY THE IMMEDIATE SCAN WAS ABANDONED: in q_mk_pile_4 the control atom 0x361
(untradeable), which is PROVEN to have an arm in the shared item deserialiser
FUN_18013fe00, produced ZERO immediate hits inside that deserialiser. Dispatch there
is a jump table, where the case value never appears as an immediate operand. An
immediate scan therefore has a proven false-absence on exactly this dispatch form,
so nothing may be concluded from it. Ghidra's decompiler DOES recover the jump table
into a C `switch`, so I read the switch.
HYPOTHESIS: FUN_18013c6d0 writes response+0xd0 (which FUN_180173e00 forwards to the
capacity applier as S+0x08 -> model+0x1fd1c) from atom 0x1c0 maximumTradePileSize,
and response+0x1c (the callback's abort gate) from an error/code atom.
CONTROL inside the same function: the arms that write +0x50 (which FUN_180173e00
forwards as S2+0x28 -> model+0x1fd2e, the PROVEN tradingEnabled gate byte). If I can
see that arm and its atom id, the offset->atom mapping method is validated on a
field whose downstream effect is already established.
"""
import re, traceback
try:
for a, tag in [
(0x18013c6d0, "settings deserialiser"),
(0x180174630, "the 0x2cd..0x370 dispatch caller"),
]:
src = dec(a, 300)
print("\n\n########## %#x %s len=%d FULL ##########" % (a, tag, len(src)))
print(src)
except Exception:
traceback.print_exc()
@@ -0,0 +1,64 @@
"""Q4 ANSWER CANDIDATE: FUN_18013adb0 is the pileSizeClientData deserialiser and it
writes response+0xc8..+0xd4, which FUN_180173e00 forwards to the capacity applier
vt+0x998 -> model+0x1fd1c (TRADE_PILE_SIZE) and model+0x1fd20.
CHAIN UNDER TEST (each link already printed in full elsewhere):
atom 0x227 pileSizeClientData -> FUN_18013adb0(reader, resp+0xc8)
resp+0xc8..0xd4 -> local_108..uStack_fc -> vt+0x998 = FUN_18011dbf0
FUN_18011dbf0: model+0x1fd1c = S[+0x08] (= resp+0xd0); model+0x1fd20 = S[+0x0c]
model+0x1fd1c -> vt+0xa58 -> published as "TRADE_PILE_SIZE" (measured 0 live)
CONTROL, same method, already closed end to end: atom 0x336 tradingEnabled ->
settings deser param_2[0xa] = resp+0x50 -> S2[10] -> model+0x1fd2e (measured 0 live,
and 0x1fd2e is the PROVEN service gate at vt+0x270). The offset arithmetic is
therefore validated on a field whose whole chain is independently established.
Remaining unknowns this query must settle:
* the ATOM NAMES of the four ints inside FUN_18013adb0 (which JSON key is capacity)
* who writes response+0x1c, the field that makes FUN_180173e00 skip BOTH appliers
* the response class name, to name the route in the write-up
"""
import struct, traceback
try:
for a, tag in [
(0x18013adb0, "pileSizeClientData deserialiser -> resp+0xc8"),
]:
src = dec(a, 300)
print("\n\n########## %#x %s len=%d FULL ##########" % (a, tag, len(src)))
print(src)
except Exception:
traceback.print_exc()
try:
print("\n\n########## head of FUN_180174630 (init of resp fields) ##########")
src = dec(0x180174630, 300)
print("\n".join(src.split("\n")[:110]))
except Exception:
traceback.print_exc()
try:
print("\n\n########## descriptor vtable 0x18022d000 + class name hunt ##########")
for i in range(0, 12):
t = qword(0x18022d000 + i * 8)
f = fm.getFunctionAt(addr(t)) if 0x180001000 <= t < 0x1801e5000 else None
print(" +%#04x -> %#x %s" % (i * 8, t, f.getName() if f else ""))
# RS4: class names whose factory/vtable is near 0x18022d000
hits = find_all(b"RS4:")
print(" total RS4: literals:", len(hits))
for h in hits:
s = rd_str(h, 80)
if "MassInfo" in s or "Mass" in s or "UserMass" in s or "Pile" in s:
print(" %#x %r" % (h, s))
except Exception:
traceback.print_exc()
try:
print("\n\n########## who writes response+0x1c ##########")
# the response object is allocated/initialised by the descriptor; find the
# constructor by looking at what FUN_180174580 does, printed FULL
src = dec(0x180174580, 300)
print("len=%d" % len(src))
print(src)
except Exception:
traceback.print_exc()
@@ -0,0 +1,65 @@
"""Two closers.
(1) THE SCALAR-DECODE TRAP. FUN_18013adb0 compares the DECODED key
(iVar2 = FUN_1800d7b30(rawint)) against 2 and 4, and decodes the value with the
same FUN_1800d7b30. The settings deser uses a DIFFERENT decoder FUN_1800d7af0
for most fields. If FUN_1800d7b30 is not identity, "key":2 on the wire is not
key 2 in the comparison and the whole recommendation is wrong. This is exactly
the failure the brief flags ("a scalar can be raw OR decoded, which broke a
control only yesterday"). Decompile both decoders and the int primitive.
(2) THE massInfo TAIL KILL-SWITCH. FUN_180174630 ends with
if (*(char *)(param_1 + 0x17c) != 0) *(u32 *)(param_1 + 0x50) = 0;
and resp+0x50 is PROVEN to be tradingEnabled (atom 0x336 -> settings deser
param_2[0xa] -> S2[10] -> model+0x1fd2e). So a non-zero byte at resp+0x17c
ZEROES the trading gate no matter what /settings said. Find its writer.
SEARCH FORM: 0x17c cannot be a disp8 (>0x7f), so every memory operand naming it
carries the literal 4 bytes 7c 01 00 00. A raw disp32 scan is therefore
form-independent here and catches mov/movzx/cmp/lea in all encodings -- the same
search that found the +0x1fd2e writer. CONTROL: run the identical scan for 0x50,
which IS a disp8 offset, and confirm it produces garbage -- that proves I know
which offsets this technique is valid for and am not over-claiming.
"""
import struct, traceback
try:
for a, tag in [(0x1800d7b30, "decoder used by pileSizeClientData (key AND value)"),
(0x1800d7af0, "decoder used by the settings deser"),
(0x1801c79d0, "INT primitive getter")]:
s = dec(a)
print("\n\n########## %#x %s len=%d ##########" % (a, tag, len(s)))
print(s)
except Exception:
traceback.print_exc()
try:
def sect(name):
for b in mem.getBlocks():
if b.getName() == name:
return int(b.getStart().getOffset()), int(b.getEnd().getOffset()) - int(b.getStart().getOffset()) + 1
TB, TS = sect(".text")
TEXT = read_bytes(TB, TS)
print("\n\n########## disp32 scan for +0x17c (form-independent) ##########")
for disp, note in [(0x17c, "the kill-switch condition byte"),
(0x50, "CONTROL: a disp8 offset, scan must be meaningless")]:
pat = struct.pack("<I", disp)
hits = []
j = TEXT.find(pat)
while j != -1:
hits.append(TB + j)
j = TEXT.find(pat, j + 1)
agg = {}
for h in hits:
f = fm.getFunctionContaining(addr(h))
agg.setdefault(f.getName() if f else "?", []).append(h)
print("\n--- disp %#x (%s): %d raw hit(s) in %d function(s)" % (disp, note, len(hits), len(agg)))
if len(hits) > 60:
print(" TOO NOISY TO BE EVIDENCE -- not reporting individual sites")
continue
for k, v in sorted(agg.items()):
for h in v:
ctx = read_bytes(h - 6, 16)
print(" %-22s %#x bytes[-6..+10]=%s" % (k, h, ctx.hex()))
except Exception:
traceback.print_exc()
@@ -0,0 +1,68 @@
"""Final closer: is FUN_180173e00 (the massInfo callback) the ONLY route to the
capacity applier, and what is the constructor default of model+0x1fd1c?
If vt+0x998 has exactly one call site, then pileSizeClientData inside userMassInfo is
the ONLY way to set TRANSFER LIST capacity, and no other endpoint can be blamed or
used.
SEARCH FORM -- the trap that produced the "applier is unreachable" error before:
a virtual call is `ff /2` with a disp, and Ghidra does not resolve it, so a
direct-call/xref search finds NOTHING. I enumerate the ModRM byte myself for
disp32 form (ff 90..97 excluding 94 which needs SIB) over the whole .text.
CONTROL: the same scan for vt+0x988 (FUN_18011dc50) must reproduce the two known
sites 0x18011e21a and 0x180173f0b. If it does not, the scan is wrong and neither
result may be used.
Also: constructor default. I find the writers of 0x1fd1c by raw disp32 (0x1fd1c is
far too large for disp8, so the 4 literal bytes appear in every encoding) and print
each with its containing function -- the same form-independent search that located
the +0x1fd2e writer.
"""
import struct, traceback
try:
def sect(name):
for b in mem.getBlocks():
if b.getName() == name:
return int(b.getStart().getOffset()), int(b.getEnd().getOffset()) - int(b.getStart().getOffset()) + 1
TB, TS = sect(".text")
TEXT = read_bytes(TB, TS)
print("text len", len(TEXT))
def vcalls(slot):
"""every `call [reg+slot]` in disp32 form: ff 90..97 (skip 94=SIB) + imm32"""
out = []
d = struct.pack("<I", slot)
for modrm in list(range(0x90, 0x98)):
if modrm == 0x94:
continue
pat = bytes([0xFF, modrm]) + d
j = TEXT.find(pat)
while j != -1:
out.append((TB + j, modrm))
j = TEXT.find(pat, j + 1)
# rex-prefixed forms are identical bytes after the rex, which the scan above
# already lands on because it does not anchor to the rex byte
return sorted(out)
for slot, note in [(0x988, "CONTROL: gate applier FUN_18011dc50, known sites "
"0x18011e21a and 0x180173f0b"),
(0x998, "the CAPACITY applier FUN_18011dbf0"),
(0xa58, "TRADE_PILE_SIZE reader")]:
hits = vcalls(slot)
print("\n=== call [reg+%#x]: %d site(s) %s" % (slot, len(hits), note))
for a, m in hits:
f = fm.getFunctionContaining(addr(a))
print(" %#x modrm=%#x in %s" % (a, m, f.getName() if f else "?"))
print("\n\n=== raw disp32 scan: every reference to +0x1fd1c ===")
pat = struct.pack("<I", 0x1fd1c)
j = TEXT.find(pat)
while j != -1:
a = TB + j
f = fm.getFunctionContaining(addr(a))
print(" %#x in %s bytes[-6..+10]=%s"
% (a, f.getName() if f else "?", read_bytes(a - 6, 16).hex()))
j = TEXT.find(pat, j + 1)
except Exception:
traceback.print_exc()
@@ -0,0 +1,65 @@
"""THE DECISIVE ONE. Does FUN_180173e00 actually run, or is every gate byte a
constructor default?
WHY IT DECIDES MY RECOMMENDATION: FUN_180173e00 calls the gate applier (vt+0x988)
and the CAPACITY applier (vt+0x998) in the SAME branch, after the same two guards.
They run together or not at all. If the callback never runs, serving
pileSizeClientData cannot possibly move TRANSFER LIST capacity and my recommendation
is void.
THE OBSERVATION THAT FORCES THE QUESTION (live, this session, controls passed):
model+0x1fd2f..+0x1fd3f all read 1, and FUN_18011dc50 writes every one of them as
`byte = (respField == 1)`. We serve {"configs": []}, so if the response struct were
zero-initialised and the applier ran, they would ALL be 0. They are 1. So exactly one
of these is true:
(A) the applier never runs and those bytes are FutDataManagerImpl ctor defaults
(B) the applier runs and the RESPONSE struct is constructed with 1s
(A) and (B) predict opposite outcomes for serving pileSizeClientData, and the live
byte pattern alone cannot separate them. The response constructor can.
TEST: decompile the FutGetUserMassInfoServerResponse constructor and read the
initialiser for resp+0x28..+0xc4 (the settings block), resp+0x50 (tradingEnabled),
and resp+0xc8..+0xd4 (the capacity block).
CONTROL inside the answer: whatever the ctor sets resp+0x80 (friendlySeasonsEnabled)
to must equal the live model+0x1fd3a, which is 1. If the ctor sets it to 1, (B) is
confirmed and the applier demonstrably runs. If the ctor zeroes it, (A) holds.
"""
import struct, traceback
try:
print("########## descriptor vtable slot +0x10 -> 0x18011f940 ##########")
print(dec(0x18011f940, 300))
except Exception:
traceback.print_exc()
try:
# the ctor should reference the RS4 name 0x18022d110 (-4 rule: the lea points at
# the "RS4:" header itself)
print("\n\n########## refs to RS4:FutGetUserMassInfoServerResponse (0x18022d110) ##########")
for frm, typ, fn, ent in xrefs_to(0x18022d110):
print(" %#x %s in %s (%#x)" % (frm, typ, fn, ent))
def sect(name):
for b in mem.getBlocks():
if b.getName() == name:
return int(b.getStart().getOffset()), int(b.getEnd().getOffset()) - int(b.getStart().getOffset()) + 1
TB, TS = sect(".text")
TEXT = read_bytes(TB, TS)
for tgt in (0x18022d110, 0x18022d000):
out = []
for i in range(0, len(TEXT) - 4):
d = struct.unpack_from("<i", TEXT, i)[0]
if TB + i + 4 + d == tgt:
out.append(TB + i)
print("\n rip-disp32 refs to %#x: %s" % (tgt, [hex(x) for x in out]))
for a in out:
f = fm.getFunctionContaining(addr(a))
print(" %#x in %s" % (a, f.getName() if f else "?"))
except Exception:
traceback.print_exc()
try:
print("\n\n########## FUN_18016c330 (installs the descriptor / issues request) ##########")
s = dec(0x18016c330, 300)
print("len=%d" % len(s)); print(s)
except Exception:
traceback.print_exc()
@@ -0,0 +1,52 @@
"""HYPOTHESIS: model+0x1fd2e (IS_TRADING_ENABLED) reads 0 either because the
constructor defaults it to 0 (never set) or because FUN_18011dc50 ran and wrote 0.
The disp32 scan found only 2 sites for 0x1fd2e, so the constructor must initialise
it as part of a WIDER store (qword/xmm) whose displacement is lower. Find the model
constructor via rip-refs to vtable 0x18021c2a0 and read the initialising store.
CONTROL: +0x1fd3a/+0x1fd3d read 1 live; whatever store covers them must produce 1,
so the same store decoded for 0x1fd2e is trustworthy.
ALSO: locate the transfer-market refusal path (route literal, CARDS_CB_ERR_* xrefs).
"""
import traceback, struct
try:
VT = 0x18021c2a0
print("=== A. xrefs to model vtable %#x ===" % VT)
for frm, typ, fn, ent in xrefs_to(VT):
print(" %#x %-14s %-40s ent=%#x" % (frm, typ, fn, ent))
print("\n=== B. disp32 scan .text for displacements 0x1fd00..0x1fd60 ===")
tb = None
for b in mem.getBlocks():
if b.getName() == ".text":
tstart = int(b.getStart().getOffset())
tlen = int(b.getEnd().getOffset()) - tstart + 1
tb = read_bytes(tstart, tlen)
print(" .text len =", len(tb))
hitmap = {}
for disp in range(0x1fd00, 0x1fd60):
pat = struct.pack("<i", disp)
i = tb.find(pat)
while i != -1:
hitmap.setdefault(disp, []).append(tstart + i)
i = tb.find(pat, i + 1)
for disp in sorted(hitmap):
for a in hitmap[disp]:
f = fm.getFunctionContaining(addr(a))
print(" disp +%#07x at %#x in %s bytes=%s" % (
disp, a, f.getName() if f else "?",
read_bytes(a - 4, 14).hex(" ")))
print("\n=== C. transfermarket route literal ===")
for pat in [b"/transfermarket", b"transfermarket", b"TransferMarket",
b"tradepile", b"watchlist", b"TRANSFER_MARKET"]:
hits = find_all(pat)
print(" %-20s %d hits: %s" % (pat.decode(), len(hits), [hex(h) for h in hits[:8]]))
print("\n=== D. CARDS_CB_ERR_* literals and xrefs ===")
for a in find_all(b"CARDS_CB_ERR_"):
s = rd_str(a, 80)
if "UNAVAIL" in s or "TRAD" in s or "MARKET" in s or "OFFLINE" in s or "CONN" in s:
xr = xrefs_to(a)
print(" %#x %-42s xrefs=%s" % (a, s, [(hex(x[0]), x[2]) for x in xr]))
except Exception:
traceback.print_exc()
@@ -0,0 +1,17 @@
"""Two loose ends.
(1) FUN_18010c3b0 also calls FUN_18011f380 four times -- is it a SECOND path that can
size the trade-pile vector (i.e. is maximumTradePileSize really the only lever for
the "TRANSFER LIST 0/N" denominator)?
(2) descriptor vt+0x10 = 0x18011f940, the response allocator: corroborate the ctor
defaults for the settings sub-struct (+0x50 tradingEnabled vs +0x54 storeEnabled).
CONTROL for (2): live +0x1fd2f=1, +0x1fd2e=0 with configs:[] and a sole writer, so any
ctor read must agree with 1 and 0.
"""
import traceback
try:
print("=== FUN_18010c3b0 ===")
s = dec(0x18010c3b0); print("len=%d" % len(s)); print(s[:5000])
print("\n=== 0x18011f940 (descriptor vt+0x10, response allocator) ===")
s = dec(0x18011f940); print("len=%d" % len(s)); print(s[:6000])
except Exception:
traceback.print_exc()
@@ -0,0 +1,24 @@
"""Which response drives the applier: standalone GET /settings, or the settings member
of userMassInfo? Enumerate every caller of the settings deserializer FUN_18013c6d0 and,
for each, whether its completion path reaches vt+0x988.
CONTROL: FUN_180174630 (the userMassInfo deser) must appear, since it is the call site
already traced at 0x180174abb.
"""
import traceback
try:
print("=== callers of FUN_18013c6d0 (settings deser) ===")
for frm, typ, fn, ent in xrefs_to(0x18013c6d0):
print(" %#x %-22s %s ent=%#x" % (frm, typ, fn, ent))
print("\n=== callers of FUN_180173e00 (the applying callback) ===")
for frm, typ, fn, ent in xrefs_to(0x180173e00):
print(" %#x %-22s %s ent=%#x" % (frm, typ, fn, ent))
print("\n=== callers of FUN_180174580 (the request builder holding that callback) ===")
for frm, typ, fn, ent in xrefs_to(0x180174580):
print(" %#x %-22s %s ent=%#x" % (frm, typ, fn, ent))
print("\n=== route literals near the massinfo request ===")
for pat in (b"userMassInfo", b"/settings", b"settings"):
for h in find_all(pat, blocks=(".rdata",))[:6]:
print(" %#x %r xrefs=%s" % (h, rd_str(h, 50),
[(hex(x[0]), x[2]) for x in xrefs_to(h)][:3]))
except Exception:
traceback.print_exc()
@@ -0,0 +1,23 @@
"""HYPOTHESIS: the settings applier FUN_18011dc50 ALREADY RAN this session. Evidence:
live model+0x1fd28=0x3c, +0x1fd49=0x96, +0x1fd54=0x1e0 and a mixed 0/1 pattern across
+0x1fd2c..+0x1fd48, none of which a zero-memset could produce, and the disp32 scan
found NO constructor writer for any of those displacements.
FALSIFIER: the model constructor (FUN_180111100 / FUN_18010cdc0, the only two functions
referencing vtable 0x18021c2a0) writes the block through a REBASED pointer (small disp),
which my disp32 scan would miss. Decompile both and look.
CONTROL: FUN_18011dbf0 is a known writer (+0x1fd1c) reached the same way.
"""
import traceback
try:
for a, tag in [(0x180111100, "vtable-ref-1 (ctor?)"),
(0x18010cdc0, "vtable-ref-2 (ctor?)"),
(0x18011dc50, "GATE APPLIER"),
(0x18011dbf0, "TRADE_PILE_SIZE APPLIER"),
(0x180173e00, "settings completion callback")]:
s = dec(a)
print("\n" + "="*78)
print("### %#x %s len=%d" % (a, tag, len(s)))
print("="*78)
print(s)
except Exception:
traceback.print_exc()
@@ -0,0 +1,19 @@
"""ESTABLISHED: FUN_180173e00 copies response+0x28..+0xc0 to the stack and passes it
to the gate applier vt+0x988, so model+0x1fd2e = (response+0x50 == 1), and
model+0x1fd1c (TRADE_PILE_SIZE) = response+0xd0, model+0x1fd20 = response+0xd4.
HYPOTHESIS: the settings deserializer FUN_18013c6d0 has a case that writes +0x50,
+0xd0, +0xd4 and its atom id names the JSON key we must send.
CONTROL: +0xd0/+0xd4 must be written by a case whose atom is a known pile-size key.
"""
import traceback
try:
for a, tag in [(0x18013c6d0, "SETTINGS DESERIALIZER"),
(0x180174630, "descriptor vt+0x20 (deser caller)"),
(0x180174580, "request builder")]:
s = dec(a, 300)
print("\n" + "="*78)
print("### %#x %s len=%d" % (a, tag, len(s)))
print("="*78)
print(s)
except Exception:
traceback.print_exc()
@@ -0,0 +1,57 @@
"""ESTABLISHED: model+0x1fd2e = (settings response+0x50 == 1), fed by deser case
0x336 = atom "tradingEnabled" as a configs[] type-string. The applier DOES run
(model+0x1fd28=60 live and the applier is its only writer), so with configs:[] every
field keeps the response-object CONSTRUCTOR DEFAULT.
NOW: enumerate every CALLER of the trading gate accessor vt+0x270 (FUN_18011c670),
form-independently, by scanning .text for all ff /2 encodings of call [reg+0x270],
and decompile the market/tradepile surfaces.
CONTROL: also scan for call [reg+0xa58] (TRADE_PILE_SIZE, known to be read by the
publisher FUN_18000d550) -- if that scan finds FUN_18000d550 the scan form is good.
"""
import traceback, struct
try:
tstart = None
for b in mem.getBlocks():
if b.getName() == ".text":
tstart = int(b.getStart().getOffset())
tb = read_bytes(tstart, int(b.getEnd().getOffset()) - tstart + 1)
print(".text len", len(tb))
def call_sites(slot):
"""all `call [reg+disp32]` (ff /2) with disp32 == slot"""
out = []
d = struct.pack("<i", slot)
for modrm in (0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97):
pat = bytes([0xff, modrm]) + d
i = tb.find(pat)
while i != -1:
a = tstart + i
# modrm 0x94 needs a SIB; skip mismatched
f = fm.getFunctionContaining(addr(a))
out.append((a, modrm, f.getName() if f else "?",
int(f.getEntryPoint().getOffset()) if f else 0))
i = tb.find(pat, i + 1)
# rex-prefixed forms are the same modrm bytes, already covered
return sorted(out)
for slot, tag in [(0x270, "IS_TRADING_ENABLED accessor"),
(0xa58, "TRADE_PILE_SIZE accessor (CONTROL)"),
(0x280, "IS_STORE_ENABLED accessor"),
(0xa60, "watch-list size accessor")]:
print("\n=== call [reg+%#x] %s ===" % (slot, tag))
for a, m, fn, ent in call_sites(slot):
print(" %#x modrm=%#04x in %-30s ent=%#x" % (a, m, fn, ent))
print("\n=== xrefs to /transfermarket literal 0x180228490 ===")
for frm, typ, fn, ent in xrefs_to(0x180228490):
print(" %#x %-12s %s ent=%#x" % (frm, typ, fn, ent))
print("=== xrefs to tradepile 0x18022fb20 / watchlist 0x18022ffa8 ===")
for lit in (0x18022fb20, 0x18022ffa8):
print(" lit %#x = %r" % (lit, rd_str(lit, 60)))
for frm, typ, fn, ent in xrefs_to(lit):
print(" %#x %-12s %s ent=%#x" % (frm, typ, fn, ent))
print("\n=== FUN_18013adb0 pileSizeClientData deser ===")
s = dec(0x18013adb0); print("len=%d" % len(s)); print(s)
except Exception:
traceback.print_exc()
@@ -0,0 +1,26 @@
"""HYPOTHESIS: the Transfer Market refusal and the greyed TO_TRADE_PILE menu entry
share the SAME term, model+0x1fd2e read through vt+0x270. 28 call sites exist.
Decompile TO_TRADE_PILE and the market-looking callers, plus the /transfermarket
request builder FUN_180162c90 and its callers, to see which predicate fires with
zero network traffic.
CONTROL: FUN_18006cc60 is the known publisher, must appear as a pure read+emit.
"""
import traceback
try:
for a, tag in [(0x1801a7260, "TO_TRADE_PILE (greyed menu)"),
(0x180162c90, "/transfermarket request builder"),
(0x180194b60, "vt+0x270 caller"),
(0x180195320, "vt+0x270 caller"),
(0x180196360, "vt+0x270 caller"),
(0x180197440, "vt+0x270 caller x3"),
(0x180199460, "vt+0x270 caller x2")]:
s = dec(a, 300)
print("\n" + "="*78)
print("### %#x %s len=%d" % (a, tag, len(s)))
print("="*78)
print(s)
print("\n=== callers of /transfermarket builder FUN_180162c90 ===")
for frm, typ, fn, ent in xrefs_to(0x180162c90):
print(" %#x %-12s %s ent=%#x" % (frm, typ, fn, ent))
except Exception:
traceback.print_exc()
@@ -0,0 +1,55 @@
"""The raw `call [reg+0x270]` scan over-matched: most hits are a DIFFERENT class
(they pass a 0x38*0x17 buffer). The FutDataManagerImpl instance is always obtained
via FUN_1800d7170() -> FUN_180009c80(&obj, ...). So the true readers of the trading
gate are functions that call FUN_180009c80 AND contain call [reg+0x270] with no arg.
CONTROL: FUN_1801a7260 (TO_TRADE_PILE) and FUN_18006cc60 (the publisher) must both
survive the refinement; the squad functions FUN_180194b60 etc must all drop out.
Also: polarity of FUN_1801a7260 via its callers / the action-id table.
"""
import traceback, struct
try:
tstart = None
for b in mem.getBlocks():
if b.getName() == ".text":
tstart = int(b.getStart().getOffset())
tb = read_bytes(tstart, int(b.getEnd().getOffset()) - tstart + 1)
getters = {}
for g, gname in [(0x180009c80, "FUN_180009c80 mgr-getter"),
(0x1800d7170, "FUN_1800d7170")]:
s = set()
for frm, typ, fn, ent in xrefs_to(g):
if ent: s.add(ent)
getters[gname] = s
print("%s: %d callers" % (gname, len(s)))
mgr = getters["FUN_180009c80 mgr-getter"]
print("\n=== vt+0x270 call sites whose function ALSO calls the mgr getter ===")
d = struct.pack("<i", 0x270)
for modrm in (0x90, 0x91, 0x92, 0x93, 0x96, 0x97):
pat = bytes([0xff, modrm]) + d
i = tb.find(pat)
while i != -1:
a = tstart + i
f = fm.getFunctionContaining(addr(a))
ent = int(f.getEntryPoint().getOffset()) if f else 0
mark = "MGR" if ent in mgr else " "
if ent in mgr:
print(" %s %#x in %s" % (mark, a, f.getName()))
i = tb.find(pat, i + 1)
print("\n=== callers of FUN_1801a7260 (TO_TRADE_PILE) ===")
for frm, typ, fn, ent in xrefs_to(0x1801a7260):
print(" %#x %-12s %s ent=%#x" % (frm, typ, fn, ent))
print("\n=== TO_TRADE_PILE / action-name literals ===")
for pat in [b"TO_TRADE_PILE", b"FROM_TRADE_PILE", b"LIST_ITEM", b"AUCTION",
b"TRANSFER", b"MARKET"]:
for h in find_all(pat)[:12]:
print(" %#x %r xrefs=%s" % (h, rd_str(h, 60),
[(hex(x[0]), x[2]) for x in xrefs_to(h)][:4]))
print("\n=== publisher FUN_18006cc60 ===")
s = dec(0x18006cc60); print("len=%d" % len(s)); print(s)
except Exception:
traceback.print_exc()
@@ -0,0 +1,31 @@
"""The UI gets its gates as a NAMED CONTEXT MAP published by CardsDLL, not by reading
fields. FUN_18006cc60 publishes the IS_* booleans; FUN_18000d550 publishes
TRADE_PILE_SIZE. The Transfer Market screen makes zero requests, so its predicate is
UI-side over that map. Enumerate the WHOLE published map (both publishers) and the
accessor slot behind each name so every term can be read live.
CONTROL: FUN_18006cc60 already decoded correctly with this method (10 names).
Also: polarity of FUN_1801a7260 via its only code caller FUN_1800e2a40, and the
TO_TRADE_PILE literal user FUN_18003e370.
"""
import traceback
try:
for a, tag in [(0x18000d550, "numeric context publisher (TRADE_PILE_SIZE)"),
(0x1800e2a40, "only code caller of TO_TRADE_PILE gate"),
(0x18003e370, "TO_TRADE_PILE literal user")]:
s = dec(a, 300)
print("\n" + "="*78)
print("### %#x %s len=%d" % (a, tag, len(s)))
print("="*78)
print(s)
print("\n=== model vtable slots used by the publishers ===")
for slot in (0x270, 0x280, 0x2b0, 0x2b8, 0x2c0, 0x2c8, 0x2d8, 0x2f0,
0xa58, 0xa60, 0xa68, 0xa70, 0xa78, 0x818, 0x988, 0x998, 0x9a0, 0x9d0):
t = qword(0x18021c2a0 + slot)
f = fm.getFunctionAt(addr(t))
print(" vt+%#05x -> %#x %s bytes=%s" % (slot, t, f.getName() if f else "",
read_bytes(t, 12).hex(" ")))
print("\n=== IS_STORE_ENABLED accessor (vt+0x280) full ===")
print(dec(qword(0x18021c2a0 + 0x280)))
except Exception:
traceback.print_exc()
@@ -0,0 +1,46 @@
"""(a) maximumTradePileSize -> FUN_18011f380(model+0x15f00, N): is that the trade-list
CAPACITY (the second 0 in the red "TRANSFER LIST 0/0")?
(b) FUN_18000d550's decompile hit a jumptable; dump the raw listing so no published
name is missed.
(c) enumerate the whole published-name family in .rdata around IS_TRADING_ENABLED
(0x1801fc118) so every UI-visible term is on the table, including any online/connected
one. CONTROL: IS_STORE_ENABLED and IS_DRAFT_MODE_ENABLED must appear in the dump.
"""
import traceback
try:
print("=== FUN_18011f380 (maximumTradePileSize consumer) ===")
s = dec(0x18011f380); print("len=%d" % len(s)); print(s)
print("\n=== raw listing 0x18000d550..0x18000d640 ===")
ci = listing.getCodeUnits(addr(0x18000d550), True)
n = 0
while ci.hasNext() and n < 70:
cu = ci.next()
if int(cu.getAddress().getOffset()) > 0x18000d640: break
print(" %#x %s" % (int(cu.getAddress().getOffset()), cu))
n += 1
print("\n=== .rdata string family around 0x1801fc118 ===")
b = read_bytes(0x1801fbe00, 0x900)
cur = b""; start = 0
for i, ch in enumerate(b):
if 32 <= ch < 127:
if not cur: start = i
cur += bytes([ch])
else:
if len(cur) >= 6:
print(" %#x %s" % (0x1801fbe00 + start, cur.decode()))
cur = b""
print("\n=== every UI context name published anywhere: strings starting IS_ ===")
seen = set()
for h in find_all(b"IS_", blocks=(".rdata",)):
s2 = rd_str(h, 60)
if s2.isupper() or "_" in s2:
if len(s2) > 5 and s2 not in seen:
seen.add(s2)
xr = xrefs_to(h)
if xr:
print(" %#x %-46s %s" % (h, s2, [(hex(x[0]), x[2]) for x in xr][:3]))
except Exception:
traceback.print_exc()
@@ -0,0 +1,27 @@
"""FALSIFIER for "tradingEnabled defaults to 0 while storeEnabled defaults to 1":
find the massinfo/settings response object's constructor and read the immediates it
writes at +0x50 (tradingEnabled, applier p[10]) and +0x54/+0x58 (storeEnabled /
storeEnabled_JP, applier p[0xb]/p[0xc]).
CONTROL: live model+0x1fd2f and +0x1fd30 read 1 and +0x1fd2e reads 0 with configs:[]
on the wire, and the applier is the sole writer of all three -- so the ctor MUST show
1,1 at +0x54/+0x58 and 0 (or absent) at +0x50 or my chain is wrong.
Also: every caller of FUN_18011f380 (the trade-pile slot-vector resize).
"""
import traceback
try:
print("=== callers of FUN_18011f380 (trade-pile vector resize) ===")
for frm, typ, fn, ent in xrefs_to(0x18011f380):
print(" %#x %-22s %s ent=%#x" % (frm, typ, fn, ent))
print("\n=== descriptor vtable 0x18022d000 ===")
for off, t, n in vtable(0x18022d000, 12):
print(" +%#05x -> %#x %s" % (off, t, n))
print("\n=== xrefs to descriptor vtable 0x18022d000 ===")
for frm, typ, fn, ent in xrefs_to(0x18022d000):
print(" %#x %-14s %s ent=%#x" % (frm, typ, fn, ent))
print("\n=== find the ctor: functions writing an immediate to [reg+0x50] near [reg+0x54] ===")
print(dec(0x180174580))
except Exception:
traceback.print_exc()
@@ -0,0 +1,38 @@
"""DIM4 Q1/Q3. Hypothesis: each row of the client action table 0x1802caa20 holds a
FACTORY fn at +0x28 that constructs the request object; from it we can read the
request class vtable (serializer) and the response class name. Also decode the two
enum converters 0x180166380 (str->bidState) and 0x180166bd0 (str->tradeState).
CONTROL: SaveSquad factory 0x1801245f0 -- a KNOWN-GOOD op whose request body
(PUT /squad/<id> with a squad object) is already proven live. If the method that
works for SaveSquad also works for the IS ops, the form matches.
"""
import traceback
try:
FACT = [
("ISSearch", 0x180124110),
("ISOfferTrade", 0x1801240e0),
("ISStart", 0x180124120),
("RelistAll", 0x1801245b0),
("ISRemoveWatch", 0x180124100),
("ISWatchTrade", 0x180124150),
("ISWatchList", 0x180124140),
("ISViewTrade", 0x180124130),
("GetTradePile", 0x180124060),
("GetAuctionCount", 0x180123d60),
("ISRemoveTrade", 0x1801240f0),
("GetSuggestedPricing", 0x180124050),
("SaveSquad(CONTROL)", 0x1801245f0),
]
for nm, a in FACT:
print("\n########## FACTORY %s @ %#x ##########" % (nm, a))
s = dec(a)
print("len(src)=%d" % len(s))
print(s)
for nm, a in [("str->bidState", 0x180166380), ("str->tradeState", 0x180166bd0)]:
print("\n########## ENUM %s @ %#x ##########" % (nm, a))
s = dec(a)
print("len(src)=%d" % len(s))
print(s)
except Exception:
traceback.print_exc()
@@ -0,0 +1,48 @@
"""DIM4 Q1/Q2. Find the REQUEST BUILDER for each market op by xref'ing the per-op
path-suffix format literal found in .rdata next to each RS4 response-class name,
then decompile the containing function (that is where the query string is built and,
for POST/PUT ops, where the request body is serialized).
Also re-read the auctionInfo record deser 0x18013e410 and the shared IS-list body
0x18013e7f0 IN FULL (len printed) so the atom set can be confirmed, not inherited.
CONTROL for the xref method: '/relist' (0x1802289a8) must land in a function that
also references route index 0 / 'ut/%s/auctionhouse'-derived state; and the record
deser must show the 12 atoms already documented at HIGH confidence in ENDPOINT_MAP.
A control that reproduces the known 12 validates the read for the unknown ones.
"""
import traceback
try:
LITS = [
("/transfermarket?type=%s&start=%d&num=%d", 0x180228490),
("&definitionId=%d", 0x1802284c8),
("/counts", 0x180228718),
("/pricelimits", 0x1802288a8),
("/relist", 0x1802289a8),
("/status?tradeIds=%lld", 0x180228ae0),
("/sold", 0x180228bec),
("?tradeId=%lld", 0x180228d18),
("/%lld/offer", 0x180228fc0),
("/expired", 0x1802290c8),
("?tradeId=", 0x1802290d8),
("?offset=%d&count=%d", 0x180229390),
("Auction state is invalid for bidding", 0x180228f80),
]
seen = {}
for nm, a in LITS:
print("\n===== XREFS to %r %#x =====" % (nm, a))
xs = xrefs_to(a)
if not xs:
print(" (none direct) trying a-4 (RS4 rule n/a here, but try anyway)")
xs = xrefs_to(a - 4)
for frm, typ, fn, ent in xs:
print(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
if ent:
seen.setdefault(ent, set()).add(nm)
for ent, tags in sorted(seen.items()):
print("\n########## BUILDER %#x (lits: %s) ##########" % (ent, sorted(tags)))
s = dec(ent)
print("len(src)=%d" % len(s))
print(s)
except Exception:
traceback.print_exc()
@@ -0,0 +1,49 @@
"""DIM4 Q1/Q2/Q3. The per-op handler table at 0x180214db8.. has 3-qword rows
[?, url_builder, body_serializer]. Decompile the body serializers for the three ops
that POST/PUT a body (ISStart 0x180165a90, ISOfferTrade 0x180164e50,
ISWatchTrade 0x180164970), the two URL builders Ghidra left undefined
(GetAuctionCount 0x180163580 '/counts', RelistAll 0x1801641c0 '/relist'), the
no-body stub 0x18011f940, and the search-filter enum stringifiers.
CONTROL 1: 0x18011f940 must decompile to a trivial/no-op emitter -- if the "slot2 is
the body serializer" reading is right, the GET-only ops all share it and it must
write nothing. If it writes a body, the slot reading is wrong.
CONTROL 2: the auctionInfo record deser 0x18013e410 must reproduce the 12 atoms
already documented HIGH in ENDPOINT_MAP; that validates the same reading method for
the response structs whose atom sets are still MED.
"""
import traceback
try:
FN = [
("GetAuctionCount_url /counts", 0x180163580),
("RelistAll_url /relist", 0x1801641c0),
("ISStart_BODY", 0x180165a90),
("ISOfferTrade_BODY", 0x180164e50),
("ISWatchTrade_BODY", 0x180164970),
("ISStart_url(generic)", 0x180122420),
("nobody_stub(CONTROL1)", 0x18011f940),
("row A slot2 0x180162530", 0x180162530),
("searchtype->str", 0x180166340),
("pos->str", 0x1801668a0),
("zone->str", 0x180166550),
("pos2->str", 0x180166c60),
("form->str", 0x180166620),
("lev->str", 0x1801667d0),
("cat->str", 0x180166300),
]
for nm, a in FN:
print("\n########## %s @ %#x ##########" % (nm, a))
f = func(a)
print("containing func = %s @ %#x" % (f.getName() if f else "NONE",
int(f.getEntryPoint().getOffset()) if f else 0))
s = dec(a)
print("len(src)=%d" % len(s))
print(s)
print("\n\n########## CONTROL2 auctionInfo record deser 0x18013e410 ##########")
s = dec(0x18013e410)
print("len(src)=%d" % len(s)); print(s)
print("\n########## shared IS-list body 0x18013e7f0 ##########")
s = dec(0x18013e7f0)
print("len(src)=%d" % len(s)); print(s)
except Exception:
traceback.print_exc()
@@ -0,0 +1,43 @@
"""DIM4 Q1/Q2/Q3/Q4. Decompile the 12 market RESPONSE deserializers to confirm/extend
their atom sets, plus the error-envelope mapper FUN_1801844c0 (reached when the
ISOfferTrade code is NOT 0x1cd) and the two URL builders Ghidra left undefined.
CONTROL: FutISSearch 0x180163420 and FutGetTradePile 0x180170810 are documented HIGH
in ENDPOINT_MAP as tail-delegating to the shared IS-list body 0x18013e7f0. If the
decompile of those two shows the call to 0x18013e7f0, the deser VAs in ENDPOINT_MAP
are right and the same list can be trusted for the MED ones (RelistAll, ISWatchTrade,
ISRemoveTrade, ISRemoveWatch) whose bodies were only partially read.
"""
import traceback
try:
DESER = [
("FutISSearch(CONTROL)", 0x180163420),
("FutGetTradePile(CONTROL)", 0x180170810),
("FutISStart", 0x180165d70),
("FutISViewTrade", 0x1801644d0),
("FutISWatchList", 0x180166130),
("FutISWatchTrade", 0x180164cd0),
("FutISOfferTrade", 0x180165410),
("FutISRemoveTrade", 0x1801648d0),
("FutISRemoveWatch", 0x1801659f0),
("FutRelistAll", 0x180164210),
("FutGetAuctionCount", 0x180163670),
("FutGetSuggestedPricing", 0x180163bb0),
("errenvelope_generic", 0x1801844c0),
("dupItemIdList elem", 0x180138e10),
]
for nm, a in DESER:
print("\n########## %s @ %#x ##########" % (nm, a))
f = func(a)
print("fn=%s @ %#x" % (f.getName() if f else "NONE",
int(f.getEntryPoint().getOffset()) if f else 0))
s = dec(a)
print("len(src)=%d" % len(s)); print(s)
# who READS the per-action enable byte at actiontable row+0x21?
print("\n########## xrefs to action table 0x1802caa20 and row0 flag 0x1802caa41 ##########")
for a in (0x1802caa20, 0x1802caa40, 0x1802caa41):
print(" -- %#x" % a)
for frm, typ, fn, ent in xrefs_to(a):
print(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
except Exception:
traceback.print_exc()
@@ -0,0 +1,68 @@
"""DIM4. Several VAs listed as "deserializer" in ENDPOINT_MAP are in fact the
response object's CONSTRUCTOR or FACTORY (they end in `*obj = &PTR_FUN_<vtable>`).
Resolve the REAL deserializer properly: RS4 name -> factory (xref) -> the .rdata
vtable the factory installs -> slot +0x08.
CONTROL: FutISSearchServerResponse must resolve to 0x180163420 and
FutGetTradePileServerResponse to 0x180170810 -- the two VAs just PROVEN correct in
q_mk_wire_4 (both visibly tail-call the shared IS-list body 0x18013e7f0). If the
method reproduces those two it can be trusted for the four that were only MED.
"""
import traceback
try:
NAMES = ["FutISSearchServerResponse", "FutGetTradePileServerResponse",
"FutISStartServerResponse", "FutISViewTradeServerResponse",
"FutISWatchListServerResponse", "FutISWatchTradeServerResponse",
"FutISOfferTradeServerResponse", "FutISRemoveTradeServerResponse",
"FutISRemoveWatchServerResponse", "FutRelistAllServerResponse",
"FutGetAuctionCountServerResponse", "FutGetSuggestedPricingServerResponse"]
def vtables_in(ent):
out = set()
f = func(ent)
if f is None:
return out
it = refs.getReferencesFrom(f.getEntryPoint())
body = f.getBody()
ai = listing.getInstructions(body, True)
while ai.hasNext():
ins = ai.next()
for r in ins.getReferencesFrom():
t = int(r.getToAddress().getOffset())
if 0x1801e5000 <= t < 0x18028a000:
try:
s0 = qword(t); s1 = qword(t + 8)
except Exception:
continue
if 0x180001000 <= s0 < 0x1801e5000 and 0x180001000 <= s1 < 0x1801e5000:
out.add(t)
return out
resolved = {}
for cls in NAMES:
print("\n===== %s =====" % cls)
hits = find_all(b"RS4:" + cls.encode() + b"\x00")
print(" RS4 literal at %s" % [hex(h) for h in hits])
for h in hits:
for frm, typ, fn, ent in xrefs_to(h):
print(" factory ref from %#x in %s @ %#x" % (frm, fn, ent))
for vt in sorted(vtables_in(ent)):
d8 = qword(vt + 8)
f8 = fm.getFunctionAt(addr(d8))
print(" vtable %#x slot+0x00=%#x slot+0x08=%#x %s"
% (vt, qword(vt), d8, f8.getName() if f8 else "(undef)"))
resolved.setdefault(cls, set()).add(d8)
print("\n\n==================== REAL DESERIALIZERS ====================")
for cls in NAMES:
print(" %-40s %s" % (cls, [hex(x) for x in sorted(resolved.get(cls, []))]))
done = set()
for cls in NAMES:
for a in sorted(resolved.get(cls, [])):
if a in done:
continue
done.add(a)
print("\n########## DESER for %s @ %#x ##########" % (cls, a))
s = dec(a)
print("len(src)=%d" % len(s)); print(s)
except Exception:
traceback.print_exc()
@@ -0,0 +1,39 @@
"""DIM4 final. (a) FutGetSuggestedPricing deser 0x180163ee0 top-level shape -- the
loop terminates on 0xd (END_ARRAY) at the outer level, so it may be a BARE top-level
ARRAY rather than a keyed object; that is a freeze-risk decision and must be read,
not guessed. (b) The auction-limit publisher literals NUM_MAX_AUCTIONS /
NUM_CURRENT_AUCTIONS / IS_MAX_AUCTIONS / TRADE_DATA_AVAILABLE -- which model fields
back the "can I list another card" gate. (c) where the numeric error code fed to the
error mappers comes from.
CONTROL for (b): TRADE_PILE_SIZE 0x1801eafe8 is ALREADY PROVEN (previous run) to be
published by FUN_18000d550 reading model vt+0xa58 -> model+0x1fd1c. If the same
lea-then-call-slot shape shows up for the AUCTION literals, the reading is the same
form that was already validated; if it does not, I report nothing for (b).
"""
import traceback
try:
print("########## SuggestedPricing deser 0x180163ee0 (FULL) ##########")
s = dec(0x180163ee0)
print("len(src)=%d" % len(s)); print(s)
print("\n########## auction-limit publisher literals ##########")
for nm, a in [("NUM_CURRENT_AUCTIONS", 0x1801f3658), ("NUM_MAX_AUCTIONS", 0x1801f3670),
("IS_MAX_AUCTIONS", 0x1801f3688), ("TRADE_DATA_AVAILABLE", 0x180239190),
("TRADE_PILE_SIZE(CONTROL)", 0x1801eafe8), ("FUT_TOTAL_AUCTIONS", 0x18020a080)]:
print("\n -- %s @ %#x" % (nm, a))
xs = xrefs_to(a)
for frm, typ, fn, ent in xs:
print(" from %#x %s in %s @ %#x" % (frm, typ, fn, ent))
if not xs:
print(" (no xrefs)")
print("\n########## publisher FUN_180163600 / count-object accessors ##########")
for a in (0x180163600, 0x1801635a0):
print("\n---- %#x ----" % a)
s = dec(a); print("len(src)=%d" % len(s)); print(s)
print("\n########## callers of the error mappers ##########")
for a in (0x180165050, 0x1801844c0):
print("\n -- callers of %#x" % a)
for c in callers(a):
print(" %s" % (c,))
except Exception:
traceback.print_exc()
@@ -0,0 +1,19 @@
"""DIM4 close-out. FUN_1800377c0 publishes NUM_CURRENT_AUCTIONS / NUM_MAX_AUCTIONS /
IS_MAX_AUCTIONS -- read which accessors back them, i.e. whether the "auction limit"
UI gate is fed by the GetAuctionCount response (ut/%s/tradePile/counts) or by a
constructor default like the trading gate was.
CONTROL: the same function shape (lea <LITERAL>; call [reg+slot]) was already proven
for TRADE_PILE_SIZE in FUN_18000d550; that is the validated form.
Also FUN_18016c060: the generic response completion path that feeds the numeric error
code into the mapper -- establishes whether the code is the HTTP status or a body field.
"""
import traceback
try:
for nm, a in [("auction-limit publisher FUN_1800377c0", 0x1800377c0),
("TRADE_PILE_SIZE publisher (CONTROL)", 0x18000d550),
("generic completion / error source FUN_18016c060", 0x18016c060)]:
print("\n########## %s @ %#x ##########" % (nm, a))
s = dec(a)
print("len(src)=%d" % len(s)); print(s)
except Exception:
traceback.print_exc()
@@ -0,0 +1,55 @@
"""Verify the pileSizeClientData key enum before changing boot-critical massinfo.
Contradiction to resolve:
- utas_server.py comment: pileSizeClientData is "the MY CLUB counter", enum
"not recoverable", so it sprays the club count across keys 0..15.
- workflow wf_29791945: parser FUN_18013adb0 has EXACTLY two arms, key 2 -> trade
pile (response+0xd0 -> model+0x1fd1c = TRADE_PILE_SIZE, the red 0/0) and key 4 ->
watch list (+0xd4 -> +0x1fd20). No club key. Spraying the club count (246) onto
key 2 would set the TRANSFER-LIST CAPACITY to 246, which is wrong.
Only one can be right. Read the parser. If it has cmp esi,2 / cmp esi,4 and no
default store, the workflow is right and the current code is a latent bug.
CONTROL: the parser must be reached from the massinfo root via arm 0x227
(pileSizeClientData atom). Confirm the atom and the call.
"""
import re, traceback
PARSER = 0x18013ADB0
try:
src = dec(PARSER)
f = func(PARSER)
print("%#x pileSizeClientData parser body %d / decompile %d chars (IN FULL)"
% (PARSER, f.getBody().getNumAddresses() if f else -1, len(src)))
print("=" * 78)
print(src)
print("\n=== raw instructions: every cmp against a small immediate + the stores ===")
a = f.getBody().getMinAddress()
end = f.getBody().getMaxAddress()
while a is not None and a.compareTo(end) <= 0:
i = listing.getInstructionAt(a)
if i is None:
a = a.add(1); continue
m = i.getMnemonicString().lower()
t = str(i)
if (m == "cmp" and re.search(r",0x[0-9a-f]$|,0x[0-9a-f]\b", t)) or \
(m == "mov" and "0xd0" in t) or (m == "mov" and "0xd4" in t) or \
(m == "mov" and "+ 0x8]" in t) or (m == "mov" and "+ 0xc]" in t):
print(" %#x %s" % (int(i.getAddress().getOffset()), t))
a = i.getAddress().add(i.getLength())
print("\n=== who calls the parser, and the pileSizeClientData atom (0x227) ===")
for adr, n in callers(PARSER):
print(" caller %#x %s" % (adr, n))
aid = None
for line in open("/home/alex/Documents/OpenFUT/fifa17-recon/docs/fut_atoms.tsv"):
p = line.rstrip("\n").split("\t")
if len(p) >= 3 and p[2] == "pileSizeClientData":
aid = p[1]
print(" pileSizeClientData atom id (from tsv):", aid)
except Exception:
traceback.print_exc()
@@ -0,0 +1,49 @@
"""Confirm the marketdata/pricelimits response shape before serving it (it just froze).
We returned {"minPrice":150,"maxPrice":15000} (an OBJECT) and the client froze at the
price screen -> classic container-type desync (0x1801c7f1a). The doc says the response
is a BARE TOP-LEVEL ARRAY of {defId,minPrice,maxPrice}, deser 0x180163ee0
(GETSUGGESTEDPRICING). Verify the element fields and types so the fix does not re-freeze.
CONTROL: minPrice=0x1ca, maxPrice=0x1c2 must appear; find the defId/id atom the element
keys identity on, and confirm each is read with the INT primitive 0x1801c79d0 (scalar),
so our int values are type-correct.
"""
import re, traceback
DESER = 0x180163EE0
try:
src = dec(DESER)
f = func(DESER)
print("%#x GetSuggestedPricing deser body %d / decompile %d chars (IN FULL)"
% (DESER, f.getBody().getNumAddresses() if f else -1, len(src)))
print("=" * 78)
print(src)
print("\n=== atoms this deser (and its element callee) dispatch on ===")
atoms = {}
for line in open("/home/alex/Documents/OpenFUT/fifa17-recon/docs/fut_atoms.tsv"):
p = line.rstrip("\n").split("\t")
if len(p) >= 3:
try: atoms[int(p[1], 16)] = p[2]
except ValueError: pass
scan = [DESER] + [a for a, _ in callees(DESER)]
for ent in scan:
try: d = dec(ent)
except Exception: continue
found = set()
for m in re.finditer(r"(case |== |!= )(0x[0-9a-f]+)\b", d):
found.add(int(m.group(2), 16))
rel = [(a, atoms.get(a, "?")) for a in sorted(found)
if a in (0x1ca, 0x1c2, 0x2e6, 0x65, 0x24d) or (atoms.get(a, "").lower() in
("defid", "id", "minprice", "maxprice", "startingbid", "buynowprice"))]
if rel:
print(" %#x %-20s -> %s" % (ent, fname(ent),
", ".join("%s(%#x)" % (n, a) for a, n in rel)))
# is it an array reader? look for the 0xd (END_ARRAY) sentinel loop
if "!= 0xd" in d or "== 0xd" in d:
print(" (has an END_ARRAY 0xd loop -> reads an ARRAY, consistent with bare-array root)")
except Exception:
traceback.print_exc()
+76 -33
View File
@@ -147,6 +147,28 @@ def squad_list_body(squad=None):
# below) and it is still NOT enough to make this bool safe -- see FUT_CLUB_RENAME.
_UI = os.environ.get("FUT_USERINFO", "roster")
# FUT_TRADING: stop banning our own trading.
#
# userInfo.feature (atom 0x11c) is a RESTRICTION map, not a grant. Sending
# feature={"trade": true} marks TRADE RESTRICTED. Verified at the instruction level
# 2026-08-06 (q_feature_trade.py): FUN_18013ec10 parses feature/trade into
# userInfo+0x17c, and at the massinfo top-level END_OBJECT the client runs
# 0x180174f10 cmp byte [rsi+0x17c], 0
# 0x180174f17 jz 0x180174f20 ; not restricted -> skip
# 0x180174f19 mov dword [rsi+0x50], 0 ; restricted -> zero the trade field
# which feeds applier 0x18011dc91 -> IS_TRADING_ENABLED (model+0x1fd2e) = 0. It runs
# LAST and unconditionally, which is why the gate byte read 0 all day no matter what
# /settings or the Blaze client-config store sent. We were disabling trading ourselves.
#
# With the flag on we send feature={} (no trade key -> +0x17c stays 0 -> the jz skips
# the zeroing -> the gate keeps its constructor default of 1). Empty object is
# type-safe: feature is an OBJECT and {} parses with no members.
#
# Default OFF for one relaunch only: this is on the critical path into FUT and has
# never been in front of the game. Verify by reading model+0x1fd2e (should become 1)
# and by checking the per-card "Place on Transfer List" entry is no longer greyed.
TRADING = os.environ.get("FUT_TRADING", "0") == "1"
# ---- FUT_CLUB_RENAME: the in-game rename experiment (DEFAULT OFF) -----------
# clubNameChangeAllowed(0x8f) -> bool at userInfo+0x62.
#
@@ -252,7 +274,9 @@ def user_info():
"clubNameChangeAllowed": _CLUB_RENAME,
"divisionOffline": 10, "divisionOnline": 10,
"purchased": False, # 0x262 -> bool at +0x68
"feature": {"trade": True},
# {"trade": true} = trade RESTRICTED (see FUT_TRADING note up top). {} lifts
# the restriction. Default keeps the historical value until one live test.
"feature": ({} if TRADING else {"trade": True}),
"reliability": {"reliability": 100, "matchUnfinishedTime": 0},
"bidTokens": {"count": 0, "updateTime": 0},
"trophies": 0, "sessionCoinsBankBalance": 0,
@@ -605,43 +629,62 @@ SETTINGS = _settings_body()
_MI = os.environ.get("FUT_MASSINFO", "full")
# ---- pileSizeClientData: the MY CLUB counter --------------------------------
# LIVE EVIDENCE (2026-08-04): the user opened MY CLUB, the client fetched GET /club
# and DISPLAYED all 99 players -- and the MY CLUB counter still read 0. So that
# counter is NOT derived from the item list; it is a PILE SIZE, delivered
# separately. massinfo's pileSizeClientData(0x227) is that member and we have never
# sent it. Parser 0x18013adb0: {"entries":[{"key":<int>,"value":<int>}]} -- key and
# value BOTH read with the int getter 0x1801c79d0, and the parser IS skip-safe.
# ---- pileSizeClientData: the TRANSFER LIST + WATCH LIST CAPACITIES -----------
# CORRECTED 2026-08-06 (q_pilesize_keys.py). The old "MY CLUB counter" theory here
# was WRONG. Parser FUN_18013adb0 has EXACTLY two storing arms and no default:
# key(0x177)==2 -> value -> param_2+0x8 -> model+0x1fd1c = TRADE_PILE_SIZE
# key(0x177)==4 -> value -> param_2+0xc -> model+0x1fd20 = watch-list size
# every other key hits the SKIP handler. So this member is the transfer-list and
# watch-list CAPACITIES, not counts and not the club. The real MY CLUB counter is
# the /hub clubPlayers field (model+0x1fd70+0x3c), which we already serve.
#
# The pile-id enum is not recoverable from the strings (the "club"/"tradepile"
# literals are just atom names in the alphabetical key table). So rather than guess:
# This is THE fix for the red "TRANSFER LIST 0/0" and the "TRANSFER LIST FULL"
# refusal on Place on Transfer List: with this member absent, model+0x1fd1c stays at
# its constructor default of 0, so the list has zero capacity and nothing can be
# listed even though trading is now enabled. Confirmed live: byte read 0, client
# said FULL.
#
# FUT_PILESIZES=probe -> emit one entry per candidate key 0..15 with a UNIQUE
# recognisable value (100+key). Whatever number MY CLUB then displays names the
# club pile's key: 103 means key 3. One launch identifies the enum.
# FUT_PILESIZES=1 -> emit the REAL counts once PILE_KEY_CLUB below is known.
# key and value both pass through FUN_1800d7b30 (test rcx,rcx / jle -> 0), so values
# must be POSITIVE; -1 does not mean unlimited. 100/50 are the stock FIFA 17
# convention (nothing in the binary carries a default; the ctor zeroes both).
#
# Default OFF: this adds a member to boot-critical massinfo. It is a documented
# member of that parser and carries only ints, so the risk is low -- but "low" is
# what I said about displayGroup before it froze the store, so it ships behind a flag.
_PILESIZES = os.environ.get("FUT_PILESIZES", "")
PILE_KEY_CLUB = int(os.environ.get("FUT_PILE_KEY_CLUB", "-1")) # set once probed
# Freeze risk: LOW. Documented int-only member of the boot-critical massinfo parser,
# skip-safe on unrecognised fields. Instant fallback: FUT_MASSINFO=squad.
# Default OFF for one live test; this adds a member to boot-critical massinfo.
_PILESIZES = os.environ.get("FUT_PILESIZES", "0") == "1"
PILE_KEY_TRADEPILE = 2
PILE_KEY_WATCHLIST = 4
def marketdata_route(h):
"""GET marketdata/pricelimits?defId=a,b,c -- FutGetSuggestedPricing (deser
0x180163ee0). The response is a BARE TOP-LEVEL ARRAY, one element per requested
defId, each {defId, minPrice, maxPrice}, all scalar ints.
FROZE THE CLIENT 2026-08-06: we returned an OBJECT {"minPrice","maxPrice"} where
the deser's root loop reads an ARRAY (while tok != 0xd). Object-where-array is the
type-desync busy loop at 0x1801c7f1a. Confirmed live: listing a card at the price
screen pinned a core. Element atoms verified: defId 0xcf, maxPrice 0x1c2, minPrice
0x1ca, all read via the INT getter 0x1801c79d0, so int values are type-correct.
The container was the whole bug.
defId can be a comma-separated list. Echo each so the client can match the band to
the item it asked about. Bands are a placeholder (150..15000); real per-item
pricing is a later refinement, not a freeze concern.
"""
from urllib.parse import urlparse, parse_qs
q = parse_qs(urlparse(h.path).query)
raw = q.get("defId", [""])[0]
ids = [int(x) for x in raw.split(",") if x.strip().isdigit()]
return 200, [{"defId": d, "minPrice": 150, "maxPrice": 15000} for d in ids]
def pile_size_body():
"""massinfo.pileSizeClientData -- see the note above."""
if _PILESIZES == "probe":
return {"entries": [{"key": k, "value": 100 + k} for k in range(16)]}
counts = {
"club": len(STORE.items()),
"purchased": len(STORE.purchased()),
"tradepile": len(STORE.listings()),
}
if PILE_KEY_CLUB >= 0:
return {"entries": [{"key": PILE_KEY_CLUB, "value": counts["club"]}]}
# No verified key yet -> announce the club count on every candidate key. Crude,
# but every value is truthful, so no pile can be told a wrong number.
return {"entries": [{"key": k, "value": counts["club"]} for k in range(16)]}
"""massinfo.pileSizeClientData -- transfer-list and watch-list CAPACITIES."""
return {"entries": [
{"key": PILE_KEY_TRADEPILE, "value": 100},
{"key": PILE_KEY_WATCHLIST, "value": 50},
]}
def massinfo():
@@ -1111,7 +1154,7 @@ ROUTES = [
# LIVE GROUND TRUTH: FIFA's market SEARCH hits /transfermarket (one word), not
# /auctionhouse (was UNMAPPED -> {} => empty market). Serve the same listings.
(re.compile(G + r"/transfermarket"), lambda m, h: auctionhouse_route(h)),
(re.compile(G + r"/marketdata"), lambda m, h: (200, {"minPrice": 150, "maxPrice": 15000})),
(re.compile(G + r"/marketdata"), lambda m, h: marketdata_route(h)),
# QUICK SELL. Live-observed 2026-08-04: the reveal screen's "Quick Sell All"
# sends POST ut/delete/%s/item -- it was UNMAPPED (catch-all {}), which the
# client ACCEPTS (no error, session survives) but which paid 0 coins: the user