Commit Graph

117 Commits

Author SHA1 Message Date
funman300 a2bd048ace feat(market): bounded Q2 candidate — one unlisted pile item as tradeState "inactive"
PHASE A settled the token from the CLIENT ITSELF, so this is not a guessed enum.
vocab_dump.py (new; static, read-only, VA->offset through the real PE section table)
dumps CardsDLL's NULL-terminated {const char*, int} vocabularies. The tradeState
table at 0x180229e40 reads exactly:

    'active' = 1   'inactive' = 2   'expired' = 3   'closed' = 4

The sibling tables (type/zone/lev/pos) match the corpus verbatim, which validates the
dumper. So "inactive" is a token the client's own parser decodes.

PHASE B, bounded as instructed. `OPENFUT_FIFA17_UNLISTED_PROBE=<wire id>` exposes
EXACTLY ONE unlisted trade-pile item on /tradePile as a non-active record; unset,
behaviour is byte-identical to before. The other stranded pile items are untouched --
no bulk migration.

Why this shape is forced rather than chosen: the route table has exactly one
trade-pile route, it carries only twelve-atom auction records, `pile` (0x226) has no
deserializer arm so membership comes from the owning list, and of those atoms only
tradeState expresses lifecycle. The row carries tradeState "inactive" with
expires/prices/bid all zero so it cannot render a countdown or a price, and reuses the
item's stable tradeId because the client keys its record store on tradeId and
re-parents itemData -- so listing the item later UPDATES the row instead of leaving a
duplicate ghost.

Both preconditions are re-checked at response time: the item must actually be in the
`trade` pile, and it must not already own a listing. Two tests cover exactly those.
counts semantics deliberately unchanged -- the inactive row is not counted.

340 tests pass, 0 failed, clippy clean. Deployed; the wire now carries all three
lifecycle states at once (expired 1000000097, active 1000000155, inactive 1000000059)
and that body is preserved as a fixture.
2026-08-17 20:25:46 +00:00
funman300 2e97ff1461 docs+tools: dump the CardsDLL route table; narrow Q2 to one candidate by elimination
Re-entry discriminator came back a CONFIRMED BUG: an unlisted transfer-list item does
not survive a fresh FUT session, so our representation cannot reconstruct trade-pile
membership. Evidence acquisition per instruction, corpus and PE first, no guessing.

Adds route_table_dump.py: static read-only dump of CardsDLL's route table from the
on-disk PE, resolving VA->file offset through the real section table instead of
assuming a single .text mapping. Output preserved as evidence. It settles "is
/tradePile the only relevant route?" -- the table holds 45 routes plus 3 empty admin
slots, and row 30 `ut/%s/tradePile` is the ONLY trade-pile route. There is no
trade-pile items route.

That plus three existing PE facts narrows the representation to exactly one candidate
by ELIMINATION rather than choice: the route carries only twelve-atom auction records;
`pile` (0x226) has no arm in the item deserializer so membership is conferred by the
owning list and cannot be added as a field; of the twelve atoms only tradeState
expresses lifecycle; and tradeState's closed vocabulary (active=1 inactive=2
expired=3 closed=4) has exactly one value not already spoken for.

So an unlisted item can only be an auctionInfo record with tradeState "inactive".
Tagged INFERRED-BY-ELIMINATION, not CONFIRMED: the remaining unknown is whether the
Flash Transfer List RENDERS such a record in the unlisted section. Records the
acceptance test (survive a full FUT reload) and the revised invariant that a
transition is complete only when a fresh session reconstructs the same visible state.

No behaviour change in this commit.
2026-08-17 20:10:26 +00:00
funman300 dcbef721f2 docs+tools: measure the FIFA 17 market gate bytes in the live client
Adds trade_gate_probe.py (read-only: /proc/<pid>/mem O_RDONLY + pread, slide proven
against the on-disk FNV prologue), extending gate_byte_probe.py to vtable slot
+0x270 exactly as the transfer-market analysis asked for.

Measured: IS_TRADING_ENABLED=1 (was 0 in the Python era), TRADE_PILE_SIZE=100
(was 0), watchListSize=50 (was 0), with four controls reading 1. So every
CardsDLL-supplied input that analysis named as a market blocker is now OPEN, which
the Rust host achieves by construction -- it emits userInfo.feature as {} so the
kill switch at 0x180174f19 never arms, and it already sends pileSizeClientData
keys 2 and 4.

This narrows the Actions-panel question to the exe-side UI script term, and rules
out ownership fields, the gate bytes, the cancel route and the state vocabularies
as candidates -- each on measured or PE-derived evidence rather than inference.
2026-08-17 19:09:03 +00:00
funman300 3a51b0ebd4 docs: correct wrong Fire2 header traps in heat2.py + fifa-blaze frame.rs
Both files documented a wrong Fire2 header layout as authoritative, the reader
trap called out in Known Issues:
- heat2.py's module docstring labelled its >IHHHHB3s header 'VALIDATED'. The
  round-trip only validates the payload length + TDF body; decode->encode with the
  same mislabelled header trivially reproduces the capture, so it never tested the
  [10:16] field boundaries. Marked superseded; cite the proven layout; warn at
  build_fire2_frame. Code unchanged (dead tooling).
- fifa-blaze frame.rs: see submodule commit f4f3396.

Bumps fifa-blaze submodule eccd46f -> f4f3396 (FIFA23 stub; not in the prod
container; no prod impact).
2026-08-16 21:29:52 +00:00
funman300 d9e80a774a test(fifa17): add explicit per-SID topology-freeze regression
Case R makes the F3 session-topology invariant explicit alongside the A-Q matrix:
for a single X-UT-SID the frozen empty-My-Packs mode never flips in either
direction (Sentinel stays Sentinel even if a capability later appears; Clean stays
Clean even if the capability is wiped), while a fresh SID from the same IP decides
independently. Complements F/G/K.
2026-08-13 05:18:13 +00:00
funman300 805d754dc8 fix(fifa17): isolate patched-client capability per session
Harden the empty-My-Packs capability binding so a verified FIFA process can never
enable clean/no-sentinel Store topology for another unverified process that merely
shares its source IP. The prototype keyed the decision by source IP alone; two FIFA
processes (concurrent, or a relaunch) share an IP, so an unpatched process could
inherit a patched one's clean-v1 mode and crash. Source IP is now auxiliary only.

- Authoritative key = the per-login UTAS session id (X-UT-SID). /ut/auth now mints
  a fresh unique SID per login (was a shared constant) and opens a session record
  keyed by that SID; the client echoes it on every later call incl.
  /store/purchasegroup (live-confirmed). The legacy constant is still accepted by
  the retired security-question gate only, never to grant clean-v1.
- Session state: _FIFA17_SESSIONS[sid] = {ip, persona, resolver, mode, created,
  last_seen}. Store mode freezes at the first /store/purchasegroup of the session
  and is immutable thereafter. Fail-closed: unknown SID, or a SID presented from a
  different source IP than it was opened on, resolves to the sentinel.
- Launcher capability (out-of-band; cannot know the SID) is matched by (ip, persona)
  as a SINGLE-USE, short-TTL pending, bound to exactly one session at whichever comes
  first: its login (pending predates auth), the registration (session already live),
  or its first store request. Ambiguous same-(ip,persona) concurrent registration is
  ignored-late -> both sentinel (never a wrong clean).
- Session cleanup: activity-based TTL sweep (sessions 3600s idle, pendings 120s);
  reaping only removes expired entries and never affects another live session.
- account_sync now clears only stale pending for the machine (pre-launch hygiene);
  it no longer resets a per-IP mode (there is no per-IP mode any more).

Backend-only: the launcher registration payload (already carries personaId) is
unchanged. Additive; P2 sentinel remains the else-branch and the default.

Tests: matrix A-Q incl. same-IP concurrent (K), same-IP+persona relaunch (L),
same-IP failed-patch (M), late-registration-vs-frozen-sessions (N), TTL expiry (O),
duplicate/idempotent registration (P), and register-before-login pending (Q).
2026-08-13 04:39:53 +00:00
funman300 b25761ea31 feat(fifa17): negotiate clean empty My Packs mode
Backend side of the handshake: suppress the synthetic 65534 My-Packs sentinel
ONLY for a session whose client has registered a verified resolver-guard
capability. Additive; the P2 active-sentinel path is retained as the else-branch
and the universal default. Fail-closed everywhere.

- Per-client state keyed by source IP (client_address[0]; the only per-connection
  discriminator in this single-account, stateless backend): _FIFA17_STORE[ip] =
  {resolver, mode}; mode in {None, "sentinel", "clean-v1"}, guarded by a lock.
- New POST /openfut/fifa17/capability endpoint: accepts only
  {"capability":"empty_mypacks_resolver","version":1,...}; unknown capability or
  version => 400 and records nothing (=> sentinel).
