Files
OpenFUT/fifa17-recon/docker/fifa17-python/tools/check_settings_flags.py
T
root 70a64e3709 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.
2026-08-10 23:54:04 +00:00

151 lines
6.4 KiB
Python

#!/usr/bin/env python3
"""Offline check: every /settings flag we ship is one the client actually switches on.
A flag name is not validated by anything at runtime. The client hashes the string
we send and switches on the result, so a typo, a renamed field or a flag that
simply has no arm in the switch is INERT and looks exactly like "the fix did not
work". This asserts each shipped name against two independent sources:
1. docs/fut_atoms.tsv -- the recovered atom table (the name must hash to an id)
2. the switch arms recovered from 0x18013c6d0 (the id must have an arm)
Source 2 is the one that matters: enableSquadBuildingSetsFeature is a perfectly
real atom with NO arm, so source 1 alone would have passed it.
Run before shipping any settings change. No server needed.
"""
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
# The 42 atoms with an arm in FUN_18013c6d0, recovered 2026-08-05 by
# tools/ghidra_queries/q_settings_flags.py + q_settings_types.py (full decompile,
# both halves of the switch, coverage asserted by char count).
SWITCH_ARMS = {
0x18: "allowGracePeriodForSquadBuildingSets",
0x19: "allowUntradeableForSquadBuildingSets",
0x6D: "cardPackStoreEnabled", 0x6E: "cardPackStoreEnabled_JP",
0x80: "checkServerDbVersion", 0x86: "clientKeepAliveResetTimeoutSec",
0x8C: "clubCreateThreshold", 0x98: "coinEnabled", 0x99: "coinEnabled_JP",
0xA3: "constrainGracePeriod", 0xBB: "couchPlayEnabled",
0xF9: "enableDraftMode", 0xFA: "enableOfflineDraftMode",
0xFB: "enableLiveMessaging", 0xFC: "enableLoyaltyBonusForConceptPlayers",
0xFD: "enableObjectives", 0xFE: "enableObjectivesAsManagerTasks",
0xFF: "enableSinglePlayerDraftMode", 0x118: "extendGameSessionTimerSec",
0x11F: "fifaPointsEnabled", 0x120: "fifaPointsEnabled_JP",
0x133: "friendlySeasonsEnabled", 0x13D: "getOperationTimeoutSec",
0x16C: "itemDbVersion", 0x1C0: "maximumTradePileSize",
0x1CD: "mtxEnabled", 0x1CE: "mtxEnabled_JP",
0x1DE: "numEndMatchRetriesAllowed", 0x20E: "packOpeningAnimationEnabled",
0x242: "pointsPackStoreEnabled", 0x257: "processingStateEnabled",
0x28A: "returningUserRewardsScreenEnabled",
0x2D0: "squadBuildingSetsGracePeriodMinutes",
0x2F1: "storeEnabled", 0x2F2: "storeEnabled_JP",
0x2F3: "storyModeRewardEnabled",
0x2F5: "championsScheduleViewPeriodInMinutes",
0x30F: "enableFloatPointSquadRating",
0x310: "enableLegacyYearInfoInItemResourceId",
0x320: "tokenRedemptionEnabled", 0x32D: "tournamentQuitEnabled",
0x336: "tradingEnabled",
}
# Arms that do NOT simply store a value. Shipping these has side effects.
SPECIAL = {
"enableObjectives": "shared arm can only CLEAR the field; 1 is a no-op, 0 disables",
"enableObjectivesAsManagerTasks": "same shared arm as enableObjectives",
"clientKeepAliveResetTimeoutSec": "reprograms a client timer with value*1000",
"getOperationTimeoutSec": "reprograms a client timer with value*1000",
"checkServerDbVersion": "makes the client go read a server_db_version config",
}
def main():
fails = []
atoms = {}
with open(os.path.join(HERE, "..", "docs", "fut_atoms.tsv")) as fh:
for line in fh:
p = line.rstrip("\n").split("\t")
if len(p) >= 3:
try:
atoms[p[2]] = int(p[1], 16)
except ValueError:
pass
# Cross-check the recovered table against the atom table both ways.
by_name = {v: k for k, v in SWITCH_ARMS.items()}
for name, aid in by_name.items():
if name not in atoms:
fails.append("switch arm %s (%#x) is not in fut_atoms.tsv" % (name, aid))
elif atoms[name] != aid:
fails.append("%s: switch says %#x, atom table says %#x"
% (name, aid, atoms[name]))
import utas_server as u
body = u.SETTINGS
if not isinstance(body, dict) or list(body) != ["configs"]:
fails.append("body must be exactly {'configs': [...]}, got %r" % (body,))
return report(fails)
rows = body["configs"]
if not isinstance(rows, list):
fails.append("configs must be a LIST (a scalar here desyncs the parser)")
return report(fails)
seen = set()
for r in rows:
if not isinstance(r, dict) or set(r) != {"type", "value"}:
fails.append("row must be exactly {type, value}: %r" % (r,))
continue
t, v = r["type"], r["value"]
# value: any scalar is safe (getter 0x1801c79d0 coerces int/float/bool/str),
# but the applier tests `== 1`, so a bool True would work and a string "1"
# would work -- ints keep it unambiguous. An object or array FREEZES.
if isinstance(v, (dict, list)):
fails.append("%s: value is %s -- an object/array here FREEZES the client"
% (t, type(v).__name__))
if not isinstance(t, str):
fails.append("type must be a string, got %r" % (t,))
continue
if t in seen:
fails.append("%s sent twice; last one wins, so this is at best confusing" % t)
seen.add(t)
if t not in by_name:
hint = " (it IS an atom, but has no arm in the switch)" if t in atoms else ""
fails.append("%s has no arm in 0x18013c6d0 -- INERT%s" % (t, hint))
elif t in SPECIAL:
print(" NOTE %-34s %s" % (t, SPECIAL[t]))
gates = {"friendlySeasonsEnabled", "enableDraftMode", "tournamentQuitEnabled"}
# Read the mode off the server module, never re-declare the default here: a
# checker with its own copy of a default tests the copy, not the server.
mode = u._SETTINGS_MODE
if mode == "gates":
for g in sorted(gates - seen):
fails.append("mode 'gates' but %s is missing" % g)
for t in sorted(seen & gates):
row = next(r for r in rows if r["type"] == t)
if row["value"] != 1:
fails.append("%s = %r; the applier tests `== 1`, nothing else opens "
"the gate" % (t, row["value"]))
print(" mode=%s, %d rows, %d distinct flags, all with a live switch arm"
% (mode, len(rows), len(seen)))
return report(fails)
def report(fails):
if fails:
print("\nFAIL (%d)" % len(fails))
for f in fails:
print(" - %s" % f)
return 1
print("PASS")
return 0
if __name__ == "__main__":
sys.exit(main())