diff --git a/fifa17-recon/docs/plan-2026-08-05-settings-gate.md b/fifa17-recon/docs/plan-2026-08-05-settings-gate.md new file mode 100644 index 0000000..ddea9ca --- /dev/null +++ b/fifa17-recon/docs/plan-2026-08-05-settings-gate.md @@ -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//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 `) 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": "", "value": }`. 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. diff --git a/fifa17-recon/tools/check_settings_flags.py b/fifa17-recon/tools/check_settings_flags.py index e05cd10..a4354ce 100644 --- a/fifa17-recon/tools/check_settings_flags.py +++ b/fifa17-recon/tools/check_settings_flags.py @@ -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) diff --git a/fifa17-recon/tools/gate_byte_probe.py b/fifa17-recon/tools/gate_byte_probe.py new file mode 100755 index 0000000..69e56e1 --- /dev/null +++ b/fifa17-recon/tools/gate_byte_probe.py @@ -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//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//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 , + 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('", hex(obj)) +vt=struct.unpack(' %#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('