fifa17-python: commit working FUT backend deployment (client/server split)
Freeze the running offline FUT backend into version control as fifa17-recon/docker/fifa17-python/ - declarative and rebuildable from a fresh checkout: * OPENFUT_BIND / OPENFUT_ADVERTISE client/server split in the responders (lsx, blaze, roster, utas, pow) + entrypoint.sh; OPENFUT_ADVERTISE is required for remote mode (compose and entrypoint fail without it) * docker-compose.yml reproducing the frozen baseline container exactly (env, ports incl. the 8085->8080 POW-content remap, /state bind, restart) * .env.example / .env for site config - the LAN IP is never hardcoded in source * tools/ + data/ staged from openfut-fut-backend:python-baseline-2026-08-10, verified byte-identical to the running container at freeze time * client_arm.sh (the 105 client-side arming counterpart) * Dockerfile bakes /app/SHA256SUMS.txt so any image is self-identifying * docs/BASELINE-python-2026-08-10.md: frozen image/container/hash record, restore instructions and rebuild-equivalence procedure Secrets (redir key/cert, .env) and runtime state (docker/state) stay gitignored. The live container is untouched pending the .105 launcher audit.
This commit is contained in:
@@ -0,0 +1,689 @@
|
||||
#!/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._file_signature = None
|
||||
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:
|
||||
signature = self._signature()
|
||||
if self._loaded and not force and signature == self._file_signature:
|
||||
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
|
||||
self._file_signature = self._signature()
|
||||
return self
|
||||
|
||||
def _signature(self):
|
||||
"""Identity of the active-account file across atomic replacements.
|
||||
|
||||
The launcher can select an account while Blaze/POW are already running
|
||||
in separate processes. inode + mtime + size lets every process notice
|
||||
the replacement on its next property read without restarting Docker.
|
||||
"""
|
||||
try:
|
||||
st = os.stat(self.path)
|
||||
return st.st_dev, st.st_ino, st.st_mtime_ns, st.st_size
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
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):
|
||||
parent = os.path.dirname(self.path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
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)
|
||||
self._file_signature = self._signature()
|
||||
|
||||
def replace(self, values):
|
||||
"""Atomically replace the active identity with validated persisted values."""
|
||||
with _LOCK:
|
||||
clean = {k: v for k, v in values.items() if k in _FIELDS and v is not None}
|
||||
if "persona_id" not in clean or "persona_name" not in clean:
|
||||
raise ValueError("persona_id and persona_name are required")
|
||||
clean["persona_id"] = int(clean["persona_id"])
|
||||
clean["persona_name"] = str(clean["persona_name"]).strip()
|
||||
if clean["persona_id"] <= 0 or not clean["persona_name"]:
|
||||
raise ValueError("persona_id must be positive and persona_name must not be empty")
|
||||
self._stored = clean
|
||||
for field in _FIELDS:
|
||||
setattr(self, "_" + field, None)
|
||||
self._loaded = True
|
||||
self._write()
|
||||
return self
|
||||
|
||||
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:]))
|
||||
Reference in New Issue
Block a user