#!/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/" 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 33068179 (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 ("" % (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. 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:]))