5d5198f5d1
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
119 lines
4.8 KiB
Python
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())
|