Files
OpenFUT/fifa17-recon/tools/fut_account.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

653 lines
27 KiB
Python

#!/usr/bin/env python3
"""Central ACCOUNT config for the FIFA 17 offline stack (OpenFUT, clean-room).
ONE source of truth for the identity every layer has to agree on. Before this
module the same persona id / display name / namespace literals were copy-pasted
into blaze_responder_v3b.py, lsx_responder_v2.py, fut_store.py, fut_seed.py and
utas_server.py -- five files, seven copies. The stack only works while all of
them agree, so the copies were a silent drift surface.
THE REAL CONSTRAINT IS CROSS-LAYER CONSISTENCY, NOT ANY PARTICULAR VALUE.
Blaze LoginResponse.SESS.PDTL, LSX GetProfileResponse and the UTAS
userInfo/squad bodies must all assert the SAME persona. That is why they
now all read this module instead of their own literal.
(The older comments in blaze/lsx claimed DSNM "MUST be CAGE" and PID "MUST
be 33068179", justified by AUTH_ERR_INVALID_PERSONA. That justification is
wrong on two counts: those are Blaze *server* error codes and we are the
server, and neither literal appears anywhere in FIFA17.exe / CardsDLL /
dbdata.dll. 33068179 occurs only inside stp-origin_emu.dll, as that emu's
own ini default. The values are kept as DEFAULTS because they are what the
currently-working stack asserts -- not because the client demands them.)
THREE TIERS
1. LOCKED wire constants -- module-level, no env, never persisted. These are
baked into the binaries or into EA's own catalogue; changing them is a
protocol change, not a preference.
2. IDENTITY -- persona id / display name / email / locale. Env-overridable,
persisted.
3. CLUB -- club name / abbreviation / established year / squad name.
Env-overridable, persisted. This tier is the offline, crash-free way to
name your club (the in-game rename path is a separate, gated experiment).
PRECEDENCE for tiers 2 and 3: env var > fut_account.json > built-in default.
PERSISTENCE
Its own file, tools/fut_account.json (override with FUT_ACCOUNT_PATH), NOT the
game save. Two reasons: the account must survive deleting fifa17_profile.json
to reset progress, and blaze/lsx must be able to import this module without
dragging in the profile store. On first load, if fut_account.json is absent
and fifa17_profile.json exists, the identity/club values are MIGRATED out of
it so an existing club name is never lost.
CLI (this is the safe club-rename path -- offline, no client involvement):
python3 tools/fut_account.py --show
python3 tools/fut_account.py --club-name 'Real OpenFUT' --club-abbr ROF
Then restart the harness. See --help.
"""
import json
import os
import sys
import threading
HERE = os.path.dirname(os.path.abspath(__file__))
ACCOUNT_PATH = os.environ.get("FUT_ACCOUNT_PATH", os.path.join(HERE, "fut_account.json"))
# Legacy home of these values; read once for migration, never written by us.
LEGACY_PROFILE_PATH = os.environ.get("FUT_PROFILE", os.path.join(HERE, "fifa17_profile.json"))
_LOCK = threading.RLock()
# ====================================================================== tier 1
# LOCKED WIRE CONSTANTS. No env override on purpose: these are not preferences.
# Each carries its provenance -- do not "clean up" a value without re-deriving it.
NAMESPACE = "cem_ea_id"
"""Persona namespace. Baked into FIFA17.exe (file offset 0x36b748) in the
BlazeSDK platform->namespace default table; exactly one occurrence. Must equal
PreAuthResponse.NASP and every later NASP/NSNM/ASRC we emit."""
PLATFORM = "pc"
"""Wire platform string. The client sends nucleusPersonaPlatform="pc" in its own
POST /ut/auth body -- we echo it, we do not choose it."""
CLIENT_PLATFORM = 4
"""Blaze::ClientPlatformType enum value for pc."""
SKU = "FFA17PCC"
"""CardsDLL FUN_180125900 literal @0x1802201e0; also the "game/<sku>" URL segment."""
TITLE_ID = "309111"
CLIENT_ID = "FIFA17-PC-SERVER-BLAZE"
CONTENT_ID = "1027460"
"""FIFA 17 EA offer id (retail)."""
ENTITLEMENT_TAG = "ONLINE_ACCESS"
"""TRIAL_ONLINE_ACCESS for FIFA17_Trial.exe."""
ENTITLEMENT_GROUP = "FIFA17PCBoxContent"
"""strstr needle @0x144334030. EntitlementComponent::onListEntitlements
(0x146f27440) keeps an entitlement only if GNAM contains "FIFA17PCBoxContent" or
"FIFA16PC", TAG is non-empty and STAT==1. Plain "FIFA17PC" matched neither and
produced an empty store."""
PERSONA_STATUS = 2
"""PersonaStatus::Code ACTIVE (verified live: table 0x14487ad20)."""
USER_SESSION_TYPE = 0
"""Blaze::UserSessionType -> normal/console user."""
_LOCKED = ("NAMESPACE", "PLATFORM", "CLIENT_PLATFORM", "SKU", "TITLE_ID",
"CLIENT_ID", "CONTENT_ID", "ENTITLEMENT_TAG", "ENTITLEMENT_GROUP",
"PERSONA_STATUS", "USER_SESSION_TYPE")
# ================================================================ tiers 2 + 3
# field -> (env var, default). Only these keys are ever persisted.
_FIELDS = {
# tier 2: identity
"persona_id": ("FUT_PERSONA_ID", 33068179),
"persona_name": ("FUT_PERSONA_NAME", "CAGE"),
"email": ("FUT_ACCOUNT_EMAIL", None), # None -> derived from persona_name
"locale": ("FUT_LOCALE", "en_US"),
"country": ("FUT_COUNTRY", "US"),
"currency": ("FUT_CURRENCY", "USD"),
# tier 3: club
"club_name": ("FUT_CLUB_NAME", "OpenFUT"),
"club_abbr": ("FUT_CLUB_ABBR", "OFC"),
"established": ("FUT_ESTABLISHED", "2026"),
"squad_name": ("FUT_SQUAD_NAME", "OpenFUT"),
# tier 4: the ONLINE (EASFC/POW) profile -- what the top-right hub bar shows.
# Served by pow_server.py; key names below are the literal strings powdll's
# parsers compare against (see pow_server.py for addresses), so these map 1:1
# onto the wire:
# pow_level -> "level", pow_exp -> "exp" (widget renders exp/expMax)
# pow_funds -> EASFC credits, the coin counter next to the cart
"pow_level": ("FUT_POW_LEVEL", 1),
"pow_exp": ("FUT_POW_EXP", 0),
"pow_exp_max": ("FUT_POW_EXP_MAX", 1000), # currLevelExpMax
"pow_funds": ("FUT_POW_FUNDS", 0), # EASFC credit balance
"pow_funds_cap": ("FUT_POW_FUNDS_CAP", 100000),
}
_INT_FIELDS = ("persona_id", "pow_level", "pow_exp", "pow_exp_max",
"pow_funds", "pow_funds_cap")
# Club-name limits, reversed from CardsDLL:
# * clubAbbr 1..3 -- the client's own write-back after a successful rename is
# FUN_180007f80(rec+0x3e, 4, "%s", abbr), a FOUR-BYTE buffer (handler
# FUN_1800829c0), so 4+ chars truncate.
# * clubName 5..15 -- view-model builder FUN_180082c30 carries
# name_min_length=5, name_max_length=0xf, abbr_max_length=3. The userInfo
# write-back buffer at rec+0x20 is 30 bytes, so 15 is the binding constraint.
CLUB_NAME_MIN = 5
CLUB_NAME_MAX = 15
CLUB_ABBR_MIN = 1
CLUB_ABBR_MAX = 3
# CardsDLL FUN_180125900 uses this literal as the display name when the OSDK
# online-user object is NULL. Seeing it means "the client has no identity", not
# "the user is called mememe" -- never adopt it.
NULL_IDENTITY_NAME = "mememe"
def validate_club(name, abbr, established=None):
"""Validate club identity against the client's own limits.
Returns (name, abbr) -- or (name, abbr, established) when `established` is
passed. Raises ValueError with a message naming the reversed constraint.
"""
if not isinstance(name, str):
raise ValueError("clubName must be a string, got %r" % type(name).__name__)
if not isinstance(abbr, str):
raise ValueError("clubAbbr must be a string, got %r" % type(abbr).__name__)
name = name.strip()
abbr = abbr.strip()
if not (CLUB_NAME_MIN <= len(name) <= CLUB_NAME_MAX):
raise ValueError(
"clubName %r is %d chars; must be %d..%d (view-model FUN_180082c30 "
"name_min_length=5 name_max_length=0xf)"
% (name, len(name), CLUB_NAME_MIN, CLUB_NAME_MAX))
if not (CLUB_ABBR_MIN <= len(abbr) <= CLUB_ABBR_MAX):
raise ValueError(
"clubAbbr %r is %d chars; must be %d..%d (write-back buffer at "
"userInfo+0x3e is 4 bytes -> FUN_180007f80(dst,4,\"%%s\",abbr))"
% (abbr, len(abbr), CLUB_ABBR_MIN, CLUB_ABBR_MAX))
if established is None:
return name, abbr
est = established
if isinstance(est, int) and not isinstance(est, bool):
est = str(est)
# userInfo deser 0x18013ec10 case 0x110 takes the STRING getter then strtol
# base 10 into rec+0x64. An int on the wire here is the scalar/string type
# mismatch class that busy-loops the SAX reader at 0x1801c7f1a.
if not isinstance(est, str) or not est.isdigit():
raise ValueError("established must be a STRING of digits (deser "
"0x18013ec10 case 0x110 -> strtol base 10), got %r"
% (established,))
return name, abbr, est
class Account:
"""Mutable singleton; see module docstring for the tier/precedence rules."""
# tier 1 re-exported as attributes so call sites can just use ACCOUNT.X
NAMESPACE = NAMESPACE
PLATFORM = PLATFORM
CLIENT_PLATFORM = CLIENT_PLATFORM
SKU = SKU
TITLE_ID = TITLE_ID
CLIENT_ID = CLIENT_ID
CONTENT_ID = CONTENT_ID
ENTITLEMENT_TAG = ENTITLEMENT_TAG
ENTITLEMENT_GROUP = ENTITLEMENT_GROUP
PERSONA_STATUS = PERSONA_STATUS
USER_SESSION_TYPE = USER_SESSION_TYPE
def __init__(self, path=None):
self.path = path or ACCOUNT_PATH
self._loaded = False
self._stored = {} # what is on disk (tier 2+3 only)
for f in _FIELDS:
setattr(self, "_" + f, None)
# ------------------------------------------------------------ persistence
def load(self, force=False):
"""Idempotent. Reads fut_account.json, migrating from the legacy game
save the first time. Never raises on a malformed file -- a broken
account file must not stop the harness booting."""
with _LOCK:
if self._loaded and not force:
return self
stored = {}
if os.path.exists(self.path):
try:
with open(self.path) as f:
raw = json.load(f)
if isinstance(raw, dict):
stored = {k: v for k, v in raw.items() if k in _FIELDS}
except (OSError, ValueError) as e:
sys.stderr.write("[account] WARN: ignoring unreadable %s (%s)\n"
% (self.path, e))
else:
stored = self._migrate_from_profile()
if stored:
self._stored = stored
try:
self._write()
except OSError as e:
# A read-only tools/ must not stop a server booting; the
# migrated values still apply for this process.
sys.stderr.write("[account] WARN: could not write %s (%s)\n"
% (self.path, e))
self._stored = stored
self._loaded = True
return self
def _migrate_from_profile(self):
"""Lift identity/club out of a pre-existing fifa17_profile.json so an
existing club name survives the move to this module. Read-only: the game
save is never modified, and its copies stay there harmlessly."""
if not os.path.exists(LEGACY_PROFILE_PATH):
return {}
try:
with open(LEGACY_PROFILE_PATH) as f:
p = json.load(f)
except (OSError, ValueError):
return {}
if not isinstance(p, dict):
return {}
out = {}
for src, dst in (("personaId", "persona_id"), ("personaName", "persona_name"),
("clubName", "club_name"), ("clubAbbr", "club_abbr"),
("established", "established")):
if p.get(src) not in (None, ""):
out[dst] = p[src]
if out:
sys.stderr.write("[account] migrated %s from %s\n"
% (",".join(sorted(out)), os.path.basename(LEGACY_PROFILE_PATH)))
return out
def _write(self):
tmp = self.path + ".tmp"
with open(tmp, "w") as f:
json.dump(self._stored, f, indent=1, sort_keys=True)
f.write("\n")
os.replace(tmp, self.path)
def save(self):
"""Persist tiers 2+3 (only fields that differ from the built-in default,
plus anything already stored). Tier 1 is never written."""
with _LOCK:
self.load()
for f in _FIELDS:
v = getattr(self, "_" + f)
if v is not None:
self._stored[f] = v
self._write()
return self
# ------------------------------------------------------------ field access
def _get(self, field):
self.load()
env, default = _FIELDS[field]
v = getattr(self, "_" + field)
if v is None:
v = os.environ.get(env)
if v is None:
v = self._stored.get(field)
if v is None:
v = default
if field in _INT_FIELDS and v is not None:
v = int(v)
return v
def _set(self, field, value):
with _LOCK:
self.load()
if field in _INT_FIELDS:
value = int(value)
setattr(self, "_" + field, value)
# tier 2 ---------------------------------------------------------------
@property
def persona_id(self):
"""Blaze SESS.BUID / SESS.UID / PDTL.PID, LSX PersonaId/UserId, UTAS
userInfo.personaId and squad.personaId. UNVERIFIED KNOB: REPACK_INTEL
Section 2.2 records the repack's decrypted .dlf license carrying
<UserId>33068179</UserId> (consumed by dbdata.dll!getTableData). No .dlf
exists on disk any more, so changing this cannot be re-checked
statically -- treat it as a deliberate single-variable experiment."""
return self._get("persona_id")
@persona_id.setter
def persona_id(self, v):
self._set("persona_id", v)
@property
def persona_name(self):
"""Blaze PDTL.DSNM / LSX GetProfileResponse Persona / UTAS sellerName."""
return self._get("persona_name")
@persona_name.setter
def persona_name(self, v):
v = str(v).strip()
if not v:
raise ValueError("persona_name must not be empty")
self._set("persona_name", v)
@property
def email(self):
"""Blaze SESS.MAIL / AccountInfo.MAIL. Derived from persona_name when unset."""
v = self._get("email")
return v if v else "%s@openfut.local" % self.persona_name.lower()
@email.setter
def email(self, v):
self._set("email", v)
@property
def locale(self):
return self._get("locale")
@locale.setter
def locale(self, v):
self._set("locale", v)
@property
def country(self):
return self._get("country")
@property
def currency(self):
return self._get("currency")
# DERIVED, read-only. Deliberately NOT independent knobs: the client sends
# both `nuc` and `nucleusPersonaId` and both came out equal, so the
# getter->field mapping is undetermined. Do not split them until a live test
# proves Blaze USER_ID/EXT_ID may legitimately differ from PERSONA_ID.
@property
def user_id(self):
"""Blaze blazeId / userId (SESS.BUID, SESS.UID, AccountInfo.UID)."""
return self.persona_id
@property
def ext_id(self):
"""Blaze XREF / EXID externalId."""
return self.persona_id
@property
def locale_dash(self):
""""en-US" form, as the client sends it in POST /ut/auth."""
return self.locale.replace("_", "-")
@property
def account_locale_int(self):
"""Packed 4-char locale for Blaze AccountInfo; 'enUS' == 0x656E5553.
Overwritten per-session by the client's own PreAuthRequest LANG/LOC."""
s = (self.locale.replace("_", "") + "\0\0\0\0")[:4]
return int.from_bytes(s.encode("latin-1"), "big")
# tier 3 ---------------------------------------------------------------
@property
def club_name(self):
return self._get("club_name")
@club_name.setter
def club_name(self, v):
name, _ = validate_club(v, self.club_abbr)
self._set("club_name", name)
@property
def club_abbr(self):
return self._get("club_abbr")
@club_abbr.setter
def club_abbr(self, v):
_, abbr = validate_club(self.club_name, v)
self._set("club_abbr", abbr)
@property
def established(self):
"""STRING of digits -- see validate_club()."""
return str(self._get("established"))
@established.setter
def established(self, v):
_, _, est = validate_club(self.club_name, self.club_abbr, v)
self._set("established", est)
@property
def squad_name(self):
return self._get("squad_name")
@squad_name.setter
def squad_name(self, v):
self._set("squad_name", v)
def set_club(self, name=None, abbr=None, established=None):
"""Atomic validated club update. Raises ValueError before mutating
anything, so a rejected rename leaves the account untouched."""
with _LOCK:
n = self.club_name if name is None else name
a = self.club_abbr if abbr is None else abbr
e = self.established if established is None else established
n, a, e = validate_club(n, a, e)
self._set("club_name", n)
self._set("club_abbr", a)
self._set("established", e)
return n, a, e
# ------------------------------------------------- tier 4: online profile
@property
def pow_level(self):
return self._get("pow_level")
@property
def pow_exp(self):
return self._get("pow_exp")
@property
def pow_exp_max(self):
return self._get("pow_exp_max")
@property
def pow_funds(self):
return self._get("pow_funds")
@property
def pow_funds_cap(self):
return self._get("pow_funds_cap")
def set_online_profile(self, level=None, exp=None, exp_max=None,
funds=None, funds_cap=None):
"""Atomic validated update of the EASFC/POW profile (the top-right hub
bar: LVL x, the exp bar, and the credit counter).
Validation is deliberately light -- unlike the club fields there is no
reversed length/range check to cite, so we only enforce what is
structurally required: non-negative ints, and exp <= exp_max so the
widget cannot render a bar past 100%."""
with _LOCK:
lv = self.pow_level if level is None else int(level)
xp = self.pow_exp if exp is None else int(exp)
xm = self.pow_exp_max if exp_max is None else int(exp_max)
fu = self.pow_funds if funds is None else int(funds)
fc = self.pow_funds_cap if funds_cap is None else int(funds_cap)
if min(lv, xp, xm, fu, fc) < 0:
raise ValueError("online-profile values must be >= 0")
if lv < 1:
raise ValueError("pow_level must be >= 1")
if xm < 1:
raise ValueError("pow_exp_max must be >= 1")
if xp > xm:
raise ValueError("pow_exp (%d) exceeds pow_exp_max (%d)" % (xp, xm))
if fu > fc:
raise ValueError("pow_funds (%d) exceeds pow_funds_cap (%d)" % (fu, fc))
for k, v in (("pow_level", lv), ("pow_exp", xp), ("pow_exp_max", xm),
("pow_funds", fu), ("pow_funds_cap", fc)):
self._set(k, v)
return lv, xp, xm, fu, fc
# ------------------------------------------------------------- adoption
def adopt_from_auth(self, body):
"""Adopt the identity the client itself asserts in POST /ut/auth.
Live-observed body (three byte-identical runs; builder CardsDLL
FUN_180125900):
{"sku":"FFA17PCC","nucleusPersonaPlatform":"pc","nuc":33068179,
"nucleusPersonaId":33068179,"nucleusPersonaDisplayName":"CAGE",
"locale":"en-US","regionCode":"US",...}
RECONCILIATION RULE: the wire is truth, the stored JSON is a cache.
Adopt-and-overwrite with a WARN; never refuse -- a mismatch is the
NORMAL state on the first boot after a rename. Returns True if anything
changed. FUT_ADOPT_AUTH=0 disables adoption entirely.
Why it matters: the squad parser 0x18013d1f0 stores personaId (atom
0x21b) at squad+0x38 and compares it against
FUN_18011a830()->vtbl[0x908]; on mismatch it silently builds a throwaway
squad instead of erroring. Same comparison in FUN_1801464e0 for squad
summaries. Adopting makes that comparison correct by construction.
"""
if os.environ.get("FUT_ADOPT_AUTH") == "0":
return False
if not isinstance(body, dict):
return False
changed = []
pid = body.get("nucleusPersonaId", body.get("nuc"))
if isinstance(pid, (int, str)) and not isinstance(pid, bool):
try:
pid = int(pid)
except (TypeError, ValueError):
pid = None
if pid and pid != self.persona_id:
changed.append("persona %d -> %d" % (self.persona_id, pid))
self._set("persona_id", pid)
name = body.get("nucleusPersonaDisplayName")
if isinstance(name, str):
name = name.strip()
if name and name != NULL_IDENTITY_NAME and name != self.persona_name:
changed.append("name %r -> %r" % (self.persona_name, name))
self._set("persona_name", name)
loc = body.get("locale")
if isinstance(loc, str) and loc:
loc = loc.replace("-", "_")
if loc != self.locale:
changed.append("locale %r -> %r" % (self.locale, loc))
self._set("locale", loc)
if changed:
sys.stderr.write("[account] WARN: adopted from /ut/auth: %s\n"
% "; ".join(changed))
self.save()
return bool(changed)
# ------------------------------------------------------------------ misc
def as_dict(self):
"""Effective tier 2+3 values plus the derived ones (for --show / logs)."""
d = {f: getattr(self, f) for f in _FIELDS}
d["email"] = self.email # resolve the derived default
d.update(user_id=self.user_id, ext_id=self.ext_id,
locale_dash=self.locale_dash,
account_locale_int=self.account_locale_int)
return d
def locked(self):
return {k: globals()[k] for k in _LOCKED}
def __repr__(self):
return ("<Account persona=%d/%r club=%r/%r est=%s ns=%s>"
% (self.persona_id, self.persona_name, self.club_name,
self.club_abbr, self.established, self.NAMESPACE))
ACCOUNT = Account()
# Back-compat aliases so the old module-level names keep resolving where they
# are still imported. Prefer ACCOUNT.<field> in new code -- these are snapshots
# taken at import time and will NOT reflect a later adopt_from_auth().
PERSONA_ID = ACCOUNT.persona_id
PERSONA_NAME = ACCOUNT.persona_name
# ===================================================================== CLI
def _main(argv):
import argparse
ap = argparse.ArgumentParser(
prog="fut_account.py",
description="Inspect / edit the OpenFUT FIFA 17 account identity. "
"Editing the club here is the SAFE rename path: it is "
"offline, validated against the client's own limits, and "
"never involves the in-game rename flow. Restart the "
"harness after changing anything.")
ap.add_argument("--show", action="store_true", help="print the account and exit")
ap.add_argument("--club-name", help="club name (%d..%d chars)" % (CLUB_NAME_MIN, CLUB_NAME_MAX))
ap.add_argument("--club-abbr", help="club abbreviation (%d..%d chars)" % (CLUB_ABBR_MIN, CLUB_ABBR_MAX))
ap.add_argument("--established", help="founding year, digits only")
ap.add_argument("--squad-name", help="default squad name")
ap.add_argument("--persona-name", help="display name (Blaze DSNM / LSX Persona)")
ap.add_argument("--persona-id", type=int, help="persona id (UNVERIFIED knob; see docstring)")
ap.add_argument("--email", help="account email (Blaze MAIL)")
# tier 4: the online (EASFC/POW) profile shown in the top-right hub bar
ap.add_argument("--pow-level", type=int, help="online profile level (LVL)")
ap.add_argument("--pow-exp", type=int, help="online profile XP into the current level")
ap.add_argument("--pow-exp-max", type=int, help="XP needed for the next level")
ap.add_argument("--pow-funds", type=int, help="EASFC credits (the coin counter)")
ap.add_argument("--pow-funds-cap", type=int, help="EASFC credit cap")
ap.add_argument("--json", action="store_true", help="machine-readable output")
a = ap.parse_args(argv)
ACCOUNT.load()
dirty = False
try:
if a.club_name or a.club_abbr or a.established:
ACCOUNT.set_club(a.club_name, a.club_abbr, a.established)
dirty = True
if a.persona_name:
ACCOUNT.persona_name = a.persona_name
dirty = True
if a.persona_id:
ACCOUNT.persona_id = a.persona_id
dirty = True
if a.email:
ACCOUNT.email = a.email
dirty = True
if a.squad_name:
ACCOUNT.squad_name = a.squad_name
dirty = True
if any(v is not None for v in (a.pow_level, a.pow_exp, a.pow_exp_max,
a.pow_funds, a.pow_funds_cap)):
ACCOUNT.set_online_profile(a.pow_level, a.pow_exp, a.pow_exp_max,
a.pow_funds, a.pow_funds_cap)
dirty = True
except ValueError as e:
sys.stderr.write("error: %s\n" % e)
return 2
if dirty:
ACCOUNT.save()
print("saved %s" % ACCOUNT.path)
if a.json:
print(json.dumps({"account": ACCOUNT.as_dict(), "locked": ACCOUNT.locked()},
indent=1, sort_keys=True))
else:
d = ACCOUNT.as_dict()
print("account file : %s" % ACCOUNT.path)
print("-- identity (env-overridable, persisted) --")
for k in ("persona_id", "persona_name", "email", "locale", "country", "currency"):
print(" %-14s %s" % (k, d[k]))
print("-- club --")
for k in ("club_name", "club_abbr", "established", "squad_name"):
print(" %-14s %s" % (k, d[k]))
print("-- derived --")
for k in ("user_id", "ext_id", "locale_dash"):
print(" %-14s %s" % (k, d[k]))
print(" %-14s 0x%08x" % ("account_locale", d["account_locale_int"]))
print("-- locked wire constants (not settable) --")
for k, v in sorted(ACCOUNT.locked().items()):
print(" %-18s %s" % (k, v))
if dirty:
print("\nrestart the harness for this to take effect: ./openfut-fut.sh restart")
return 0
if __name__ == "__main__":
sys.exit(_main(sys.argv[1:]))