- account_sync (the launcher's required per-launch call) resets the per-ip record
  => a new FIFA process starts unfrozen with no inherited capability.
- Store topology is frozen at the FIRST /store/purchasegroup per session:
  clean-v1 iff a v1 capability is registered, else sentinel; immutable thereafter
  (late capability logged + ignored this session; a disappeared capability does
  not un-freeze a clean session). This enforces the SESSION-STABLE invariant.
- store_catalog zero-owned-packs branch: clean-v1 emits NO mypacks group (the
  client guard routes category -1 to Browse); every other case emits the existing
  active 65534 sentinel verbatim. PACK_CATALOG / pack 70 / normal packs / profile
  untouched. FIFA-17 only; not lifted into game-independent Core.
- Tests: full matrix A-J incl. concurrency isolation (two IPs, no global leak) and
  no cross-process capability leak.

Design: docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md.
2026-08-13 04:03:37 +00:00
funman300 1c396dd562 feat(fifa17): report verified client patch capability
autopatch side of the verified patched-client capability handshake: prove, at
runtime, that the empty-My-Packs resolver guard is active for a specific FIFA
process, and advertise it once on stdout for the launcher to relay.

- Add a per-pid guard verification state derived by a pure, testable
  guard_state_after(cur_before, orig, patch, wrote_ok, cur_after) returning one
  of VERIFIED / UNSUPPORTED_BUILD / WRITE_FAILED / VERIFY_FAILED (NOT_ATTEMPTED
  is the pre-evaluation constant). VERIFIED means the live bytes at RVA 0x14858
  are 7f 0f (JG) after enforcement (from an applied 75 0f->7f 0f, or already
  patched). The existing fail-closed byte guard (guarded_action / STORE_PATCHES_
  GUARDED) is unchanged — this only observes the outcome.
- Emit exactly once per FIFA pid: on VERIFIED,
    [store-guard] verified capability fifa17.empty_mypacks_resolver=1 fifa_pid=<pid>
  otherwise a non-advertising
    [store-guard] guard status=<STATE> fifa_pid=<pid> (no capability advertised)
- Capability constants: EMPTY_MYPACKS_RESOLVER_VERSION=1, fully-qualified name
  "fifa17.empty_mypacks_resolver".
- Tests: 5 guard-state cases + capability-constant assertions (standalone-runnable).

The capability = "the guard was verified in THIS FIFA process", never merely
"the code is present". Design: docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md.
2026-08-13 04:03:37 +00:00
funman300 b0d5e04bb9 fix(fifa17): guard missing store category resolution
Port the PROVEN empty-"My Packs" resolver crash-guard into the canonical
autopatch.py /proc-mem patcher. When no `mypacks` purchase group exists, a
fresh FIFA 17 client resolves category id -1; CardsDLL FUN_1800147f0 at RVA
0x14858 (`JNZ 0x14869`, bytes 75 0f) treats every non-zero category as
resolvable, calls FUN_180014420, gets NULL, and dereferences [NULL+0x48] at
0x180014882 (0xC0000005). Rewriting JNZ->JG (7f 0f) preserves positive-category
resolution (EDI>0) while routing zero/negative categories to the existing
Browse/list-all path -> no NULL lookup, no crash, Store opens on Browse Packs.

- STORE_PATCHES_GUARDED table pins RVA 0x180014858 orig 75 0f -> patch 7f 0f.
- Applied every tick, fail-closed via guarded_action(): apply only when the
  live bytes are the known original; no-op when already patched; SKIP+log an
  unrecognised CardsDLL build (never blindly overwritten).
- Runtime watch loop moved under `if __name__ == "__main__"` so the module
  imports cleanly for unit testing; script behavior is unchanged. Existing
  ProtoSSL cert-gate and STORE_PATCHES enforcement are byte-identical (indent
  only).
- test_autopatch_guard.py: pure test covering PATCH/NOOP/SKIP and pinning the
  exact guarded RVA/bytes.

Proven on the tested build (CardsDLL 4706a881...) by a clean fresh-process
no-sentinel A/B (R1). Dormant while the backend active-sentinel is present.
See docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md PART IV.
2026-08-13 03:13:39 +00:00
funman300 f42279f869 fix(fifa17): keep empty My Packs group client-safe
When the account owns zero unopened packs, store_catalog() emits a synthetic `mypacks` group placeholder (id 65534, absent from PACK_CATALOG). Change its state from "inactive" to "active".

Root cause (bug 6c): FIFA 17's Store/Scaleform path resolves the `mypacks` category even with zero unopened packs (category chosen client-side via the movie's CATEGORY_ID -> screen+0x290; no server field gates it). CardsDLL FUN_1800147f0 then dereferences the resolved group with no null guard, so an absent group crashes the client (CardsDLL+0x14882, [NULL+0x48], minidump-confirmed). An inactive placeholder avoids the crash but makes the Store report the pack unavailable on entry and bounce to the Hub; an active placeholder lets the Store open normally.

65534 stays economy-safe: pack_by_id() returns None, so store_buy()/purchased_items() cannot open it or grant items/coins, and grant_unopened_pack() rejects it. Explicit selection is rejected client-side ("This pack is no longer available") and sends no backend request. This is a FIFA-17 client-compatibility shim (P2), not an EA-authentic representation, confined to the FIFA-17 backend (not OpenFUT Core). A clean zero-pack UX needs a client-side fix (docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md).

Adds regression tests (test_empty_mypacks.py): empty -> one active 65534 placeholder (absent from PACK_CATALOG); non-empty [70] -> no placeholder, genuine pack shown; economy safety; normal packs 1/5/6/7 untouched.
2026-08-13 01:08:37 +00:00
funman300 696386a9c1 client_arm: verify the hosts entry by resolution, not by presence
The old check was `grep easw /etc/hosts && echo ok`. It passed on ANY
matching line -- including a line that shadows ours. glibc returns the
first match, and the sed above only deletes lines this script wrote
(`# openfut`), so a foreign entry earlier in the file wins forever and
re-running the script never helps.

Observed today: a leftover `127.0.0.1 easw.easports.com` from the
single-machine era, before the backend moved to its own host. Every arm
reported "/etc/hosts ok" while the name resolved to loopback.

Now it resolves the name -- the same call the game makes -- and compares
address to address, so a server given as a hostname is handled too. On a
mismatch it prints the offending lines with line numbers and says how to
fix them.

It does NOT delete them. This script writes one tagged line and owns only
that line; silently removing entries a user put there by hand is a bigger
hazard than the shadowing it would cure.

Reported as a warning, not an error, because it is survivable: the
responders advertise the server address, so the game stops using this
hostname after the first redirected contact. FIFA reached the FUT hub
today with this exact misconfiguration in place. Claiming it is fatal
would be wrong, and a check that overstates its findings gets ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:19:52 +00:00
funman300 8f5f54833f ci: tripwire against lab addresses creeping back into tracked source
Cheap insurance, explicitly not the real check -- the semantic tests in
deployment_config.rs are what prove propagation, using two TEST-NET addresses
and bind != advertise. This grep only stops the lab subnet reappearing months
from now when the reasoning has been forgotten.

Deployment config legitimately contains real addresses and lives in gitignored
files, so it is never scanned. The frozen baseline doc is allowlisted BY PATH:
it records what a past deployment actually was, and rewriting it would falsify
the record.

Also swapped the lab IP for a TEST-NET placeholder in the usage examples and
error messages of compose/entrypoint/client_arm. Those were already correct
architecture -- every one requires the address via ${VAR:?} -- but using the
real lab IP as the example is the same 'happens to match our lab' smell, and
placeholders keep the tripwire allowlist near-empty.

Mutation-tested: adding a lab address to a source file makes it exit 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 02:59:41 +00:00
funman300 f451406058 audit: eliminate deployment-address hardcoding; single typed endpoint config
Mandatory OpenFUT architecture audit. Two real defects found and fixed, plus
the config surface tightened so neither class can recur.

DEFECT 1 -- hidden localhost fallback. The Rust host defaulted POW hosts to
127.0.0.1 while every other URL followed OPENFUT_ADVERTISE, so a remote
deployment would emit loopback POW URLs and fail far from the cause. It also
diverged from the deployed Python entrypoint, which derives them
(POW_HOST="${POW_HOST:-$ADV:8094}"). POW endpoints now derive from the
advertised address; explicit overrides still win.

DEFECT 2 -- Default gave loopback silently. `Endpoints::default()` and
`AdapterConfig::default()` supplied 127.0.0.1, so anything constructing a
config by omission got loopback with no signal. Both `Default` impls are
REMOVED. Loopback is now `Endpoints::loopback()` / `AdapterConfig::loopback()`:
an explicit, greppable decision. Production uses `advertising(host)`.

CONFIGURABILITY. `blaze_port` and `utas_port` are now config, not literals.
The advertised Blaze port is our choice -- the client goes wherever
<serverinstanceinfo> sends it -- and 8099 is the client's own built-in default
but still deployment config. A bad port value is an error, not a silent
fallback to the previous one.

TEST-NET EVERYWHERE. Committed fixtures and tests used the lab's real LAN
address; a test that passes because its constant matches the current lab
proves nothing about relocatability. Redirector fixtures regenerated on
RFC 5737 TEST-NET-1/2/3 plus loopback. Harness scripts no longer default the
client IP to the lab address -- client-state.sh now requires it.

SEVEN REQUIRED TESTS in tests/deployment_config.rs plus host-side coverage:
remote config never silently becomes localhost; missing advertise fails
clearly; bind may differ from advertise; changing the Blaze port changes the
redirect; changing the host updates all 200+ generated URLs with no
stragglers; no helper bypasses central config; mutations are detectable.

MUTATION TESTED, and it found a hole in the audit tests themselves. Hardcoding
utas_base, reverting the POW derivation and re-hardcoding the Blaze port were
all caught. Making the redirector read `bind` instead of `advertise` was NOT:
`advertising()` sets bind == advertise, so the two sources were
indistinguishable. That is the single most likely bypass -- the oracle really
does read bind for nucleusConnect -- so the test now forces bind != advertise
and asserts the bind address never reaches the wire. Re-mutated: caught.

