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:
funman300
2026-08-05 19:24:02 -07:00
parent 897259c8fb
commit d0dbfa99c0
4 changed files with 234 additions and 3 deletions
@@ -0,0 +1,153 @@
# The /settings feature gate - live-test script
> **CORRECTION, 2026-08-05 evening. Section 1 of this document is FALSE and the
> test in section 3 should not be run as written.**
>
> Section 1 claims `IS_FRIENDLY_SEASON_ENABLED` and `IS_DRAFT_MODE_ENABLED` "have
> never been set to true by anything, on any run". They are measured as **1**, on
> two separate launches, while `/settings` was answering `{"configs": []}`:
>
> ```
> disp 0x1fd3a (friendlySeasonsEnabled) value = 1
> disp 0x1fd3d (enableDraftMode) value = 1
> disp 0x1fd45 (packOpeningAnimationEnabled) value = 1
> ```
>
> Reproduce with `tools/gate_byte_probe.py` (needs the client at the FUT hub, since
> CardsDLL loads only then): it resolves the pid by comm,
> re-derives the CardsDLL slide from `/proc/<pid>/maps`, proves it against the FNV
> prologue at `0x180180d00` read from disk, walks the model singleton at
> `DAT_1802e6398`, and decodes each displacement out of its accessor stub
> (`0f b6 81 <disp32>`) rather than assuming it.
>
> **Where the reasoning went wrong.** The finding that `FUN_18011dc50` is the only
> writer and that the `FutDataManagerImpl` constructor never touches those bytes was
> correct. The inference drawn from it was not. The applier runs whether or not the
> configs array has content, and the settings 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". Those are different claims and
> only the first one was established.
>
> Seasons therefore does not refuse because its gate byte is false. Its gate byte is
> true. The mechanism is still unknown and needs a fresh diagnosis. Everything below
> the correction is kept as the record of a wrong turn, not as a plan.
Written 2026-08-05, after reversing `FutGetSettingsServerResponse` end to end.
Nothing here has been in front of the game yet. The code default is `off`, which
serves the exact historical `{"configs": []}`, so the tree is currently at the
proven baseline and this test is opt-in.
Full schema, atom ids, gate bytes and accessor addresses are in `ENDPOINT_MAP.md`
under `FutGetSettingsServerResponse`. This file is only the experiment.
---
## 1. The claim being tested
`GET /settings` is requested 11 times a session and has always been answered with
an empty array. The array is not decoration:
- Each element is `{"type": "<name>", "value": <scalar>}`. The client hashes the
**string value** of `type` through the atom hasher and switches on it, 42 arms
wide, so a flag is a row rather than a key.
- `FUN_18011dc50` is the **only** writer of the `IS_*` UI gate bytes inside
`FutDataManagerImpl`, and every line of it is `byte = (field == 1)`.
- The `FutDataManagerImpl` constructor never touches those bytes. The whole
16620-char decompile was scanned for the block; it is absent.
So `IS_FRIENDLY_SEASON_ENABLED` and `IS_DRAFT_MODE_ENABLED` have never been set
to true by anything, on any run, in the whole history of this project.
That is a mechanism for the standing bug in which **Seasons refuses while making
zero requests to any of the four servers.** No response shape could ever explain
that. A UI key evaluated from a byte nobody wrote does.
**The store is the control that makes this readable.** `IS_STORE_ENABLED` is the
same kind of byte read the same way, and the store screen works. It works because
`storeEnabled` and its siblings already reach the client through the **Blaze**
client-config store (`FUT_RS4_CONFIG`). That list contains no seasons flag, no
draft flag, no tournament flag. Same mechanism, one populated, one blank.
This is a hypothesis with a mechanism, not a demonstrated cause.
---
## 2. Pre-flight, from the terminal, costs nothing
```bash
cd fifa17-recon/tools
FUT_SETTINGS=gates python3 check_settings_flags.py # expect: 14 rows, PASS
python3 check_settings_flags.py # expect: mode=off, PASS
```
The checker asserts every shipped flag name against **both** the atom table and
the recovered switch arms. Both are needed: `enableSquadBuildingSetsFeature` is a
genuine atom with no arm in this switch, so the atom table alone would wave
through a flag that does nothing. A misnamed flag is silently inert and looks
exactly like a failed fix, which is the failure mode this guards.
---
## 3. The run
Budget: **one launch.**
```bash
cd fifa17-recon/tools
FUT_SETTINGS=gates ./openfut-fut.sh start
~/Desktop/launch-fifa17.sh
```
Then, in order, and write down what each one does:
1. **Store.** Open it. This is the control and it goes first, because if
populating the array broke the store then the applier demonstrably ran and
everything after this reads differently.
2. **Transfer list capacity.** Transfers → Transfer List. Read the capacity
number. We send `maximumTradePileSize = 77`, a number FUT would never choose
on its own.
3. **Seasons.** Single-player Seasons, the exact path that has been refusing.
4. **FUT Draft.** Both the offline and online entries.
5. **Tournaments**, for `tournamentQuitEnabled`.
---
## 4. Reading the result
The control in step 2 is what makes a negative result informative, so read it
before concluding anything about steps 3 to 5.
| Store (1) | Capacity (2) | Seasons (3) | Reading |
|---|---|---|---|
| works | **77** | opens | Confirmed. The gate was the empty array. Make `gates` the default and move to the `/match` shape, which has been blocked behind this. |
| works | **77** | still refuses | The array reached the consumer and the flag was applied, so the gate is **upstream of the UI key**. The settings line is then dead for Seasons and the next move is a live probe of the refusal path, not more response work. This is a real result, not a null one. |
| works | not 77 | still refuses | The array never reached the consumer at all. Everything above is untested rather than refuted. Suspect the massinfo `settings` member (the deser's other caller) is what the client actually reads, and check which of the two paths fires in `/tmp/utas.log`. |
| **breaks** | any | any | The applier ran and re-asserting the store flags did not hold them. Fall back to `FUT_SETTINGS=keep`, which sends only the already-working flags plus the control. If `keep` also breaks the store, populating the array is harmful in itself and the whole approach is wrong. |
`keep` exists precisely so that "populating the array at all" and "the new gates"
can be separated without guessing, and it costs one restart to use.
---
## 5. What would make this whole plan wrong
**The gate might not be a UI key at all.** Seasons could be refusing on an
entitlement, a persona attribute, or a Blaze session property evaluated inside
the Denuvo-packed executable, in which case no `/settings` body reaches it. The
step-2 control is what tells these apart: it distinguishes "the flag did not
help" from "the array was never consumed", and no boolean flag can do that alone.
**The store control could be weaker than it looks.** The argument assumes
`IS_STORE_ENABLED` currently comes from the Blaze store rather than from a
default. If it turns out the store screen does not read that key at all, then it
is not a control for anything and the reasoning in §1 loses its anchor.
**Draft has a second known suspect.** `GET ut/%s/squad/mode/draft/state` is still
answered by the generic `/squad` handler with a full active-squad object, which
is a textbook type-desync candidate. If Draft still fails while Seasons opens,
that route is the next thing to look at, not the flag.
**A negative result here is worth having.** The settings array has been the
standing suspect for the greyed-out entry points for two rounds without anyone
sending a single flag. Ruling it out costs one launch and removes it from the
backlog permanently.
+3 -1
View File
@@ -119,7 +119,9 @@ def main():
print(" NOTE %-34s %s" % (t, SPECIAL[t])) print(" NOTE %-34s %s" % (t, SPECIAL[t]))
gates = {"friendlySeasonsEnabled", "enableDraftMode", "tournamentQuitEnabled"} 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": if mode == "gates":
for g in sorted(gates - seen): for g in sorted(gates - seen):
fails.append("mode 'gates' but %s is missing" % g) fails.append("mode 'gates' but %s is missing" % g)
+66
View File
@@ -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)
+12 -2
View File
@@ -481,7 +481,14 @@ def club_rename_route(h):
# screen is live-proven working. Once a populated configs array makes the applier # 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 # 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. # 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 # 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. # 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 # 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 # result is ambiguous between "the flags did not help" and "the configs array never
# reached the consumer". With it, those two look different. # 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(): def _settings_body():