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:
funman300
2026-08-04 09:42:59 -07:00
parent 59934b4ef0
commit 5d5198f5d1
20 changed files with 3217 additions and 144 deletions
+54 -3
View File
@@ -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 "?"