Wire behaviour unchanged: oracle fixtures still current, 153 tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 02:55:50 +00:00
funman300 3153a93edf fifa17-recon: drop superseded docker-side tools/data copies
fifa17-recon/tools (authoritative) and fifa17-recon/data now feed the Docker
build directly via the curated runtime-tools.list manifest. The duplicated
fifa17-python/tools+data are removed so the repo has a single source of truth;
the rebuilt openfut-fut-backend:dev image is byte-identical to the previous
deployment (verified: manifest diff empty, 446/446 contract checks pass).
2026-08-10 17:21:58 -07:00
funman300 f64106ed8b fifa17-recon: fix compose dockerfile path for relocated build context 2026-08-10 17:20:27 -07:00
funman300 9faaf12dd7 fifa17-recon: Docker build consumes authoritative tools via curated manifest
Build context moves from docker/fifa17-python/ up to fifa17-recon/ so the
Dockerfile reads the single-source tools/ and data/ trees. Only the 77 runtime
files listed in runtime-tools.list are installed into /app/tools (baseline image
minus the two git-ignored certs, regenerated in-image). memdump and recon
artifacts are excluded via fifa17-recon/.dockerignore.
2026-08-10 17:19:07 -07:00
funman300 83539e33ec fifa17-recon: take running-backend versions of 8 runtime files (direction fix)
The earlier reconcile committed the local working-tree versions of these
files, which are OLDER than the deployed backend. The running container (C)
is byte-identical to docker/fifa17-python/tools (B) and is a strict superset:
it adds profile_path_for/select_account/ensure_security_question (fut_store),
safe_header_for_log/safe_request_path/security_question_route (utas_server),
account_sync_route/_match_call/match_ready_body, plus POW balance fields and
match lifecycle support, with zero unique local functions lost.

