Files
OpenFUT/fifa17-recon/tools/fut_admin.py
T
funman300 5d5198f5d1 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
2026-08-04 09:42:59 -07:00

119 lines
4.8 KiB
Python

#!/usr/bin/env python3
"""OpenFUT save maintenance — inspect and repair fifa17_profile.json offline.
Exists because the pack→club hand-off is not proven. Cards from an opened pack land
in the PENDING pile (`profile["purchased"]`) and only reach the club when the client
sends `PUT ut/%s/item` (FutMoveCard) from the reveal screen's "send to club". Across
every logged session that request has fired **zero** times, while 12 cards sit
pending — so either the flow was never exercised in-game, or the client does not
issue it the way we assume. Same shape as the squad blocker: an assumed client
request that never actually arrives.
Until a live pack-open settles it, this is the manual path.
SAFETY: the client desyncs fatally (logout) if a card exists in BOTH the pending pile
and the club — see docs/CARD_SYSTEM.md and Store.move_items. `--flush-purchased`
therefore MOVES (never copies): each card is removed from `purchased` in the same
transaction that appends it to `items`. Run it with FIFA CLOSED so the client cannot
be holding a stale view of either pile.
Usage:
fut_admin.py --show profile summary (default)
fut_admin.py --flush-purchased move every pending card into the club
fut_admin.py --flush-purchased -n dry run: show what would move
fut_admin.py --backup timestamped copy of the profile
"""
import argparse
import datetime
import json
import os
import shutil
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from fut_store import STORE # noqa: E402
def _fmt(it):
return "asset=%-7s rating=%-3s pos=%-4s id=%s" % (
it.get("assetId"), it.get("rating"), it.get("preferredPosition"), it.get("id"))
def show():
p = STORE.profile()
rec = p.get("record", {})
print("profile : %s" % STORE.path)
print("club : %s (%s) est %s" % (p.get("clubName"), p.get("clubAbbr"),
p.get("established")))
print("coins : %s points: %s" % (p.get("coins"), p.get("points")))
print("record : %s-%s-%s matches: %s"
% (rec.get("won", 0), rec.get("draw", 0), rec.get("loss", 0),
p.get("matchesPlayed", 0)))
print("club items : %d" % len(p.get("items", [])))
print("squads saved : %d" % len(p.get("squads", [])))
print("packs opened : %s" % p.get("packsOpened", 0))
print("listings : %d" % len(p.get("listings", [])))
print("clientdata : %s" % (sorted(p.get("clientdata", {})) or "none"))
pend = p.get("purchased", [])
print("PENDING pack items: %d%s"
% (len(pend), " <-- not in the club; see --flush-purchased" if pend else ""))
for it in pend[:20]:
print(" %s" % _fmt(it))
if len(pend) > 20:
print(" ... and %d more" % (len(pend) - 20))
def backup():
dst = "%s.%s.bak" % (STORE.path,
datetime.datetime.now().strftime("%Y%m%d-%H%M%S"))
shutil.copy2(STORE.path, dst)
print("backup -> %s" % dst)
return dst
def flush(dry_run):
pend = list(STORE.profile().get("purchased", []))
if not pend:
print("nothing pending — the club already has every pack card")
return 0
print("%d pending card(s)%s:" % (len(pend), " (DRY RUN)" if dry_run else ""))
for it in pend:
print(" %s" % _fmt(it))
if dry_run:
print("\ndry run — nothing written. Re-run without -n to move them.")
return 0
backup()
# Reuse the server's own move path so the pending/club invariant is enforced in
# exactly one place: move_items() deletes from `purchased` in the same locked
# transaction that appends to `items`.
moved = STORE.move_items([{"id": it["id"], "pile": "club"} for it in pend])
p = STORE.profile()
print("\nmoved %d card(s) into the club" % len(moved))
print("club items now: %d pending now: %d"
% (len(p.get("items", [])), len(p.get("purchased", []))))
if p.get("purchased"):
print("WARNING: %d card(s) did not move — ids missing from the pending pile"
% len(p["purchased"]))
return 0
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--show", action="store_true", help="profile summary (default)")
ap.add_argument("--flush-purchased", action="store_true",
help="move pending pack cards into the club (run with FIFA closed)")
ap.add_argument("-n", "--dry-run", action="store_true", help="with --flush-purchased")
ap.add_argument("--backup", action="store_true", help="timestamped profile copy")
a = ap.parse_args(argv)
if a.backup:
backup()
if a.flush_purchased:
return flush(a.dry_run)
show()
return 0
if __name__ == "__main__":
sys.exit(main())