fifa17-recon: match rewards, POW online layer, account backend, quick sell
Second session. FUT core loop, the EASFC/POW online layer, a central account
backend, and a lot of corrections. Everything risky is behind an env flag with
the default set to whatever was live-proven.
WORKING END TO END (live-verified this session):
* match loop -- POST/PUT/POST/DELETE ut/%s/match, rewards via FutDestroyMatch
(0x180121b60). Play a match, get coins, W/D/L updates.
* packs -- buy, cards land in the club, session survives (FUT_PACK_AUTOCLUB=1)
* quick sell -- POST ut/delete/%s/item was UNMAPPED and paid NOTHING; six cards
were destroyed for 0 coins. Now credits discardValue.
* POW/EASFC online -- the "EA FC servers unreachable" banner is powdll's layer,
a THIRD http api on :8094 nobody had served. Redirect needs no root: powdll
FUN_18005a460 reads FIFA_POW_URL from the same client-config store as
ROSTERUPDATE_URL. FUT_POW=1.
* account backend -- fut_account.py replaces 7 hardcoded copies of the persona
across 5 files; club/persona/online-profile editable via CLI.
CORRECTIONS TO ENDPOINT_MAP (all re-extracted from the deserializers):
* FutStoreGetPackTypes: id/packType/isPremium/quantity/saleType/purchaseLimit/
purchaseCount are NOT skipped no-ops -- all are parsed. extPrice inner objects
take externalPriceId(0x11a), not amount/currency.
* FutMoveCard 0x180128600 has NO skip handler (FUN_180135ff0 appears zero times,
unique among FUT deserializers) and parses only itemData -> dreamSquads.
* class -> deserializer resolution: the name literal is preceded by a 4-BYTE
HEADER and the factory LEA points at the header, so look up name_addr - 4.
Six attempts failed on this; now ghidra_env.class_deser(). Unlocked 11 SBC/
Draft schemas.
* live-only endpoints the request table never lists: ut/%s/squad/list,
ut/%s/user/club, ut/%s/club/stats/*, ut/%s/clientdata/<key>. The template
table is a floor, not a ceiling -- the log is the only ground truth.
* 163 RS4 call names exist; we served 17. All now served.
FIXED: club/stats/* was answering with the entire 28-item club inventory on every
poll (it fell through to the generic /club route).
UNSOLVED: the pack reveal's "Send to Club" (PUT ut/%s/item) kills the FUT session
whatever we answer -- {} included -- while its sibling quick-sell endpoint accepts
a bare {}. Seven hypotheses eliminated by live test, documented in
REBUILD_RESEARCH.md S14c so none get re-walked. FUT_PACK_AUTOCLUB routes around it.
Also unfixed: store tiles render "unknown" (displayGroup is parsed RECURSIVELY by
the same element parser; sending it FROZE the store, so FUT_STORE_GROUPS=1 is
default off).
Tests: test_fut_contract.py 380 (live, read-only) + test_match_rewards.py 51 (pure).
Note: fut_store.py carries some pre-existing uncommitted changes from before this
session (pack catalogue ids, pending-pile behaviour) that could not be separated
from this session's additions in the same file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
This commit is contained in:
@@ -33,13 +33,19 @@ pyghidra.start(verbose=False)
|
||||
from ghidra.app.decompiler import DecompInterface # noqa: E402
|
||||
from ghidra.util.task import ConsoleTaskMonitor # noqa: E402
|
||||
|
||||
DLL = "/tmp/fut/cardsdll.dll"
|
||||
PROJ_DIR, PROJ = "/tmp/ghidra_fut", "cardsdll"
|
||||
# Defaults target CardsDLL; override for another binary, e.g. powdll (the EASFC/POW
|
||||
# layer, which is UNPACKED unlike FIFA17.exe):
|
||||
# GHIDRA_DLL=/tmp/pow/powdll_Win64_retail.dll GHIDRA_PROJ_DIR=/tmp/pow \
|
||||
# GHIDRA_PROJ=powproj ghidra_env.py <query.py>
|
||||
DLL = os.environ.get("GHIDRA_DLL", "/tmp/fut/cardsdll.dll")
|
||||
PROJ_DIR = os.environ.get("GHIDRA_PROJ_DIR", "/tmp/ghidra_fut")
|
||||
PROJ = os.environ.get("GHIDRA_PROJ", "cardsdll")
|
||||
PROG = os.environ.get("GHIDRA_PROG", os.path.basename(DLL))
|
||||
|
||||
# nested_project_location=False -> use /tmp/ghidra_fut/cardsdll.gpr itself (the
|
||||
# already-analysed project) instead of creating /tmp/ghidra_fut/cardsdll/.
|
||||
_ctx = pyghidra.open_program(DLL, project_location=PROJ_DIR, project_name=PROJ,
|
||||
analyze=False, program_name="cardsdll.dll",
|
||||
analyze=False, program_name=PROG,
|
||||
nested_project_location=False)
|
||||
flat = _ctx.__enter__()
|
||||
prog = flat.getCurrentProgram()
|
||||
@@ -156,6 +162,51 @@ def vtable(a, n=64):
|
||||
return out
|
||||
|
||||
|
||||
def class_deser(cls):
|
||||
"""FutXServerResponse class name -> [(deserializer, vtable, factory), ...].
|
||||
|
||||
THE -4 RULE. A response class's name literal is preceded by a 4-BYTE HEADER,
|
||||
and the factory's `lea r8,[rip+...]` points at THAT header, not at the text.
|
||||
So the reference to look up is `name_addr - 4`. Six attempts at class->deser
|
||||
resolution failed before this was noticed -- four of them returned zero
|
||||
candidates and were nearly written up as "the class has no deserializer".
|
||||
Ghidra does create the reference, so no manual instruction decoding is needed.
|
||||
|
||||
From the factory, the object's vtable is the .rdata address it references whose
|
||||
first two qwords are functions; the deserializer is vtable slot +0x08.
|
||||
|
||||
Verified against known-good controls: FutSquadSave -> 0x180171a60,
|
||||
FutSquadList -> 0x180172140, FutCreateMatch -> 0x180120380 (3/3 correct when it
|
||||
resolves). It DOES produce false negatives -- FutDestroyMatch and
|
||||
FutSeasonLoadData return nothing despite having known deserializers -- so treat
|
||||
an empty result as "unknown", never as "no deserializer exists". Always include
|
||||
a control with a known answer in any batch.
|
||||
"""
|
||||
res = []
|
||||
for a in find_all(cls.encode() + b"\x00"):
|
||||
for frm, typ, fn, ent in xrefs_to(a - 4):
|
||||
if not ent:
|
||||
continue
|
||||
f = func(ent)
|
||||
if f is None:
|
||||
continue
|
||||
for ad in f.getBody().getAddresses(True):
|
||||
ins = listing.getInstructionAt(ad)
|
||||
if ins is None:
|
||||
continue
|
||||
for r in ins.getReferencesFrom():
|
||||
t = int(r.getToAddress().getOffset())
|
||||
if not (0x1801E5000 <= t <= 0x1802891FF):
|
||||
continue
|
||||
try:
|
||||
v0, v1 = qword(t), qword(t + 8)
|
||||
except Exception:
|
||||
continue
|
||||
if (fm.getFunctionAt(addr(v0)) and fm.getFunctionAt(addr(v1))):
|
||||
res.append((v1, t, ent))
|
||||
return res
|
||||
|
||||
|
||||
def fname(a):
|
||||
f = func(a)
|
||||
return f.getName() if f else "?"
|
||||
|
||||
Reference in New Issue
Block a user