diff --git a/fifa17-recon/docs/ENDPOINT_MAP.md b/fifa17-recon/docs/ENDPOINT_MAP.md index fd2294f..aa122ff 100644 --- a/fifa17-recon/docs/ENDPOINT_MAP.md +++ b/fifa17-recon/docs/ENDPOINT_MAP.md @@ -1243,12 +1243,83 @@ reader → infinite spin at `0x1801c7f1a` (the hub freeze). - **Handled:** `utas_server.massinfo()` → `{userInfo, squad, settings, userData}`; `FUT_MASSINFO=full|squad|userinfo|settings|empty` bisects it one member per relaunch. -### FutGetSettingsServerResponse — CONFIDENCE: HIGH ✅ HANDLED -- **Deser:** `0x18013c6d0` -- **HTTP:** `GET ut/%s/settings` +### FutGetSettingsServerResponse — CONFIDENCE: HIGH ✅ HANDLED (schema) / the 42 flags are RECOVERED, UNTESTED +- **Deser:** `0x18013c6d0` (1982 bytes, 12061-char decompile, read end to end) +- **HTTP:** `GET ut/%s/settings`, and the `settings` (0x2bf) member of `userMassInfo` + (both callers of the deser: `0x18014e590` and `0x180174630`) - **Fields:** single wrapper key `configs` (0xa2) → array of config entries - `{ type (0x354), value (0x377) }`. -- **Handled:** `utas_server.SETTINGS = {"configs": []}`. Min JSON: `{"configs":[]}`. + `{ type (0x354), value (0x377) }`. The key ladder really does hold nothing else. + +**The mechanism the key ladder hides.** A flag is not a JSON key. When an element +closes, the client feeds the STRING VALUE of `type` back through the atom hasher +(`FUN_180180d00`) and switches on the result, 42 arms wide: + +```json +{"configs": [{"type": "friendlySeasonsEnabled", "value": 1}]} +``` + +So the flag vocabulary is the same atom table everything else uses, and the client +hashes our string itself — a flag cannot be misnamed silently, it simply falls +through to the default arm and is ignored. + +- **`value` is type-forgiving.** Its getter `0x1801c79d0` accepts int (token 2), + float (3), bool (4) and string (5, via `sscanf "%I64d"`), coercing all four to + int64. `1`, `"1"` and `true` are equivalent. This is one of the few scalar + getters in the API with NO desync risk on scalars. An object or array is still + a freeze. +- **The applier demands exactly 1.** `FUN_18011dc50` is the only writer of the + gate bytes and every line is `gate_byte = (field == 1)`. Not truthiness. `2`, + `-1` and `"yes"` all read as OFF. + +**Flags that publish a UI gate key.** `FUN_18006cc60` publishes IS_* state keys by +reading single bytes inside `FutDataManagerImpl` (service id `0xed84b11`, ctor +`0x18010cdc0`). Those bytes are written ONLY by the applier, and the ctor never +touches them (whole 16620-char ctor scanned): + +| flag `type` | field | gate byte | UI key | +|---|---|---|---| +| `tradingEnabled` | `[10]` | `0x1fd2e` | `IS_TRADING_ENABLED` | +| `storeEnabled` / `_JP` | `[0xb]` / `[0xc]` | `0x1fd2f` / `0x1fd30` | `IS_STORE_ENABLED` (accessor `0x18011c600` picks `_JP` when region == 4) | +| `friendlySeasonsEnabled` | `[0x16]` | `0x1fd3a` | `IS_FRIENDLY_SEASON_ENABLED` | +| `tournamentQuitEnabled` | `[0x20]` | `0x1fd3b` | `IS_TOURNAMENT_QUIT_ENABLED` | +| `processingStateEnabled` | `[0x21]` | `0x1fd3c` | `IS_PROCESSING_STATE_ENABLED` | +| `enableDraftMode` | `[0x17]` | `0x1fd3d` | `IS_DRAFT_MODE_ENABLED` | +| `enableOfflineDraftMode` = `enableSinglePlayerDraftMode` | `[0x18]` | `0x1fd3e` | (shared arm, one field) | +| `storyModeRewardEnabled` | `[0x1f]` | `0x1fd3f` | `IS_STORY_MODE_REWARD_ENABLED` | +| `returningUserRewardsScreenEnabled` | `[0x19]` | `0x1fd40` | `IS_RETURNING_USER_REWARDS_SCREEN_ENABLED` | + +**Why this is the standing suspect for Seasons and Draft.** Both refuse while +making zero requests to any of the four servers, which no response shape can +explain. A UI key evaluated from a byte that nothing ever wrote does explain it. +The store is the control: `IS_STORE_ENABLED` reads the same kind of byte and its +screen works, because `storeEnabled` and friends are already shipped through the +**Blaze** client-config store (`FUT_RS4_CONFIG` in `blaze_responder_v3b.py`) — +and that list contains no seasons, draft or tournament flag. Same mechanism, one +population, one blank. + +This is a hypothesis with a mechanism, not a confirmed cause. It predicts that +sending the flags opens the screens; if they still refuse, the gate is upstream +of the UI key and the whole settings line is dead. + +**Two arms that are not simple assignments:** +- `enableObjectives` (0xfd) and `enableObjectivesAsManagerTasks` (0xfe) share an + arm that can only ever CLEAR `[0x1c]`: `if (value == 0) field = 0`. Sending 1 + is a no-op. Objectives cannot be turned ON here, only off. +- `clientKeepAliveResetTimeoutSec` (0x86, vtable +0x68) and `getOperationTimeoutSec` + (0x13d, +0x58) do not store a field; they call a timer object with `value * 1000`. + Sending a small number shortens client timeouts. Leave them alone. + +**`maximumTradePileSize` (0x1c0) is the positive control.** It lands in `[0]` and +is passed to `FUN_18011f380`, and transfer-list capacity is visible in game. It +distinguishes "the flag did not help" from "the configs array never reached the +consumer at all", which no boolean flag can do on its own. + +**Not in the switch:** `enableSquadBuildingSetsFeature` (0x100) is a real atom but +has NO arm here, so SBC is gated somewhere else. Scanned the full decompile; +this absence is asserted over the whole function, not a slice. + +- **Handled:** `utas_server.SETTINGS`, `FUT_SETTINGS` (default `gates`). + `off` restores the historical `{"configs": []}`. ### FutGetHubDataServerResponse — CONFIDENCE: LOW (full schema) / HIGH (served {} works) — GAP - **Wrapper:** `0x1801736ad` → inner `0x180173a50` / `0x180173b10` / `0x180173c00`. diff --git a/fifa17-recon/tools/.gitignore b/fifa17-recon/tools/.gitignore new file mode 100644 index 0000000..2bbb2f0 --- /dev/null +++ b/fifa17-recon/tools/.gitignore @@ -0,0 +1 @@ +gvenv/ diff --git a/fifa17-recon/tools/check_settings_flags.py b/fifa17-recon/tools/check_settings_flags.py new file mode 100644 index 0000000..e05cd10 --- /dev/null +++ b/fifa17-recon/tools/check_settings_flags.py @@ -0,0 +1,148 @@ +#!/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"} + mode = os.environ.get("FUT_SETTINGS", "gates") + 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()) diff --git a/fifa17-recon/tools/ghidra_queries/q_datamanager_ctor.py b/fifa17-recon/tools/ghidra_queries/q_datamanager_ctor.py new file mode 100644 index 0000000..cfd4d8b --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_datamanager_ctor.py @@ -0,0 +1,24 @@ +"""FUT::FutDataManagerImpl constructor -> the settings defaults. + +The settings struct sits at object+0x28 (FUN_18014e590 hands param_1+0x28 to the +deser), so field N of the flag table is at byte offset 0x28 + N*4: + + 0x50 tradingEnabled[10] 0x54 storeEnabled[0xb] <- CONTROL, works today + 0x80 friendlySeasonsEnabled[0x16] <- Seasons + 0x84 enableDraftMode[0x17] <- Draft + 0x88 enableOffline/SinglePlayerDraftMode[0x18] + 0x98 enableObjectives[0x1c] (settings can only CLEAR this one) + +Print the whole constructor and the coverage numbers. Do NOT conclude "field X is +not initialised" from a partial read: this repo lost seven attempts to exactly +that mistake (the FutMoveCard retraction), so the char counts are printed and the +full text follows. +""" +VA = 0x18010CDC0 +f = func(VA) +src = dec(VA) +print("FUT::FutDataManagerImpl ctor %#x" % VA) +print(" body bytes : %d" % f.getBody().getNumAddresses()) +print(" decompile : %d chars (entire text printed below)" % len(src)) +print("=" * 78) +print(src) diff --git a/fifa17-recon/tools/ghidra_queries/q_gate_accessors.py b/fifa17-recon/tools/ghidra_queries/q_gate_accessors.py new file mode 100644 index 0000000..c136c3f --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_gate_accessors.py @@ -0,0 +1,43 @@ +"""Decompile the gate accessors themselves. + +The publisher calls virtuals on the object returned by the service locator: + +0x270 IS_TRADING_ENABLED +0x280 IS_STORE_ENABLED (CONTROL: works today) + +0x2b0 IS_FRIENDLY_SEASON_ENABLED +0x2b8 IS_TOURNAMENT_QUIT_ENABLED + +0x2c0 IS_PROCESSING_STATE_ENABLED +0x2c8 IS_DRAFT_MODE_ENABLED + +0x2d8 IS_STORY_MODE_REWARD_ENABLED +0x2f0 IS_RETURNING_USER_REWARDS_... + +FutDataManagerImpl's ctor installs three vtables (multiple inheritance): +PTR_LAB_18021c2a0 at +0, PTR_FUN_18021cda8 at +8, PTR_LAB_18021cdb8 at +0x10. +The locator hands back one of the sub-objects, so try all three and keep whichever +resolves these slots to real functions. Reading the accessor settles what each +gate actually reads, which the field-offset arithmetic can only guess at. +""" +SLOTS = {0x270: "IS_TRADING_ENABLED", 0x280: "IS_STORE_ENABLED (CONTROL)", + 0x2B0: "IS_FRIENDLY_SEASON_ENABLED", 0x2B8: "IS_TOURNAMENT_QUIT_ENABLED", + 0x2C0: "IS_PROCESSING_STATE_ENABLED", 0x2C8: "IS_DRAFT_MODE_ENABLED", + 0x2D8: "IS_STORY_MODE_REWARD_ENABLED", 0x2F0: "IS_RETURNING_USER_REWARDS"} + +for vt in (0x18021C2A0, 0x18021CDA8, 0x18021CDB8): + print("#" * 78) + print("# vtable %#x" % vt) + print("#" * 78) + ok = 0 + for off, name in sorted(SLOTS.items()): + try: + t = qword(vt + off) + except Exception as e: + print(" +%#05x %-34s " % (off, name)) + continue + f = fm.getFunctionAt(addr(t)) if 0x180000000 <= t < 0x181000000 else None + print(" +%#05x %-34s -> %#x %s" % (off, name, t, f.getName() if f else "(not a function)")) + if f: + ok += 1 + print(" resolved %d/%d" % (ok, len(SLOTS))) + if ok >= len(SLOTS) - 1: + print("\n -- accessor bodies --") + for off, name in sorted(SLOTS.items()): + t = qword(vt + off) + if fm.getFunctionAt(addr(t)): + print("\n === +%#05x %s ===" % (off, name)) + print(dec(t)) + print() diff --git a/fifa17-recon/tools/ghidra_queries/q_gate_applier.py b/fifa17-recon/tools/ghidra_queries/q_gate_applier.py new file mode 100644 index 0000000..393e732 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_gate_applier.py @@ -0,0 +1,16 @@ +"""FUN_18011dc50 is the ONLY writer of every gate byte. Read it in full. + +This is the join between the /settings response and the IS_* UI keys, so it +answers both open questions at once: what each gate byte is copied from, and +whether anything gives it a value when the configs array is empty. +""" +VA = 0x18011DC50 +f = func(VA) +src = dec(VA) +print("%#x body %d bytes / decompile %d chars (printed IN FULL)" + % (VA, f.getBody().getNumAddresses(), len(src))) +print("=" * 78) +print(src) +print("\n-- who calls the applier --") +for a, n in callers(VA): + print(" %#x %s" % (a, n)) diff --git a/fifa17-recon/tools/ghidra_queries/q_gate_bytes.py b/fifa17-recon/tools/ghidra_queries/q_gate_bytes.py new file mode 100644 index 0000000..eb63b92 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_gate_bytes.py @@ -0,0 +1,58 @@ +"""The gate accessors are one-line getters Ghidra never turned into functions, so +read their bytes and decode by hand. + +Expected shape for a bool getter: 0F B6 81 C3 (movzx eax,byte [rcx+d]) +or 8A 41 C3 (mov al,[rcx+d]). The disp is the field offset inside +FutDataManagerImpl, which is what identifies which flag each IS_* key publishes. + +CONTROL: +0x280 IS_STORE_ENABLED, whose screen is live-proven working while the +server sends an empty configs array. Its offset anchors the mapping and its +default is known-good by observation. +""" +TARGETS = [(0x270, "IS_TRADING_ENABLED", 0x18011C670), + (0x280, "IS_STORE_ENABLED (CONTROL)", 0x18011C600), + (0x2B0, "IS_FRIENDLY_SEASON_ENABLED", 0x18011C500), + (0x2B8, "IS_TOURNAMENT_QUIT_ENABLED", 0x18011C660), + (0x2C0, "IS_PROCESSING_STATE_ENABLED", 0x18011C5B0), + (0x2C8, "IS_DRAFT_MODE_ENABLED", 0x18011C4B0), + (0x2D8, "IS_STORY_MODE_REWARD_ENABLED", 0x18011C640), + (0x2F0, "IS_RETURNING_USER_REWARDS", 0x18011C5C0)] + + +def decode(b): + """Return (field_offset, note) for the simple getter forms.""" + if b[0:3] == b"\x0f\xb6\x81": + return int.from_bytes(b[3:7], "little"), "movzx eax,byte[rcx+d32]" + if b[0:3] == b"\x0f\xb6\x41": + return b[3], "movzx eax,byte[rcx+d8]" + if b[0:2] == b"\x8b\x81": + return int.from_bytes(b[2:6], "little"), "mov eax,[rcx+d32]" + if b[0:2] == b"\x8b\x41": + return b[2], "mov eax,[rcx+d8]" + if b[0:2] == b"\x8a\x41": + return b[2], "mov al,[rcx+d8]" + if b[0:2] == b"\x8a\x81": + return int.from_bytes(b[2:6], "little"), "mov al,[rcx+d32]" + return None, "UNRECOGNISED" + + +print("%-6s %-32s %-12s %-10s %s" % ("SLOT", "KEY", "ADDR", "FIELD@", "FORM / BYTES")) +print("-" * 110) +rows = [] +for slot, name, va in TARGETS: + b = read_bytes(va, 12) + off, form = decode(b) + rows.append((slot, name, va, off)) + print("+%#05x %-32s %#-12x %-10s %s | %s" + % (slot, name, va, hex(off) if off is not None else "?", form, b.hex())) + +# Field offset -> flag-table index, anchored on the struct base used by the parser. +print("\nIf the flag struct starts at object+0x28, index = (offset-0x28)/4:") +for slot, name, va, off in rows: + if off is None: + continue + idx = (off - 0x28) / 4 + print(" %-32s offset %#-6x -> field[%s]" % (name, off, idx if idx % 1 else int(idx))) + +print("\n=== 0x18011c600 (the one Ghidra did define) ===") +print(dec(0x18011C600)) diff --git a/fifa17-recon/tools/ghidra_queries/q_gate_publisher.py b/fifa17-recon/tools/ghidra_queries/q_gate_publisher.py new file mode 100644 index 0000000..3c3aebf --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_gate_publisher.py @@ -0,0 +1,12 @@ +"""FUN_18006cc60 publishes IS_STORE_ENABLED, IS_FRIENDLY_SEASON_ENABLED and +IS_DRAFT_MODE_ENABLED from one place. Read it, and read what it reads from. + +IS_STORE_ENABLED is the control: its screen works today under an empty configs +array, so whatever source it reads is one whose default does NOT block. If +Seasons and Draft read the same source the same way, then the empty array is not +their blocker and the hypothesis dies here. +""" +print(dec(0x18006CC60)) +print("\n-- callees --") +for a, n in callees(0x18006CC60): + print(" %#x %s" % (a, n)) diff --git a/fifa17-recon/tools/ghidra_queries/q_gate_writers.py b/fifa17-recon/tools/ghidra_queries/q_gate_writers.py new file mode 100644 index 0000000..b999809 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_gate_writers.py @@ -0,0 +1,56 @@ +"""What sets the gate bytes, and what are they born as? + +The IS_* keys read single bytes in a contiguous block inside FutDataManagerImpl: + 0x1fd2e tradingEnabled 0x1fd2f storeEnabled 0x1fd30 storeEnabled_JP + 0x1fd3a friendlySeasons 0x1fd3b tournamentQuit 0x1fd3c processingState + 0x1fd3d draftMode 0x1fd3f storyModeReward 0x1fd40 returningUserRewards +These are NOT the int fields the /settings parser fills, so something copies +across. Two questions, and the second is the one that decides whether an empty +configs array blocks Seasons: + + (1) who WRITES these bytes (the applier, and the constructor default) + (2) what value does the constructor give them + +CONTROL: 0x1fd2f storeEnabled. Its screen works today under an empty configs +array, so whatever the ctor gives it is a non-blocking default, and any other +byte born the same way is equally non-blocking. +""" +import re, struct + +OFFS = {0x1FD2E: "tradingEnabled", 0x1FD2F: "storeEnabled (CONTROL)", + 0x1FD30: "storeEnabled_JP", 0x1FD3A: "friendlySeasonsEnabled", + 0x1FD3B: "tournamentQuitEnabled", 0x1FD3C: "processingStateEnabled", + 0x1FD3D: "enableDraftMode", 0x1FD3F: "storyModeRewardEnabled", + 0x1FD40: "returningUserRewardsScreenEnabled"} + +print("#" * 78) +print("# (1) every function whose code embeds one of these offsets") +print("#" * 78) +for off, name in sorted(OFFS.items()): + pat = struct.pack(" born true + param_2[0x16] friendlySeasonsEnabled <- Seasons + param_2[0x17] enableDraftMode <- Draft + param_2[0x18] enableOffline/SinglePlayerDraftMode + param_2[0x1c] enableObjectives(AsManagerTasks) <- only ever CLEARED, so its + default is the only value it can ever have unless we send 0 +Field N sits at byte offset 0x28 + N*4. +""" +for va in (0x180117020, 0x1801170B0): + print("=" * 78) + print("%#x" % va) + print("=" * 78) + print(dec(va)) + print("-- callees --") + for a, n in callees(va): + print(" %#x %s" % (a, n)) + print() diff --git a/fifa17-recon/tools/ghidra_queries/q_settings_defaults.py b/fifa17-recon/tools/ghidra_queries/q_settings_defaults.py new file mode 100644 index 0000000..82eee92 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_settings_defaults.py @@ -0,0 +1,21 @@ +"""Recover the settings-struct DEFAULTS. + +Every IS_* gate key is read off one object via vtable accessors: + +0x270 IS_TRADING_ENABLED +0x280 IS_STORE_ENABLED (CONTROL: screen works today) + +0x2b0 IS_FRIENDLY_SEASON_ENABLED +0x2c8 IS_DRAFT_MODE_ENABLED +The server has always sent {"configs":[]}, so whatever those return today IS the +constructor default. IS_STORE_ENABLED's screen works, so its default is true; +the question is whether Seasons and Draft share that or default false. + +Route: resolve the object -> its vtable -> decompile the four accessors to learn +which field each reads -> find the constructor and read the initialisers. +""" +print("=" * 78) +print("FUN_180009c80 -- resolves the object (also called by the settings deser)") +print("=" * 78) +print(dec(0x180009C80)) + +print("=" * 78) +print("FUN_1800d7170 -- the argument it is given") +print("=" * 78) +print(dec(0x1800D7170)) diff --git a/fifa17-recon/tools/ghidra_queries/q_settings_defaults2.py b/fifa17-recon/tools/ghidra_queries/q_settings_defaults2.py new file mode 100644 index 0000000..f899004 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_settings_defaults2.py @@ -0,0 +1,19 @@ +"""The settings defaults, from the handler's own constructor. + +FUN_18014e590 (the /settings body parser) writes the flag struct at object+0x28. +FUN_18014e2d0 sits in the same code region and is the sub-object constructor the +FutDataManagerImpl ctor invokes for that slot, so it should initialise those +fields. Byte offset of flag-table field N is 0x28 + N*4: + + 0x50 tradingEnabled[10] 0x54 storeEnabled[0xb] <- CONTROL, works today + 0x80 friendlySeasonsEnabled[0x16] 0x84 enableDraftMode[0x17] + 0x88 offline/singleplayer draft[0x18] 0x98 enableObjectives[0x1c] +""" +for va in (0x18014E2D0, 0x18014E590): + f = func(va) + src = dec(va) + print("=" * 78) + print("%#x body %d bytes / decompile %d chars (printed in full)" + % (va, f.getBody().getNumAddresses(), len(src))) + print("=" * 78) + print(src) diff --git a/fifa17-recon/tools/ghidra_queries/q_settings_flags.py b/fifa17-recon/tools/ghidra_queries/q_settings_flags.py new file mode 100644 index 0000000..a9e1d7b --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_settings_flags.py @@ -0,0 +1,85 @@ +"""Extract the whole /settings flag switch, and name every case. + +MECHANISM (recovered 2026-08-05, q_settings.py). The body really is just +`configs` (0xa2) -> array of {type(0x354), value(0x377)}, so ENDPOINT_MAP's key +ladder was right. What it missed is what happens AFTER an element closes: + + iVar2 = FUN_180180d00(type_string) <- the atom hasher, applied to the + STRING VALUE of `type` + switch (iVar2) { case 0x98: ... } <- ~40 arms, one per feature flag + +So a flag is not a JSON key. It is a row: {"type":"coinEnabled","value":"1"}, +and the client hashes "coinEnabled" itself. That is why the array being empty +leaves every flag at its C++ constructor default. + +This prints every case with its atom name, its getter, and its destination field +so the response can be built without guessing. +""" +import re, os + +# NOTE: queries are exec'd inside ghidra_env.py, so __file__ is the HARNESS, not +# this file. Relative-to-__file__ paths silently resolve one directory too high, +# and the resulting exception skips the harness's os._exit(0), which then hangs +# the JVM until the timeout kills it. Use an absolute path. +REPO = "/home/alex/Documents/OpenFUT/fifa17-recon" + +ATOMS = {} +tsv = os.path.join(REPO, "docs", "fut_atoms.tsv") +for line in open(tsv): + p = line.rstrip("\n").split("\t") + if len(p) >= 3: + try: + ATOMS[int(p[1], 16)] = p[2] + except ValueError: + pass + +GETTER = { + "FUN_1800d7af0": "int/bool", + "FUN_1800d7ad0": "short", + "FUN_1800d7b10": "?", +} + +src = dec(0x18013C6D0) +f = func(0x18013C6D0) +print("decompile %d chars / body %d bytes -- full text searched" % + (len(src), f.getBody().getNumAddresses())) + +# The switch arms live after the element loop closes. Walk line by line and keep +# the most recent case label so each arm's body can be attributed. +cur = None +arms = {} +for line in src.splitlines(): + m = re.search(r"\bcase (0x[0-9a-f]+):", line) + if m: + cur = int(m.group(1), 16) + arms.setdefault(cur, []) + continue + m = re.search(r"if \(iVar2 == (0x[0-9a-f]+)\)", line) + if m: + cur = int(m.group(1), 16) + arms.setdefault(cur, []) + continue + if cur is not None and line.strip(): + arms[cur].append(line.strip()) + +print("\n%-8s %-42s %-10s %s" % ("ATOM", "NAME", "GETTER", "DESTINATION")) +print("-" * 100) +unknown = [] +for a in sorted(arms): + body = " ".join(arms[a])[:200] + g = next((GETTER.get(x, x) for x in GETTER if x in body), "") + dst = "" + m = re.search(r"param_2\[(0x[0-9a-f]+|\d+)\]", body) + if m: + dst = "param_2[%s]" % m.group(1) + m2 = re.search(r"\*\(undefined1 \*\)\(param_2 \+ (0x[0-9a-f]+|\d+)\)", body) + if m2: + dst = "byte param_2+%s" % m2.group(1) + if "(**(code **)" in body: + dst += " (vcall)" + name = ATOMS.get(a, "?? UNKNOWN ATOM") + if a not in ATOMS: + unknown.append(a) + print("%-8s %-42s %-10s %s" % (hex(a), name, g, dst)) + +print("\n%d switch arms; %d unnamed" % (len(arms), len(unknown))) diff --git a/fifa17-recon/tools/ghidra_queries/q_settings_gate.py b/fifa17-recon/tools/ghidra_queries/q_settings_gate.py new file mode 100644 index 0000000..ec05fb8 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_settings_gate.py @@ -0,0 +1,40 @@ +"""Does a /settings flag gate the Seasons and Draft screens? + +The hypothesis. Seasons refuses while making ZERO requests to any of the four +servers, so nothing on the wire can be wrong. A UI state key evaluated from a +client-side default explains that exactly. The settings deser writes +friendlySeasonsEnabled -> param_2[0x16] and enableDraftMode -> param_2[0x17], and +.rdata carries the UI keys IS_FRIENDLY_SEASON_ENABLED and IS_DRAFT_MODE_ENABLED. + +This asks whether those two are the same value, and what it defaults to when the +configs array is empty (which is what the server has always sent). + +A positive result is: the key's writer reads the settings field. A negative +result is: the writer reads something else, which kills the hypothesis cheaply. +CONTROL: IS_STORE_ENABLED, whose flag (storeEnabled -> param_2[0xb]) is attached +to a screen that is live-proven WORKING while we send an empty configs array, so +whatever it defaults to is a default that does NOT block. +""" +KEYS = ["IS_FRIENDLY_SEASON_ENABLED", "IS_DRAFT_MODE_ENABLED", "IS_STORE_ENABLED"] + +for k in KEYS: + print("=" * 78) + print(k) + print("=" * 78) + hits = find_all(k.encode() + b"\x00") + if not hits: + print(" no string found -- check the literal") + continue + for h in hits: + print(" string @ %#x" % h) + xs = xrefs_to(h) + if not xs: + print(" NO XREFS (may be reached by table/pointer, not a direct lea)") + for frm, typ, fn, ent in xs: + print(" ref from %#x in %s (%#x)" % (frm, fn, ent)) + +print("\n\n" + "#" * 78) +print("# WHO CALLS THE SETTINGS DESER, and what object does it fill?") +print("#" * 78) +for a, n in callers(0x18013C6D0): + print(" %#x %s" % (a, n)) diff --git a/fifa17-recon/tools/ghidra_queries/q_settings_service.py b/fifa17-recon/tools/ghidra_queries/q_settings_service.py new file mode 100644 index 0000000..ae8eff8 --- /dev/null +++ b/fifa17-recon/tools/ghidra_queries/q_settings_service.py @@ -0,0 +1,32 @@ +"""Find the concrete settings service class, so its constructor defaults can be read. + +FUN_180009c80 resolves the object through a service locator keyed by 0x0ed84b11 +(service) / 0x0ed84b12 (interface), so the vtable is not reachable by following +pointers statically. But the class that REGISTERS itself under 0x0ed84b11 must +name the constant too, and its registration sits next to its construction. + +Also decompile the two callers of the settings deser: the GET /settings path +(0x18014e590) and the massinfo path (0x180174630), to see where the filled +struct is handed off, since the publisher reads the SERVICE, not the response. +""" +import struct + +for cid in (0x0ED84B11, 0x0ED84B12): + pat = struct.pack(" int/bool"), + (0x1800D7AD0, "converter applied to value -> short")): + print("\n" + "=" * 78) + print("%#x %s" % (va, why)) + print("=" * 78) + print(dec(va)) diff --git a/fifa17-recon/tools/utas_server.py b/fifa17-recon/tools/utas_server.py index 13e156d..3d5e42d 100755 --- a/fifa17-recon/tools/utas_server.py +++ b/fifa17-recon/tools/utas_server.py @@ -460,8 +460,79 @@ def club_rename_route(h): return 200, {} -# GET ut/game//settings (0x18013C6D0) recognises ONE key: configs (0xa2). -SETTINGS = {"configs": []} +# GET ut/game//settings (0x18013C6D0) recognises ONE key: configs (0xa2), +# an array of {type(0x354), value(0x377)}. The flag is not the JSON key: the +# client hashes the STRING VALUE of `type` through the atom hasher FUN_180180d00 +# and switches on it, 42 arms wide. See ENDPOINT_MAP "FutGetSettingsServerResponse". +# +# WHY THIS IS NOT `{"configs": []}` ANY MORE. The applier FUN_18011dc50 is the only +# writer of the IS_* UI gate bytes and every line is `byte = (field == 1)`. The +# FutDataManagerImpl ctor never touches those bytes. So a flag we do not send is a +# gate that is never opened, and IS_FRIENDLY_SEASON_ENABLED / IS_DRAFT_MODE_ENABLED +# have never been sent by anything. That is a mechanism for the standing bug where +# Seasons refuses while making ZERO requests to any of the four servers. +# +# FREEZE SAFETY. `value`'s getter 0x1801c79d0 takes int/float/bool/string and +# coerces to int64, so a scalar cannot desync the token reader here. Ints are used +# below. Never put an object or an array in `value`. +# +# THE STORE FLAGS ARE RE-ASSERTED DELIBERATELY. storeEnabled/coinEnabled/... reach +# the client today through the BLAZE config store, not through here, and the store +# 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") + +# 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. +_SETTINGS_KEEP = ( + "storeEnabled", "storeEnabled_JP", "coinEnabled", "coinEnabled_JP", + "cardPackStoreEnabled", "cardPackStoreEnabled_JP", "pointsPackStoreEnabled", + "tradingEnabled", +) + +# The gates nothing has ever populated. These are the point of the exercise. +_SETTINGS_GATES = ( + "friendlySeasonsEnabled", # [0x16] -> 0x1fd3a -> IS_FRIENDLY_SEASON_ENABLED + "enableDraftMode", # [0x17] -> 0x1fd3d -> IS_DRAFT_MODE_ENABLED + "enableSinglePlayerDraftMode", # [0x18] -> 0x1fd3e (shares its arm with + "enableOfflineDraftMode", # enableOfflineDraftMode) + "tournamentQuitEnabled", # [0x20] -> 0x1fd3b -> IS_TOURNAMENT_QUIT_ENABLED +) + +# NOT sent, and each for a reason: +# enableObjectives / enableObjectivesAsManagerTasks -- their shared arm can only +# CLEAR the field (`if (value == 0) field = 0`), so 1 is a no-op and 0 would +# switch objectives OFF. Nothing to gain, something to lose. +# clientKeepAliveResetTimeoutSec / getOperationTimeoutSec -- these do not set a +# field, they reprogram client timers with value*1000. +# itemDbVersion / checkServerDbVersion -- checkServerDbVersion makes the client +# go read a server_db_version config; leave the DB-version path alone. +# enableSquadBuildingSetsFeature -- a real atom with NO arm in this switch, so +# it does nothing here whatever we send. + +# The positive control. maximumTradePileSize lands in field [0] and feeds +# 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),) + + +def _settings_body(): + """off -> the historical {"configs": []} baseline. + keep -> re-assert only the already-working flags, plus the control. Isolates + "does populating configs at all change anything" from the new gates. + gates-> keep, plus the gates nothing has ever sent. The actual experiment.""" + if _SETTINGS_MODE == "off": + return {"configs": []} + rows = [{"type": k, "value": 1} for k in _SETTINGS_KEEP] + if _SETTINGS_MODE == "gates": + rows += [{"type": k, "value": 1} for k in _SETTINGS_GATES] + rows += [{"type": k, "value": v} for k, v in _SETTINGS_PROBE] + return {"configs": rows} + + +SETTINGS = _settings_body() # GET ut/game//userMassInfo (GetUserMassInfo, deser FUN_180174630). #