Files
OpenFUT/fifa17-recon/tools/fut_store.py
T
funman300 ffb0e6033c fifa17-recon: real card pool -- 79 players, three tiers, and packs that differ
The pool was 18 players rated 85 to 94. open_pack() split it with `(rating >= 75) ==
gold`, so the bronze pack's filter matched NOTHING and fell back to the whole pool:
all three packs dealt gold rares and the bronze pack was a lie. The file's own TODO
asked for "a full dbdata.dll extract (~18k players)".

NEW fut_cards.py: 79 players, 39 gold / 20 silver / 20 bronze, 7 leagues, 20 nations,
18 teams, every outfield position plus GK, no duplicate asset ids. PACK_CATALOG now
carries a weighted `tiers` draw per pack. Simulated 40 opens of each:

  Bronze Pack   {'bronze': 159, 'silver': 41}    39 distinct cards, 12 positions
  Gold Pack     {'silver': 110, 'gold': 170}     59 distinct cards, 12 positions
  Premium Gold  {'gold': 392,  'silver': 48}     57 distinct cards, 12 positions

WHY THIS IS NOT THE dbdata EXTRACT, and why that does not matter yet. dbdata.dll is a
real PE with one export, getTableData, whose 2.5MB payload sits in a section named
.xdata that disassembles as obfuscated code rather than a table directory, so the base
DB is not statically extractable without running that export under Wine or defeating
the obfuscation.

More to the point it would change nothing on screen today. Per docs/CARD_SYSTEM.md the
card view-model 0x1800d7920 reads EVERY rendered field (rating +0xb4, position +0x146,
nation +0x148, teamid +0x94, six attrs +0x98..0xac, name +0xdd) from a definition
record resolved at item+0x10 out of the client's own CardsDb map, and NEVER from our
item JSON. Offline that map is EMPTY, so every lookup misses and a blank record is
emitted. No assetId we send, real or invented, can produce a named card until that map
is populated. That is a separate job (CARD_SYSTEM options A/B/C) about the CLIENT's
map, not about our pool.

What the pool DOES control is everything the server is source of truth for: the
gold/silver/bronze split, leagueId/nation/teamid which are exactly what the club-stats
drill-downs read (those now work, S20, and were being fed 5 leagues from 13 nations),
preferredPosition which decides whether a squad can be filled at all, and the six
attributes behind the market filters.

Asset ids: 18 are genuine FIFA 17 ids and are listed in VERIFIED_ASSET_IDS. The rest
are structural, and the docstring says so plainly rather than passing them off as real
players. Because the CardsDb map is empty offline an id being wrong has no visible
effect today; if the identity work lands, that set is the diff target.

Backwards compatible: open_pack() still honours the legacy `gold` boolean when no
tiers are given, and the old list survives as _LEGACY_POOL for the starter squad.

392 + 61 checks green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 15:23:23 -07:00

409 lines
16 KiB
Python

