Files
OpenFUT/fifa17-recon/tools/check_settings_flags.py
T
funman300 d0dbfa99c0 fifa17-recon: the /settings gate bytes were never zero, and the plan built on that is dead
Yesterday's settings-gate plan asserted that IS_FRIENDLY_SEASON_ENABLED and
IS_DRAFT_MODE_ENABLED "have never been set to true by anything, on any run", and
proposed spending a launch on that premise. Measured against the running client,
both read 1, and so does packOpeningAnimationEnabled, while /settings has only ever
been answered {"configs": []}.

  disp 0x1fd3a (friendlySeasonsEnabled)      value = 1
  disp 0x1fd3d (enableDraftMode)             value = 1
  disp 0x1fd45 (packOpeningAnimationEnabled) value = 1

Reproduced on two separate launches and two different pids.

Where the reasoning went wrong: the finding that FUN_18011dc50 is the only writer of
those bytes, and that the FutDataManagerImpl constructor never touches them, was
correct. The inference was not. The applier runs whether or not the configs array has
content, and the struct it is handed defaults these fields to 1, so the bytes were
being written all along. "Nothing populates the array" was treated as "nothing writes
the byte". Only the first of those was ever established.

Seasons therefore does not refuse because its gate byte is false. Its gate byte is
true. That diagnosis restarts, and the live test in section 3 should not be run as
written. The doc keeps the wrong turn on the record rather than quietly deleting it.

tools/gate_byte_probe.py makes this repeatable instead of a one-off. It is read-only
(O_RDONLY + pread), resolves the pid by comm, re-derives the CardsDLL slide from
/proc/<pid>/maps rather than caching it across launches, proves the slide against the
FNV prologue at 0x180180d00 read from the on-disk PE before trusting any address, and
decodes each gate displacement out of its accessor stub (0f b6 81 <disp32>) rather
than reading it from a table. Needs the client at the FUT hub, since CardsDLL loads
only then.

Also carries the two /settings changes that were pending from before: the mode
defaults to `off` (the live-proven baseline, since nothing here has faced the game)
and the transfer-pile probe is 77 rather than 100, because 100 is a stock-looking
number that would prove nothing if it showed up in game.

Live: 439 contract checks pass. check_settings_flags.py passes in all four modes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 19:24:02 -07: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())