70a64e3709
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.
151 lines
6.4 KiB
Python
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())
|