83539e33ec
The earlier reconcile committed the local working-tree versions of these files, which are OLDER than the deployed backend. The running container (C) is byte-identical to docker/fifa17-python/tools (B) and is a strict superset: it adds profile_path_for/select_account/ensure_security_question (fut_store), safe_header_for_log/safe_request_path/security_question_route (utas_server), account_sync_route/_match_call/match_ready_body, plus POW balance fields and match lifecycle support, with zero unique local functions lost. Reconciled tree is now a strict superset of B with every shared file byte-identical; verified via md5 map (0 missing, 0 differing).
690 lines
29 KiB
Python
690 lines
29 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._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:]))
|