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>
This commit is contained in:
@@ -119,7 +119,9 @@ def main():
|
||||
print(" NOTE %-34s %s" % (t, SPECIAL[t]))
|
||||
|
||||
gates = {"friendlySeasonsEnabled", "enableDraftMode", "tournamentQuitEnabled"}
|
||||
mode = os.environ.get("FUT_SETTINGS", "gates")
|
||||
# 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)
|
||||
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read the FutDataManagerImpl UI gate bytes out of the LIVE FIFA 17 client.
|
||||
|
||||
Why this exists: on 2026-08-05 the /settings gate plan concluded that
|
||||
IS_FRIENDLY_SEASON_ENABLED and IS_DRAFT_MODE_ENABLED had never been set true by
|
||||
anything. Measured against the running client, both are 1, and have been all along.
|
||||
The applier FUN_18011dc50 runs whether or not the configs array has content, and the
|
||||
settings struct it is handed defaults these fields to 1. "Nothing populates the array"
|
||||
is not "nothing writes the byte".
|
||||
|
||||
Read-only. Opens /proc/<pid>/mem O_RDONLY and preads. Nothing here can write.
|
||||
|
||||
Nothing is assumed:
|
||||
* the pid is resolved by exact /proc/*/comm match, never hardcoded
|
||||
* the CardsDLL base is read from /proc/<pid>/maps, never cached across launches
|
||||
(Wine copies the sections into anonymous memory, so only the 4 KiB PE header is
|
||||
file-backed and `grep CardsDLL maps` returns exactly ONE line, which is easy to
|
||||
misread as "barely mapped")
|
||||
* the slide is PROVEN against the FNV atom-hash prologue at 0x180180d00, read from
|
||||
the on-disk PE, before any other address is trusted
|
||||
* each gate byte displacement is DECODED from its accessor stub (0f b6 81 <disp32>,
|
||||
movzx eax, byte [rcx+disp32]) rather than taken from a table
|
||||
|
||||
Requires the client to have reached Ultimate Team, since CardsDLL loads only then.
|
||||
Usage: python3 gate_byte_probe.py
|
||||
"""
|
||||
import os, struct, sys
|
||||
pid=None
|
||||
for d in os.listdir('/proc'):
|
||||
if d.isdigit():
|
||||
try:
|
||||
if open('/proc/%s/comm'%d).read().strip()=='FIFA17.exe': pid=int(d); break
|
||||
except Exception: pass
|
||||
assert pid, "not running"
|
||||
print("pid", pid)
|
||||
base=None
|
||||
for ln in open('/proc/%d/maps'%pid):
|
||||
if 'CardsDLL' in ln:
|
||||
base=int(ln.split('-')[0],16); print("cardsdll map line:", ln.strip())
|
||||
assert base
|
||||
slide = base - 0x180000000
|
||||
print("base %#x slide %#x" % (base, slide))
|
||||
fd=os.open('/proc/%d/mem'%pid, os.O_RDONLY)
|
||||
def rd(va,n): return os.pread(fd, n, va)
|
||||
# control: FNV prologue, bytes taken from the on-disk PE
|
||||
pe=open('/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll','rb').read()
|
||||
# .text rva 0x1000 rawptr 0x400
|
||||
def f(va): return va-0x180000000-0x1000+0x400
|
||||
ctl_disk=pe[f(0x180180d00):f(0x180180d00)+32]
|
||||
ctl_live=rd(0x180180d00+slide,32)
|
||||
print("CONTROL FNV", "MATCH" if ctl_disk==ctl_live else "MISMATCH", ctl_live.hex())
|
||||
# model singleton
|
||||
dat=0x1802e6398+slide
|
||||
obj=struct.unpack('<Q', rd(dat,8))[0]
|
||||
print("DAT_1802e6398 ->", hex(obj))
|
||||
vt=struct.unpack('<Q', rd(obj,8))[0]
|
||||
print("vtable live %#x static %#x" % (vt, vt-slide))
|
||||
for off,name in [(0x2b0,'friendlySeasons'),(0x2c8,'draftMode'),(0x2e0,'packOpeningAnimation')]:
|
||||
slot=struct.unpack('<Q', rd(vt+off,8))[0]
|
||||
stub=rd(slot,8)
|
||||
disp=struct.unpack('<I', stub[3:7])[0] if stub[:3]==b'\x0f\xb6\x81' else None
|
||||
val=rd(obj+disp,1)[0] if disp is not None else None
|
||||
print(" slot +%#x -> %#x stub=%s disp=%s value=%s" % (off, slot-slide, stub.hex(), hex(disp) if disp else None, val))
|
||||
# unopenedPacks total
|
||||
print("model+0x20950 =", struct.unpack('<I', rd(obj+0x20950,4))[0])
|
||||
os.close(fd)
|
||||
@@ -481,7 +481,14 @@ def club_rename_route(h):
|
||||
# screen is live-proven working. Once a populated configs array makes the applier
|
||||
# run, it writes EVERY gate byte from this struct, so omitting them could turn the
|
||||
# working store off. Sending them as 1 pins them to the state they are already in.
|
||||
_SETTINGS_MODE = os.environ.get("FUT_SETTINGS", "gates")
|
||||
#
|
||||
# DEFAULT IS `off`, deliberately. Populating the array is what makes the applier
|
||||
# run at all, and it then writes EVERY gate byte from this struct, including the
|
||||
# ones behind screens that work today. The house rule is that a flag defaults to
|
||||
# the live-proven value and nothing here has been in front of the game yet. Flip
|
||||
# it for the test: `FUT_SETTINGS=gates ./openfut-fut.sh start` (the orchestrator
|
||||
# runs the servers under its own environment, so an export is enough).
|
||||
_SETTINGS_MODE = os.environ.get("FUT_SETTINGS", "off")
|
||||
|
||||
# Flags that are already live-proven ON via the Blaze store. Re-asserted so the
|
||||
# applier cannot regress a working screen. Keep in sync with FUT_RS4_CONFIG.
|
||||
@@ -515,7 +522,10 @@ _SETTINGS_GATES = (
|
||||
# FUN_18011f380, and transfer-list capacity is READABLE IN GAME. Without it a null
|
||||
# result is ambiguous between "the flags did not help" and "the configs array never
|
||||
# reached the consumer". With it, those two look different.
|
||||
_SETTINGS_PROBE = (("maximumTradePileSize", 100),)
|
||||
# 77 on purpose: it has to be a number FUT would never pick by itself. 100 is the
|
||||
# stock-looking transfer-list size, so reading "x/100" in game would prove nothing.
|
||||
# The profile holds 0 listings, so a small cap cannot strand anything.
|
||||
_SETTINGS_PROBE = (("maximumTradePileSize", 77),)
|
||||
|
||||
|
||||
def _settings_body():
|
||||
|
||||
Reference in New Issue
Block a user