Reconciled tree is now a strict superset of B with every shared file
byte-identical; verified via md5 map (0 missing, 0 differing).
2026-08-10 17:12:27 -07:00
funman300 695421cfd4 Merge remote-tracking branch 'origin/main' into fifa17-fut-squad-and-userinfo 2026-08-10 17:08:08 -07:00
funman300 8cba70dc90 fifa17-recon: reconcile authoritative tools with running backend (B)
- Add 8 files present in docker/fifa17-python/tools but missing from the
  top-level tree: fut_accounts.py + 7 test_*.py contracts (all committed in
  the server's docker tree; byte-identical to the running image).
- Preserve newer responder work already matching the running container:
  utas_server.py (offlineSeason), lsx_responder_v2.py (OPENFUT_BIND),
  blaze_responder_v3b.py, autopatch.py, pow_server.py, fut_store.py,
  test_fut_contract.py, fifa17-hook-m1.sh.
- Add 30 newer ghidra_queries (draft purchase/state, SBC 9-26, runtime
  registries). Local tree is now a strict superset of B with all shared
  files byte-identical.
2026-08-10 17:08:06 -07:00
root 28773e7cf1 fifa17-python: sync tools to running container state
The frozen baseline image predates two hot-patches made in the running
container after build:
* utas_server.py: FUT_MODES-gated offlineSeason block in GetHubData's club
  response (keeps the offline-season summary valid)
* test_hub_offline_season_contract.py added to /app/tools

Sync fifa17-python/tools to the running container (verified byte-identical,
237 files incl. the redir cert pair) and snapshot the live FS as
openfut-fut-backend:python-running-2026-08-10 (docker commit). A fresh build
from the committed sources now reproduces the running backend exactly
(baked SHA256SUMS.txt diffed against the container manifest: identical).
2026-08-10 23:56:58 +00:00
root 3ae5587a38 docs: baseline manifest equivalence note (pycache + cert deltas expected) 2026-08-10 23:54:59 +00:00
root 70a64e3709 fifa17-python: commit working FUT backend deployment (client/server split)
Freeze the running offline FUT backend into version control as
fifa17-recon/docker/fifa17-python/ - declarative and rebuildable from a
fresh checkout:

* OPENFUT_BIND / OPENFUT_ADVERTISE client/server split in the responders
  (lsx, blaze, roster, utas, pow) + entrypoint.sh; OPENFUT_ADVERTISE is
  required for remote mode (compose and entrypoint fail without it)
* docker-compose.yml reproducing the frozen baseline container exactly
  (env, ports incl. the 8085->8080 POW-content remap, /state bind, restart)
* .env.example / .env for site config - the LAN IP is never hardcoded in source
* tools/ + data/ staged from openfut-fut-backend:python-baseline-2026-08-10,
  verified byte-identical to the running container at freeze time
* client_arm.sh (the 105 client-side arming counterpart)
* Dockerfile bakes /app/SHA256SUMS.txt so any image is self-identifying
* docs/BASELINE-python-2026-08-10.md: frozen image/container/hash record,
  restore instructions and rebuild-equivalence procedure

Secrets (redir key/cert, .env) and runtime state (docker/state) stay gitignored.
The live container is untouched pending the .105 launcher audit.
2026-08-10 23:54:04 +00:00
funman300 cc694774a3 wip: checkpoint FIFA 17 SBC research for Windows migration 2026-08-07 12:03:22 -07:00
funman300 3d3239bab9 feat: document and stage FIFA 17 SBC hook workflow 2026-08-07 11:44:05 -07:00
funman300 a7e3e43ae9 fifa17-recon: the refusing modes have no server fix, and the hub-atom lead is cosmetic too
Completed the refusing-modes workflow (ground truth + 4 per-mode investigations +
adversarial verify each + synthesis). All four mode families -- Seasons, Draft,
SBC/Objectives, Tournaments -- are NOT_SERVER_REACHABLE, HIGH confidence, all four
adversarial refutations failed.

Live re-confirmed on pid 24653 (slide proven via FNV control): every named
mode-gating byte reads ENABLED=1 (IS_FRIENDLY_SEASON_ENABLED +0x1fd3a,
IS_TOURNAMENT_QUIT_ENABLED +0x1fd3b, IS_DRAFT_MODE_ENABLED +0x1fd3d, plus the
unnamed offline-draft-enable +0x1fd3e) yet the tiles stay greyed.

The new lead this pass added -- do the six /hub mode sub-objects gate availability?
-- is refuted: friendlySeason/offlineSeason/onlineSeason/draftSummary/tournament/
tournamentProgress carry only stats and display strings, no enabled/available/
unlocked atom. They are cosmetic, exactly like hub.tradePile. The one
server-writable input that exists (friendlySeasonsEnabled -> +0x1fd3a via applier
FUN_18011dc50) has its sole reader in the packed FIFA17.exe front-end via a vtable
getter with no CardsDLL caller, and it is already 1. The refusal is decided in the
Denuvo-packed Frostbite front-end, which has no server surface.

docs/plan-2026-08-06-refusing-modes.md: full evidence chains, gate-byte table, the
six sub-deser field maps, per-mode verdicts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lrx9to3pihN6Sm9sXgc8np
2026-08-06 18:59:23 -07:00
funman300 31fc590b99 fifa17-recon: the FUT-hub Transfer List tile counts, and the hub parser is NOT reflection
The Transfer List hub tile read "0 items / Selling 0" while a card was actively
listed. Enumerating the /hub parser FUN_180139610 straight from the on-disk
CardsDLL (objdump) refutes the old ENDPOINT_MAP claim that it uses C++ reflection
with "no atom ladder, nothing to enumerate": it has an ordinary running-sum atom
ladder reading 18 atoms. The tile is fed by hub.tradePile (0x333), a nested object
(sub-deser 0x18013ead0) reading count/selling/sold as scalar ints -- the same
scheme as GetAuctionCount, so serving it in the hub body is freeze-safe. The tile
never re-polls the standalone /tradePile/counts, which is why fixing that endpoint
alone did not move the tile.

Also: the hub tile polls LOWERCASE tradepile/counts while the Transfer List screen
uses camelCase tradePile; our case-sensitive routes matched only the screen, so the
tile's counts call fell through to /trade and got a shape the counts deser skips.
Made the tradePile routes case-insensitive.

And bake the proven transfer-market flags (FUT_TRADING/PILESIZES/TRADEABLE/
DISCARD_TABLE/DISCARD_SEND) into openfut-fut.sh so a plain `start` brings up the
working state instead of regressing trading to greyed-out.

- tools/utas_server.py: hub_data() serves tradePile:{count,selling,sold};
  tradePile routes now re.I
- tools/openfut-fut.sh: utas launched with the working flag set
- docs/ENDPOINT_MAP.md: full 18-atom hub map + tile map, correction of the
  reflection claim
- tools/ghidra_queries/objdump_atom_ladder.py: the objdump-based atom-ladder
  decoder used to derive the above

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lrx9to3pihN6Sm9sXgc8np
2026-08-06 18:28:52 -07:00
funman300 245c22161b fifa17-recon: correct tradePile/counts shape, and narrow the marketdata array fix
Two follow-ups on the working transfer market.

1. GET /tradePile/counts now returns the FutGetAuctionCount shape
   ({count, maxAuctionsAllowed, offered, selling, sold}, all scalar ints, atoms
   0xbc/0x1bf/0x1e5/0x2b8/0x2c9) via a dedicated route ordered before /tradePile.
   Previously it fell through to tradepile_route and got the auction-LIST body, which
   the counts deser skips, leaving every tally at its constructor default. Survivable
   but wrong; the doc flags the loaded byte at +0x28 as gating a completion-handler
   branch. selling reflects real STORE.listings().

2. Narrowed the marketdata bare-array fix to /pricelimits only. The client sends TWO
   marketdata requests: /marketdata/pricelimits (GetSuggestedPricing, a bare array,
   the thing that froze) and plain /marketdata?defId=N (price comparison, an OBJECT).
   The prior commit returned the array for both, which the contract suite caught
   (test_market_bodies: 'list' has no attribute get) -- plain /marketdata wants
   {minPrice,maxPrice} and was never the freeze. Returning the array for it would be
   the same desync in reverse. Now: pricelimits -> array, plain marketdata -> object.

The contract suite catching my over-broadened fix before it reached the game is the
suite doing its job. 439 contract checks pass, market unit suite passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:39:38 -07:00
funman300 43557989f5 fifa17-recon: the transfer market works -- listed a card end to end, no freeze
The subsystem that was fully greyed-out this morning now lists a card on the transfer
market: price screen, Submit, "your item is now up for trade", TRANSFER LIST 0/100,
auctionCount 1, and STORE.listings() holds the auction. Every step verified at the
instruction level first, then confirmed live. Three fixes, all behind flags, all off by
default until this run proved them.

1. WE WERE BANNING OUR OWN TRADING. userInfo.feature (atom 0x11c) is a RESTRICTION map,
   not a grant; we sent feature={"trade":true}, which is a trade BAN. Verified in
   q_feature_trade.py: FUN_18013ec10 parses feature/trade into userInfo+0x17c, and at
   the massinfo END_OBJECT the client runs
     cmp byte [rsi+0x17c],0 / jz skip / mov dword [rsi+0x50],0
   feeding applier 0x18011dc91 -> IS_TRADING_ENABLED (model+0x1fd2e) = 0. It runs LAST
   and unconditionally, which is why the gate read 0 all day regardless of /settings or
   the Blaze config store. FUT_TRADING sends feature={} instead. Live: gate flipped
   0 -> 1 on UT re-entry (model rebuilt, pointer changed, byte read 1).

2. TRANSFER LIST CAPACITY 0/0. pileSizeClientData (massinfo atom 0x227, parser
   0x18013adb0) is the capacity, NOT the "MY CLUB counter" the old comment claimed.
   Verified in q_pilesize_keys.py: exactly two storing arms, key 2 -> model+0x1fd1c
   (TRADE_PILE_SIZE) and key 4 -> +0x1fd20 (watch list), every other key SKIP'd. The old
   code would have sprayed the 246 club count into the capacity. FUT_PILESIZES sends
   key 2 = 100, key 4 = 50. Live: capacity read 0 -> 100, header showed 0/100.

3. THE PRICE SCREEN FROZE THE CLIENT. GET marketdata/pricelimits was answered with an
   OBJECT {minPrice,maxPrice}; the deser 0x180163ee0 reads a BARE TOP-LEVEL ARRAY
   (root loop while tok != 0xd), so object-where-array desynced the SAX reader into the
   0x1801c7f1a busy loop (confirmed live: utime climbing 227 ticks/s, core pinned).
   Verified in q_pricelimits.py: element fields defId 0xcf, maxPrice 0x1c2, minPrice
   0x1ca, all scalar ints. marketdata_route now returns a bare array, one element per
   requested defId. Live: price screen opened and Submit succeeded.

Corrected along the way, all now in the code: two prior "trading root causes" from
earlier today were wrong (the Blaze IS_TRADING_ENABLED keys are output-only names, and
the applier is a virtual method at vtable+0x988, not unreachable). Those refutations are
recorded in blaze_responder_v3b.py and the doc.

Also lands the transfer-market recon doc (plan-2026-08-06-transfer-market.md) and the
market Ghidra query set.

Server-authoritative economy note: the 5% transfer fee and the price bands (currently a
150..15000 placeholder per defId) are not yet real; that is refinement, not a freeze.
The live-auction market SCREEN ("List on Transfer Market" browse) is a separate surface
still to do (P4 auction-counts route, P5 empty market bodies).

Live: 439 contract checks pass. Card listed and persisted, auctionCount 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:33:10 -07:00
funman300 a3fd51692f fifa17-recon: the trading gate is still shut, and two of yesterday's conclusions were wrong
Ships the club-item subtype correction and the tradeable plumbing, and records two
refutations of claims made earlier in the same session. Nothing here is a working fix
for trading; the honest state is that the gate is still closed and we now know more
about why.

REFUTED 1: "the Blaze client-config store opens the trading gate". It does not, and the
flag is inert. IS_TRADING_ENABLED is an OUTPUT NAME. FUN_18006cc60 is a publisher: at
0x18006ccc6 it calls [rax+0x270] to READ gate byte 0x1fd2e, then lea rdx,[
IS_TRADING_ENABLED] and hands the value out under that name. The only rip-relative
reference to the literal 0x1801fc118 in all of .text is that lea; there is no comparison
against it anywhere, so no client-config key of that name can be read as an input. That
also undermines the IS_* store keys shipped beside it: their apparent success was never
actually attributed to them.

REFUTED 2: "the gate byte flipped to 1". It reads 0. It was measured as 1 shortly after
CardsDLL mapped and that was over-claimed as a success; a thorough re-measurement read 0
on the SAME pid and model pointer, and a fresh session reads 0 with an unambiguous raw
dump (model+0x1fd18.. = 01000000 00000000 00000000 00000000 3c000000 01 01 00 01, the 00
being 0x1fd2e). Either the first read was transient or something clears it after login.
The only writer is FUN_18011dc50 at 0x18011dc91, so a 0 means something RAN and wrote it.

AND THE "/settings IS DEAD" CLAIM FALLS TOO. FUN_18011dc50 is not unreachable: it is a
VIRTUAL method at model vtable slot +0x988 (absolute pointer 0x18021cc28). A direct-call
search found no callers because Ghidra does not resolve virtual calls, which is the same
dispatch-form trap that has now produced seven wrong verdicts here. The real chain is
    settings response -> FUN_180174630 -> FUN_18013c6d0 (deser)
      -> completion callback FUN_180173e00 -> vt+0x988 and vt+0x998 -> gate bytes
and FUN_180173e00 bails before applying anything unless the int at response+0x1c is
zero. Which atom writes +0x1c is unknown and is the thing worth chasing.

The measurement behind that claim also had a gap: it checked +0x1fd14, +0x1fd4c and
+0x1fd54 for the maximumTradePileSize=77 probe but NOT +0x1fd1c, which is the actual
TRADE_PILE_SIZE (read via vt+0xa58 = FUN_18011bf30). So the probe never tested the field
it needed to. Serving 77 and reading +0x1fd1c is the clean falsifier and is still open.

Recovered and worth keeping: an authoritative slot-to-name table from the publisher.
  vt+0x270 IS_TRADING_ENABLED -> +0x1fd2e        vt+0x2b0 IS_FRIENDLY_SEASON_ENABLED -> +0x1fd3a
  vt+0x2b8 IS_TOURNAMENT_QUIT_ENABLED -> +0x1fd3b vt+0x2c0 IS_PROCESSING_STATE_ENABLED -> +0x1fd3c
  vt+0x2c8 IS_DRAFT_MODE_ENABLED -> +0x1fd3d      vt+0x2d8 IS_STORY_MODE_REWARD_ENABLED -> +0x1fd3f
  vt+0x2f0 IS_RETURNING_USER_REWARDS_SCREEN -> +0x1fd40  vt+0xa58 TRADE_PILE_SIZE -> +0x1fd1c
That also locates the red TRANSFER LIST 0/0: it is +0x1fd1c, currently 0.

WHAT IS ACTUALLY SHIPPED HERE, all default off:
  * FUT_TRADEABLE sends untradeable=false. Verified landing at item+0x49 (stored
    INVERTED by case 0x361) on a live club record. Applied on every READ path, not only
    in _item(), because the save holds 246 items minted before the flag existed and the
    club route serves them straight from the save. That gap was caught by reading the
    served JSON, not by unit-testing the factory.
  * FUT_TRADING adds tradingEnabled and IS_TRADING_ENABLED to the Blaze config. Kept
    only as a record of the refutation, with the reasoning inline so nobody retries it.
  * fut_clubitems FAMILIES subtypes corrected: kit 9, stadium 10, badge 11 (cardtype 7,
    not 9), ball 30, league logo 31. Every previous value sat in the 0x91..0x96 TROPHY
    block. probe_shelf's candidate set lacked 9, 10 and 11, so the probe route the docs
    preferred could never have answered this for three of five families.
  * Club kits and badges now carry teamid, reintroduced ALONE after the 2026-08-05 crash
    (which was never bisected; value is the established suspect and that response also
    carried 30 items across five wrong subtypes). itemType dropped: it was unobserved and
    never copied into the record.

Live: 439 contract checks, 414 card-family checks, market suite, all pass. The transfer
market still refuses with zero requests and the menu entries are still greyed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 12:47:02 -07:00
funman300 e578443d73 fifa17-recon: tradingEnabled is 0, and that is why the transfer options are greyed out
Card-subsystem pass, 11 agents plus three adversarial verifiers. Full writeup in
docs/plan-2026-08-06-card-subsystem.md. Two of the results below correct things I
committed earlier today.

THE GREYED-OUT TRANSFER OPTIONS ARE EXPLAINED. "Place on Transfer List" and "List on
Transfer Market" have been disabled in the reveal screen and nobody knew why.
TO_TRADE_PILE (FUN_1801a7260) requires BOTH item+0x49 tradeable AND a service gate at
vtable slot +0x270. That slot is `movzx eax, byte [rcx+0x1fd2e]; ret`, and 0x1fd2e is
the tradingEnabled gate byte. Read live and reproduced independently:

  slot +0x2b0 friendlySeasons  disp 0x1fd3a  VALUE=1
  slot +0x2c8 draftMode        disp 0x1fd3d  VALUE=1
  slot +0x2e0 packOpeningAnim  disp 0x1fd45  VALUE=1
  slot +0x270 tradingEnabled   disp 0x1fd2e  VALUE=0

tradingEnabled is the FIRST gate byte found that is not 1. This partly rehabilitates
the settings work from this morning: that plan died because every gate it targeted
already read 1, and the conclusion drawn was that the settings array does not matter.
It does. It matters for a flag nobody was looking at, and tradingEnabled is ALREADY in
_SETTINGS_KEEP, plumbed and never sent because _SETTINGS_MODE defaults to off.

So the fix is two things, not one: FUT_SETTINGS=keep AND untradeable false. Shipping
only the boolean would look like the finding failed.

THE DISCARD "MISS" NEVER EXISTED, which corrects e3092ca. fcc_discardcoins is resident
and complete, the client lookup runs and is correct, and it lands at item+0x3c. The
tile simply binds +0x38, which is OUR value, and nothing falls back to +0x3c. So the
client was not failing a lookup; it was faithfully displaying the 0 we sent. Same
observable, completely different mechanism, and the version in e3092ca is wrong.
FUT_DISCARD_SEND remains exactly the right fix, now for the right reason.

WHAT FUT PAYS FOR STAFF IS NO LONGER UNKNOWN. Same formula, but the rating input is the
table `value` column: gkcoachcards 9000081 value 66 gives 36, and the client's own
+0x3c reads 36. That closes the gap I flagged in e3092ca as not-guessed.

CLUB ITEM SUBTYPES, the standing unknown in CARD_SYSTEM.md, are settled: kit 9,
stadium 10, badge 11 are cardtype 7 (not 9), ball 30, league logo 31 by elimination.
All five constants in fut_clubitems.FAMILIES are wrong and all five currently sit in
the TROPHY block 0x91..0x96. Note the probe route the doc preferred could never have
answered this: probe_shelf()'s candidate set lacks 9, 10 and 11, so it would have spent
a launch and returned nothing for three of five families.

THE CARD MODEL FIELD MAP now exists, 28 rows, every field we send with the byte it
lands on and whether the client keeps it. Built by diffing what we serve against the
parsed records in the live heap (stride 0x180, anchored by a satellite back-pointer
rather than by assuming the +0x38 offset). Corrections that change what we serve:
+0x54 is the discard LEVEL not itemType, +0x49 is untradeable INVERTED, +0x5c is
itemState, definitionId is not an atom at all.

A HIGH-CONFIDENCE ABSENCE CLAIM WAS REFUTED IN VERIFICATION: playStyle IS stored, at
+0x88. Its controls were raw scalars while playStyle is a DECODED scalar, so the
control was the wrong FORM. That is a new variant of the absence trap, which has now
cost six wrong verdicts, and it is recorded in the doc.

FIX TO MY OWN PATCH from e3092ca: purchased() and last_pack() lacked the _with_discard
wrapper that items() had, so the pending pile, which is the one place a quick-sell
value is actually read, served unstamped cards. Found by verification, not testing.
All three read paths now stamp.

Correcting an overstatement in e3092ca: "turning the flag off is a true revert" holds
for the read paths, which copy, but NOT for cards minted while armed, because _item()
stamps at creation and those persist (9 items currently). Kept deliberately: the pack
reveal serves itemList straight from open_pack(), not through purchased(), so removing
creation-stamping would leave the screen that matters unstamped. Persisted values are
correct and self-heal, since every read recomputes and overwrites.

Nothing here has been on screen. Six patches are proposed in the doc as pasteable text,
env-flagged, defaulting off, none applied.

Live: 439 contract checks, 414 card-family checks, market suite, all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 10:07:01 -07:00
funman300 e3092ca0f9 fifa17-recon: the client now shows the quick-sell value it is actually paid
Follow-on to 21a81ad, both halves confirmed live.

FUT_DISCARD_TABLE IS PROVEN. Quick selling a 75-rated rare gold paid 600 and the
balance moved 9,844,900 -> 9,845,500, exact. The invented tier would have paid 150.
75 * 800 / 100 = 600, straight off the recovered fcc_discardcoins row.

BUT THE SCREEN SAID 0, which is why this commit exists. The value was right and
invisible: "Quick Sell 0" on the card and "Quick Sell all remaining Items 0" too, so
the wallet contradicted the display on every card. Cause is the guard the table work
had already reversed. FUN_18013fe00 stores our discardValue (atom 0xd7) at item +0x38
and 0x180141025 skips the client's own fcc_discardcoins lookup only when that value is
NON-ZERO. We seeded 0, so the client ran its own lookup, that lookup returns no row for
our cards, and it rendered 0.

FUT_DISCARD_SEND puts the value on the wire and the client uses ours verbatim. Live
result, one launch:
    a 77-rated rare gold shows "Quick Sell 616"   (77 * 800 / 100 = 616, exact)
    "Quick Sell all remaining Items" shows 5,640
and 5,640 is exactly the sum of the ten rated PLAYER cards in the pending pile. The
eleventh, a consumable, is excluded by the client from the bulk figure; the twelfth is
a staff card we deliberately send no value for. Both halves of the display now agree
with what the server credits.

WHY THE CLIENT'S OWN LOOKUP MISSES for our cards is still UNKNOWN. This routes around
that question rather than answering it, and it is worth answering.

TWO CORRECTNESS FIXES THE REPORT DID NOT COVER, both found by running the whole save
through the formula rather than trusting the 22/22 sample:
  * The recovered formula scales by rating, so a rating-less STAFF card collapses to 0.
    The old tier paid 50, so shipping it as-is was a regression. discard_value() now
    returns None when the formula does not apply and callers fall back. What FUT really
    pays for staff and consumables is UNKNOWN; the likely answer is the unscaled table
    price, but that is a guess and is not shipped as one.
  * A missing table row also returns None rather than 0, for the same reason.
  Verified across all 246 club items plus the pending pile: not one pays 0 coins.

discardValue is stamped inside the single item factory so every path gets it (pack
contents, starter grant, club, market), and additionally on the club READ path, because
_item() only covers cards minted from now on while the save already holds 246 built
before the flag existed. Stamped on the way out and NOT persisted, so the save stays
clean and turning the flag off is a true revert. Confirmed: 0 items on disk carry the
key.

FUT_DISCARD_SEND requires FUT_DISCARD_TABLE and silently stays off without it, so the
invented tier can never reach the screen and become authoritative-looking.

Freeze risk: low and in the safe direction. discardValue is a plain INT read by the
scalar getter 0x1801c79d0; every freeze on this project has come from an object or
array where a scalar was expected, never the reverse.

Both flags still default OFF. Live: 439 contract checks pass, market suite passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 07:55:05 -07:00
funman300 21a81ad63c fifa17-recon: the real quick-sell table, and the grouping bug is not in our layer
Multi-agent pass over the store subsystem, 11 agents, findings run through three
adversarial verifiers. Full writeup in docs/plan-2026-08-05-store-subsystem.md.

THE REAL DISCARD TABLE IS RECOVERED. quick_sell() paid an invented rating tier
(600/300/150/50) that was wrong for every single card. The real table is
fcc_discardcoins in the client's own game DB, 141 rows keyed (cardtype, level, rare),
read out of the running client and verified 22/22 against live items:

    value = round_half_up(rating * price / 100)
    level    = 3 if rating >= 75, 2 if 65..74, else 1   (0x180141e8a..0x180141ea3,
               derived from rating, NOT a wire field)
    cardtype = FUN_1800d8330(cardsubtypeid), decoded from its jump table and checked
               across every subtype 0..599 with zero disagreements

A 94-rated gold rare is 752, not 600. A 76 rare is 608, not 150. A 55 bronze is 17,
not 50.

This also closes a disagreement nobody had noticed: the CLIENT already computes and
displays the correct value locally whenever our discardValue (atom 0xd7) is 0 or
absent. FUN_18013fe00 stores our value at item +0x38 and the guard at 0x180141025
skips the local computation when it is non-zero. So the screen has been showing the
real number while the server paid a made-up one, on every quick sell ever made.

Verified beyond what the report claimed, because a missing table row pays ZERO and
that would be a regression the old flat tier could not produce: across all 236 items
in the live profile, 230 map to cardtype 1 and 6 to cardtype 6, and NOT ONE would pay
0 coins. Table reproduces at 141 rows and the worked example lands exactly.

ZERO WIRE CHANGE, FUT_DISCARD_TABLE default off. Nothing new is sent; only the coin
figure the server credits moves. This is the patch worth defaulting on after one
in-game check, which is simply quick-selling a card and seeing the coins paid match
the value the card was already displaying.

THE GROUPING BUG IS NOT IN CARDSDLL, and the fix ranked first would have wasted a
launch. Live in the running client all three display groups own exactly the right
pack, there is exactly one copy of each pack record in 4 GiB, and nothing we send is
mis-parsed. The parsed model is correct and the Scaleform layer picks the wrong pack
when turning a tile click into a category id. displayGroupAssetId is served as 1/5/6
while the screen's category field reads 3, and group tiles carry a hardcoded
CATEGORY_ID of 0. Confirmed by direct read: ordinal 3, assetId 6, i.e. Premium, while
the last click was Gold.

The heap map that made this possible, all scoped to one pid: display-group vector
control block, 3 elements of 0x108; group record fields at +0x00 sortPriority,
+0x04 displayGroupAssetId, +0x40 a one-element pack vector; inner pack record 0x1a8
with packType at +0x38, ids at +0x70/+0xac, price at +0xa0, quantities at +0xc0..+0xd0.

extPrice SHOULD BE DELETED, not corrected. Both sub-parsers read only
externalPriceId; amount and currency are discarded. Sending the key at all creates an
"mtx" currency row that switches on a real-money price line the client can never fill
offline, which is the literal "or %1s" on every tile.

A WORRY NOBODY HAD RAISED, and I confirmed it from our own logs: the client has sent
packId 6 on every purchase it has ever made, four for four tonight and six for six
across history. We have never observed a successful buy of anything but Premium Gold.

Also settled: FUT_STORE_DISPLAYGROUP=0 is the right resting state, argued from
mechanism rather than from history; FUT_USERINFO=packs stays off because the
unopened-pack counter is client-mutable and the flag ladder silently drops squadList;
POST /user is a latent hard freeze that has never fired because the client never
issues that POST.

Honest coverage: the ActionScript layer is unread by everyone and every remaining
store mystery lives there.

Live: 439 contract checks pass, market suite passes, both flags off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 07:43:51 -07:00
funman300 3f3d5704a7 fifa17-recon: quick sell has never been reachable, and finalFunds is the rendered price
Live run, 2026-08-05 evening. Three results, one of them a route that has been dead for
the whole life of the project.

QUICK SELL WAS NEVER SERVED. Captured on the wire:
    DELETE /ut/game/fifa17/item/100000240      (single card, id in the URL, no body)
ENDPOINT_MAP documented the path as `ut/delete/game/%s/item`, ROUTES was built from the
doc, and the regex therefore never matched a real quick sell. Every quick sell fell
through to the catch-all, so quick_sell_route() and STORE.quick_sell() behind it had
never once been called.

The empty response was not a harmless no-op. This body is BALANCE-BEARING: the client
takes its coin total from it, so with totalCredits absent it rendered an uninitialised
value. A real session showed 1,133,686,384 coins against a true balance of 9,889,600.
Display artifact only, corrected by the next GET /user/credits, and the save was never
touched, but it is the reason a stub is not acceptable here.

quick_sell_url_route() serves the real form and returns the corrected shape,
{"items":[{"id":N}],"totalCredits":N}. DEFAULT ON, which the house rule now permits:
two quick sells fired through it in one session, each credited 150 and removed the
card, and the coin arithmetic reconciled exactly against the 15,000 pack purchases
either side of them. An unknown id returns {"items": []} rather than claiming a sale we
cannot account for.

Still UNKNOWN and deliberately not chased: whether the client ASSIGNS totalCredits as
the new balance or ADDS it as a delta. We send the new balance. The discriminating
window is about five seconds wide, because the client refetches GET /user/credits
straight afterwards, so either reading self-corrects and the practical impact is a brief
wrong number. It mattered only while we answered {}, because that garbage persisted.
The credit amount is STORE.quick_sell()'s invented rating tier, not FUT's real discard
table, which remains unknown.

finalFunds IS THE RENDERED COIN PRICE. Served funds=15000 / finalFunds=4321 on one pack
and the tile read 4,321. funds is not displayed. ENDPOINT_MAP updated to CONFIRMED LIVE
with the method recorded. FUT_PRICE_PROBE, the flag that produced it, stays default off
and is disarmed: it puts a price on a tile that the buy path does not charge.

TWO UNPLANNED FINDINGS, both recorded for the next round rather than fixed here:
  * The store grouping is broken and it is NOT cosmetic. All three packs collapse into
    one display group, and the Bronze, Gold and Premium group tiles all drill into the
    same single Premium Gold pack, so TWO OF THREE PACKS CANNOT BE BOUGHT. We send
    displayGroup {"value": name} but never displayGroupAssetId (0xda), so everything
    lands in group 0. The docs had this parked as a cosmetic "tiles read unknown"
    issue; it is an availability bug.
  * The FIFA Points price renders as the literal "or %1s", an unsubstituted printf
    placeholder. extPrice.finalPrice is served as {"amount":N,"currency":"mtx"} and
    "mtx" is evidently not a currency token the client resolves. Cosmetic.

Corrected in passing: packContentInfo DOES reach the tile (11 ITEMS / 11 GOLD /
11 RARES against exactly what we serve). An earlier screen showing zeros was the
display-GROUP level, which carries no content info. D3 was right.

Live: 439 contract checks pass, both probe flags off, store prices back to honest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 20:03:41 -07:00
funman300 89da7b7609 fifa17-recon: /hub refutes yesterday's envelope conclusion, and two ENDPOINT_MAP freezes
Three things: the envelope rule was wrong and is corrected, /hub is settled, and two
documented response shapes that would freeze the client are fixed.

THE CORRECTION. The previous commit concluded that a three-token root consumes `{`, the
first field name and that field's value without dispatching them, so the first key of a
flat body was silently eaten, and that `login` had therefore never been delivered on
POST /user. That is WRONG and is withdrawn, along with the claim that the key order of
the auth dict is load-bearing.

The first call to FUN_1801c7f10 returns token 7 and consumes NO input. It is a
once-only start-of-document token, guarded by the flag at parser+0xda together with the
zero character counter at parser+0x30. So the three tokens are BOF, `{`, and the FIRST
FIELD NAME, and the key loop dispatches from that first key onward. The `== 10` test on
the third token is not an envelope check, it is the empty-object early-out: for `{}` the
third token is END_OBJECT and the root exits with its constructor defaults intact, which
is why answering `{}` has always been safe.

Corrected enum: 7=BOF 9=START_OBJECT 10=END_OBJECT 11=FIELD_NAME 12=START_ARRAY
13=END_ARRAY. The enum itself was right before; the inference from it was not.

HOW IT WAS CAUGHT, which is the part worth keeping. Not by more decompiling. /hub is
served flat and the wrong model predicted its first key would be discarded, so the
prediction was checked against the client's own memory: clubPlayers read back as 205,
the value the server sent, at model+0x1fd70+0x3c with the slide proven against the FNV
prologue first. One live read refuted a chain of otherwise sound static reasoning in
about a minute. tools/hub_counter_probe.py keeps it repeatable.

Consequence worth flagging: a wrapper is not just unnecessary for these roots, it would
be harmful, since a wrapper key hashes to an atom with no arm and the whole object is
skipped. That makes the createPackResponse envelope DOUBTFUL rather than confirmed.
Atom 0xbe has no arm in FUN_180162880. There is no live evidence either way because
nothing has ever parsed that body, so the buy path is left exactly as it is.

TWO ERRORS OF MINE ON THE WAY, both recorded in the doc because both are cheap to
repeat. I searched for RS4:FutGetHubServerResponse, found nothing and reported that no
hub class existed; the class is FutGetHubDataServerResponse (literal 0x18022ce40,
vtable 0x18022cd48, deser 0x1801738b0, control FutSquadSave -> 0x180171a60 matched in
the same run). Then I scanned 152 deserializers for clubPlayers, got zero hits and a
passing control, because the guard is `!= 0x90` and my pattern only matched `== 0x`.
The control passed only because auctionCount happens to use `==`. A control that does
not exercise the same code shape as the target is not a control. The comment already at
utas_server.py:1076 had the hub chain right the whole time.

ENDPOINT_MAP corrections, both freeze-risky as written, neither affecting what we serve
today:
  * duplicateItemIdList is an ARRAY OF OBJECTS (element deser 0x180138e10), not the int
    list at :1095. Bare ints where the element parser expects objects is a tokenizer
    desync, i.e. a hard freeze at 0x1801c7f1a. Control that this is not a misread:
    dreamSquads 0xe9 in FutMoveCard genuinely is a bare int array.
  * FutDiscardCardServerResponse is {"items":[{"id":N}],"totalCredits":N}. There is no
    top-level id.

No behaviour change. utas_server.py is comment-only. 439 contract checks pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 19:35:27 -07:00
funman300 1605e6effd fifa17-recon: the envelope rule, and the one key of the auth body that is silently eaten
The open question was whether a response deserializer DESCENDS a wrapper or PROBES for
one. Three roots spend an identical three tokenizer calls before dispatch, yet we serve
some bodies wrapped and some flat, and not all of those could be right.

TOKEN ENUM, decoded from the class table at DAT_18023dd40 and the switch in the
classifier FUN_1801c67a0 (the push/pop arms key off container state 2 = object,
3 = array):

  9  START_OBJECT      case 0x64, pushes state 2
  10 END_OBJECT        case 0x65, pops state 2
  11 FIELD_NAME        confirmed independently: FUN_18013bd40 tests +0xd0 == 0xb then
                       atom-hashes the string at +0xf8
  12 START_ARRAY       case 0x66, pushes state 3
  13 END_ARRAY         case 0x67, pops state 3
  1  error             the caseD_78 sink

So the three tokens are `{`, the first FIELD_NAME, and the token opening that field's
value. The envelope is structurally required and its name is NEVER hashed, which is why
FutCreatePack's ladder has no arm for createPackResponse (0xbe) and does not need one.
Coverage for that absence: the ladder has exactly four arms (0xec, 0x16e, 0x1dd, 0x264)
and 0xbe does not occur anywhere in the full 4702-char decompile, printed in full.

The competing reading rested on a factual error. It claimed the /purchased root spends
the same three tokens. FUN_180124ee0 spends TWO and hands off to FUN_18013bd40, which
spends the third. Same total, split across two functions. /purchased never was a
counterexample.

THE BUG THIS FOUND IS NOT THE ONE THAT WAS PREDICTED. The doc expected starterPack,
squad and userData to be swallowed on POST /user. They are not. FutCreateUser
(0x18014cc60) has ladder arms for exactly the five keys we send, and four of them
dispatch correctly at the outer level. The one that does not is `login`: its name is
eaten as the anonymous envelope and its value as the third token. It has an arm, so the
client wants it, and it has never once been delivered.

The second-order consequence matters more than the first. The key order of that dict is
load-bearing and nothing said so. Put userData first and the client loses the entire
user record, silently, with no error and no log line. That warning now sits in the code
next to the dict, which is the only place someone about to reorder it would look.

No behaviour change here. The utas_server.py edit is a comment. 439 contract checks
still pass. The probable proper fix, wrapping all five keys one level down inside a
single envelope key, is a hypothesis with a mechanism rather than a proven fix, and it
touches the login path, so it is not made here and would go behind a flag defaulting
off.

Writeup is section 2 of docs/plan-2026-08-05-pack-opening.md, added by the previous
commit. Opened by this and still UNKNOWN: GET /hub is answered with a flat two-key
body, which under this rule a three-token root would silently truncate, but there is no
FutGetHubServerResponse class and neither atom has a code xref, so /hub may not go
through a generated root at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 19:25:01 -07:00
funman300 afdbb364ca fifa17-recon: pack opening reversed end to end, and there is no pack-inventory endpoint
A twelve-agent pass over the parts of pack opening we did not understand, run against
the live client (CardsDLL slide proven, not assumed) plus static CardsDLL. Findings
below survived an adversarial verification round that corrected several of them; where
a verifier and a finder disagreed, the verifier won.

THE HEADLINE IS A NEGATIVE, and it deletes work rather than creating it. There is no
pack-inventory endpoint in FIFA 17 and there never was. Proven three independent ways:
the 48-entry UTAS route template array at 0x18021df80, a regex for "ut/" over the whole
PE, and the 125-row client action table at 0x1802caa20, which is the complete set of
requests the client can originate. "Serve the pack inventory" comes off the backlog.
The unclaimed-pack tile and My Packs are two fields on responses we already build.

Corrections to ENDPOINT_MAP.md, both freeze-risky as written:
  * duplicateItemIdList is an array of OBJECTS (element parser 0x180138e10: itemId
    0x16d, duplicateItemId 0xeb, itemLoans 0x16f, duplicateItemLoans 0xed), not the
    int list documented at :1095 and :218. Control that this is not a misread:
    dreamSquads 0xe9 in FutMoveCard genuinely is a bare int array and parses with no
    inner object loop. We serve [], so this is a docs bug today and a live freeze the
    moment somebody implements it from the map as written.
  * FutDiscardCardServerResponse is {"items":[{"id":N}],"totalCredits":N}. There is no
    top-level id. :968-971 is wrong twice over.

packContentInfo is DECORATIVE. It is read only into a store-tile view model, and
nothing compares the declared counts against the delivered itemList, so open_pack()
does not have to honour the distribution.

The reveal is entirely CLIENT-SIDE. Walkout, tiering, colours and ordering are
arithmetic over fields we already send. Genuine outstanding server work reduces to
three items: duplicates, quick-sell credit, unopenedPacks.

Perishable intel captured: the real FIFA 17 retail pack catalogue, 41 SKUs with Origin
offer ids, recovered from the client heap as a parsed copy of data/store/storecfg.xml.
It is in no file on disk, only in a running process.

futmem/ is a standalone read-only Rust crate for this kind of work (maps, find,
strings, read). Read-only by construction: it opens /proc/<pid>/mem with File::open
and there is no code path in it that can write to another process, because a live game
session depends on that. Its own [workspace] table keeps it out of the parent
workspace. Chunked scanning overlaps by pattern_len-1 so a match spanning a chunk
boundary is still found.

utas_server.py gains FUT_PORT/FUT_LOG so a throwaway instance can be started without
bouncing the one the live client is using. Defaults unchanged (8099, /tmp/utas_server.log).
Noted for the record: this edit came from a research agent that had been told not to
touch server code. It is benign and useful, but it was out of scope.

Not committed: the doc proposes ENDPOINT_MAP.md changes as pasteable text rather than
applying them, and every proposed server change defaults off per the house rule.
Nothing in this commit changes a response the client sees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 19:24:37 -07:00
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
funman300 897259c8fb fifa17-recon: the /settings 42-flag gate, and why Seasons never asks
ENDPOINT_MAP said this class reads one key, `configs`, and that was true and
useless. What it missed is what happens after each element closes: the client
feeds the STRING VALUE of `type` back through the atom hasher and switches on
the result, 42 arms wide. A flag is a row, not a key, and the client hashes our
string itself.

Followed it to the end. FUN_18011dc50 is the only writer of the IS_* UI gate
bytes inside FutDataManagerImpl, every line is `byte = (field == 1)`, and the
constructor never touches those bytes. So a flag nobody sends is a gate nobody
opens. friendlySeasonsEnabled and enableDraftMode have never been sent by
anything, which is a mechanism for Seasons refusing while making zero requests
to any of the four servers.

The store is the control that makes this readable: IS_STORE_ENABLED is the same
kind of byte and its screen works, because storeEnabled and friends already
ship through the Blaze config store. That list has no seasons or draft flag.

Ship the gates behind FUT_SETTINGS (off/keep/gates, default gates), and
re-assert the working store flags in the same array on purpose: once a
populated array makes the applier run, it writes EVERY gate byte, so omitting
them could switch off a screen that works today.

maximumTradePileSize=100 rides along as a positive control, because a boolean
that changes nothing cannot distinguish "the flag did not help" from "the array
never reached the consumer".

check_settings_flags.py asserts each shipped name against the atom table AND
the recovered switch, since a misnamed flag is silently inert and looks exactly
like a failed fix. enableSquadBuildingSetsFeature is the reason both checks are
needed: a real atom with no arm here.

Live: 439 contract checks pass, market unit suite passes.
Not yet tested in game.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 12:11:20 -07:00
funman300 9348b83374 fifa17-recon: club-item research -- cardtype map exact, itemState carries equipped state
Researched rather than guessed, after a guessed field crashed the client.

VERIFIED: FUN_1800d8330 returns cardtype 9 for exactly 0x1e, 0x1f, 0x91..0x96,
0xe7..0xe9, 0xec; fcc_misccards' cardsubtype 231 anchors the 0xe7 block to misc, so
badges/kits/stadia/balls/logos live in 0x1e, 0x1f and 0x91..0x96.

VERIFIED, and it answers a question nobody had asked: the itemState enum at
0x180229d20 is WAITING_FOR_GAME, inGame, forSale, offered, activeBadge, activeHomeKit,
activeAwayKit, activeBall, activeStadium, active. An EQUIPPED club item is the same
item with itemState set, not a different subtype. 'free' is right for owned-but-not-
equipped, which is what we already send.

VERIFIED: club items have no category group table (consumables and staff both do), and
the route is club?type= with SINGULAR names, observed live.

STILL UNKNOWN and labelled so: which subtype means which family. Not in any of the 149
dumped tables, no group table, and cardtype 9 has no merge arm so a wrong value cannot
announce itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 12:06:31 -07:00
funman300 ccf912c157 fifa17-recon: club items crashed the client -- unestablished fields, and too wide a blast radius
The game hung and then crashed on the first equippables fetch. My fault twice over.

CAUSE, primary. I copied teamid, leagueid and value straight out of the fcc row as
extras. `value` appears elsewhere as an OBJECT member (displayGroup {"value": ...}),
and a scalar where an object is expected is the type-desync busy loop at 0x1801c7f1a
-- which presents exactly as "the game is taking its time" and then dies. Omission is
safe; an unestablished field is not. That is this project's own rule and I broke it
for three fields that were not needed to draw a card. All three are gone.

CAUSE, contributing. The last request before the crash was type=equippables&count=11
and we answered with 30 items spanning FIVE unverified cardsubtypeids at once: the
widest possible blast radius for a wrong shape, and it tells you nothing about which
subtype was wrong. equippables now answers [] until the subtypes are confirmed one
family at a time, and shelf() takes a families= filter so a test can serve exactly one.

Adds FUT_CLUBITEMS=probe:<family>, which serves one item per candidate subtype for a
single family, so the screen names the correct subtype instead of me guessing a third
time. Eight items, one family, one question.

The flag already defaulted off, so a plain restart cannot serve any of this.

439 + 414 checks green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 11:59:38 -07:00
funman300 f5002a0d4d fifa17-recon: serve club items -- the type names are SINGULAR and they were on the wire
Arming the counters made the client name the route within seconds, exactly as it did
for consumables:

    club?type=equippables&count=11
    club?type=stadium&count=200
    club?type=ball&count=200

So it IS club?type= for this family, with SINGULAR names -- stadium and ball, not
stadia and balls -- and equippables as the combined club-customisation view. Those
requests were being answered from STORE.items(), which holds no club items because
the shelf is synthetic, so they correctly returned empty and looked like nothing was
happening.

Now serves the shelf: stadium 4, ball 6, badge 8, kit 8, equippables 30 (all four
combined), with player untouched at 205.

The cardsubtypeid values remain UNVERIFIED. cardtype 9 has no arm in the merge, so a
wrong subtype cannot announce itself; whether these render is now a live question and
the next screenshot answers it.

439 + 414 checks green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 11:55:07 -07:00
funman300 3b58b29094 fifa17-recon: club-item counts, and packs that contain more than footballers
Correcting an overstatement first: I said every card family works. Balls, stadia,
badges and kits do not, and this is the start of that, not the finish.

CLUB ITEMS (FUT_CLUBITEMS=1, default off). tools/fut_clubitems.py builds shelves from
the game's own tables: fcc_balls 42, fcc_stadium 78, fcc_badgecards 656,
fcc_kitcards 1482, fcc_leaguelogos 44, each with its real carddbid AND its real
cardassetid. Counts are wired into the club stat set, replacing the honest zeros
rather than appending a second row per id (the deserializer does
store[ctx][statId] = value, so two rows for one id is a coin toss).

Counts FIRST and on purpose. The consumables round proved the count gates the fetch:
the client does not ask for an item list until club/stats reports a non-zero count,
and the CLUB tab reads 0x1e balls, 0x28 kits, 0x14 stadia. Arming the counters is what
makes the client name the item route, which is the one thing static reading has never
produced for this family -- cardtype 9 has NO arm in the merge, so nothing about it is
discoverable from the card DB.

What is deliberately NOT guessed: which cardsubtypeid means ball versus stadium. The
eight values that reach cardtype 9 are {30,31,145..150} and the assignment appears in
none of the 149 dumped tables. probe_shelf() serves one item per candidate so the
screen can say which is which, rather than shipping a guess into someone's club.

PACKS (FUT_PACK_MIX=1, default on). A pack is no longer eleven footballers: roughly a
quarter of each pack is now consumables and occasionally staff, as a ratio so it
scales from a 5-card bronze to an 11-card premium. Club items are excluded until their
subtypes are verified -- a pack is the worst place to discover a wrong subtype,
because the card lands in the save and has to be cleaned out by hand.

Also fixes the art id AT THE SOURCE. fut_consumables now sets cardassetid from the
fcc_ tables, so a pack-granted consumable renders correctly too. The earlier fix only
corrected the copy served by the club route, which meant packs would have dealt cards
with the green NOT FOUND box again.

Pack tests run against a COPY of the profile, after an earlier test persisted 15 cards
into the live save and had to be undone by hand.

439 + 414 checks green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 11:51:49 -07:00
funman300 cef1e8d1f4 fifa17-recon: consumable artwork CONFIRMED FIXED, and the two wrong guesses recorded
Real card art, tier colours and the GK glove icon all draw once cardassetid carries
the art id. Records both refuted hypotheses with the evidence that killed them, since
each was plausible and someone will reach for them again.

The generalisable trap: an fcc_ row has BOTH carddbid and cardassetid, they are not
interchangeable, and _item copies resourceId into cardassetid -- right for players,
wrong for every other family. Club items will hit it next: balls 37, kits 35,
stadium 36, badges 39, league logos 40.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 11:47:05 -07:00
funman300 214be11202 fifa17-recon: the green NOT FOUND box was a wrong art id, cardassetid != carddbid
A player photographed a green tag under every consumable card. It is not a status
label at all: external/ion_fut/artAssets/.../notfound.swf is the client's placeholder
for an art asset it could not resolve, so the card was drawing 'no such artwork'.

The cause is two id columns that look interchangeable and are not. In the game's own
fcc_ tables a consumable row carries BOTH carddbid (5003001) and cardassetid (3), and
the art is keyed on the small one. fut_store._item copies resourceId into cardassetid,
which is correct for players and wrong for every other family, so the client looked up
art id 5003001, found nothing, and fell back.

Now mapped from data/tables/fcc_*.json, dumped read-only from the client's own
database, so these are the game's ids rather than a guess: training 3, contract 7,
healing 10, misc 45. The same table also gives balls 37, kits 35, stadium 36,
badges 39, league logos 40, which is what the club-item family will need.

Two earlier guesses at this badge were wrong and are recorded as such: it is not the
untradeable flag (changing it left the tag untouched, and the record showed the flag
had flipped) and not a loc-string failure.

439 + 414 checks green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 11:45:08 -07:00
funman300 c41b1514d3 fifa17-recon: consumables are served tradeable, so the untradeable badge clears
A player pointed at a green tag under every consumable card. It is ours, not the
client's: the deserializer sets a flag from (untradeableCount < count), we set
untradeableCount == count, so every stack read as fully untradeable and drew the
badge. UNTRADEABLE_COUNT and UNTRADABLE_COUNT are UI state keys in .rdata.

In FIFA 17 a pack-opened consumable is normally tradeable, so this was our own data
showing through rather than a rendering fault. Now untradeableCount is 0 and the
served copy carries untradeable false. FUT_CONSUM_UNTRADEABLE=1 restores the old
behaviour.

TODO/CONFIRM: the exact badge text was not read, only its source. If the tag survives
this change it is a different label and the untradeable theory is wrong.

439 + 414 checks green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 11:38:47 -07:00
funman300 550313362c fifa17-recon: consumables CONFIRMED LIVE -- artwork, stacks and correct amounts
Rendering with real artwork, quantity badges and +5/+10/+15 amounts, so atom 0x1b
reaches record+0xbf. The client's own dialog names the class we resolved: 'Search
Type: Consumables Search'.

Records the three things that each had to be right and each failed silently with a
200: the count gates the fetch, the route is club/consumables/<category> (a /club
PREFIX, so it was being answered with the player list), and the element is a five-atom
stack wrapper whose 0x16a member is the only thing that carries the item.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 11:36:47 -07:00
funman300 122b7b94d2 fifa17-recon: consumables are STACK records, not bare items
The route was right and the body was wrong. We served bare items, the client took the
response and inserted NOTHING (the live card map held only the 11 squad players), and
the screen stayed empty with no error logged anywhere. A 200 with a well-formed body
that the consumer silently discards is the worst failure shape there is, and it is the
third time this project has hit it.

FutConsumablesSearchServerResponse resolved from the RS4 literal 0x1802222f8:
factory 0x180130a10, vtable 0x180222200, deserializer +0x08 = 0x180130d10 (6873
chars). It takes itemData(0x16b) at the root like the club list, but its ELEMENT is
not an item. It is a five-atom wrapper and exactly one of the five carries the item:

    0xbc  count             int
    0xd7  discardValue      int
    0x16a item              -> FUN_18013fe00, the item parser itself
    0x287 resourceId        int
    0x362 untradeableCount  int

Everything else goes to the value-skip handler, which is precisely why a bare item was
accepted and did nothing. It also explains the UI: FUT draws consumables as one stack
with a quantity, not as N cards, and the wrapper is that quantity.

Identical consumables are now collapsed by resourceId and counted.

439 + 414 checks green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 11:34:01 -07:00
funman300 ccb736fa71 fifa17-recon: the consumables item route, found live -- GET club/consumables/<category>
The counter WAS the gate, and fixing it produced the request within seconds:

    11:23:36  GET /ut/game/fifa17/club/consumables/training
    11:23:40  GET /ut/game/fifa17/club/consumables/contracts

Neither is club?type=, and neither is the /consumables/%s template we had been
hunting in the binary. The client asks here, and it asks ONLY once
club/stats/consumables reports a non-zero count. Two rounds of item-shape work went
unrequested for want of a counter.

Worse, the path is a /club prefix, so it fell through to the generic route: the
consumables screen was answered with the 194-card PLAYER list. It asked for training
cards and got Cristiano Ronaldo.

Now routed above the generic /club, serving the shelf filtered by category. The
segment names come from the UI group table at 0x180203260; training and contracts are
CONFIRMED on the wire, the other five are from that table and matched case
insensitively, with singular "contract" accepted because the client has used both.
An unknown segment serves the whole shelf and logs loudly rather than showing an empty
screen, since a new spelling is a wire fact worth catching.

Per-category counts match the on-screen panel exactly: training 42, contracts 13,
fitness 6, healing 21, position 20, playStyle 24, managerLeague 0. That correspondence
is what makes an empty list distinguishable from a wrong mapping.

439 + 414 checks green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 11:26:53 -07:00
funman300 2f8a6512db fifa17-recon: answer the CONSUMABLES panel with consumable counts, not player counts
The tab was empty because we answered the wrong question. The client asks
GET club/stats/consumables 41 times a session; we replied with the PLAYER stat set
(players 205, playersGold 189 ...), which that panel does not read. Confirmed on
screen: seven categories present and selectable, every one reading 0.

Now appends 14 consumables* rows counted from the SHELF. The shelf, not STORE.items():
the consumables we serve are a synthetic overlay never granted into the save, so
counting the store gives fourteen zeros, which on screen is byte-identical to failure
and would have made the experiment unreadable. Counts match the independently derived
expectation exactly: 126 total, 21 healing, 7 player contracts, 21 player training,
3 player fitness, 20 position, 21 GK training, 6 manager contracts, 19 playstyle.

Safe by construction: the vocabulary is an ATOM switch (FUN_18012fd40, 40 arms,
default return 0), so an unrecognised name is inert rather than fatal, and the rows
are APPENDED -- the player, nation and league rows that drive the working screens are
untouched. Zero rows unless FUT_CONSUMABLES is armed.

Default ON because answering the consumables panel with player counts is wrong by
inspection rather than a judgement call. FUT_CONSUM_STATS=0 reverts.

439 + 414 checks green.

The open question this sets up: whether a non-zero count makes the client request an
item list at all. If it does, the log names the route and /consumables/%s is settled
for free. If the numbers move and no request follows, the panel renders from counts
alone and the 126-item shelf was never needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 11:19:29 -07:00
funman300 0f83d73364 fifa17-recon: the consumables panel asks 41 times a session and we answer with players
Round 3, 10 agents. The headline is measured, not inferred: GET club/stats/consumables
is requested 41 times per session by the real client (ProtoHttp), and _club_stat_set()
answers it with the PLAYER stat set. The panel reads 14 consumables* names that we
have never sent, so it is told '205 players' when it asked how many contracts the club
owns, and it has nothing to show.

That also explains why last round's 126-item consumable shelf was never requested. It
serves type=contract|training|healing|development and an UNTYPED /club with no
team=/league=, and all 9 of the client's untyped requests this session carry team=. The
only +126 item(s) line in the whole log came from one of our own probes.

Vocabulary recovered: the 14 consumables* rows plus badgeDBid 0x2e, kitsHome 0x29,
kitsAway 0x2a, leagueLogos 0x2f, trophiesSeasonOnline 0x38.

Other measured surfaces the client asks for and we fob off: GET /settings 11x answered
with an empty config array (a 40-flag feature gate, the biggest untouched lever in the
project), leaderboards/options 5x with {}, user/accountinfo 4x with {}.
club/stats/staff is a DIFFERENT class (FutStaffBonus); the staff counts come from the
Stats2 store, which is why the staff screen worked while we answered {}.

Refuted: ENDPOINT_MAP's claim that objectives have no route. FUN_180151610 builds
<base>/objective/%d/reward and FUN_180147780 builds .../complete.

New modules only. utas_server.py is deliberately untouched: whether to wire the counts
depends on a free observation the human can make on the client that is already running,
and spending a restart before that is what this round exists to avoid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-05 11:13:54 -07:00