"""Persistent FUT profile store for the FIFA 17 offline backend (OpenFUT).
A single JSON file holds the user's save: coins/points, owned club items, squads,
and record. Loaded once, saved on every mutation. This is the "user + saved files"
layer; packs and the starter grant build on it. (Later this gets ported into
openfut-core's SQLite backend behind a FIFA-17 bridge; JSON keeps iteration fast
while we nail the wire format.)
Item shape mirrors what /club and /squad already serve (fut_seed.player_item):
resourceId/assetId + attrs; identity (name/photo/club/nation) resolves locally in
FIFA from dbdata.dll on the club-search/add path (see docs/CARD_SYSTEM.md).
"""
import json, os, sys, threading
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import fut_cards
from fut_account import ACCOUNT # single source of truth for identity/club
PROFILE_PATH = os.environ.get("FUT_PROFILE", os.path.join(HERE, "fifa17_profile.json"))
# Back-compat snapshots. Identity now lives in fut_account.ACCOUNT so Blaze, LSX
# and UTAS cannot drift apart; prefer ACCOUNT.<field> in new code. These are
# import-time snapshots and will NOT reflect a later adopt_from_auth().
PERSONA_ID = ACCOUNT.persona_id
PERSONA_NAME = ACCOUNT.persona_name
_LOCK = threading.Lock()
# Starter squad granted on first run (real FIFA17 assetIds; identity resolves
# locally in-game). (assetId, rating, position, nation, leagueId, teamid, [6 attrs])
STARTER_PLAYERS = [
(20801, 94, "LW", 38, 53, 243, [90, 93, 82, 91, 33, 80]), # Ronaldo
(158023, 93, "RW", 52, 53, 241, [89, 90, 86, 96, 26, 61]), # Messi
(200389, 87, "GK", 44, 53, 240, [86, 88, 47, 88, 25, 86]), # Oblak
(183907, 90, "CB", 21, 19, 21, [80, 58, 70, 71, 89, 85]), # Boateng
(155862, 89, "CB", 45, 53, 243, [77, 62, 72, 74, 87, 84]), # Ramos
(197445, 87, "LB", 40, 19, 21, [86, 68, 79, 82, 82, 79]), # Alaba
(189332, 86, "LB", 45, 53, 241, [92, 66, 78, 84, 80, 71]), # Alba
(182521, 88, "CM", 21, 19, 21, [61, 79, 89, 82, 72, 74]), # Kroos
(183277, 88, "LM", 7, 13, 5, [89, 80, 82, 92, 37, 62]), # Hazard
(176580, 92, "ST", 60, 53, 241, [83, 90, 79, 87, 42, 80]), # Suarez
]
ITEM_ID_BASE = 100000000
def _item(item_id, asset, rating, pos, nation, league, team, attrs, version=0x00):
return {
"id": item_id,
"resourceId": (version << 24) | asset,
"assetId": asset,
"cardassetid": asset,
"definitionId": (version << 24) | asset,
"cardsubtypeid": 0,
"itemType": "player",
"rareflag": 1,
"rating": rating,
"preferredPosition": pos,
"nation": nation,
"teamid": team,
"leagueId": league,
"playStyle": 250,
"attributeList": [{"index": i, "value": v} for i, v in enumerate(attrs)],
"itemState": "free",
"owners": 1,
"untradeable": True,
"contract": 7,
"fitness": 99,
}
def _new_profile():
"""First-run grant: opening coins + the starter squad as owned items."""
items = [_item(ITEM_ID_BASE + i + 1, a, r, p, n, lg, tm, at)
for i, (a, r, p, n, lg, tm, at) in enumerate(STARTER_PLAYERS)]
return {
"version": 1,
# personaId/personaName/clubName/clubAbbr/established are NOT seeded
# here any more -- they belong to fut_account.ACCOUNT. _sync_identity()
# mirrors them into the save on every load so existing readers
# (utas_server's userInfo, tradepile sellerName) keep working unchanged
# and can never disagree with what Blaze/LSX assert.
"coins": 15000,
"points": 0,
"record": {"won": 0, "draw": 0, "loss": 0},
"nextItemId": ITEM_ID_BASE + len(STARTER_PLAYERS) + 1,
"items": items, # owned club items
"purchased": [], # unassigned/pending items from opened packs
"squads": [], # saved squads (raw squad objects from PUT /squad)
"packsOpened": 0,
}
class Store:
def __init__(self, path=PROFILE_PATH):
self.path = path
self._p = None
def load(self):
if self._p is not None:
return self._p
if os.path.exists(self.path):
with open(self.path) as f:
self._p = json.load(f)
else:
self._p = _new_profile()
self._sync_identity()
self._save()
self._sync_identity()
return self._p
def _sync_identity(self):
"""Mirror ACCOUNT's identity/club into the in-memory save.
The save file used to OWN these five keys; they now live in
fut_account.json (which is where ACCOUNT migrated them from on first
run, so this is a no-op for an existing profile). Mirroring rather than
deleting keeps every current reader working without an edit, and makes
drift between the save and the wire impossible by construction."""
p = self._p
p["personaId"] = ACCOUNT.persona_id
p["personaName"] = ACCOUNT.persona_name
p["clubName"] = ACCOUNT.club_name
p["clubAbbr"] = ACCOUNT.club_abbr
p["established"] = ACCOUNT.established
return p
def _save(self):
tmp = self.path + ".tmp"
with open(tmp, "w") as f:
json.dump(self._p, f, indent=1)
os.replace(tmp, self.path)
# ---- accessors used by utas_server -------------------------------------
def profile(self):
return self.load()
def refresh_identity(self):
"""Re-mirror ACCOUNT into the save AND persist it.
Call this after anything mutates ACCOUNT at runtime (utas_server's club
rename, or the /ut/auth persona adoption) so the save cannot lag a session
behind the wire. Identity itself is owned by fut_account.json -- this only
keeps the save's copy honest."""
with _LOCK:
self.load()
self._sync_identity()
self._save()
return self._p
def profile_identity(self):
"""Identity/club as served to the client. Sourced from ACCOUNT, never
from the save -- use this instead of profile().get("clubName")."""
return {
"personaId": ACCOUNT.persona_id,
"personaName": ACCOUNT.persona_name,
"clubName": ACCOUNT.club_name,
"clubAbbr": ACCOUNT.club_abbr,
"established": ACCOUNT.established,
}
def coins(self):
return self.load()["coins"]
def items(self):
return self.load()["items"]
def add_items(self, new_items):
with _LOCK:
p = self.load()
for it in new_items:
it.setdefault("id", p["nextItemId"]); p["nextItemId"] += 1
p["items"].append(it)
self._save()
return new_items
def spend(self, amount):
with _LOCK:
p = self.load()
if p["coins"] < amount:
return False
p["coins"] -= amount
self._save()
return True
def grant_coins(self, amount):
with _LOCK:
self.load()["coins"] += amount
self._save()
def quick_sell(self, ids):
"""Remove cards (from either pile) and credit their discard value.
-> (count_sold, coins_credited). discardValue is 0 on our seeded cards, so
fall back to a rating-based figure rather than paying nothing."""
def value(it):
dv = it.get("discardValue") or 0
if dv:
return int(dv)
r = it.get("rating") or 0
return 600 if r >= 85 else 300 if r >= 80 else 150 if r >= 75 else 50
with _LOCK:
p = self.load()
want = {i for i in ids if i is not None}
total = 0
sold = 0
for pile in ("purchased", "items"):
keep = []
for it in p.get(pile, []):
if it.get("id") in want:
total += value(it)
sold += 1
else:
keep.append(it)
p[pile] = keep
if sold:
p["coins"] = p.get("coins", 0) + total
self._save()
return sold, total
def set_clientdata(self, key, value):
"""Persist an opaque client blob (ut/%s/clientdata/<key>). We never
interpret it -- the client wrote it, the client reads it back."""
with _LOCK:
p = self.load()
p.setdefault("clientdata", {})[key] = value
self._save()
def get_clientdata(self, key):
return self.load().get("clientdata", {}).get(key, {})
def record_match(self, result, coins):
"""Commit a finished match: bump the W/D/L record and credit coins.
`result` is "won" | "draw" | "loss". Returns the new (record, coins) so the
caller can build FutDestroyMatchServerResponse without a second read --
allCoins must be the balance AFTER crediting, and reading it separately
would race another mutation."""
with _LOCK:
p = self.load()
rec = p.setdefault("record", {"won": 0, "draw": 0, "loss": 0})
if result in rec:
rec[result] += 1
p["coins"] = p.get("coins", 0) + max(0, int(coins))
p.setdefault("matchesPlayed", 0)
p["matchesPlayed"] += 1
self._save()
return dict(rec), p["coins"]
def save_squad(self, squad):
with _LOCK:
p = self.load()
sid = squad.get("id", 0)
p["squads"] = [s for s in p["squads"] if s.get("id") != sid] + [squad]
self._save()
def move_items(self, requests):
"""FutMoveCard: transfer item(s) from the pending/purchased pile into their
target pile (FIFO's model), persist, return the moved cards. A purchased
card must NOT exist in both the purchased pile and the club, or the client
desyncs -> fatal logout. Cards live in profile["purchased"] until moved."""
with _LOCK:
p = self.load()
pending = p.setdefault("purchased", [])
by_id = {it["id"]: it for it in pending}
moved = []
for r in requests:
it = by_id.get(r.get("id"))
if it is None:
continue
pile = r.get("pile", it.get("pile", "club"))
it["pile"] = pile
if pile == "club":
it["itemState"] = "free"
p.setdefault("items", []).append(it)
moved.append(it)
if moved:
moved_ids = {it["id"] for it in moved}
p["purchased"] = [x for x in p["purchased"] if x["id"] not in moved_ids]
self._save()
return moved
def purchased(self):
"""Items still held in the purchased/unassigned pile (returned by
GET /purchased/items); they move to the club via FutMoveCard (PUT /item)."""
return self.load().get("purchased", [])
def active_squad(self):
sq = self.load()["squads"]
return sq[0] if sq else None
def reconstruct_squad(self, squad):
"""FIFA's updateActiveSquad PUT stores each slot as itemData={id:<clubItemId>}
(a reference). Re-embed the FULL club item by id so the squad reloads with
real players instead of empty slots ('active squad resets')."""
by_id = {it["id"]: it for it in self.items()}
out = dict(squad)
players = []
for pl in squad.get("players", []):
iid = (pl.get("itemData") or {}).get("id", 0)
if iid and iid in by_id:
players.append({**pl, "itemData": by_id[iid]})
else:
players.append(pl)
out["players"] = players
return out
# ---- transfer-market listings (user's own sale pile) -------------------
def list_for_sale(self, item_id, start, buynow):
with _LOCK:
p = self.load()
p.setdefault("listings", [])
p["listings"] = [l for l in p["listings"] if l.get("itemId") != item_id]
tid = 900500000 + p.get("nextListingSeq", 0)
p["nextListingSeq"] = p.get("nextListingSeq", 0) + 1
p["listings"].append({"tradeId": tid, "itemId": item_id,
"startingBid": start, "buyNowPrice": buynow})
self._save()
return tid
def listings(self):
return self.load().get("listings", [])
def remove_listing(self, tid):
with _LOCK:
p = self.load()
p["listings"] = [l for l in p.get("listings", []) if l.get("tradeId") != tid]
self._save()
def new_item_id(self):
with _LOCK:
p = self.load(); i = p["nextItemId"]; p["nextItemId"] += 1; self._save()
return i
def open_pack(self, price, count, gold=True, tiers=None):
"""Deduct `price` coins, generate `count` player items from the pool, and
place them in the PENDING purchased pile (unassigned). They are NOT owned
club items until moved there via FutMoveCard (PUT /item). Returns None if
not enough coins.
`tiers` is a weighted list of tier names, e.g. ["bronze"]*8 + ["silver"]*2,
so a bronze pack can actually contain bronzes. The old signature took a
single `gold` boolean and split the pool at rating 75, which with the old
18-player pool (all rated 85 to 94) meant EVERY pack, including the bronze
one, dealt gold rares. `gold` is still honoured when `tiers` is absent so
nothing that calls this the old way changes behaviour.
"""
import random
if not self.spend(price):
return None
if tiers:
picks = [random.choice(fut_cards.pool_for(random.choice(tiers)))
for _ in range(count)]
else:
pool = [p for p in PACK_POOL if (p[1] >= 75) == gold] or PACK_POOL
picks = [random.choice(pool) for _ in range(count)]
items = [_item(self.new_item_id(), a, r, p, n, lg, tm, at)
for (a, r, p, n, lg, tm, at) in picks]
with _LOCK:
p = self.load()
p.setdefault("purchased", []).extend(items)
p["packsOpened"] += 1
self._save()
return items
def last_pack(self):
return self.load().get("purchased", [])
# Card pool for packs. Now lives in fut_cards.py (79 players across three rating
# tiers, 7 leagues, 20 nations, 18 teams, every outfield position plus GK). The old
# 18-entry list below is kept ONLY as the starter-squad source and as the fallback
# for callers that still pass the legacy `gold` boolean.
PACK_POOL = fut_cards.POOL
_LEGACY_POOL = STARTER_PLAYERS + [
(167495, 90, "GK", 27, 19, 22, [86, 88, 52, 88, 22, 88]), # Neuer
(192985, 88, "RM", 21, 19, 22, [80, 82, 85, 85, 63, 68]), # De Bruyne
(188545, 89, "ST", 37, 16, 240, [77, 88, 75, 82, 42, 82]), # Lewandowski
(169193, 87, "CDM",54, 16, 240, [70, 66, 80, 78, 82, 84]), # Alonso(X)
(202126, 86, "ST", 18, 13, 5, [79, 84, 74, 82, 45, 79]), # Kane
(177003, 88, "CM", 14, 13, 5, [65, 78, 88, 79, 71, 66]), # Modric(X)
(190871, 87, "LW", 54, 16, 240, [90, 78, 80, 88, 36, 61]), # Neymar(X)
(184941, 85, "CB", 14, 4, 5, [72, 40, 55, 60, 86, 85]), # (X)
]
# 3 store packs (price in coins, card count, gold-only). Ids are stable.
# `tiers` is the weighted draw for each pack. A bronze pack is mostly bronze with a
# chance of silver, a gold pack is mostly gold. Before fut_cards existed the pool had
# no silver or bronze players at all, so all three packs were identical in practice.
PACK_CATALOG = [
{"id": 1, "name": "Bronze Pack", "price": 400, "count": 5, "gold": False,
"tiers": ["bronze"] * 8 + ["silver"] * 2},
{"id": 5, "name": "Gold Pack", "price": 5000, "count": 7, "gold": True,
"tiers": ["gold"] * 6 + ["silver"] * 4},
{"id": 6, "name": "Premium Gold", "price": 15000, "count": 11, "gold": True,
"tiers": ["gold"] * 9 + ["silver"] * 1},
]
def pack_by_id(pid):
for p in PACK_CATALOG:
if p["id"] == pid:
return p
return None
STORE = Store()