docs: remove docs/ — migrated into the OpenFUT-Vault (single source of truth)

The entire docs/ tree (24 top-level notes, 68 evidence captures, 2 plans, research) has
been migrated into ~/OpenFUT-Vault, the curated Obsidian vault, which is now the sole
home for project documentation. Merges preserved all detail (obsolete material kept
under "Superseded" sections); evidence/plans were copied byte-identical; every migrated
note records its Source: docs/<original>.md provenance. Vault commit f55a5ba.

Documentation lives in the vault from here on. Code/script comments that still reference
docs/ paths are stale pointers only (no build dependency); they can be repointed at the
vault opportunistically. References to fifa17-recon/docs/ are a different tree and are
unaffected.
This commit is contained in:
funman300
2026-08-18 16:26:44 +00:00
parent 5c40b4993f
commit 6ae3364bd0
89 changed files with 0 additions and 15610 deletions
-142
View File
@@ -1,142 +0,0 @@
# FIFA 17 FUT — Card Taxonomy (single source of truth)
Status: verified 2026-08-12. This document supersedes every earlier scattered
taxonomy claim in the repo (see "Superseded taxonomy" at the bottom).
Evidence labels: **OBSERVED** = read directly from a shipped table or decompiled
function; **INFERRED** = derived from OBSERVED facts; **HYPOTHESIS** = plausible,
not yet proven. `UNKNOWN` is a valid answer and is stated as such.
## Provenance
- Authoritative tables: `10.10.0.105:/home/alex/Documents/OpenFUT/fifa17-recon/data/tables/`,
copied byte-identical into this repo at `fifa17-recon/data/tables/`.
SHA-256 manifest + verification: `docs/evidence/fifa17-recon/table-hashes.sha256`
(36 files: 31 `fcc_*.json` + 5 staff tables; combined hash-of-hashes
`10f239add919089354d8dbff873fc9737b0a0f80f6ac41b1aa2a096c0ec8d331`).
- Each table file is a DECODED dump `{table, source, rowcount, schema[], rows[]}`;
card instances are in `rows[]`, one object per card. Row counts and id ranges
below were re-extracted from the in-repo `.120` copies.
- Semantic (subtype→kind) labels are decompiler-derived, carried in
`fifa17-recon/tools/fut_consumables.py` (`BY_SUBTYPE`, `_DOC`), generated by
`tools/build_consumables.py` from `FUN_1800d8330`, `FUN_18013f4d0`,
`FUN_1801bfac0`, `FUN_1801aa230`, `FUN_180048780` + the fcc tables.
- Staff family selection: `fifa17-recon/docs/CARD_SYSTEM.md` (2026-08-04/05 blocks)
and `fifa17-recon/tools/fut_staff.py` / `fut_coaches.py`.
## Family selector (OBSERVED, binary)
Before registering an item, `FUN_180141660` merges the client's local DB. It
switches on record `+0x4c` (`cardtype`), which `FUN_1800d8330` derives from JSON
atom `0x6c cardsubtypeid` alone:
```
cardsubtypeid 0..3 -> cardtype 1 players
4 -> cardtype 2 managercards
5 -> cardtype 3 headcoachcards
6 -> cardtype 10 gkcoachcards
7 -> cardtype 5 physiocards
8 -> cardtype 4 fitnesscoachcards
0x1e,0x1f,0x91..0x96,0xe7..0xe9,0xec -> cardtype 9 club items + misc
absent (default 342) -> cardtype 0 no merge
```
Key: `carddbid == resourceId` for non-player families (queried RAW, no mask);
players are queried by `playerid = resourceId & 0xffffff` (atom `0x287`), and
`assetId` (atom `0x23`) is never read by the merge.
## Consumable & staff families — evidence table (OBSERVED, `.120` tables)
| Family | Table | Rows | carddbid range | cardsubtype set | cardassetid (ART) |
|---|---|---|---|---|---|
| Contracts | `fcc_contractcards.json` | 13 | 50010015001013 | {201, 202} | {7, 8} |
| Fitness + Healing | `fcc_healingcards.json` | 27 | 50020015002030 | {211,212,213,215,216,217,218,219,220} | {9, 10} |
| Training (6 sub-families) | `fcc_trainingcards.json` | 143 | 50030015003159 | 51-57, 61-67, 91-110, 121-136, 250-273, 300-340 | {1,3,32,34,35,50,51} |
| Misc | `fcc_misccards.json` | 42 | 50040015004042 | {231, 232, 233, 236} | {43, 44, 45, 46} |
| Badges | `fcc_badgecards.json` | 656 | 60000006000656 | (none in row) | {39} |
| Stadiums | `fcc_stadium.json` | 78 | 62000006200077 | (none in row) | {36} |
| Kits | `fcc_kitcards.json` | 1482 | 63000006400654 | (none in row) | {35} |
| League logos | `fcc_leaguelogos.json` | 44 | 80100008010044 | (none in row) | {40} |
| League logo stickers | `fcc_leaguelogostickers.json` | 39 | 80100008010039 | (none in row) | {40} |
| Balls | `fcc_balls.json` | 42 | 81201948120236 | (none in row) | {37} |
| Managers | `managercards.json` | 417 | 10000011001552 | (staff; by cardsubtypeid 4) | (assetid col) |
| Head coaches | `headcoachcards.json` | 124 | 20000042000328 | (staff; subtype 5) | — |
| GK coaches | `gkcoachcards.json` | 121 | 90000019000324 | (staff; subtype 6) | — |
| Physios | `physiocards.json` | 51 | 40000024000259 | (staff; subtype 7) | — |
| Fitness coaches | `fitnesscoachcards.json` | 115 | 30000193000328 | (staff; subtype 8) | — |
Notes:
- **Contracts (5001xxx)** — 201 = player_contract, 202 = manager_contract
(OBSERVED, `BY_SUBTYPE`).
- **Fitness + Healing (5002xxx)** — subtype **214 is a valid enum value but ships
ZERO rows** (OBSERVED: 214 absent from `rows[]`). Enum split (OBSERVED,
`BY_SUBTYPE`): healing = 211-218, player_fitness = 219, squad_fitness = 220.
The `+0x58 rareflag == 1` trap flips subtype 219 to squad-fitness.
- **Training (5003xxx)** — SIX sub-families, all OBSERVED in `rows[]` and labelled
in `BY_SUBTYPE`:
- `gk_training` 51-57 (7)
- `player_training` 61-67 (7)
- `position_mod` 91-110 (20)
- `formation_mod` 121-136 (16)
- chem/play styles 250-273 (24): `player_playstyle` 250-268 (19) +
`gk_playstyle` 269-273 (5)
- `manager_league` 300-**341** in the client enum (42), but the shipped table
contains only 300-340 (41 rows) — 341 is defined, not shipped.
- Client enum also defines vestigial/unshipped ranges NOT in the table:
`manager_formation_mod` 71-86, and `DEAD_ZONE`
58-60,68-70,87-90,111-120,203-210 (28). These have no rows.
- **Misc (5004xxx)** — subtypes {231,232,233,236}; cardassetid ART 43-46.
cardsubtype 231 = 0xe7 anchors the 0xe7..0xe9 block to misc via `FUN_1800d8330`.
- **Club items (badges/stadiums/kits/logos/balls)** carry no `cardsubtype` in the
row; they are keyed by `carddbid` range and distinguished on the wire by ART
`cardassetid` (badges 39, stadium 36, kits 35, logos 40, balls 37). **Kits live
at 63000006400654 and are NOT badges** (badges are 6000xxx). The client-side
subtype→family map (which of 0x1e,0x1f,0x91..0x96 is ball vs stadium vs badge vs
kit) is **UNKNOWN** — it is in none of the dumped tables and cardtype 9 has no
miss-fill arm (see `CARD_SYSTEM.md`, "Club items", 2026-08-05).
## Staff — subtype/cardtype selectors (OBSERVED, binary)
| cardsubtypeid | cardtype | Family | Table | Rows | carddbid range |
|---|---|---|---|---|---|
| 4 | 2 | Manager | `managercards.json` | 417 | 10000011001552 |
| 5 | 3 | Head coach | `headcoachcards.json` | 124 | 20000042000328 |
| 6 | 10 | GK coach | `gkcoachcards.json` | 121 | 90000019000324 |
| 7 | 5 | Physio | `physiocards.json` | 51 | 40000024000259 |
| 8 | 4 | Fitness coach | `fitnesscoachcards.json` | 115 | 30000193000328 |
`cardsubtypeid -> rec+0x50` is the only family selector; `FUN_1800d8330` maps it to
`rec+0x4c cardtype`. Manager merge = `FUN_1801356c0` (queries `managercards` by
`carddbid` raw); coach merges live in the respective tables (`fut_coaches.py`).
All five render live with real photos/bonuses, zero "DB Error" (CARD_SYSTEM.md,
2026-08-05).
## Players (context, out of taxonomy scope here)
Players use cardsubtypeid 0..3 -> cardtype 1; merged by `playerid = resourceId &
0xffffff` against the local `players` table. Identity/name/nation/team come from
the client DB; rating/position/attributes come from our item JSON. See
`CARD_SYSTEM.md` and the migration work in `openfut-adapter-fifa17`.
## Superseded taxonomy (report only — no other files edited)
The earlier working taxonomy (in prior agent-session notes / long-term memory, and
partially in the original `TablesTaxonomy` scout output) got four things wrong.
Corrected here:
1. **Chemistry / play styles are 250-273, NOT 91-136.** 91-110 = `position_mod`,
121-136 = `formation_mod`. (OBSERVED in `fcc_trainingcards` + `BY_SUBTYPE`.)
2. **6300xxx/6400xxx are KITS, not badges.** Badges are 6000xxx. (OBSERVED.)
3. **5004xxx misc cards exist** (subtypes 231/232/233/236, ART 43-46). Previously
omitted. (OBSERVED, `fcc_misccards`.)
4. **8010xxx league logos exist** (+ stickers), ART 40. Previously omitted.
(OBSERVED, `fcc_leaguelogos`.)
**Repo blast-radius of the superseded values: NONE.** A repo-wide grep
(`docs/`, `openfut-adapter-fifa17/`, `openfut-core/`, `fifa17-recon/`) for the wrong
claims (`91-136` chem styles; `6300/6400xxx` as badges) returns zero hits.
`fifa17-recon/docs/CARD_SYSTEM.md` and `plan-2026-08-06-card-subsystem.md` already
use the correct `250..273` for chem styles and the correct staff subtypes 4-8;
`CARD_SYSTEM.md` is a mechanism log, not a taxonomy, and asserts none of the wrong
ranges. The superseded values therefore live only in non-repo agent memory, which
should be corrected to point at this document.
-175
View File
@@ -1,175 +0,0 @@
# openfut-core — Correctness Issues (audit 2026-08-17)
Read-only audit of `openfut-core` (game-agnostic axum + SQLite/sqlx economy authority).
Four reported issue classes confirmed with exact `file:line` evidence, **plus a bonus
HIGH-severity SBC duplicate-card economy exploit**. Nothing here is fixed yet —
fixing bumps Core off the frozen P1 reference (`fbb54ea`, the current known-good
production Core) and one item needs a DB migration + prod backfill, so this needs a
**go/no-go** before rebuild+redeploy.
> **Zero live users right now**, so the HIGH-severity economy exploits are not
> currently exploitable — but they are the exact class (coin overspend + card
> duplication) that crashed live clients earlier (project memory), so they should be
> fixed before any real play.
## Architecture context (why the bugs cluster)
Two generations of economy code coexist:
- **NEW** `services/economy.rs` + `routes/economy.rs`**fully atomic + validated**:
every compound op acquires a connection, `BEGIN IMMEDIATE`, composes
transaction-scoped primitives (`debit`/`credit`/`add_item`/`remove_item`/
`consume_entitlement`), commits/rolls back via `finish()`, rejects negative amounts,
and has an extensive in-module test suite. **This is the reference fix pattern.**
- **OLD** per-feature services (`club`, `pack`, `market`, `sbc`, `checkin`, `upgrades`,
`match_service`, `season`) — predate it, still do read/check/write directly against
the `&Pool` with each statement on its own connection: no transaction. `economy.rs`'s
own module doc explicitly warns these "cannot offer that guarantee".
Issues 2 and 3 live entirely in the OLD generation; the fix is to route them through
`economy.rs`'s proven atomic ops (or give the pool helpers `&mut SqliteConnection`
transactional variants). Request flow is `X-OpenFUT-Game` header → active profile →
club → service; clients never pass a `club_id`, so cross-club access is **not** a
vector — the risks are intra-club concurrency + unvalidated payloads.
---
## Issue 1 — `sbc_submissions` missing `club_id` (milestone always 0) · **LOW**
- **Root cause:** `migrations/0001_initial.sql:96-102` creates `sbc_submissions(id,
profile_id, sbc_id, submitted_card_ids, passed, submitted_at)` — no `club_id`, and no
later migration adds one. But `src/routes/club.rs:94-99` (`get_milestones`) runs
`SELECT COUNT(*) FROM sbc_submissions WHERE club_id = ? AND passed = 1`. SQLite errors
`no such column: club_id`; the error is swallowed by `.unwrap_or(0)` → the
`sbcs_completed` milestone is **always 0**. Writer `services/sbc.rs:74-83` inserts
`profile_id`, not `club_id`.
- **Impact:** wrong milestone stat only. No crash, no economy corruption.
- **Fix (matches the ticket — needs migration + backfill):** new migration `0019`:
`ALTER TABLE sbc_submissions ADD COLUMN club_id TEXT;` then backfill
`UPDATE sbc_submissions SET club_id = (SELECT c.id FROM clubs c WHERE c.profile_id =
sbc_submissions.profile_id);` and bind `club_id` in `sbc.rs:submit_sbc`'s INSERT
(`club_id` is already a param at `sbc.rs:41`).
- **Simpler alternative (no migration):** change the `club.rs:95` query to
`WHERE profile_id = ?` (column already exists). Ticket asks for the column, so both
are recorded.
- **Migration required:** YES (for the ticket's fix); NO (for the alternative).
## Issue 2 — Non-atomic check-then-act economy mutations (TOCTOU) · **HIGH**
Each does read → check → write across multiple pool round-trips with no
`BEGIN IMMEDIATE`, so concurrent requests race → overspend / duplication / double reward:
| Site | Race |
|---|---|
| `services/club.rs:88-115` `spend_coins` | SELECT coins → `if balance<amount` → UPDATE; two concurrent spends both pass → **overspend / negative balance**. Shared primitive used by all callers below. |
| `services/pack.rs:58-141` `open_pack` | read `pack.opened` → INSERT cards loop → UPDATE opened=1; concurrent double-open → **card duplication** |
| `services/pack.rs:144-160` `buy_pack` | `spend_coins` then `grant_pack`, separate ops → crash between = coins gone, no pack |
| `services/market.rs:129-186` `buy_listing` | SELECT sold=0 → spend → UPDATE sold=1 → INSERT; concurrent double-buy → **two cards minted** |
| `services/market.rs:188+` `sell_card` | SELECT owned → DELETE → add_coins; concurrent double-sell → **double credit** |
| `services/sbc.rs:35-107` `submit_sbc` | validate → DELETE cards → INSERT submission → add_coins/grant_pack; same cards to two SBCs → **double reward** |
| `services/checkin.rs:60-120` `claim` | SELECT last → same-day check → reward → INSERT; concurrent → **double claim** |
| `services/upgrades.rs:64-95` `change_position` | fetch → spend_coins → UPDATE |
| `services/match_service.rs:97-235` + `season.rs` reward | many sequential writes; partial failure leaves partial rewards |
- **Impact:** corrupts economy state (coin overspend + card duplication).
- **Fix:** wrap each compound op in one `BEGIN IMMEDIATE`…`finish()` transaction
exactly as `services/economy.rs:214-236` already does; route pack/market/sbc/checkin/
upgrades through `economy.rs`'s composed atomic ops (or add `&mut SqliteConnection`
variants of `spend_coins`/`add_coins`/`grant_pack`/`add_item`).
- **Migration required:** NO (code-only).
## Issue 3 — Missing input validation: SBC duplicate-card exploit · **HIGH**
- **Root cause:** `services/sbc.rs:53-70` iterates `req.owned_card_ids` with **no dedup
and no length bound**. The same `owned_card_id` repeated N times resolves the same card
N times (each `fetch_optional` succeeds); `validate_sbc` (`sbc.rs:110-116`) counts it
toward `squad_size` and passes; the DELETE loop deletes it once → **a user satisfies
any SBC with ONE card duplicated → free rewards**. Entry point `routes/sbc.rs:30-49`
forwards `req` unvalidated. Unbounded Vec length is also a DoS.
- **Fix:** in `submit_sbc`, reject duplicate ids (collect into a `HashSet`, compare
`len`) and bound the list (e.g. ≤ 30) before resolving → `AppError::BadRequest`.
- **Already-good validation (no change):** `economy.rs:64-67,84-87` reject negative
amounts; `match_service.rs:100-104` clamps goals 0..99; `upgrades.rs` validates
boost 1..3 / positions. Minor: `routes/economy.rs post_grant_reward` forwards an
unbounded `amount` (trusted host caller; add an upper-bound sanity guard).
- **Migration required:** NO.
## Issue 4 — `season.rs` panics + checkin index panic · **LOW**
- **Root cause:** `services/season.rs` `.expect()` on `fetch_optional` Options at
`:23` (`get_or_create`), `:69` and `:144` (`record_match`) — panic if the `seasons`
row is absent when expected. `seasons.profile_id` is PRIMARY KEY
(`migrations/0006_seasons_loans_packs.sql:2`), so the concurrent-insert case surfaces
as a UNIQUE error via `?` (not the panic), lowering probability — but it still
panics-on-invariant, aborting that request (axum → 500 for the request; not a full
server crash).
- **Secondary:** `services/checkin.rs:~52,~88` index `STREAK_COINS[idx]` with
`idx = ((streak-1)%7) as usize`; Rust `%` can be negative → a corrupt/negative
persisted `streak_day` yields a negative index → **panic**.
- **Fix:** replace each `.expect(...)` with
`.ok_or_else(|| AppError::Internal("season row missing".into()))?`; guard the checkin
index with `.rem_euclid(7)` (or clamp `streak_day >= 1` on read).
- **Migration required:** NO.
---
## Fix plan summary
| Issue | Severity | Migration | Files |
|---|---|---|---|
| 1 sbc_submissions club_id | LOW | YES (or none via alt) | `migrations/0001` (+new 0019), `routes/club.rs`, `services/sbc.rs` |
| 2 non-atomic mutations | HIGH | NO | `services/{club,pack,market,sbc,checkin,upgrades,match_service,season}.rs` → route through `services/economy.rs` |
| 3 SBC duplicate-card exploit | HIGH | NO | `services/sbc.rs`, `routes/sbc.rs` |
| 4 season/checkin panics | LOW | NO | `services/season.rs`, `services/checkin.rs` |
**Recommended order:** 3 (smallest, highest-value: kills the free-reward exploit) → 2
(the transactional refactor, largest) → 4 (defensive hygiene) → 1 (cosmetic; do with
the alt query unless the column is wanted).
**Deployment note:** all of these change `openfut-core`, which is currently frozen at
the P1 reference (`fbb54ea`) in production. Fixing + rebuilding + redeploying prod-core
is a deliberate step off that reference — get a go/no-go first. Only Issue 1's
column-add needs a migration + prod backfill; 2/3/4 are code-only.
---
## Resolution (2026-08-17, on `openfut-core` @ `fbb54ea` + these edits)
All four issue classes fixed in the canonical superproject submodule
`openfut-core`; full test suite green (179 tests) + clippy clean + a new
regression test `tests/integration_test.rs::test_sbc_rejects_duplicate_cards`.
| Issue | Fix | Files |
|---|---|---|
| 3 SBC dup-card exploit | dedup (`HashSet`) + `MAX_SBC_CARDS`=30 bound in `submit_sbc`, before card resolution → `BadRequest` | `services/sbc.rs` |
| 1 sbc_submissions club_id | migration `0019` adds `club_id` + backfills from `clubs`; `submit_sbc` INSERT now binds `club_id` | `migrations/0019_*.sql`, `services/sbc.rs` |
| 4 season/checkin panics | `.expect()` → `.ok_or_else(AppError::Internal)?` (3 sites); checkin index `% 7` → `.rem_euclid(7)` (2 sites) | `services/season.rs`, `services/checkin.rs` |
| 2 non-atomic mutations | statement-level compare-and-swap (see below) | `services/{club,pack,market,checkin}.rs` |
### Issue 2 — how it was fixed, and the residual
Rather than the full transaction refactor (threading `&mut SqliteConnection`
through every service), the concurrency-exploitable races were closed with
single-statement **compare-and-swap** — the atomic unit SQLite already gives us,
no transaction plumbing, minimal blast radius on the working prod economy path:
- `club::spend_coins` — `UPDATE … SET coins = coins - ? WHERE id = ? AND coins >= ?`
+ `rows_affected` guard; also rejects negative amounts. Kills **overspend** for
every caller (the shared root primitive).
- `pack::open_pack` — claims the pack (`UPDATE … opened = 1 WHERE … AND opened = 0`)
**before** minting cards; loser aborts. Kills **card duplication** via double-open.
- `market::buy_listing` — claims the listing (`sold 0→1`) before charging; releases
the claim if the debit fails. Kills **double-mint**.
- `market::sell_card` — `DELETE … WHERE id = ? AND club_id = ?` + `rows_affected`
guard before crediting. Kills **double-credit** via double-sell.
- `checkin::claim` — conditional `INSERT … SELECT … WHERE NOT EXISTS (today's row)`
+ `rows_affected` guard; pays out only if the claim landed. Kills **double-claim**.
**Residual (accepted, documented):** the *multi-statement all-or-nothing* edges that
need a real transaction to close — `pack::buy_pack` (spend then grant: a crash between
loses coins with no pack), `sbc::submit_sbc` (concurrent submits sharing cards could
double-consume mid-loop), and `match_service`/`season` reward chains (partial writes on
crash). These are **partial-failure durability edges, not statement-level races**, and
require concurrency that a single-player FIFA17 client does not generate. Closing them
is the `&mut SqliteConnection` transaction refactor originally proposed; deferred as
low-value for single-player. Overspend + duplication + double-credit — the vectors that
corrupt economy state — are all closed.
-79
View File
@@ -1,79 +0,0 @@
# FIFA17 Content Completeness (33-record gap)
Evidence: ContentGapMap scout vs import-input.json (persona 33068179, 1995 items),
fifa17-recon/data/tables, manifest/fifa17-import-manifest.json (OBSERVED).
## Summary
| Category | Expected | Resolvable | Unrecoverable |
|---|---|---|---|
| Legend players | 13 instances (10 defs) | 0 | 13 (need .105 Legends locale names) |
| Consumables | 17 | 17 | 0 |
| Staff | 3 | 3 | 0 |
## Consumables (17/17 RESOLVABLE — carddbid present in fcc tables; semantics from cardsubtypeid)
| wire_id | resourceId | subtype | kind | table |
|---|---|---|---|---|
| 100000239 | 5003012 | 54 | gk_training | fcc_trainingcards |
| 100000249 | 5003011 | 54 | gk_training | fcc_trainingcards |
| 100000260 | 5003004 | 52 | gk_training | fcc_trainingcards |
| 100000272 | 5003059 | 91 | position/playstyle mod | fcc_trainingcards |
| 100000250 | 5003060 | 92 | position/playstyle mod | fcc_trainingcards |
| 100000327 | 5003065 | 97 | position/playstyle mod | fcc_trainingcards |
| 100000238 | 5003066 | 98 | position/playstyle mod | fcc_trainingcards |
| 100000261 | 5003068 | 100 | position/playstyle mod | fcc_trainingcards |
| 100000316 | 5003068 | 100 | position/playstyle mod | fcc_trainingcards |
| 100000293 | 5003103 | 258 | player_playstyle | fcc_trainingcards |
| 100000326 | 5003112 | 267 | player_playstyle | fcc_trainingcards |
| 100000283 | 5003116 | 271 | gk_playstyle | fcc_trainingcards |
| 100000294 | 5001004 | 201 | player_contract | fcc_contractcards |
| 100000304 | 5001008 | 202 | manager_contract | fcc_contractcards |
| 100000305 | 5001009 | 202 | manager_contract | fcc_contractcards |
| 100000315 | 5002027 | 217 | healing | fcc_healingcards |
| 100000426 | 5002013 | 213 | healing | fcc_healingcards |
## Staff (3/3 RESOLVABLE)
| wire_id | resourceId | subtype | role | table |
|---|---|---|---|---|
| 100000271 | 3000083 | 8 | fitnesscoach | fitnesscoachcards |
| 100000427 | 3000083 | 8 | fitnesscoach | fitnesscoachcards |
| 100000282 | 9000081 | 6 | gkcoach | gkcoachcards |
## Legends (13 instances / 10 defs — UNRECOVERABLE from .120)
All defer via NoName gate (openfut-import-fifa17/src/lib.rs:476-478). 6 assets absent
from players.json; 236250/236253/236257 resolve only to placeholder nameid 24313='171918'.
dcplayernames.json/editedplayernames.json EMPTY in .120 dump. Version formula holds for all
(resourceId == (version<<24)|assetId) — so they auto-promote the instant a .105-derived
name row exists; NO code change needed, only name data.
| asset | resourceId(ver) | rareflag/rating/pos | wire copies |
|---|---|---|---|
| 169193 | 169193(v0) | 1/r87/CDM | 100000057,100000083,100000146,100000157 |
| 211029 | 211029(v0) | 1/r73/CB | 100000165 |
| 224512 | 224512(v0) | 1/r67/LB | 100000170 |
| 227403 | 227403(v0) | 1/r64/ST | 100000160 |
| 236743 | 236743(v0) | 1/r60/LB | 100000163 |
| 237510 | 237510(v0) | 1/r59/CB | 100000162 |
| 236250 | 17013466(v1) | 3/r78/ST | 100001102 |
| 236253 | 17013469(v1) | 3/r77/CAM | 100001932 |
| 236253 | 100899549(v6) | 21/r77/CAM | 100001472 |
| 236257 | 117676769(v7) | 22/r78/LM | 100001043 |
### .105 read-only verdict (PROVEN 2026-08-14) — UNRECOVERABLE, confirmed
SSH read-only to the FIFA machine (10.10.0.105). Install `/mnt/games/FIFA 17`
is retail PC (FIFA17.exe, build 2017-06-09, changelist 3175939, sku FFA17PCC).
The resident FIFA17.exe database (recon `data/tables`) shows the DLC/Legend name
tables ship ZERO rows in the retail PC build:
- `dcplayernames.json`: rowcount:0, rows_emitted:0 (nameid range 30000-35000 = DLC names)
- `editedplayernames.json`: rowcount:0, rows_emitted:0
- `playernames.json`: 24314 rows (standard players only)
FUT Legends were Xbox-One-exclusive in FIFA 17; the PC client contains no Legend
names at all. The 13 Legend instances are therefore GENUINELY UNRECOVERABLE from
any PC install (.105 == .120, same retail build) — NOT a filtering artifact and
NOT fabricated. Architecture is already correct: they auto-promote the instant a
name row is supplied (e.g. from an Xbox FIFA17 DB), with no importer code change.
## Implementation
- Consumables+staff: NEW emit path in openfut-import-fifa17 (classify already buckets;
plan_definitions only ingests PlayerCard). Add definition builders + Core CardDefinition
rows to fifa17-production-cards.json + owned instances in apply.rs. Data 100% present.
- Legends: attempt read-only .105 Legends locale research; if names recoverable add roster
rows (auto-promote). Otherwise document as unrecoverable (do NOT fabricate names).
-121
View File
@@ -1,121 +0,0 @@
# FIFA 17 "FUT Squad Update" download failure — root cause
**Status:** FIXED in the cert generators (commit `fbc0da2`) — the SAN now carries the
advertised IP. Verified without the client (a verifying TLS client rejects the old
DNS-only cert by IP and accepts the new one; the entrypoint reconcile is idempotent).
Live confirmation needs the production container rebuilt with `OPENFUT_ADVERTISE` set,
which is operator-gated. See "Fix options (applied)" below.
**Symptom (client):** entering the FUT hub shows
> An error occurred downloading the FUT Squad Update. Please try again.
Recovered from the live client's memory with `scripts/client-error-string.py`, not from a
log. Nothing server-side reports an error: every UTAS route answers 200 and the Blaze
session stays healthy, answering PINGs while the client sits on the dialog.
## What it is
"Squad Update" here is the **roster update**, not the player's lineup. The Blaze responder
advertises it as the FUT loading gate (`blaze_responder_v3b.py`):
```python
ROSTER_HOST = "%s:8081" % _ADVERTISE
("ROSTERUPDATE_URL", "https://%s/fifa17/fut/rosterupdate.xml" % ROSTER_HOST)
("ROSTER_URL", "https://%s/fifa17/roster/" % ROSTER_HOST)
```
The client fetches that URL over TLS, **by IP**, from `roster_server.py`.
## Root cause
The client completes TCP, sends a ClientHello, receives the certificate, and then
**aborts the handshake itself**. Captured and decoded from the wire:
```
CLIENT: ClientHello client_version = TLS1.2
offers 8 suites: RSA_AES_256_GCM_SHA384, RSA_AES_128_GCM_SHA256,
RSA_AES_256_CBC_SHA256, RSA_AES_128_CBC_SHA256, RSA_AES_256_CBC_SHA,
RSA_AES_128_CBC_SHA, RSA_RC4_128_SHA, RSA_RC4_128_MD5
SERVER: ServerHello TLS1.2 -> Certificate (909 B) -> ServerHelloDone
CLIENT: Alert level=fatal desc=certificate_unknown
```
Protocol and cipher selection are **compatible** — TLS1.2 with an RSA suite is offered and
chosen. The client rejects the **certificate**:
```
subject/issuer = CN = winter15.gosredirector.ea.com (self-signed, CA:TRUE)
SAN = DNS:winter15.gosredirector.ea.com, DNS:*.gosredirector.ea.com, DNS:*.ea.com
notBefore = Aug 13 01:12:30 2026
sha256 = FF:66:69:48:E2:02:AB:F4:A4:40:78:B1:8C:E1:F9:65:96:32:BB:FF:1F:3D:CC:BC:07:54:02:11:8F:4D:12:3F
```
Two facts matter:
1. **The SAN carries DNS names only, no `iPAddress` entry**, while the advertised URL is an
IP literal (`https://10.10.0.120:8081/...`). A DNS-only SAN cannot match an IP dial.
2. **The certificate was regenerated on Aug 13.** `roster_server.py` loads the same
`redir_cert.pem`/`redir_key.pem` as the Blaze redirector, so the redirector rejects
identically — visible as `REDIR REJECTED ... TLS/SSL connection has been closed (EOF)`
in the Blaze log. That is tolerated only because the client then reaches Blaze main over
plain TCP; the roster fetch has no such fallback, so it is where the failure surfaces.
`roster_server.py` still carries the comment *"ProtoSSL cert-verify is patched (autopatch),
so our self-signed cert is accepted."* That assumption no longer holds for this path.
autopatch demonstrably patched both gates in the failing process — the log shows
`pid 56298: PATCHED cert gates` and the live bytes read back as the patched patterns
(`909090909090` at `0x146132548`, `31c0c3` at `0x1461361b0`) — and the client still sent
`certificate_unknown`. So **those two gates do not govern this validation**; either another
check exists on the roster path, or it validates before reaching them.
## Ruled out, with evidence
| Hypothesis | Evidence against |
| --- | --- |
| Missing/mistyped UTAS field | 0 structural differences across 14 hub routes vs production (`scripts/hub-diff-prod-staging.py`) |
| Player squad shape | matches production field-for-field, including `squadType`, `custom`, `chemistry`, `manager` |
| Squad save | PUT/GET round-trips exactly, every field and slot |
| Cert gates unpatched | autopatch log + live byte read both confirm patched |
| Roster server broken | serves `200`, `application/xml` over TLS1.2 `AES256-GCM-SHA384` |
| Advertised host wrong | production and staging Blaze envs are identical for `OPENFUT_ADVERTISE`, `POW_*`; the scripts are byte-identical |
| TLS version mismatch | client offers TLS1.2 and the server selects it |
| Blaze session | healthy, PING/PING_REPLY throughout |
## Probing gotcha
A default modern Python TLS context gets `SSLV3_ALERT_HANDSHAKE_FAILURE` against this
server and looks like a server fault. It is not — the server requires legacy-compatible
cipher selection. Probe with `ALL:@SECLEVEL=0`, and note it rejects TLS1.0/1.1 outright
(`TLSV1_ALERT_PROTOCOL_VERSION`), accepting only TLS1.2.
## Why production appears unaffected
Unresolved, and worth stating plainly rather than guessing. Production advertises the same
URL, backed by the same server and certificate, so the same rejection should occur. The
operator reports production is clean. The likely explanation is that a production client
does not re-fetch the roster (already satisfied), while a session against a different Blaze
forces the fetch — but that has not been measured. The known-good hook log used as a
control (Aug 12 19:13) **predates the Aug 13 certificate regeneration**, so it is not
evidence about the current certificate.
## Fix options — option 1 applied (`fbc0da2`)
1. **Reissue the certificate with an `iPAddress` SAN****APPLIED.** The three cert
generators now put the advertised IP in the SAN: the docker entrypoint reconciles it at
startup from `OPENFUT_ADVERTISE` (runtime value, unknown at build time; reissued only
when missing, so restarts don't churn the fingerprint), the Dockerfile keeps `openssl`
in the runtime image and bakes a loopback-IP baseline, and `openfut-fut.sh` defaults the
SAN IP to the host's primary LAN IP. Smallest change, benefits the redirector too. Note
`openfut-tls` only *loads* the cert — it does not generate it — so the generation fix
lives in these three scripts regardless of whether the roster is served by Python or the
Rust `openfut-roster-host`.
2. **Serve the roster over plain HTTP** — not taken. Sidesteps TLS entirely but only
acceptable off production, and option 1 fixes it properly everywhere.
3. **Find the real validation site** on the roster path and extend the patch set — not
needed once the SAN matches; kept on record as the most-faithful alternative.
Option 1 was chosen because it is testable without the client: a verifying TLS client
checking the cert by IP fails on the old cert and passes on the new one. That test now
passes (`scripts`-style probe run at fix time); the remaining step is the operator
rebuilding the production container so the reissued cert is actually served.
File diff suppressed because it is too large Load Diff
-241
View File
@@ -1,241 +0,0 @@
# FIFA 17 seller-facing SOLD state — static recovery from CardsDLL
Reverse engineering of `CardsDLL_Win64_retail.dll`
(md5 `4de3493131d7d2ff7f8b360c5ac9b655`), Ghidra 12.1.2 headless via pyghidra,
13,382 functions, image base `0x180000000`. Queries and raw output:
`docs/evidence/market-sold-re-2026-08-17/`.
Confidence tags: **[PE]** read from the binary · **[PE-PROOF]** a proven *negative*
(an exhaustive absence) · **[PLAN]** corpus prose, not a decompile · **[UNDECIDABLE]**
proven not answerable from this binary.
---
## 1. There is no `sold` token, and now it is exhaustive
Both vocabularies dumped in full to their sentinels, not sampled. **[PE]**
`tradeState` table `0x180229e40` — exactly 4 rows, then a `{0,0}` terminator:
```text
active=1 inactive=2 expired=3 closed=4
```
`itemState` table `0x180229cc0` — exactly 12 rows, then a `{0,-1}` terminator:
```text
invalid=0 free=1 WAITING_FOR_GAME=2 inGame=2 forSale=5 offered=6
activeBadge=100 activeHomeKit=101 activeAwayKit=102 activeBall=103
activeStadium=104 active=255
```
Neither contains `sold`. So the seller's sold state **must** be a combination of
existing atoms. This closes the question that previously rested on a partial dump.
## 2. What `closed` actually does — the complete flag computation
Decompiled from the `auctionInfo` deserializer `0x18013e410`. `local_70` is
`tradeState`, `local_40` is `bidState`. **[PE]**
```c
if (local_70 == 4) { /* tradeState == closed */
local_3b = local_40 != 0; /* bidState != none */
} else {
local_3b = (local_40 - 1U & 0xfffffffd) == 0; /* bidState in {1,3} */
}
local_3a = local_40 - 2U < 2; /* bidState in {2,3} */
```
Evaluated:
| | `none`(0) | `outbid`(1) | `highest`(2) | `buyNow`(3) |
|---|---|---|---|---|
| `local_3b`, tradeState==closed | 0 | **1** | **1** | **1** |
| `local_3b`, otherwise | 0 | 1 | 0 | 1 |
| `local_3a` (any tradeState) | 0 | 0 | **1** | **1** |
## 3. The complete record → Flash mapping
From the publisher `0x1801bf030`, every property it sets, with its record offset.
This supersedes the previous partial list. **[PE]**
| Flash property | record | meaning |
|---|---|---|
| `TRADEID_LOWER` / `TRADEID_UPPER` | +0x38 | tradeId, split into two 32-bit halves |
| `DURATION` | +0x90 | formatted; **`FUT_AUCTION_EXPIRED`** when the value underflows (i.e. `expires == 0`) |
| `TIME_REMAINING` | +0x90 | expires, seconds |
| `MIN_CREDITS` | +0x78 | currentBid |
| `MAX_CREDITS` | +0x70 | buyNowPrice |
| `RESERVEDPRICE` | +0x74 | startingBid |
| `YOURBID` | +0xb8 | **bidState, passed through verbatim** |
| `STATE` | +0x88 | **tradeState, passed through verbatim** |
| **`COINS_AWARDED`** | **+0xbf** | **the `coinsProcessed` atom (0x2f4), u8** |
| `UUID_UPPER` / `UUID_LOWER` | itemData+8 | |
| `CARD_ID` / `FIFA_ID` | itemData+0x18 | `FIFA_ID` masks `& 0xffffff` |
| `CARD_TYPE` | itemData+0x4c | |
| `CARD_OFFERSTATE` | itemData+0x5c | itemState |
| `IS_WATCHED` | +0xbc | `watched` atom |
| `INBOX` | +0xbe | `local_3a` — bidState ∈ {highest, buyNow} |
| `IS_GLOW` | +0xbd | `local_3b` — the table in §2 |
| `TRADE_DATA_AVAILABLE` | — | constant 1 |
**`coinsProcessed`'s consumer is now traced.** The corpus recorded its type and
noted that no consumer had ever been found; it is published to the movie as
**`COINS_AWARDED`**. That is a settlement/"you have been paid" signal, exactly as
the corpus guessed but never demonstrated.
## 4. `highest` vs `buyNow` on a closed row is UNDECIDABLE from CardsDLL
**[UNDECIDABLE]**, and this is a proof, not a failed search.
For `tradeState == closed`, §2 gives `IS_GLOW = (bidState != none)` and
`INBOX = (bidState ∈ {highest, buyNow})`. Both `highest`(2) and `buyNow`(3)
therefore produce **`IS_GLOW=1, INBOX=1`** — bit-identical. No native consumer can
tell them apart.
But §3 sharpens *why* it is undecidable: `bidState` is **not** consumed only through
those flags. It is published verbatim as `YOURBID`, alongside `STATE` and
`COINS_AWARDED`. The movie receives the raw values. So the discrimination exists —
it just lives entirely in the APT/ActionScript front end, which is unread.
Consequence: **no amount of further CardsDLL work can answer "which `bidState` does
a seller see".** Only an AVM1 read of `external/ion_fut/screens/trading/tradepile`,
or a live behavioural A/B, can. This retires the question as a static target.
The corpus's own lifecycle table (`fifa17-recon/docs/plan-2026-08-06-transfer-market.md:720-729`)
asserts the seller sees `closed` / **`highest`** with `coinsProcessed 1`, and assigns
`closed` / `buyNow` to the *buyer*. That is **[PLAN]**, and it **contradicts** the
common third-party lore that a sold seller row is `closed` + `buyNow`. Given §4 the
contradiction cannot be resolved statically — but note the corpus reading is the one
that leaves `buyNow` meaning "*I* bought it now", which is self-consistent with
`YOURBID` being a property about the viewer's own bid.
## 5. The clear-sold verb EXISTS — PE-proven
The request builder `0x1801647c0`: **[PE]**
```c
if (*(longlong *)(param_1 + 0x10) == 0) {
FUN_180007f80(&local_38, 0x20, "/sold"); /* no tradeId -> bulk */
} else {
FUN_180007f80(&local_38, 0x20, "/%lld"); /* one specific tradeId */
}
```
One builder, two forms, on route base `ut/delete/%s/trade` (`DELETETRADE`), response
class `RS4:FutISRemoveTradeServerResponse` (`0x180228bc8`, with the `/sold` literal
at `0x180228bec` immediately after it):
```text
DELETE ut/delete/{ns}/trade/{tradeId} remove one trade
DELETE ut/delete/{ns}/trade/sold remove ALL sold trades
```
Corroborated by the client's own request-name table, where
**`RemoveAllSoldFromTradePile`** (`0x1801efae8`) sits beside `RemoveFromTradePile`,
`AddToWatchList` and `RemoveFromWatchList`.
**Architectural consequence.** A bulk "remove all sold" verb only makes sense if
sold rows **persist in the seller's pile until explicitly cleared**. That is
incompatible with our current host, where a sold/cancelled listing leaves
`/tradePile` the instant the CAS lands (the Fix A invariant). Implementing the sold
path will require revisiting that invariant — and doing so needs live validation,
because Fix A itself was a live-confirmed correction.
## 6. The seller-facing SOLD counter is real, and we hardcode it to 0
A complete chain, wire atom → struct → Flash → localised caption, with no inference
at any step. **[PE]**
Hub `tradePile` sub-deserializer `0x18013ead0`:
| atom | id | writes | Flash slot (publisher `0x1800b1dc0`, tile `0x1c0`) |
|---|---|---|---|
| `count` | 0xbc | +0x1d4 | `TEXT0` with caption `FUT_UC_ITEMS` |
| `notification` | 0x1da | +0x1d6 | `NOTIFICATION`, capped at 99 |
| `selling` | 0x2b8 | +0x1d2 (**and** counts-struct +0x36) | `TEXT2` with caption `FUT_TF_SELLING` |
| **`sold`** | **0x2c9** | **+0x1d8** | **`TEXT3` with caption `FUT_TF_SOLD`** |
The sibling tile `0x1d0` (Transfer Targets) uses `FUT_TF_WINNING` (+0x1c8) and
`FUT_TF_OUTBID`. `selling` writing two structs independently re-confirms the
corpus's counts-struct offset for `selling`.
So FIFA 17 renders a **SOLD** count to the seller, sourced from atom `sold` (0x2c9).
Our host and the Python oracle both hardcode `sold: 0`, so that bucket can never
populate. This is direct client evidence about `sold` — the thing
`MARKET_SOLD_SETTLEMENT.md` required before touching `/tradePile/counts`. It
establishes that `sold` is *displayed*; it does **not** yet establish what should be
counted in it (rows awaiting clear? rows sold this session?).
## 7. Atom-name decoder (method note, reusable)
Atom IDs are **indices into an alphabetically sorted pointer table** of atom-name
strings, base `0x1802d2760`. Validated against all twelve known `auctionInfo`
atoms — 12/12 agree — and cross-checked against `fifa17-recon/docs/fut_atoms.tsv`.
**[PE]**
```text
atom_id = (pointer_slot_address - 0x1802d2760) / 8
```
Newly resolved: `sold`=0x2c9, `count`=0xbc, `offered`=0x1e5, `selling`=0x2b8,
`maxAuctionsAllowed`=0x1bf, `credits`=0xc0, `auctionInfo`=0x35, `total`=0x325,
`duplicateItemIdList`=0xec, `itemState`=0x172, `offers`=0x1e6, `coins`=0x95.
Caution when using it: look up a name by finding the pointer slot **inside the table
range**, not by taking the first matching string in the binary. Common words such as
`sold`, `offered` and `count` appear in several unrelated tables, and taking the
first hit produces confident nonsense (it initially reported `sold` as ABSENT and
`offered` as a negative index).
## 8. An auction-outcome vocabulary exists but this client ignores it
The atom table contains a 7-value outcome vocabulary: **[PE]**
```text
0x36 auctionLostBidRejected 0x39 auctionSoldBid 0x3b auctionWonBid
0x37 auctionLostOutbid 0x3a auctionSoldBuyNow 0x3c auctionWonBuyNow
0x38 auctionLostOutbidSelf
```
These would distinguish seller-sold-by-bid from seller-sold-by-buy-now explicitly.
**No CardsDLL deserializer consumes them.** Every candidate function that compares
against three or more of `0x36..0x3c` was checked and none is an atom dispatcher
(no value-SKIP `0x180135ff0`, no atom loop `0x1801c7f10`) — they are small-immediate
coincidences. **[PE-PROOF]** So the vocabulary is server-side or telemetry, and it
does not carry the sold state to this client.
## 9. The 5% fee is NOT in the client — Task B is undecidable from here
**[PE-PROOF]**, three independent absences:
* no `0.95` or `0.05` constant, `double` or `float`, anywhere in the binary;
* no localisation key for tax/fee/net/proceeds/commission/"you will receive" — the
only `FUT_TF_*` keys in the binary are `SELLING`, `SOLD`, `WINNING`, `OUTBID`;
* the 17 functions using both `5`/`95` and `100` as immediates are all unrelated
(they include the item deserializer) — no fee arithmetic exists.
The client therefore never computes or displays a net. It learns the seller's
balance only from `credits`.
**This makes the rounding rule unmeasurable by experiment against our own server.**
Whatever we credit is what the client displays; there is no client-side expectation
to compare against, so there is no oracle. The only evidence that could settle
floor-the-fee (150 → 143) versus floor-the-proceeds (150 → 142) is an original
EA-era capture of a seller's balance across a known-price sale, which we do not
have.
Accordingly the rule stays a **documented choice**: fee = `floor(gross × 5 / 100)`,
proceeds = `gross fee`, chosen because `fee + proceeds == gross` holds exactly at
every input. It is pinned by regression tests at 0, 1, 19, 20, 21, 39, 40, 100, 101,
119, 120, 149, 150, 151, 199, 200, 1 000, 15 000, 15 000 000 and `i64::MAX`, so it
cannot drift silently.
## 10. What would close the remaining gaps
| Gap | Only remaining route |
|---|---|
| `highest` vs `buyNow` on a seller's sold row | AVM1 disassembly of `tradepile.isInActiveAuction` / `PreCheckCardOptions`, or a live behavioural A/B (the movie gets `YOURBID` verbatim, so the two ARE separable by the client's behaviour) |
| What `sold` should count | live observation with a real sold row |
| Whether sold rows persist until cleared | live: does the client issue `DELETE .../trade/sold`? |
| The 5% rounding rule | an original EA-era seller-balance capture; nothing in reach |
-887
View File
@@ -1,887 +0,0 @@
# FIFA 17 Transfer Market — wire findings
Reverse-engineering record for the FIFA 17 UTAS transfer-market surface, kept so
future agents do not reopen settled questions or re-guess enum spellings.
Every claim carries a confidence tag:
| Tag | Meaning |
|---|---|
| **CONFIRMED** | Observed from our own FIFA17.exe client or live host capture |
| **FIFA17-HISTORICAL** | Supported by contemporaneous FIFA 17 implementations (`lorenzh/fut-api`, `futapi/fut` v0.2.18 — the last pre-FIFA-18 release) |
| **INFERRED** | Best explanation, not directly captured |
| **UNKNOWN** | Requires instrumentation; do NOT implement from guesswork |
Authority reminder: FIFA 17 field names, enum spellings, sentinel ids and
empty-state shapes come from captures or the Python oracle — never from a modern
FUT toolkit. Later-FIFA API drift is a known hazard, and reversing a container
type or inventing an enum is the documented client-freeze class.
---
## The auction record (`auctionInfo[]`)
What we emit today, on `/tradePile`, `/trade/status` and market browse:
```json
{
"tradeId": 1000000097,
"itemData": { "...full shaped card...": "", "itemState": "listFS" },
"tradeState": "active",
"buyNowPrice": 15000,
"startingBid": 150,
"currentBid": 0,
"offers": 0,
"bidState": "none",
"expires": 3600,
"tradeOwner": true,
"sellerId": 33068179,
"sellerName": "CAGE",
"sellerEstablished": 1,
"watched": false,
"coinsProcessed": 0
}
```
| Field | Confidence | Note |
|---|---|---|
| `tradeOwner` (bool) | **FIFA17-HISTORICAL** | Exists in FIFA 17 auctionInfo. That it is *the* Actions-panel gate is **UNKNOWN** pending live confirmation. |
| `sellerId` | **FIFA17-HISTORICAL** exists; type numeric is **INFERRED** | Set to the configured persona so it agrees with `tradeOwner`. Never baked in. |
| `sellerName` | **CONFIRMED** it must be the player | `fut_account.py` annotates the persona property as "Blaze PDTL.DSNM / LSX GetProfileResponse Persona / **UTAS sellerName**". EA's `"EASFC"` here is wrong for an own listing. |
| `offers` | **FIFA17-HISTORICAL** | `0` valid for active/unbid. |
| `bidState: "none"` | **FIFA17-HISTORICAL** | Valid for active/unbid. Other observed concepts: `highest`, `buyNow`. Do NOT "fix" this. |
| `expires` | **FIFA17-HISTORICAL** | **SECONDS REMAINING, not an epoch.** Historical durations: 3600, 10800, 21600, 43200, 86400, 259200. |
| `itemData.itemState: "listFS"` | **UNKNOWN** | Plausible and unchanged. Public FIFA 17 material gives no trustworthy enumeration. Do not guess replacements — capture. |
| `itemData.untradeable` | **FIFA17-HISTORICAL** field; our blanket `false` is **INFERRED** | See "Known debt" below. |
| `marketDataMinPrice` / `marketDataMaxPrice` | **do NOT add** | These entered the public parser only after its FIFA 18 migration. |
### Why the differential could not catch the missing fields
Our record's key set was **identical to the Python oracle's**, so field-for-field
parity was green. The oracle omits `tradeOwner` / `sellerId` / `offers` as well,
because *its* remove flow was never driven by a real client either — the only
historical live datapoint is a counts-tile bug. Oracle parity is therefore
**necessary but not sufficient** for any flow the oracle never actually served.
The differential now asserts we cover every oracle key AND that our extra keys are
exactly `{offers, sellerId, tradeOwner}`, so the deliberate superset is pinned
while a new unexplained divergence still fails.
---
## Routes
| Route | Confidence | Behaviour |
|---|---|---|
| `GET …/trade/status` | **CONFIRMED** the client polls it continuously | Live auction-state refresh. It previously fell through `starts_with("trade")` into the buy/view arm, where the tail has no numeric id, so **every poll returned `{"auctionInfo": []}`**. Now a real handler: optional `tradeIds` filter, else the whole active pile. Unknown ids are absent, never an error. |
| `DELETE /ut/game/<sku>/trade/<id>` | **FIFA17-HISTORICAL** | The spelling contemporaneous FIFA 17 clients use, no body, no meaningful response body. Previously landed in the buy/view arm and **silently cancelled nothing while returning 200.** Now maps to MarketCancel. |
| `DELETE /ut/delete/game/<sku>/trade/<id>` | **CONFIRMED** (oracle) | The oracle's spelling; retained because the differential exercises it. Whether FIFA17.exe ever uses it is **UNKNOWN**. |
| `POST …/auctionhouse` | **CONFIRMED** | List for sale. The client sends only `itemData.id`; the server resolves wire id → Core instance → `card_id`/`resourceId` and enforces ownership. |
| `GET …/tradePile/counts` | **INFERRED** | Five scalar ints (`count`, `maxAuctionsAllowed`, `offered`, `selling`, `sold`); a DISTINCT deserializer from `/tradePile`. Exact FIFA 17 semantics of `count` (active auctions vs whole pile) is **UNKNOWN** — we report active auctions and deliberately did NOT speculate. |
| `PUT …/item` (move) | **FIFA17-HISTORICAL** | `{"itemData":[{"pile":"trade"|"club","id":ID}]}``{"itemData":[{id,pile,success}]}`. Transfer-List membership is a **separate operation from creating an auction**. |
### Pile encoding
* MOVE commands take a **string** pile (`"trade"`, `"club"`) — **FIFA17-HISTORICAL**.
* Returned `itemData.pile` is documented **numeric** in FIFA 17 auction data — **FIFA17-HISTORICAL**.
* The numeric mapping is **UNKNOWN**. Do not unify the two representations, and do
not derive a mapping from unrelated `pileSize` keys.
---
## Q2 — Transfer List item that is not currently auctioned
A real FUT state: an item in the Transfer List with no active auction (freshly
moved, or expired unsold). **CONFIRMED** to exist as a concept (the external hub
spec §27, and move-vs-list being separate operations).
Its wire representation is **UNKNOWN**: `tradeId` 0 / omitted / null, `tradeState`
value or omission, and `itemData.itemState` are all unestablished.
Consequence, and the reason this matters: our `/tradePile` renders only `active`
listings, so keying the `/club` exclusion on the `trade` **pile** stranded 4 cards
in no screen at all (hidden from the club, absent from the Transfer List).
Commit `f2c4927` keys exclusion on the **active listing** instead, which is
self-healing. That is a workaround, not fidelity — the faithful model needs the
unlisted state represented.
**Required capture** (four states, full structural diff, not just a shortlist):
```
A. moved Club -> Transfer List, NEVER listed
B. actively listed
C. listing expired unsold
D. listing sold
```
Diff at least: `tradeId`, `tradeOwner`, `tradeState`, `bidState`, `expires`,
`offers`, `currentBid`, `startingBid`, `buyNowPrice`, `sellerId`, `sellerName`,
`itemData.id`, `itemData.itemState`, `itemData.pile`, `itemData.untradeable`.
Do NOT drop the unlisted state from the model just because its encoding is unknown.
---
## Deferred, with reasons
* **5% transfer tax** — **INFERRED** architecture only: auction closes → Core
settles → seller credited gross × 0.95, with `auctionInfo` continuing to carry
gross. No trustworthy FIFA 17 field named `tax`/`netPrice`/`sellerProceeds` was
recovered, and no separate settle operation. Not blocking; do not couple
settlement to clearing the sold auction without a capture.
* **Bid / Transfer Targets** — not implemented. Watched / active bid / winning /
outbid / won / expired are distinct states and must not collapse to a flat list.
* **Unassigned** — FIFA 17 had a dedicated Unassigned service; the exact FIFA 17
URL is **UNKNOWN**. Our 29-item `purchased` pile is this state and is currently
rendered inside `/club`. Do not manufacture a route from a modern toolkit.
* **Match CREATE / READY / PLAY** — **UNKNOWN** and explicitly not portable from
public FUT web-app work (the web app could not start matches). Instrument the
real client from Play Match to kickoff before implementing.
---
## Known debt
`shape_item` reports `untradeable: false` for **every** owned instance. Correct
today (Core models no untradeable items) and necessary — a hardcoded `true` greyed
out both list buttons — but it will misrepresent SBC / promo / loan rewards once
those exist. `untradeable` belongs on the owned-item instance as authoritative
state, not inferred from definition, resourceId or rarity.
---
## MEASURED in the live client — 2026-08-17
Read out of the running `FIFA17.exe` (pid-resolved, CardsDLL slide proven against
the on-disk FNV prologue) with `fifa17-recon/tools/trade_gate_probe.py`, which
extends `gate_byte_probe.py` to vtable slot `+0x270` as the transfer-market
analysis asked for. Read-only: `/proc/<pid>/mem` `O_RDONLY` + `pread`.
| Gate | Python era (2026-08-06) | Now | Owner |
|---|---|---|---|
| `IS_TRADING_ENABLED` `model+0x1fd2e` (slot `+0x270`) | **0** | **1** | settings struct `+0x28`; was zeroed by `userInfo.feature.trade` |
| `TRADE_PILE_SIZE` `model+0x1fd1c` | **0** | **100** | `userMassInfo.pileSizeClientData` key 2 |
| watch-list size `model+0x1fd20` | **0** | **50** | same member, key 4 |
| `storeEnabled` `model+0x1fd2f` | 1 | 1 | control |
| `IS_FRIENDLY_SEASON` / `IS_DRAFT_MODE` / `packOpeningAnimation` | 1 | 1 | controls |
**CONFIRMED: every CardsDLL-supplied input the transfer-market analysis named as a
blocker is now open.** The Rust host does this by construction — it emits
`userInfo.feature` as `{}` (no `trade` member, so the kill switch at `0x180174f19`
never arms: it fires only when atom `0x330` inside `0x11c` parses as exactly 1) and
it already sends `pileSizeClientData` keys 2 and 4. Serving `tradingEnabled: 1` in
the settings `configs` array would NOT have worked, because that tail runs after
every member is parsed and would overwrite it.
### What this rules out
The Transfer List Actions panel not opening on an own listing is therefore **not**:
* an ownership field — FIFA 17's auctionInfo has no `tradeOwner`/`sellerId` atom;
* `IS_TRADING_ENABLED`, `TRADE_PILE_SIZE` or the watch-list size — all measured open;
* the cancel route — `DELETE ut/delete/{ns}/trade/{tradeId}` is the PE's spelling and
is what we serve;
* `tradeState` / `bidState` / `expires` spellings — all three are the PE's own
vocabularies and values.
Per the analysis's own falsifier ("if the byte reads 1 and the screen still refuses,
the exe-side predicate has a term we have not enumerated"), the remaining term is
**exe-side UI script**, which CardsDLL does not own and the server cannot set.
Status: **UNKNOWN**, and it is now the narrowest it has ever been.
### Confirmed fidelity bug found on the way
`expires` was a frozen `3600` on every poll, so the client's live countdown never
moved and an auction could never run out. Now derived from `created_at + duration`
(duration taken from the `ISStart` body), clamped at 0, with an aged-out active
listing projecting as `expired`/`none` — FIFA 17's relistable state. Verified live:
the standing listing correctly reads `expires: 0` once past its hour.
---
## RESOLVED against the live client — 2026-08-17 (supersedes the tags above)
Driven by the real FIFA 17 client end to end: list → expire → **relist** → active,
with the operator confirming each UI state. Live-client behaviour OUTRANKS both the
Python oracle and contemporaneous Web App implementations wherever they disagree.
### `tradeOwner` — do not re-litigate this
```text
tradeOwner exists in the FIFA 17-era FUT API:
FIFA17-HISTORICAL
tradeOwner required by FIFA17.exe Transfer List Actions:
DISPROVEN for the current client path
```
It is absent from the twelve atoms FIFA 17's `auctionInfo` deserializer
(`0x18013e410`) reads, so the client value-SKIPs it at `0x180135ff0`. It was
implemented, deployed, observed to change nothing, and REMOVED. A future agent
rediscovering the old Web App sources will find this note before spending
deployments on it again.
### What actually gated the Actions panel — all lifecycle/state bugs
```text
CONFIRMED — FIFA17.exe live client
Own Transfer List interactivity does NOT depend on tradeOwner in this path.
trade/status polling is LOAD-BEARING for Transfer List state. The tail carries no
numeric id, so it fell through the `trade…` buy/view arm and answered every poll
with an empty auctionInfo. Route is ISVIEWTRADE:
GET ut/{ns}/trade/status?tradeIds=a,b,c
auctionInfo.expires is SECONDS REMAINING *and must evolve with wall-clock time*.
A structurally valid but FROZEN value breaks lifecycle behaviour: the auction never
ages into expired/relistable, which is the state where Re-list appears.
Relisting must PERSIST. FIFA 17 relists by re-sending ISStart (POST /auctionhouse)
for an item that already has a listing row, so the primary-key conflict IS the
relist. Swallowing it as success left the stale expired row intact and produced a
client-visible lifecycle failure behind an HTTP 200.
```
### Known-good own active auction (frozen fixture)
Captured at `docs/evidence/market-lifecycle-2026-08-17/`, including
`_index.json._countdown_proof` which records `expires` decrementing (`frozen:
false`) so the live clock is machine-checkable rather than asserted in prose.
```text
tradeState = active
bidState = none
expires = decrementing (seconds remaining)
sellerName = CAGE
auction record = 12 atoms
response envelope = 4 members (auctionInfo, credits, duplicateItemIdList, total)
ISViewTrade body = 2 members (auctionInfo, credits)
pricelimits = BARE ARRAY (container type is load-bearing)
```
**Do not "improve" this shape without a live-client retest.**
### The development rule this established
A response can be structurally plausible, pass differential parity, and render
perfectly while still being behaviourally wrong, because FIFA expects an **evolving
server-side state machine** rather than a static object that looks like one. The
frozen `expires` is the canonical example: every field was the right name, type and
vocabulary, and the feature was still broken.
---
## INVESTIGATION CLOSED — active own auction is not seller-actionable
**Do not reopen without new direct FIFA17.exe evidence contradicting the lifecycle
below.** The correct FIFA 17 lifecycle is:
```text
ACTIVE AUCTION tradeState=active, expires>0/counting down
-> seller CANNOT withdraw it through Transfer List actions
-> the item is not seller-actionable while the auction runs
EXPIRED UNSOLD tradeState=expired, expires=0
-> the item becomes actionable again
-> relist / return-to-club / other expired-item actions
```
### Confidence tags
```text
CONFIRMED — live FIFA17.exe:
active listing with ticking expires is NON-selectable
expired listing IS selectable
relisting makes it active and therefore non-selectable again
no cancel request is ever emitted by the client
expires must advance with the wall clock
no button prompt is offered on the Transfer List for an active auction
CONFIRMED — local RE corpus:
every CardsDLL-side prerequisite passes (IS_TRADING_ENABLED=1,
TRADE_PILE_SIZE=100, watch-list=50, item+0x49 tradeable)
MAY_BE_REMOVED is a CONSTANT 1 — it cannot be the gate and the server
cannot move it
the eight-flag array FUN_18003e370 publishes is the CLUB-CARD Actions menu
and contains NO transfer-auction cancellation flag
the auction parser is limited to the known twelve atoms
HISTORICAL FUT / FIFA17-era:
active auctions are committed until sale or expiry
expired Transfer List items expose relist/return actions
FIFA 17 trading guidance tells players to relist once auctions expire
UNKNOWN, and no longer required for backend fidelity:
the exact Flash/ActionScript branch that makes active cards non-selectable
```
### Explicitly out of scope now
Do NOT add auction fields, change `itemState`, revisit `tradeOwner`, probe
`MAY_BE_REMOVED`, add an active-auction cancel feature, or disassemble Flash in
order to make active auctions selectable. Three of those were already tried and
refuted; the rest are ruled out above.
### Return-to-club transition (implemented)
A pile move to `club` now ENDS any `active` auction on that item. Without it the
pile reads `club` while the listing row stays `active`, so the card is filtered out
of `/club` (exclusion keys on active listings) AND still rendered in the Transfer
List — the move silently appears to do nothing. `reserved` (mid-sale) and `sold`
rows are never touched, so a card can never be both sold and returned.
---
## Q2 — the unlisted transfer-list item, MEASURED 2026-08-17
The operator moved a card Club -> Transfer List **without listing it**
(`PUT /ut/game/fifa17/item`, no `POST /auctionhouse`). State captured immediately,
read-only. Fixture: `docs/evidence/market-lifecycle-2026-08-17/`.
Server truth at capture: trade pile held **6** items, of which **1** had an active
auction and **5** were unlisted.
| Surface | Unlisted item | Actively-listed item (contrast) |
|---|---|---|
| `/tradePile` `auctionInfo` | **absent** | present, `tradeState: active` |
| `/tradePile/counts` | **not counted** (`count 1, selling 1`) | counted |
| `/club` | **present**, `clubPlayers` unchanged at 1965 | **absent** (excluded) |
| `itemData.itemState` | `free` | `listFS` |
| `itemData.pile` | **field not emitted at all** | not emitted |
| `itemData.untradeable` | `false` | `false` |
**Finding: we do not represent the unlisted state anywhere on the wire.** Such an
item is byte-identical to a club item; only the host's own pile store knows it is in
the trade pile, and nothing the client receives says so.
Still **UNKNOWN** (do not guess): the FIFA 17 encoding of that state. The PE
documents returned `itemData.pile` as NUMERIC with an unrecovered mapping, and
`tradeState` decodes through a closed table walk (`active=1 inactive=2 expired=3
closed=4`) where an unrecognised `bidState` is silently swallowed as `none` — so a
wrong enum yields a plausible-looking but WRONG UI. The corpus explicitly warns that
`inactive` decodes but "no client path treats it specially; do not emit it".
Open discriminator, needs the client only: after backing out of FUT and re-entering,
does the Transfer List still show an unlisted item? If it does not, the move is not
durable from the client's point of view and the state must be represented; if it
does, `tradePile` is auctions-only by design and there is nothing to fix.
---
## Domain boundary: pile membership vs auction lifecycle
Two SEPARATE facts, but several mutations require a COORDINATED transition. Both
limbo bugs this session came from conflating them, so this is modelled explicitly
and covered by tests rather than patched route by route.
| Mutation | Pile | Auction |
|---|---|---|
| Club -> Transfer List | becomes `trade` | none necessarily exists |
| list item | stays `trade` | becomes `active` |
| clock runs out unsold | stays `trade` | becomes `expired` (projection; no row mutated) |
| relist | stays `trade` | `active` again, clock restarted, new prices |
| return expired item to Club | becomes `club` | any `active` association MUST end |
| sold / reserved | — | MUST NOT be ended by a generic move-to-club |
Invariant, stated so it can be tested rather than remembered: **an item must never be
simultaneously excluded from `/club` and absent from `/tradePile`.** That is the limbo
state, and it is reachable in two ways, both now closed:
1. Excluding by *pile* rather than by *active listing* (fixed in `f2c4927`) — the
pile can hold items with no auction, and `/tradePile` renders auctions only.
2. Moving to `club` while leaving the auction `active` (fixed in `4e31fb9`) — the
pile says club, the exclusion still fires, and the card shows in neither place.
Tests: `returning_an_expired_listing_to_the_club_ends_its_auction`,
`a_pile_move_never_disturbs_a_sale_in_flight`,
`club_excludes_listed_items_and_paginates_the_visible_set`.
---
## Q2 representation — narrowed to ONE candidate by elimination (2026-08-17) — **SUPERSEDED, see the final section**
Re-entry discriminator result: **CONFIRMED BUG.** After fully leaving and re-entering
FUT, the active auction reconstructs correctly under LISTED ITEMS, but the unlisted
TRANSFER LIST section is **empty** — item `100000059` does not survive. The
immediate post-move visibility was client-local only, so our representation cannot
durably reconstruct trade-pile membership.
The representation is now pinned by elimination over PROVEN facts, not chosen:
1. **Only one route can own a trade-pile list.** The complete CardsDLL route table
(`.rdata 0x18021df80`, 45 routes + 3 empty admin slots) is dumped verbatim at
`docs/evidence/market-lifecycle-2026-08-17/cardsdll-route-table.txt` via
`fifa17-recon/tools/route_table_dump.py` (static, read-only, VA→offset resolved
through the real PE section table). Row 30 `ut/%s/tradePile` is the ONLY
trade-pile route. There is no trade-pile *items* route.
2. **That route carries only auction records.** `FutGetTradePile` (`0x180170810`)
deserializes the shared IS-list body (`0x18013e7f0`), whose `auctionInfo`
elements go through `0x18013e410` — the twelve-atom auction record.
3. **`pile` cannot be set from the wire.** `pile` (atom 0x226) has NO arm in the item
deserializer `FUN_18013fe00` (checked in all four dispatch forms); `item+0x60` is
assigned by the OWNING LIST, reading 1 for every `/club` item and 6 for every
`/purchased` item. So membership is conferred by *arriving in the list*, never by
a field we can add.
4. **Of the twelve atoms, only `tradeState` can express lifecycle.**
5. **`tradeState`'s vocabulary is closed and has exactly one unused value.** The table
walk at `0x180229e40` decodes `active=1 inactive=2 expired=3 closed=4`, anything
else `-1`. `active`, `expired` and `closed` are all already spoken for by the
observed lifecycle.
**Therefore an unlisted trade-pile item can only be an `auctionInfo` record with
`tradeState: "inactive"`.** That is the sole encoding the client's own parser can
accept for "in the pile, no auction" — reached by elimination, not invention.
Status: **INFERRED-BY-ELIMINATION**, not yet CONFIRMED. One binary question remains,
and it is about client *rendering*, not encoding: does the Flash Transfer List place
an `inactive` record in the unlisted section? The corpus's warning that "`inactive`
decodes but no client path treats it specially" was established for the market
PRESENTATION function (`flagA`/`flagB`), where `inactive` is indeed unremarkable —
that is consistent with, and does not contradict, using it for list membership.
Ruled out on evidence, do not retry: adding `itemData.pile` (inert, no deserializer
arm), a second route (none exists), and `tradeId: 0` / invented `itemState` values
(unnecessary — `tradeState` alone carries the distinction).
### Acceptance test for whatever lands
```text
move item Club -> Transfer List
leave FUT entirely
re-enter FUT
item appears in the unlisted TRANSFER LIST section
item can be returned to Club
no auction exists unless explicitly listed
```
Plus the active lifecycle must remain unchanged: active listing under LISTED ITEMS,
`expires` counting down, active non-selectable, expiry making it actionable, relist
working.
### Revised domain invariant
Core pile membership is NOT equivalent to `/tradePile` visibility. Core ownership/pile
state and auction lifecycle are separate authoritative facts; the wire may expose them
through different FIFA 17 resources. **A transition is complete only when a fresh FIFA
session can reconstruct the same user-visible state** — re-entry is the acceptance
test, not the immediate post-mutation response.
---
## Q2 — LIVE-CONFIRMED: `tradeState: "inactive"` is the unlisted representation — **SUPERSEDED, see the final section**
Operator-observed in the real FIFA 17 client, 2026-08-17, with the bounded one-item
probe (`OPENFUT_FIFA17_UNLISTED_PROBE`). Fixture:
`docs/evidence/market-lifecycle-2026-08-17/inactive-row-live-confirmed.json`.
### The observed `inactive` UI contract
| Wire | Client presentation |
|---|---|
| `tradeState: "inactive"` | row placed under the right-hand **TRANSFER LIST** section, NOT under LISTED ITEMS |
| `startingBid: 0` | Start Price = `0` |
| `buyNowPrice: 0` | Buy Now Price = `-` (absent) |
| `currentBid: 0` | Current Bid = `-` (absent) |
| `expires: 0` | Time Remaining = `-` (no timer) |
| `bidState: "none"` | no bid presentation |
| `itemData.itemState: "free"` | renders as a normal owned card |
An `active` row in the same body continued to render separately under LISTED ITEMS,
so the two sections are driven by `tradeState` and the experiment did not disturb the
known-good active path.
Token provenance: dumped from the client's own `{const char*, int}` table at
`0x180229e40` (`active=1 inactive=2 expired=3 closed=4`) — see
`cardsdll-vocab-tables.txt` and `fifa17-recon/tools/vocab_dump.py`. Nothing here was
guessed.
Regression test: `market::tests::unlisted_candidate_is_a_non_active_pile_row` pins the
whole tuple, plus that the row is never emitted for an item outside the `trade` pile
and never duplicates a real auction
(`unlisted_candidate_never_duplicates_a_real_auction`).
### Expired -> Club coupled transition, verified
Operator returned the naturally-expired Ronaldo to the club. Server state after:
```
listing 1000000097 state = cancelled (the coupled transition fired)
pile Ronaldo = club
/tradePile Ronaldo absent
/club Ronaldo present, itemState free
hub clubPlayers 1964 -> 1965, auctionCount 1
counts {count 1, selling 1, sold 0}
```
All of that is durable store state rather than a client-local view, so it survives a
session boundary by construction. Pending the operator's final exit/re-enter
confirmation before this is tagged CONFIRMED.
### Counts observations so far (semantics still UNCHANGED and unresolved)
| State | `count` | `selling` | `sold` |
|---|---|---|---|
| 1 active | 1 | 1 | 0 |
| 2 active | 2 | 2 | 0 |
| 1 active + 1 inactive (probe) | 1 | 1 | 0 |
| unlisted pile items (no probe) | not counted | not counted | 0 |
So `count` currently tracks AUCTION entries, not total Transfer List membership. Do
not change this until the full state set (empty / unlisted / active / expired / sold /
mixed) has been observed — it remains an open question whether FIFA 17 expects
`count` to include non-active pile members.
---
## Q2 status downgrade + the "Expired" differential EXPLAINED (2026-08-17) — **SUPERSEDED, see the final section**
Phase C was a PARTIAL pass. Corrected conclusions:
```text
tradeState "inactive" = CONFIRMED section/lifecycle discriminator
(rows persist across a fresh session and land under
TRANSFER LIST, not LISTED ITEMS)
complete ACTIONABLE unlisted representation = was UNKNOWN; see below
```
### The visual differential, and its cause
| | one-item probe | generalized rows |
|---|---|---|
| Start Price | 0 | 0 |
| Buy Now / Current Bid | `-` | `-` |
| Time Remaining | `-` | **`Expired`** |
Cause: **route coverage, not a field.** `/tradePile` advertised the unlisted
tradeIds, but `GET …/trade/status` (ISVIEWTRADE) answered them from the market store
ONLY — and an unlisted pile member has no listing row, so the poll returned an empty
`auctionInfo`. Observed live as `route=market-status requested=1 returned=0` repeating
for the row the operator had selected (`tradeIds=1000000122`), while the same id was
present in `/tradePile`.
The client polls `/trade/status` for the row it is displaying, and an empty answer
degrades it: Time Remaining renders `Expired` and no actions are offered. The earlier
probe showed `-` simply because the client had not yet polled that id — the logs at
the time show only `tradeIds=1000000097`. So `expires`, `tradeState`,
`itemData.itemState` and `pile` were all innocent, and NO field was changed.
This is the same defect class as the original `trade/status` bug: a route the client
polls being answered with an empty set. The corpus predicted it — `tradeId` must
resolve across `/transfermarket`, `/tradePile`, `/watchList` AND `/trade/status`. We
had stability but not coverage.
Fix: both routes now share ONE pile enumeration (`Server::resolve_trade_pile`), so an
id advertised by `/tradePile` always resolves on `/trade/status`. Verified live: the
six inactive ids went from `returned=0` to `returned=6`. `/trade/status` still answers
only the ids actually asked about, and a real auction always wins over an inactive row
for the same id.
Regression test: `trade_status_resolves_the_unlisted_ids_tradepile_advertises`.
### Investigations NOT needed as a result
`itemData.itemState` and numeric `itemData.pile` were queued for PE recovery on the
assumption the encoding was incomplete. The cause was route coverage, so neither was
touched and neither is implicated. If the actions still do not appear, those remain
the next candidates — and `itemState`'s vocabulary should be dumped statically with
`fifa17-recon/tools/vocab_dump.py` (already validated against the tradeState table)
rather than guessed.
---
## Q2 — RETRACTED, and CLOSED as unrepresentable (2026-08-17, RE of the FUT front-end)
**Everything in the two Q2 sections above is superseded.** The `tradeState:"inactive"`
projection has been removed from `GET …/tradePile` and `…/trade/status`, and the
`UnlistedCandidate` machinery deleted. Live after the fix: `total` 7 → 1, only the real
auction, `inactive` rows 0, coins unchanged, and all six former rows still present in
`/club` (invariant intact).
### Why the earlier conclusion was wrong
It was `LIVE-CONFIRMED` that the row **renders**. That was never in doubt, and it is
not the same claim as the row being usable. The rows were never actionable, and could
not have been:
| Fact | Confidence |
|---|---|
| `tradeState` reaches ONE native branch, `cmp …,0x4` at `0x18013e619` ("is it `closed`?"). `inactive`(2) and `expired`(3) take the same edge → bit-identical `flagA`/`flagB`. Exhaustive 22-site census of `[reg+0x88]` reads across the PE. | CONFIRMED |
| `tradeState` is handed to the movie verbatim as the Flash property `STATE` (publisher `0x1801bf030`); the action gate is in the APT/ActionScript FUT front-end, not CardsDLL. | CONFIRMED |
| The trade-pile movie class partitions rows with `getCardsInAuction`/`isInActiveAuction` (traces `initPile() - IN AUCTION:` / `- NOT IN AUCTION:`), and only auction rows reach `PreCheckCardOptions``handleTradeCardAction`. | CONFIRMED (interned AS symbols + trace strings, live) |
| A client rule excludes non-auction pile rows from the action path (Category C). | STRONGLY SUPPORTED |
| The gate term is `STATE == 2`. | **NOT PROVEN**`STATE`, `CARD_OFFERSTATE` and the `RESERVEDPRICE`/`MAX_CREDITS` pair all differ together in every row we can observe |
| In the shipped game, unlisted pile rows were a CLIENT-SIDE transient built by the movie from a `TO_TRADEPILE` Flash message (`FUN_1801be6a0`), never server-delivered. | STRONGLY SUPPORTED |
This fires the corpus's own pre-registered falsifier E3
(`plan-2026-08-06-transfer-market.md:368-373`): the native predicates all pass
(`IS_TRADING_ENABLED=1`, `TRADE_PILE_SIZE=100`, `tradeable=1`) and an `expired` row in
the same section IS actionable, so "the Flash layer has its own gate and CardsDLL is no
longer the right place to look" is now an observation.
**The corpus said so before any of this was written**, at
`plan-2026-08-06-transfer-market.md:731-733`: "`inactive` decodes but no client path
treats it specially; **do not emit it**." The projection was built anyway, and the
earlier reasoning above (that the warning "was written about the PRESENTATION
function") was motivated rather than evidenced.
**Consequence for the product:** a server cannot put an unlisted item on the Transfer
List in an actionable state. `/tradePile` carries only twelve-atom auction records and
`pile` has no deserializer arm, so anything rendered there must be a real auction.
Unlisted trade-pile membership is now internal state with no wire expression; those
cards live in `/club`, which is where the client can act on them.
### Open: `itemData.itemState: "listFS"` is not a FIFA 17 token
Independent of the gate, and CONFIRMED: `listFS` occurs **0 times** in
`CardsDLL_Win64_retail.dll` (md5 `4de3493131d7d2ff7f8b360c5ac9b655`) and 0 times in
4.26 GiB of live process memory; it decodes to **1** through `FUN_180166660`, observed
live on our listed row. FIFA 17's value for a listed item is `forSale` (5), from the
12-row table at `0x180229cc0`. We emit `listFS` on `/tradePile` and `/trade/status`, and
so does the Python oracle (`utas_server.py:3921`) — which is exactly why the
differential never caught it: **oracle parity is necessary but not sufficient.**
### Fix B — `forSale` PROMOTED, live-confirmed on the expired path (2026-08-17)
Shipped alone, then validated by an operator FIFA 17 session against the already-expired
row `1000000155` (res 158023, 93 RW).
| Claim | Status |
|---|---|
| `listFS` is not a FIFA 17 token and decodes to 1 | **CONFIRMED** (binary + 4.26 GiB process scan + live row) |
| `forSale` is FIFA 17-native, value 5 | **CONFIRMED** (12-row table at `0x180229cc0`) |
| An expired row carrying `forSale` is still ACTIONABLE, and Return to Club works | **LIVE-CONFIRMED** — operator returned the card; server verified below |
| The result is durable (a fresh session reconstructs it) | **LIVE-CONFIRMED** (Gap 2, 2026-08-17) — the operator exited FUT and re-entered; the host log shows `auth-delete` then two fresh `sid_opened=true` sessions, each re-fetching `/club` and `/tradePile`, and the card was still in My Club. 18/18 server checks passed identically before and after. See the Gap 2 closure section. |
| An ACTIVE seller row carrying `forSale` renders correctly | **LIVE-CONFIRMED** (Gap 1, 2026-08-17) — see the closure section below; the client decoded `CARD_OFFERSTATE = 5` out of its own auction record |
| `CARD_OFFERSTATE` is the Flash action-gate term | **STILL UNPROVEN** — this change does not test it, and the result does not bear on it |
What this rules out: `CARD_OFFERSTATE` is not a gate term that *requires* 1. An expired
row decoding `CARD_OFFERSTATE = 5` remained actionable, so the earlier worry — that every
actionable row we had ever seen carried `itemState = 1` — was a coincidence of our own
bad token, not a client rule. It narrows nothing else: `STATE` and the
`RESERVEDPRICE`/`MAX_CREDITS` pair are untouched and still confounded with each other.
Wire A/B (`docs/evidence/market-lifecycle-2026-08-17/itemstate-forsale-ab.json`): on the
same expired row, **exactly one field changed**`itemState` `listFS``forSale`.
`tradeId`, `tradeState`, `expires`, `startingBid`, `buyNowPrice`, `currentBid`,
`bidState`, `sellerName`, `sellerEstablished`, `watched`, `coinsProcessed`, the atom
count (12), and the whole `itemData` card are byte-identical; coins unchanged; Fix A's
zero `inactive` rows intact.
Risk was lower than it first appeared, for a reason worth recording: the market SEARCH
route (`/transfermarket`) **already** emitted `forSale`, so the client has been decoding
`forSale` (5) on a live path all along — only the seller's own pile carried the invalid
token.
Differential consequence: `market query tradePile` moves from `PARITY` to
`DIFFERENT-BY-DESIGN`, pinning `oracle == "listFS"` and `rust == "forSale"` so the
divergence stays visible. That assertion previously demanded parity and passed while
**both** sides were wrong — the mechanism by which this defect survived every
differential run. **Where the FIFA 17 binary contradicts the Python oracle, the binary
wins.**
Revert is one line if the live A/B fails: both `auction_record_as(l, "forSale")` calls in
`market.rs` back to `"listFS"`, rebuild, restart `prod-host`. On failure the rule is to
revert and go to the AVM1 gate work — NOT to compensate with another field change.
**Claim boundary:** this is protocol correctness of one token. It is not evidence about
the Flash decision tree; `STATE`, `CARD_OFFERSTATE` and `RESERVEDPRICE`/`MAX_CREDITS`
remain confounded.
### Gap 1 CLOSED — an ACTIVE seller row under `forSale` (2026-08-17)
The one thing the Fix B promotion could not test, because no active listing existed
during that session. Closed with a deliberately disposable card: a **75 ST**, resourceId
`212188`, wire id `100000178` — one of **three identical owned copies**, not in the
11-man squad, `untradeable: false`, discardValue 600. Listed **through the real FIFA 17
client** at Start 150 / Buy Now 200 for the shortest offered duration (1h), so expiry
would arrive naturally. No timestamp was touched.
Listing creation was entirely Rust-owned:
`PUT …/item` (club → pile) → `route=market-list POST item_id=100000178 listed=true
trade_id=1000000178``POST …/auctionhouse 200`, then the client polling
`GET …/trade/status?tradeIds=1000000178` (`requested=1 returned=1`) for the row it draws.
Wire, active row `1000000178`
(`docs/evidence/market-lifecycle-2026-08-17/gap1-active-forsale.json`):
| Field | Value |
|---|---|
| `itemState` | **`forSale`** |
| `tradeState` | `active` |
| `startingBid` / `buyNowPrice` / `currentBid` | 150 / 200 / 0 |
| `bidState` | `none` |
| `expires` | **3562 → 3556** over a 6-second sample — a live clock, not a frozen scalar |
| atom count | **12**, exactly what `0x18013e410` reads |
| `itemData` | full card, 6-entry `attributeList` |
| `/tradePile/counts` | `count 1, selling 1, offered 0, sold 0` |
| coins | 29,843,976 unchanged |
| Fix A | 0 `inactive` rows |
`/trade/status` stayed coherent with `/tradePile` throughout, agreeing on tradeId,
`active`, `forSale` and a decreasing `expires`.
**The decisive observation** is not the wire — it is what the client made of it. A
read-only `/proc/<pid>/mem` decode of the live trade-pile auction record (slide proven by
FNV control MATCH first; `O_RDONLY` + `pread` only) returned:
```text
tradeId=1000000178 state=1(active) bid=0(none) buyNow=200 start=150 cur=0 expires=3457
item CARD_ID=212188 cardType=1 tradeable=1 itemState=5(forSale) pile=0 rating=75
```
`itemState = 5(forSale)`. The same field read `-1(<unrecognised>)` under `listFS`. This is
the direct A/B on the only changed field, taken from the client's own memory rather than
from our response — the strongest form of this evidence available without AVM1 work.
`pile = 0` again, confirming pile is still never set from the wire.
Operator UI acceptance: card under **LISTED ITEMS** (not the TRANSFER LIST section), Start
150 / Buy Now 200 correct, **Time Remaining visibly counting down**, normal card art, no
malformed row, no duplicate, no premature Expired, no crash, and correctly
**non-actionable while active**. The client's container populating with exactly this row
(`rows=1`) independently corroborates that the Transfer List screen bound and drew it.
**No code change was required** — the promoted implementation was already correct on the
active path, which is what the test was designed to find out.
Two process notes worth keeping. First, an early capture reported "countdown NO"; that was
a bug in the capture script (it re-read a hardcoded copy id rather than the row it had
resolved), not a server fault — the same run's `/trade/status` already showed a lower
`expires` than `/tradePile`. Verify the instrument before believing a negative. Second,
the client's auction-record containers read **0 rows** while the operator was on any other
screen; they populate only while the Transfer List is bound. An empty container is not
evidence of an empty pile.
**Claim boundary unchanged:** this proves the client *decodes* the token. It says nothing
about the Flash action-gate term.
### Proving the gate (not yet done)
Disassemble the AVM1 action buffer of `tradepile.isInActiveAuction` (and
`PreCheckCardOptions`): either walk from the interned symbol at `0xb84d7498` to its APT
function object in the live process, or extract
`external/ion_fut/screens/trading/tradepile` from `ui.sb` + the `cas` archives offline.
Nothing else in reach separates `STATE` from `CARD_OFFERSTATE` from the price pair,
because all three differ together in every observable row.
---
## Gap 2 CLOSED — Return to Club survives a full exit/re-entry (2026-08-17)
The claim the Fix B promotion could only make server-side. Closed on the same
disposable card as Gap 1 (75 ST, res `212188`, wire id `100000178`, tradeId
`1000000178`) after it expired **naturally** — the 1h auction was allowed to run
out. No timestamp was mutated at any point in either gap.
### The whole lifecycle, observed rather than forced
A read-only sampler polled `/tradePile` once a minute for the auction's full hour
(55 samples). `expires` is derived from `created_at + duration`, so watching is the
only honest way to see expiry happen:
```text
t=1787012091 active expires 3175 itemState forSale
... counting down, one sample a minute ...
t=1787015271 expired expires 0 itemState forSale <- natural expiry
t=1787015301 (absent) total 0 <- Return to Club
```
`itemState` stayed `forSale` across the `active -> expired` transition, which is the
Fix B behaviour under the one state change we had never watched live.
### Return to Club, then two session boundaries
The host log records the coupled transition and the re-entries:
```text
route=move-items wire=100000178 pile=club auction_cancelled=1
route=auth-delete
route=auth ... ip=10.10.0.105 persona=33068179 sid_opened=true
route=hub clubPlayers=1966 auctionCount=0
route=club total=1986 emitted=1966 dropped_no_asset=0
... the full auth -> userMassInfo -> hub -> club -> tradePile -> trade/status
cycle then repeats for a SECOND fresh session ...
```
`auction_cancelled=1` is `cancel_active_for_core_item` firing: the pile move to
`club` ends the auction that put the card there, which is the coupled transition
that keeps pile membership and auction lifecycle from disagreeing.
Two independent fresh sessions each rebuilt the state from durable storage, and the
operator confirmed the card was still in My Club. One session boundary was the
requirement; two is stronger.
### Server state — 18/18, byte-identical before and after re-entry
| Surface | Result |
|---|---|
| `/tradePile` | `total 0`, 0 rows, no reference to the item or the tradeId |
| `/tradePile/counts` | `count 0, selling 0, sold 0, offered 0` |
| `/trade/status` | tradeId `1000000178`**`closed`**, `expires 0` — still resolves, correctly terminal |
| `/club` | **1966** items; `id=100000178 res=212188 rating=75 ST itemState=free untradeable=False` |
| Duplicate owned ids | **0** across all 1966 |
| Market store | listing `1000000178` = `cancelled`; **0 active, 0 reserved** anywhere |
| Coins | **29,843,976** unchanged for the entire test |
| Fix A | 0 `inactive` rows |
The `1965 → 1966` club count is the pile-aware exclusion working in both directions:
the card left `/club` while actively listed and returned on cancel. A tradeId that
can still be asked about still answers — terminally — rather than vanishing.
**No code change was required for either gap.** Both tests were designed to find out
whether the promoted implementation was already correct on paths it had not been
exercised on. It was.
---
## FROZEN BASELINE — the known-good transfer-list lifecycle (2026-08-17)
With both gaps closed, this lifecycle is the reference. A future change that alters
any line here is a regression until proven otherwise.
```text
CLUB
itemState free, present in /club, absent from /tradePile
| PUT /item {pile:"trade"} + POST /auctionhouse (ISStart)
v
ACTIVE AUCTION
/tradePile: tradeState active, itemState forSale, 12 atoms,
expires counting down from duration, currentBid 0, bidState none
/tradePile/counts: count 1, selling 1
/club: EXCLUDED while listed
/trade/status: agrees with the pile on every field
client: renders under LISTED ITEMS, CARD_OFFERSTATE = 5, non-actionable
| wall clock reaches created_at + duration
v
EXPIRED
/tradePile: tradeState expired, expires 0, itemState still forSale
client: row becomes ACTIONABLE, offers Return to Club
| PUT /item {pile:"club"} -> cancel_active_for_core_item
v
CLUB (terminal)
/tradePile: total 0 /tradePile/counts: all zero
/club: present again, itemState free market store: cancelled
/trade/status: old tradeId -> closed, expires 0
coins: unchanged throughout
survives: FUT exit + re-entry, repeatedly
```
Invariants this baseline pins:
* an item is never in limbo — it is in `/club` or in an active auction, never neither;
* `expires` is SECONDS REMAINING and must evolve with wall-clock;
* pile membership and auction lifecycle are separate facts that must be changed
together;
* a tradeId resolves on every route that can be asked about it, even when terminal;
* only real active auctions appear in `/tradePile` (Fix A);
* a listed item carries `itemState forSale` (Fix B), and the client decodes 5;
* an active own auction is intentionally non-actionable;
* coins move only when coins should move — this whole lifecycle moves none.
What is still NOT established: the SOLD path (no counterparty exists yet; settlement
is implemented and unit-tested in Core but never client-validated — see
`MARKET_SOLD_SETTLEMENT.md`), `/tradePile/counts` exact FIFA semantics, and the AVM1
action gate.
-352
View File
@@ -1,352 +0,0 @@
# Transfer-market sold settlement + transaction fee
How a completed market sale moves coins and ownership. Written while the
`forSale` expiry lifecycle test was running, so **nothing here has been deployed
or validated against a FIFA client** — see the confidence table.
Every claim carries one of:
| Tag | Meaning |
|---|---|
| **CODE** | Read directly out of the current implementation |
| **TESTED** | Pinned by a unit or integration test that fails if it regresses |
| **FIFA17-HISTORICAL** | Supported by contemporaneous FIFA 17 knowledge, not by our client binary |
| **NEEDS-LIVE** | Requires a real FIFA 17 client to confirm; must not be treated as settled |
---
## 1. What the Buy Now path did before this work
Traced end to end, **CODE**:
```text
POST/PUT /ut/game/fifa17/trade/<id>
-> openfut-utas-host/src/lib.rs route classification (EconomyRoute::MarketBuy)
-> market.rs::handle_market_buy
store.get_listing(id) market_store.rs
bid < buy_now -> echo raised bid, NO coin movement, return
store.reserve_listing(id) CAS active -> reserved
econ.balance() pre-check, 461 if short
econ.purchase_item(price, "market-buy:<id>", listing.card_id)
-> POST {core}/economy/purchase-item
-> openfut-core services/economy.rs::purchase_item
BEGIN IMMEDIATE; debit(club, cost); add_item(club, item, card); COMMIT
store.complete_sale(id) CAS reserved -> sold
-> auctionInfo record with tradeState "closed", bidState "highest",
currentBid = price, itemData.itemState "free"
```
The module header at `openfut-utas-host/src/market.rs:12-16` states it plainly:
a buy-now *"debits the buyer and MINTS the won card into their club … there is no
real counterparty and no seller credit"*.
### The ten questions, answered from code
| # | Question | Answer (**CODE**) |
|---|---|---|
| 1 | Where are buyer coins debited? | `services/economy.rs::debit` inside `purchase_item`, one `BEGIN IMMEDIATE` transaction. Host pre-checks with `econ.balance()` only to distinguish 461 from 503. |
| 2 | Does Core know the seller? | **No.** Every `/economy/*` route resolves ONE club from the `X-OpenFUT-Game` active profile (`routes/economy.rs:23-28`); no request shape carries a counterparty. |
| 3 | Does the auction persist seller-owned item identity? | **Yes**`listings.core_item_id` and `wire_item_id` (`market_store.rs:199-203`). The settlement inputs already existed; the buy path just ignored them. |
| 4 | Transfer or mint? | **Mint.** `purchase_item` INSERTs a new `owned_cards` row keyed `market-buy:<tradeId>`. The seller's row is untouched, so a sold card existed **twice**. |
| 5 | Is the seller credited? | **No.** Nowhere. |
| 6 | Any tax calculation? | **No.** Zero occurrences of `*5/100`, `0.95`, `fee`, `tax` in Core, host, adapter *or* the Python oracle. It existed only as prose in `FIFA17_HUB_BEHAVIOR_SPEC.md:884` and `FIFA17_TRANSFER_MARKET_WIRE.md:127-131`, both labelled unimplemented. |
| 7 | Auction state sequence? | `active -> reserved -> sold`, host-side (`market_store.rs`). On any Core failure `reserved -> active`. |
| 8 | Is there a `reserved` state? | **Yes**, and it is the concurrency guard, not decoration — `reserve_listing` is a `BEGIN IMMEDIATE` compare-and-swap, so of two concurrent buyers exactly one proceeds to a debit. |
| 9 | When does the item leave the seller's `/tradePile`? | When the listing stops being `active`: the pile projection emits only real active auctions (the Fix A invariant). `sold`/`cancelled` rows vanish from the pile the moment the CAS lands. |
| 10 | What makes a retry safe? | The same CAS. A second Buy Now finds the listing non-`active`, so `reserve_listing` returns `false` and the handler returns an empty `auctionInfo` — the client reads "auction gone", and no economy call is made. |
### Deficiencies
1. **Ownership duplication.** A sold card ends up owned by both parties. Structural, not a race.
2. **No seller credit** — selling was economically pointless.
3. **No fee**, so no sink; every synthetic buy was pure coin destruction and every sale would have been pure creation.
4. **Core could not express a counterparty at all** — the single-club resolver had no seller/buyer distinction to make.
5. `UPDATE owned_cards SET club_id` did not exist anywhere in Core: there was **no ownership-transfer primitive** to call.
---
## 2. The settlement transaction
`openfut-core/src/services/economy.rs`, alongside the existing tx-scoped
primitives so it reuses the module's proven atomicity (`pool.acquire()` +
`BEGIN IMMEDIATE` + `finish()`), **CODE**:
```rust
pub enum SaleBuyer<'a> { Club(&'a str), Outside }
pub struct SaleTerms { pub gross: i64, pub fee: i64 }
pub async fn settle_sale(
pool: &Pool, item_id: &str, seller_club_id: &str,
buyer: SaleBuyer<'_>, terms: SaleTerms,
) -> AppResult<SaleReceipt>
```
One transaction: debit buyer `gross` → evict the item from every squad →
transfer the existing row → credit seller `gross - fee`. The fee is simply never
credited anywhere, which is what destroys it.
Exposed as `POST /economy/settle-sale`. It is the **only** economy route that
names clubs explicitly, because a sale has two sides and the active-profile
resolver can only describe one. Both are optional: an omitted `seller_club_id`
means the active club (the production shape — the player listed the item), and an
omitted `buyer_club_id` means `Outside`, *not* the active club, so the route can
never accidentally settle a club against itself.
### Ownership transfer, not duplication
```sql
UPDATE owned_cards SET club_id = ? WHERE id = ? AND club_id = ?
```
No INSERT and no DELETE on the two-party path, so the instance id, its
`chemistry_style`/`position_override`/`training_bonus` and its `acquired_at` all
survive and the inventory row count cannot change. Duplication is ruled out
structurally rather than by an assertion. `acquired_at` is deliberately preserved:
this is the same instance under new ownership, not a new acquisition.
**TESTED**`settle_sale_transfers_ownership_and_splits_coins` asserts both that
the item's owner changed and that `SELECT COUNT(*) FROM owned_cards` is still 1.
### The seller is pinned, not inferred — and this was a real bug
The first implementation derived the seller from *current* ownership, which reads
naturally ("the seller is whoever owns it") and is wrong. The concurrency test
caught it immediately: **both** racing buyers succeeded, because after the first
sale the item was owned by buyer B, so the second call read B as the seller and
cheerfully chain-sold it B → C. Core has no listing concept, so it had no way to
notice that this was a replay of one listing rather than a new sale.
The fix is that `seller_club_id` is the club the caller **believes** owns the item,
and every ownership statement is predicated on it. That turns the `UPDATE` into an
ownership compare-and-swap which is authoritative about "this sale already
happened", independently of any caller-side listing state. Safety is not weakened:
a caller still cannot credit a club that never owned the item, because the credit
only follows a matched CAS.
**TESTED**`two_buyers_racing_one_item_settle_once` (exactly one winner, loser's
balance untouched, one instance, seller paid once) and
`settling_the_same_sale_twice_pays_once`.
### Squad eviction is mandatory
`squad_players.owned_card_id` is a foreign key onto `owned_cards(id)` and the pool
enables `foreign_keys`. Two consequences, both **CODE**:
* an `Outside` sale of a squadded card would fail outright on the FK;
* a two-party transfer preserves the row id, so a surviving `squad_players` row
would leave the **previous** owner fielding a card they no longer own.
So both paths evict first, and the receipt reports `squad_slots_freed`.
**TESTED**`sale_evicts_the_item_from_the_sellers_squad`.
> Noticed while reading, NOT fixed here (out of scope, no behaviour changed):
> `routes/cards.rs:187-192` (quick sell) and `services/market.rs` both DELETE an
> owned card without clearing `squad_players` and without a transaction, so
> quick-selling a squadded card should fail on the same FK. Worth a follow-up.
---
## 3. The fee arithmetic
Lives in `openfut-adapter-fifa17/src/fut/economy_policy.rs`, beside `pack_price`
and `match_reward_total`, because 5% is a **FIFA policy constant** and Core must
stay game-neutral. Core validates `0 <= fee <= gross` and never computes a rate.
```rust
pub const TRANSFER_MARKET_FEE_PERCENT: i64 = 5;
pub fn transfer_market_fee(gross: i64) -> i64 { /* floor, i128 intermediate */ }
pub fn seller_proceeds(gross: i64) -> i64 { gross.max(0) - transfer_market_fee(gross) }
```
Integer only. Floating point is never used for coin settlement: `0.05` is not
representable in binary, and at large prices a `f64` round trip can create or
destroy a coin. The multiply widens to `i128`, so overflow is unreachable for any
`i64` price and no price ceiling has to be assumed.
**Rounding is a CHOICE, and it is NEEDS-LIVE.** The fee is floored, so the seller
keeps the fractional coin. That was chosen because it makes
```text
fee + proceeds == gross
```
hold exactly at every input, which is what the accounting invariant rests on. The
discriminating case against the alternative (flooring the seller's 95% instead) is
a gross of **150**: this rule pays **143**, the alternative pays **142**. Nothing
in the corpus or the client binary settles which the real server did — the client
is only ever told the gross, and no `tax`/`netPrice`/`sellerProceeds` wire field
exists. **A future live test should list something at 150 coins and read the
credited amount.**
**TESTED** at `0, 1, 19, 20, 21, 39, 40, 100, 150, 200, 1_000, 15_000,
15_000_000, i64::MAX`, plus `fee + proceeds == gross` swept over 0..2000 and the
extremes, plus negative-gross rejection.
| gross | fee | proceeds | why it is in the table |
|---|---|---|---|
| 19 | 0 | 19 | last fee-free price |
| 20 | 1 | 19 | first price that pays |
| 150 | 7 | 143 | **the NEEDS-LIVE discriminator** (alternative: 8 / 142) |
| 15,000 | 750 | 14,250 | canonical fixture |
| i64::MAX | MAX/20 | remainder | proves no overflow |
---
## 4. Accounting invariant
For `SaleBuyer::Club`, **TESTED** by `sale_conserves_coins_minus_the_fee`:
```text
buyer_debit == seller_credit + fee
total modelled coins shrink by EXACTLY the fee
21,000 -> 20,250 (difference 750)
```
For `SaleBuyer::Outside` the identity deliberately differs and the doc comment
says so: the counterparty owns no `clubs` row, so nothing is debited and the
seller's proceeds **enter** the economy from outside. **TESTED** by
`settle_sale_to_outside_retires_the_item_and_pays_net`, which asserts total coins
*rise* by the proceeds. Conflating the two would look like a conservation bug.
---
## 5. Rejected sales
All **TESTED** by `invalid_sales_change_nothing`, which re-seeds a fresh fixture
per case and asserts both balances and the item's owner afterwards:
| Case | Outcome |
|---|---|
| buyer cannot afford `gross` | rejected, nothing moves |
| buyer == seller | rejected at the Core domain boundary — not a market path, and it would otherwise debit and credit one club while destroying the fee |
| item does not exist | rejected |
| item not owned by the named seller (**includes every replay**) | rejected |
| buyer club does not exist | rejected |
| `fee > gross` | rejected |
| negative fee / negative gross | rejected |
| ownership changed mid-flight | rejected by the CAS |
A zero-price sale is **legal** (a free transfer, no fee) — **TESTED** by
`zero_price_sale_is_a_free_transfer`.
Auction-level states (`expired`, `cancelled`, already-`sold`) are **not** Core's
business: Core has no listing concept, and the host's `active -> reserved` CAS
already refuses those. Duplicating that check in Core would create a second
authority on auction lifecycle.
---
## 6. Atomicity
`services::economy` is the **only** module in Core that is genuinely atomic; it
uses `pool.acquire()` + raw `BEGIN IMMEDIATE` + `finish()` deliberately, because a
DEFERRED `pool.begin()` upgrades to a write at first write, where SQLite returns
`SQLITE_BUSY` immediately instead of honouring `busy_timeout`
(`services/economy.rs:218-223`). Settlement is built inside that module for
exactly this reason, so it inherits the guarantee rather than re-deriving it.
Handlers receive a pooled clone, never `&mut SqliteConnection`, so the transaction
boundary is inside the service function. **CODE.** No externally observable state
exists where the buyer is debited without the item moving, or the seller credited
without the buyer debited — every step shares one connection inside one
transaction, and `finish()` rolls back the whole thing on any error.
### Rejection precedence, and a test that passed for the wrong reason
Inside one transaction the order of the steps cannot change the final state — any
error rolls everything back. It does change **which reason** a rejection carries,
and that turned out to matter.
The first implementation debited the buyer before touching ownership, on the
reasonable-sounding grounds that an unaffordable sale should fail before ownership
moves. The isolated staging harness then showed that a replayed settlement was
refused with `insufficient balance: have 5000, need 15000` — because the buyer had
already spent the coins on the sale that succeeded. The affordability guard fired
first, so **the ownership CAS was never consulted**, and the unit test named
`settling_the_same_sale_twice_pays_once` was passing without ever exercising the
replay guard it claimed to test.
Two fixes, both **TESTED**:
* ownership is now judged before affordability, so a replay is reported as
`item not owned by club` (404) — the true cause;
* the replay test now asserts the error is specifically `AppError::NotFound`, and
adds a deliberately CHEAP replay (gross 100 out of the buyer's remaining 5,000)
that only ownership can possibly refuse.
The lesson generalises: a guard that is merely *shadowed* by an earlier guard is
untested, and a green assertion on "the second call failed" says nothing about
which mechanism failed it.
---
## 6b. Isolated staging harness
`scripts/settlement-staging.py` — stdlib-only, exercises the settlement against a
**real Core over real HTTP** with zero production contact. It picks an ephemeral
loopback port (production 8099/8199/18080 sit in a hard deny-list checked in three
places), runs Core once to apply its own migrations, seeds the canonical two-party
fixture by direct SQL, relaunches Core, then prints BEFORE / PURCHASE / AFTER with
PASS-FAIL lines and cleans up in a `finally`.
Result: **31/31 checks pass**, exit 0.
```text
BEFORE seller 1,000 buyer 20,000 owner club-seller rows 1 total 21,000
POST /economy/settle-sale {item_id, gross 15000, fee 750, seller, buyer} -> 200
AFTER seller 15,250 buyer 5,000 owner club-buyer rows 1 total 20,250
fee destroyed 750 duplicates 1 coins destroyed 750
RETRY (identical) -> 404 item not owned by club: item-x nothing moved
REPLAY GUARD (gross 100) -> 404 item not owned by club: item-x nothing moved
```
Two things it found that the unit tests had not:
1. the rejection-precedence bug above;
2. Core's **content preflight** aborts startup when an owned card references a
`CardDefinitionId` that no loaded pack defines (`content preflight failed: 1
owned card(s) reference CardDefinitionId(s) not loaded (e.g. ["def-x"])`), so
the harness writes a one-entry content pack for its synthetic card rather than
quietly substituting a stock card id.
---
## 7. What is NOT done, and why
* **No production wire change, no deployment.** The host gained the *capability*
(`CoreEconomy::settle_sale`) but `handle_market_buy` is untouched: the synthetic
buy path (`core_item_id == None`) has no counterparty, so minting there is
correct and unchanged.
* **Nothing decides that a player's listing has sold.** That trigger is the real
remaining feature, and it needs the seller-facing sold wire representation,
which is deliberately deferred — `tradeState`/`itemState` for a sold row are
**NEEDS-LIVE** and must not be guessed. `docs/FIFA17_TRANSFER_MARKET_WIRE.md`
records that no `sold` token exists in FIFA 17's `tradeState` table
(`active=1 inactive=2 expired=3 closed=4`).
* **`/tradePile/counts` semantics unchanged.** Its `sold` field stays as-is; the
Core facts a correct `sold` count would need now exist, but the route output
must not change without client evidence.
* **The 5% rate itself is FIFA17-HISTORICAL**, and the rounding rule is
**NEEDS-LIVE** (the 150-coin discriminator above).
## 8. Confidence summary
| Claim | Status |
|---|---|
| The old buy path minted a duplicate and never credited a seller | **CODE** |
| No fee arithmetic existed anywhere in the project | **CODE** |
| Ownership now transfers as one row, with no duplicate possible | **TESTED** |
| Buyer debited, seller credited net, fee destroyed, in one transaction | **TESTED** |
| `buyer_debit == seller_credit + fee` | **TESTED** |
| Canonical 15,000 fixture: 20,000→5,000 / 1,000→15,250 / fee 750 | **TESTED** |
| A replayed settlement pays once | **TESTED** |
| Two racing buyers settle exactly once | **TESTED** |
| A sold card leaves the seller's lineup | **TESTED** |
| Invalid sales leave the economy unchanged | **TESTED** |
| The fee rate is 5% of gross | **FIFA17-HISTORICAL** |
| The fee is floored (150 → 143, not 142) | **UNDECIDABLE from available evidence** — CardsDLL contains no fee arithmetic, no `0.95`/`0.05` constant and no tax/net caption, so the client never computes or displays a net and there is no oracle to measure against. See `FIFA17_SOLD_WIRE_RE.md` §9. Remains a pinned CHOICE. |
| The seller-facing sold wire state | **PARTIALLY RECOVERED** from the binary — see `FIFA17_SOLD_WIRE_RE.md`. Proven: no `sold` token in either vocabulary (exhaustive); `coinsProcessed` is published to Flash as `COINS_AWARDED`; a bulk clear-sold verb `DELETE …/trade/sold` exists; the seller's SOLD counter is real (atom `sold` 0x2c9 → `FUT_TF_SOLD`). Still UNDECIDABLE statically: `highest` vs `buyNow`. |
| The client renders a settled sale correctly | **NEEDS-LIVE** — never exercised |
No Core test here establishes anything about the FIFA 17 wire contract. They
establish the domain transaction only.
-208
View File
@@ -1,208 +0,0 @@
# FIFA 17 FUT Match Lifecycle
Design + contract reference for the **FUT match loop** as OpenFUT implements it on
the backend/responder side. Consolidates knowledge previously scattered across
`docs/PROJECT_STATE.md`, `fifa17-recon/tools/utas_server.py` (`match_route`),
`openfut-utas-host` (Rust economy END leg), `fifa17-recon/tools/test_match_lifecycle.py`,
and the vault (`Protocol Findings.md`, `Project State.md`, `Known Issues.md`).
Evidence labels: **OBSERVED** (live), **PROVEN** (test/static-analysis),
**HYPOTHESIS** (reasoned, not yet live-confirmed).
> ## Status caveat (read first)
> **No football match has ever started or completed in FIFA against this stack.**
> The lifecycle below is validated by CardsDLL static analysis (RPC descriptor
> blocks) + isolated persistence replay (`test_match_lifecycle.py`), **not** in-game
> acceptance. The reward amounts are FUT-plausible env-tunable defaults, **not**
> reversed values. Two blockers keep `FUT_MODES` **off by default** (see
> [Open questions](#open-questions--blockers)).
## Scope
This documents the **UTAS responder handshake + economy reward** for a match — the
HTTP calls CardsDLL makes around a match and the state they mutate. It does **not**
cover the actual football simulation (Blaze game-server side, "past the FUT hub"),
which remains unverified.
## The loop: a four-call state machine
CardsDLL issues six match RPCs; four form the playable loop. All share the base
path `ut/<sku>/match`; the operation is discriminated by **suffix + body**, not by
HTTP verb (verb selection lives outside CardsDLL, so the classifier is verb-agnostic).
```mermaid
stateDiagram-v2
[*] --> Created: POST /match (no matchId)
Created --> Ready: POST|PUT /match/ready {matchId}
Ready --> Playing: POST /match {matchId} (bare path + int matchId)
Playing --> Ended: POST|PUT|DELETE /match/end {matchId, endReason, ...}
Ended --> [*]: rewards credited, W/D/L + matchesPlayed persisted
```
### Call classifier (`_match_call`, utas_server.py:3325)
The discriminator (**PROVEN** from CardsDLL RPC descriptors):
1. path ends `/match/end`**END** (routed as `FutDestroyMatch` regardless of verb).
2. body has integer `matchId` on the **bare** `/match` path → **PLAY** (`FutPlayGame`).
CREATE and PLAY share `ut/<sku>/match`; the presence of an int `matchId` is the
only discriminator — this is why a bare `/match` carrying `matchId` must NOT
allocate a new match.
3. path ends `/match/ready`**READY** (`FutMatchReady`).
4. otherwise → **CREATE** (`FutCreateMatch`).
## Per-call contracts
### CREATE — `POST /ut/<sku>/match` (empty/no `matchId`)
CardsDLL: `FutCreateMatch` @ `0x180120380`; deserializes `startDateTime`(740,int),
`reportIdEnabled`(641,bool). `squad`(717,nested) is a **FREEZE-RISK** and is omitted
(SKIP-safe).
Response (`match_route`, utas_server.py:3399):
```json
{"startDateTime": <unix_ts:int>, "reportIdEnabled": false, "id": <matchId:int>}
```
Effect: allocates a match id; **advances `nextItemId` by 1** (PROVEN,
`test_match_lifecycle.py:51`).
### READY — `POST|PUT /ut/<sku>/match/ready` (`{matchId}`)
CardsDLL: `FutMatchReady` — no deserializer at all on the request; the server
response parser has two scalar members + one nested member.
Response (`match_ready_body`, utas_server.py:3353):
```json
{"matchId": <int>, "opponentPersonaId": <int, default 0>}
```
- `opponentPersonaId` defaults to `0` — a neutral placeholder, **never** the
logged-in user's persona.
- The parser also has a nested `items` member (the opponent squad). It is **omitted
deliberately** until the opponent-squad item contract is recovered from a live
capture; unrecognized/absent members are skip-safe. **This omission is one of the
two blockers.**
### PLAY — `POST /ut/<sku>/match` (bare path, `{matchId:int}`)
CardsDLL: `FutPlayGame` — no request deserializer.
Response: `{}` (empty). Effect: **none** — must NOT allocate a match or advance
`nextItemId` (PROVEN, `test_match_lifecycle.py:62-63`). This is purely a client
keepalive/transition ack.
### END — `POST|PUT|DELETE /ut/<sku>/match/end` (`{matchId, endReason, myMatchStats, opponentMatchStats}`)
CardsDLL: `FutDestroyMatch` @ `0x180121b60`**the rewards call**. Routed as
DestroyMatch regardless of verb (`FUT_MATCH_END`, default ON).
> **Wire path (Rust vs Python).** The Rust-owned reward is classified by `classify_economy`
> on **`POST /ut/delete/game/<sku>/match`** — EA/CardsDLL tunnels DELETE-semantics ops through the
> `/ut/delete/game/` prefix (`FutDestroyMatch` = `DELETE ut/%s/match/{id}`). Python's `match_route`
> additionally accepts `/ut/game/<sku>/match/end`. Which exact form the retail client emits is
> unverified (no match ever played); the Rust economy owns the `/ut/delete/game` form, and a
> `/ut/game/.../match/end` would fall to Python. Both credit the same reward shape.
Request fields that matter:
- `endReason` (atom 260) — **STRING enum, the AUTHORITATIVE result signal**. A score
comparison is NOT how the client reports the outcome. Nine values, mapped to a
win/draw/loss bucket:
| endReason | bucket |
|---|---|
| `WIN`, `DNF_WIN` | won |
| `DRAW`, `DNF_DRAW`, `NO_CONTEST` | draw |
| `LOSS`, `DNF_LOSS`, `DNF`, `QUIT` | loss |
| (missing/unknown) | draw (neutral fallback — credits without inventing a win) |
- `myMatchStats` / `opponentMatchStats` — literal-keyed objects, 15 int fields each,
first is `goals`. **Omitted by the client when `endReason` is `DNF`/`QUIT`**, so
nothing may require them. Used only as a fallback outcome probe if `endReason` is
absent.
Response (`destroy_match_body` / Rust `build_match_reward_body`) — every field a
**top-level scalar** (zero freeze risk) except the deliberately nested reward:
```json
{
"allCoins": <post-credit balance:int>,
"matchCoins": <per-result coins:int>,
"seasonCoins": 0,
"tournamentCoins": 0,
"boostConis": 0, // EA's typo — exact key required
"participationAward": <int>,
"teamOfTournamentWinner": false,
"gameModeAward": { "coins": <total award:int> }
}
```
> **Critical correction (2026-08-04):** the reward `coins` (atom 149) is read by the
> deserializer **only inside `gameModeAward`**, never as a top-level key. An earlier
> top-level `"coins"` was silently skipped and never reached the client — the one
> field most obviously named "the reward" was the one going nowhere.
Effect (persisted to the active FUT save): credit coins; increment the matching
`record.{won,draw,loss}`; increment `matchesPlayed` (PROVEN,
`test_match_lifecycle.py:74-81`).
## Reward policy
Env-tunable defaults (FUT-plausible, **not reversed**`economy_policy.rs:20-36`,
`utas_server.py:3201-3206`):
| Result | Match coins | Participation | Total |
|---|---|---|---|
| Win | 400 (`FUT_MATCH_COINS_WIN`) | 0 (`FUT_MATCH_PARTICIPATION`) | 400 |
| Draw | 200 (`FUT_MATCH_COINS_DRAW`) | 0 | 200 |
| Loss | 100 (`FUT_MATCH_COINS_LOSS`) | 0 | 100 |
`allCoins` = post-credit balance; `gameModeAward.coins` = per-result + participation.
## Ownership split (Rust vs Python)
The match loop is **partially migrated**. Only the coin-crediting END leg is
economy state, so only it is Rust/Core-owned; the CREATE/READY/PLAY legs are still
served by the Python oracle.
| Call | Owner | Where |
|---|---|---|
| CREATE | Python | `utas_server.py::match_route` (via host `Route::Passthrough`) |
| READY | Python | `utas_server.py::match_route` |
| PLAY | Python | `utas_server.py::match_route` |
| END (reward) | **Rust/Core** (on `POST /ut/delete/game/<sku>/match`) | `EconomyRoute::MatchEnd``handle_match_end``build_match_reward_body`; outcome via `economy_policy::match_result_from_reason`, coins via `match_result_coins`/`match_reward_total`. 503 on Core error, never Python. Python also accepts `/ut/game/<sku>/match/end`. |
END is classified in `classify_economy` (`openfut-utas-host/src/lib.rs`) and dispatched
by the economy authority barrier ahead of the general classifier — so it can never
also reach the Python passthrough (NEVER-BOTH). The Rust END writes the coin reward
through the single Core economy transaction (`grant_reward`); W/D/L record + match
count still live in the Python save until CREATE/READY/PLAY migrate.
## Test coverage
`fifa17-recon/tools/test_match_lifecycle.py` (PROVEN, isolated — temp profile, no
live server): drives CREATE→READY→PLAY→END and asserts:
- CREATE returns `reportIdEnabled:false` + advances `nextItemId` by 1.
- READY returns exactly `{matchId, opponentPersonaId:0}`.
- PLAY returns `{}` and does **not** advance `nextItemId`.
- END credits `MATCH_COINS["won"] + MATCH_PARTICIPATION`, persists
`record == {won:1,draw:0,loss:0}` and `matchesPlayed == 1`.
## Open questions / blockers
1. **READY `items` contract (opponent squad)** — the nested `items` member of
`FutMatchReadyServerResponse` is unserved pending a live capture. Whether the
client requires it present/non-empty to enter a match is unknown. **Blocker.**
2. **Client mode-entry gate** — reaching a match from the FUT hub UI is unverified;
`FUT_MODES` stays **off by default** (the season/tournament routes return `{}`).
3. **No in-game acceptance** — every claim here is static-analysis + isolated replay.
The first live match is expected to reveal whether the static read was complete
(every match body is logged for exactly this reason).
4. **Football simulation** — the actual gameplay (Blaze game-server) is out of scope
here and unverified.
## Sources
- `docs/PROJECT_STATE.md` (match lifecycle status), `docs/direction.md` (Tier-2 loop).
- `fifa17-recon/tools/utas_server.py`: `_match_call` (3325), `match_ready_body` (3346),
`match_route` (3357), `destroy_match_body` (3281), `_match_result` (3236),
`MATCH_COINS`/`MATCH_PARTICIPATION` (3201), `_END_REASON` (3229); CardsDLL RPC
descriptor block (3183-3197).
- `openfut-adapter-fifa17/src/fut/economy_policy.rs` (reward policy + outcome map).
- `openfut-utas-host/src/lib.rs`: `EconomyRoute::MatchEnd`, `handle_match_end`,
`build_match_reward_body`.
- `fifa17-recon/tools/test_match_lifecycle.py` (lifecycle regression).
- Vault: `02 Reverse Engineering/FIFA 17/Protocol Findings.md`,
`06 Agent Memory/{Project State,Known Issues}.md`.
-165
View File
@@ -1,165 +0,0 @@
# Overnight session handoff — 2026-08-17
Autonomous session while you slept. Low-ceremony per your instruction. Everything
below is verified as noted; nothing was committed or pushed (see
[Uncommitted work](#uncommitted-work--needs-your-review)).
## TL;DR
1. **Production promotion completed** (you chose "promotion"): prod-host swapped to the
post-P1 build, content-gap imported. Then I migrated the **remaining Python UTAS
routes that have a known contract** to Rust: account/sync, ut/auth (SID mint),
userMassInfo (full), clientdata, club/stats/{country,league,team}, and the trivial
static acks. The client's **observed FUT-hub/economy flow is now fully Rust**; a
tail of lower-traffic routes **without a captured wire shape** (item-defs,
user-identity, watchList/marketdata, non-active `squad/<n>`, draft, and mode-gated
season/tournament/champion/leaderboards/sbs) **still proxy to the Python oracle**.
2. **Launcher redesigned to a shareholder-grade egui UI** (your headline ask). Builds
clean; screenshots captured.
3. **New docs:** `MATCH_LIFECYCLE.md` (you asked), `CORE_CORRECTNESS_ISSUES.md`
(4 known Core bugs + 1 bonus exploit, ready to fix on your go/no-go).
4. **Nothing committed** — all work is in the working tree for your review (git state is
delicate: preserved-dirty Core submodule + a concurrent `funman300` actor + detached
launcher branch; I didn't want to entangle that unsupervised).
## What's live in production now (`10.10.0.120:8099`)
| Thing | State |
|---|---|
| prod-host binary | post-P1 `fda40d12` **+ my migration rebuild** (release, in `target/release/openfut-utas-host`) |
| prod-host pid | 3207781 (hub-managed, restart=no; retained spec points at the rebuilt binary) |
| Catalog | `9f6addaa` (post-P1) |
| Core content (cards) | `136d8d68` (post-P1, +18 content-gap defs) → Core loads **1710** defs |
| Core owned | **1982** (1962 players + 17 consumables + 3 staff) |
| Coins | **29,876,776** (baseline — reset from the P1 test value when the content-gap DB was swapped in; you said data isn't precious) |
| prod-core | **fixed build** from canonical submodule (`fbb54ea` + 4 correctness fixes), DB migrated ver 18 → 19; binary now `/home/alex/OpenFUT/target/release/openfut-core` |
| UTAS routes (Rust) | economy, club, squad (0/active/list/PUT), account/sync, ut/auth, userMassInfo, **user**, clientdata, hub, settings, accountinfo, leaderboards/options, match/reset, phishing, club/stats/{year,consumables,staff,country,league,team}, watchList, static acks (store/keepalive/captcha/tfa/livemessage/activeMessage) |
| Still Python (:8199) | item-defs (item/resource, defid), club-identity (clubUser, user/list, user/club), `squad/<n>` (n≠0), draft, marketdata, mode-gated (season/tournament/champion/leaderboards/sbs → `{}` while off), and match CREATE/READY/PLAY. See `docs/PRODUCTION_AUTHORITY_MATRIX.md`. |
Smoke-verified live in prod (in the prod netns): all migrated routes return
`owner=RUST`, coins consistent, clientdata round-trips, club/stats context modes emit
distinct nation/league/team buckets. Scripts:
`/home/alex/openfut-promotion/economy-2026-08-17-p2/{p2_precheck,smoke_migrated,smoke_clubstats}.py`.
## Changes made (all verified: builds clean, tests green)
### 1. Production promotion (deployed)
- Host binary `e5be8730` (P1) → `fda40d12` (post-P1) + catalog `9f6addaa`.
- Content-gap DB swapped in (owned 1962 → 1982). Backups in
`/home/alex/openfut-promotion/economy-2026-08-17-p2/backup/` + `ROLLBACK.txt`.
### 2. Route migration to Rust (deployed, rebuilt binary)
- `POST /ut/auth` — Rust mints the SID (`OPENFUT-SID-{:016X}`), opens the Rust session,
adopts persona from body. No Python. (`+ /ut/delete/auth`.)
- `POST /openfut/account/sync` — full Rust envelope; coins/unopenedPacks from Core.
- `GET /userMassInfo`**full** Rust envelope (was a Python-proxy+overlay hybrid).
- `GET/PUT /clientdata/<key>` — new host `ClientDataStore` (JSON-persisted).
- `GET /club/stats/{country,league,team}` — made `club_stats_body` context-aware
(nation/league/team buckets); classify now routes all `club/stats/*` to Rust.
- `GET /squad/0` — routed to the Rust active-squad projection (verified structurally
identical to Python `squad/0`: same 15 keys, players=23).
- `GET /watchList` (+ no-op add/remove) — empty list + authoritative Core credits.
- Static acks (`store`, `match/keepalive`, `captcha`, `tfa`, `livemessage`,
`activeMessage`) — Rust constants (StaticAck route), byte-identical to the oracle.
- Captured the remaining routes' Python wire shapes as reference fixtures for later
migration: `docs/evidence/route-shapes-2026-08-17/` (user, defs, marketdata,
clubUser, watchList, squad/0, season/tournament/champion/sbs, draft).
- Files: `openfut-utas-host/src/{lib.rs,clientdata_store.rs(new),config.rs}`,
`openfut-adapter-fifa17/src/fut/{non_economy.rs,club_stats.rs}`,
`openfut-utas-host/tests/economy_integration.rs`.
- Tests: `openfut-utas-host` + `openfut-adapter-fifa17` full suites **GREEN**
(188 adapter + 76 host lib + all integration incl the 116s economy integration).
### 3. Launcher redesign + polish + live account panel (built, NOT deployed — client tool)
- **Redesign**: new `openfut-launcher/src/theme.rs` design system (palette, embedded
fonts, egui Visuals/Style, card/pill helpers). Branded hero header (OF monogram),
left nav rail, card-based dashboard with status pills, prominent accent Launch CTA,
console-style Logs. All existing launch/health/preflight/service/config logic preserved.
- **Polish**: OpenFUT window/taskbar icon (OF monogram `IconData`), Config tab rebuilt
into themed cards, consistency sweep.
- **Live "Your Club" panel** (new feature): a background `AccountMonitor` (mirrors
`HealthMonitor`, 5s poll, non-blocking) fetches the account summary and the Dashboard
shows a "Your Club" card — club name/abbr, Manager, **COINS hero number**, Level + XP
bar, unopened packs, account funds — with clean loading/offline/error states.
- Builds clean (0 warnings). Screenshots preserved (for your shareholder demo) in
`/home/alex/openfut-post-p1/launcher-screenshots-2026-08-17/``launcher_account.png`
(the populated "Your Club" card: COINS 29,876,776, Level 12, packs 3) is the headline;
plus dashboard/setup/logs/config + the offline state. All reviewed — product-quality.
- Files: `openfut-launcher/src/{theme.rs(new),account_monitor.rs(new),app.rs,main.rs,
account_sync.rs,config.rs}` + `assets/` (fonts + icon). All additive; behavior preserved.
### 4. Core correctness fixes (deployed 2026-08-17)
All four `CORE_CORRECTNESS_ISSUES.md` classes fixed in the canonical `openfut-core`
submodule and deployed to prod-core (see that doc's "Resolution" section):
- **Issue 3 (HIGH, exploit):** SBC duplicate-card free-reward — `submit_sbc` now dedups
ids + bounds the list (`MAX_SBC_CARDS`=30) → `BadRequest`. Regression test added.
- **Issue 2 (HIGH):** non-atomic economy mutations — closed the concurrency-exploit
races with single-statement compare-and-swap (`spend_coins` conditional debit,
`open_pack`/`buy_listing`/`sell_card`/`checkin` claim-then-act). Multi-statement
partial-failure edges (`buy_pack`, concurrent SBC, match/season chains) left as
documented residual — need the transaction refactor, negligible for single-player.
- **Issue 4 (LOW):** `season.rs` `.expect()` panics → graceful `AppError`; checkin index
`% 7` → `.rem_euclid(7)`.
- **Issue 1 (LOW):** `sbc_submissions.club_id` — migration `0019` (add + backfill) +
`submit_sbc` binds it; milestone query now correct.
- Verified: Core suite **179 green** + clippy clean; migration dry-run on a prod-DB copy;
post-deploy prod migration ver 19, owned 1982, coins 29,876,776, all Core + host
endpoints 200. Rollback: `backup/prod-core.preCoreFix.db` (ver 18) + old binary path —
see `backup/ROLLBACK_CORE_FIX.txt`.
## New / updated docs
- `docs/MATCH_LIFECYCLE.md` (NEW) — consolidated FUT match loop design (CREATE→READY→
PLAY→END), contracts, reward policy, ownership split, blockers. (You asked for this.)
- `docs/CORE_CORRECTNESS_ISSUES.md` (NEW) — 4 known Core bugs + 1 bonus SBC
duplicate-card exploit, each with file:line + concrete fix + severity. **Needs your
go/no-go** (fixing bumps Core off the frozen P1 reference).
- `docs/PRODUCTION_AUTHORITY_MATRIX.md` (UPDATED) — reflects the completed migration.
- Vault `06 Agent Memory/Current Priorities.md` (UPDATED).
## Uncommitted work — needs your review
**I committed nothing** (git state is delicate: openfut-core is intentionally
preserved-dirty; a concurrent `funman300` actor; launcher on detached HEAD `d1a71bd`).
Review + commit these when you're ready:
- Superproject (mine): `openfut-utas-host/src/{lib.rs,config.rs}`,
`openfut-utas-host/src/clientdata_store.rs`,
`openfut-utas-host/tests/{economy_integration.rs,host_test.rs}`,
`openfut-adapter-fifa17/src/fut/{club_stats.rs,non_economy.rs}`,
`docs/{PRODUCTION_AUTHORITY_MATRIX.md,MATCH_LIFECYCLE.md,CORE_CORRECTNESS_ISSUES.md,OVERNIGHT_HANDOFF_2026-08-17.md,PYTHON_RETIREMENT_PLAN.md}`,
and the reference fixtures `docs/evidence/route-shapes-2026-08-17/`.
- Launcher submodule (mine): `src/{app.rs,main.rs,theme.rs(new),account_monitor.rs(new),account_sync.rs,config.rs}`, `assets/` (fonts + icon).
- **Leave the pre-existing dirt alone** (not mine): `CLAUDE.md`, `README.md`,
`.env.example`, `docker-compose.yml`, `AGENTS.md`, `setup.sh`, `openfut-bridge`,
`fifa17-recon/docker/...`, `docs/{ARCHITECTURE,ROADMAP,docker,fifa17-emulation}.md`,
`docs/research/`, `scripts/utas-filter-diff.py`.
- `openfut-core` (mine, this session): `migrations/0019_sbc_submissions_club_id.sql` (new),
`src/services/{sbc.rs,club.rs,pack.rs,market.rs,checkin.rs,season.rs}`,
`tests/integration_test.rs`. Built + deployed to prod-core; still detached HEAD at
`fbb54ea` (edits uncommitted, per your branch strategy).
## Open decisions for you
1. ~~**Core correctness bugs**~~ — **DONE (2026-08-17):** all four classes fixed +
deployed to prod-core (see "Core correctness fixes" above and the Resolution section
of `docs/CORE_CORRECTNESS_ISSUES.md`). prod-core is now off the frozen P1 point,
running `fbb54ea` + fixes at migration ver 19. Residual (documented): the
multi-statement transaction refactor for partial-failure atomicity — negligible for
single-player; do it if/when concurrency matters.
2. **Match handshake legs** (CREATE/READY/PLAY) — the only routes still on Python.
Deferred: no match has ever been played in-game, READY `items` contract unknown,
`FUT_MODES` off (see `docs/MATCH_LIFECYCLE.md`). Migrating them risks the economy
`/match/end` routing for a never-exercised path — I judged it not worth it unmonitored.
3. **Aux service container cutover** (blaze/redirector/roster → Rust) — operator-gated
container change; unchanged.
## Rollback (still hot)
- Python P2 image `openfut-fut-backend:p2-rollback` (b1b929953f) + profile 39bb3e83 +
`rollback-to-python-p2.sh`.
- prod-host P1 binary + P1 catalog backed up in
`/home/alex/openfut-promotion/economy-2026-08-17-p2/backup/` (see `ROLLBACK.txt`).
- prod state (pre-content-gap) backed up: `backup/prod-core.preB.db`,
`prod-identity.preB.json`, plus P1 `fifa17-production-{catalog,cards}.p1.json`.
-165
View File
@@ -1,165 +0,0 @@
# Production Authority Matrix (post-P1 -> P2-routes promoted 2026-08-17)
> **P2-routes promoted to production 2026-08-17.** prod-host swapped P1 `e5be8730` -> post-P1
> `fda40d12`; catalog `35a0913b` -> `9f6addaa` (resolves all owned assets, dropped_no_asset=0).
> Every previously-Python non-economy route now RUST in prod (OBSERVED prod log). Rollback hot:
> restore P1 binary + backup catalog (`economy-2026-08-17-p2/backup/`).
Definitive inventory of every production-reachable FIFA17 route/service and its
current owner. Derived from the live `prod-host` dispatch log (owner= labels,
real Client A session 2026-08-14), `openfut-utas-host/ROUTE_AUTHORITY.md`, and the
prod container config (`OPENFUT_SERVERS="blaze roster pow"`).
Evidence labels: OBSERVED (live log/db), PROVEN (test), INFERRED, HYPOTHESIS.
Legend: owner R = Rust/Core, P = Python oracle (:8199 proxied via PYTHON_FALLBACK).
## UTAS HTTP (front door: openfut-utas-host :8099)
### ECONOMY — already Rust (Python economy hits = 0, OBSERVED)
| Method/path (tail) | Prod owner | Writes state | Rust handler | Py proxy |
|---|---|---|---|---|
| GET /user/credits | R | no | handle_credits | NO |
| GET /store/purchasegroup[/all] | R | no | handle_purchasegroup | NO |
| PUT /store/transaction[/<id>] | R | coins,inv,pile | handle_store_buy | NO |
| POST /purchased[/items] | R | coins,inv,ent,pile | handle_pack_open | NO |
| GET /purchased[/items] | R | no | shape_purchased_reveal | NO |
| DELETE /item/<id> | R | coins,inv | handle_quick_sell_path | NO |
| POST /ut/delete/../item | R | coins,inv | handle_quick_sell_body | NO |
| PUT /item | R | inv,pile | handle_move_items | NO |
| POST /ut/delete/../match | R | coins | handle_match_end | NO |
| POST /auctionhouse,/transfermarket | R | listings | handle_market_list | NO |
| GET /tradePile | R | no | handle_market_query | NO |
| GET /tradePile/counts | R | no | handle_market_counts | NO |
| /trade/<id> (POST/PUT/GET) | R | coins,inv,listings | handle_market_buy | NO |
| DELETE /ut/delete/../trade/<id> | R | listings | handle_market_cancel | NO |
**Transfer-market semantics (live-verified 2026-08-17).** Three things here are
load-bearing and were each a live defect:
1. `/tradePile` and `/tradePile/counts` are **different deserializers** and MUST NOT
share a handler. `/counts` is FutGetAuctionCount: five scalar ints
(`count`, `maxAuctionsAllowed`, `offered`, `selling`, `sold`) and nothing else.
Served the `auctionInfo` body it skips every field, leaving the counts at 0 — the
hub tile shows a listing while the Transfer List screen shows no active sale.
2. An auction record's `itemData` MUST be the **full card object** (the same shape
`/club` emits), not a stub. A listing therefore persists a shaped-card SNAPSHOT
(`listings.item_json`) at list time. The seller's own pile stamps
`itemState: listFS`; market search uses `forSale`.
3. `POST /auctionhouse` resolves the listed card **server-side** from the wire item
id (`wire → Core instance → card_id + resourceId`); the client's FutISStart body
carries only the id. This also enforces that you can only list what you own.
### NON-ECONOMY — Rust-owned (route migration 2026-08-17, OBSERVED prod log)
| Route | Owner | Notes |
|---|---|---|
| POST /ut/auth (+ /ut/delete/auth) | R | Rust mints the SID (OPENFUT-SID-{:016X}); opens Rust session; adopts persona from body. No Python. |
| POST /openfut/account/sync | R | full Rust envelope; coins/unopenedPacks from Core; clubName OpenFUT / clubAbbr OFC constants |
| GET /userMassInfo | R | FULL Rust envelope (userInfo+squad+settings+pileSizeClientData); no Python. coins from Core, squad == /squad/active |
| GET/PUT /clientdata/<key> | R | host ClientDataStore (JSON-persisted); PUT acks {}, GET returns blob or {} |
| capability (/openfut/fifa17/capability) | R | -> Bound (CleanV1) |
| GET /club, /club/* readers | R | Core-backed collection (dropped_no_asset=0). Cards with an **ACTIVE listing are excluded** — a listed card has left the club. Keyed on the listing, NOT on the `trade` pile: the pile can hold cards with no listing (bare move, or cancelled/sold) and `/tradePile` renders only ACTIVE listings, so hiding the pile would make those invisible in BOTH views. Keying on the listing is self-healing — cancel/sale restores club visibility with no extra transition. Pagination then runs over the club-visible set (Core cannot filter host-owned listing state, so letting it paginate would yield short pages); with nothing hidden the fast Core-paginated path is unchanged. |
| GET /squad/0, /squad/active, /squad/list; PUT /squad/<n> | R | Core squad projection + tx; GET /squad/0 == active squad (verified structurally identical) |
| GET /user/accountinfo | R | {} |
| GET /user | R | `{"userInfo": …}` — same userInfo builder as userMassInfo (shared); squad rating is Core-authoritative (DIFFERENT-BY-DESIGN vs Python's stale value) |
| GET /settings | R | {"configs":[]} |
| GET /leaderboards/options | R | {} |
| PUT /match/reset | R | {} |
| GET /phishing/trusteddevice | R | security-question stateless ack |
| GET /hub | R | Core-derived counts; `clubPlayers` excludes actively-listed cards, `auctionCount`/`tradePile` from the durable market store |
| GET /club/stats/{year,consumables,staff,country,league,team} | R | Core aggregation; context buckets keyed nation/league/team (owned=1982); staff={} |
| GET /store, /match/keepalive, /captcha, /tfa, /livemessage, /activeMessage | R | unconditional constant acks (byte-identical to the oracle; StaticAck route) |
| GET /watchList (+ PUT/POST/DELETE) | R | empty watch list + authoritative Core credits; add/remove is a no-op ack (oracle persists none) |
| GET /season, /tournament, /champion, /clubUser, /user/list | R | FUT modes + club-identity off → `{}` (FeatureOffEmpty; byte-identical to the flag-off oracle). Migrated + deployed 2026-08-17 |
| POST /ut/.../match/end (DestroyMatch) | R | economy reward (coins credited via Core grant_reward) |
| GET /item/resource, /defid | R | `{itemData:[item_def…]}` — asset=rid&0xffffff; hardcoded Ronaldo (20801) + placeholder ("Player",75,CM,attrs 70), mirroring the oracle's `item_def`. Client renders from its LOCAL DB, so the placeholder is exact parity. Migrated + deployed 2026-08-17 |
| GET /marketdata, /marketdata/pricelimits | R | suggested pricing, constant band 150..15000. `/pricelimits` = bare ARRAY (one per defId); plain `/marketdata` = OBJECT — container type is load-bearing (object-where-array froze a live client). Migrated + deployed 2026-08-17 |
### NON-ECONOMY — still Python (PYTHON_FALLBACK)
| Method/path | Owner | Reason |
|---|---|---|
| POST /user/club (rename) | P | mutating club rename; Core has `clubs` but rename needs a Core write (deferred). Reads `clubUser`/`user/list` are now Rust. |
| GET /squad/<n> (n≠0, non-active) | P | no multi-squad Core model (Rust owns squad/0, squad/active, /squad/list, PUT) |
| /squad/mode/draft/* | P | FUT Draft mode |
| GET /leaderboards, /sbs/* | P | mode-gated (FUT_MODES/_SBC off → `{}`/content; `/sbs/sets` ships content); real behavior needs the mode logic ported. `season`/`tournament`/`champion` are now Rust. |
| POST /ut/.../match (CREATE), /match/ready (READY), /match (PLAY) | P | match handshake legs; no match ever played in-game (see docs/MATCH_LIFECYCLE.md) |
## AUXILIARY SERVICES (prod container OPENFUT_SERVERS="blaze roster pow")
All aux services are **Python in production today** (container `entrypoint.sh` runs
`blaze_responder_v3b.py`/`roster_server.py`/`pow_server.py`). Rust equivalents exist
outside Docker; deploying them is operator-gated (production deployment forbidden here).
| Service | Rust crate | Completeness | Prod owner | Reachable | Blocker to candidate |
|---|---|---|---|---|---|
| Blaze (:42130) | openfut-blaze-host + openfut-protocol-blaze + adapter::blaze | COMPLETE, gate-proven to real FUT (Gate 10: 3 logins, 10 pack opens, 448/448 py suite) | Python | yes | ERRC error-reply placement unresolved; wire into candidate bring-up |
| Redirector (:42127 TLS) | openfut-redirector-host + openfut-tls (OpenSSL vendored) | COMPLETE, 1 live handshake 2026-08-11 (TLSv1.2/AES256-GCM-SHA384) | Python | yes | never full-path gated; cert consistency |
| Roster (:8081) | openfut-roster-host | COMPLETE, oracle-parity + lifecycle tests, unit-only | Python | yes | never live-gated |
| POW (:8094 + :8080) | NONE (only pow_server.py) | no Rust host; 58 templates, bodies placeholder | Python | yes | LARGEST: no crate + bodies un-reversed |
| Nucleus (:42131) | none (advertised string only) | n/a | Python stub (advertised, unused) | NO (0 live hits) | DEAD in current flow — keep advertising URL, no migration |
RETIRED/out-of-scope: fifa-blaze (FIFA23 capture stub), openfut-bridge (FIFA23). Not in FIFA17 flow.
## NON-ECONOMY UTAS TARGET OWNER (confirmed via scouts)
Core already exposes GET/PUT /settings, GET /club|/collection|/statistics|/profile, /squad/*.
New Rust arm = Route variant + classify() arm (lib.rs:118-140) + owner-labelled handler.
| Route | Port complexity | Target | Notes |
|---|---|---|---|
| user/accountinfo | trivial-static ({}) | R | host constant |
| settings | trivial-static ({"configs":[]}) | R | host constant / Core /settings |
| leaderboards/options | trivial-static | R | host constant |
| match/reset | trivial-static ({}) | R | host constant ack |
| phishing/trusteddevice | small (validate hex + constant) | R | security-question: stateless ack, always verified/trusted |
| clientdata/userHubData | small stateful | R | Core PUT /settings upsert keyed userHubData |
| hub | medium (derived counts) | R | Core collection + market counts |
| club/stats/year | medium (rating-tier aggregation) | R | Core /collection + /statistics |
| club/stats/consumables | medium | R | BLOCKED on 17-consumable import |
| club/stats/staff | trivial ({}) or derived | R | BLOCKED on 3-staff import |
| account/sync | small-medium (persona select + save) | R | hardest: no direct Core route; host session/persona logic |
| ut/auth envelope | small (SID mint) | R (currently OBSERVE) | Python still mints envelope; boundary decision |
## MIGRATION PRIORITY ORDER (Phase 12)
1. Content gap consumable+staff emit (unblocks club/stats/{consumables,staff}) — import crate.
2. Trivial-static host routes: accountinfo, settings, leaderboards/options, match/reset, phishing/trusteddevice.
3. clientdata/userHubData (Core settings upsert).
4. hub + club/stats/year (Core-derived aggregation).
5. userMassInfo full ownership (envelope scaffold; econ+squad already Rust).
6. account/sync + ut/auth envelope (account/session boundary).
7. Aux candidate wiring (blaze/roster/redirector already built) + POW (blocked) + Nucleus (dead).
## ECONOMY EXPECTATION
Python economy hits = 0 (OBSERVED live + PROVEN by NEVER-BOTH/no-fallback tests).
Any nonzero Python economy hit = P1 regression, priority zero.
## STATUS
Baseline + economy + Rust-owned non-economy rows: OBSERVED/PROVEN.
Aux + non-economy target owners: confirmed via HostSourceMap/PythonContractMap/AuxServiceMap/ContentGapMap scouts.
## CANDIDATE MIGRATION STATUS (post-P1 progress, off-production)
DONE (Rust-owned in candidate source; committed on top of 5020137; workspace tests green):
- Content gap: consumable+staff emit (20 instances / 18 defs; verified real-profile re-import 1962+20).
- Non-economy routes migrated to Rust: user/accountinfo, settings, leaderboards/options,
match/reset, phishing/{trusteddevice,question,validate}, club/stats/staff, hub,
club/stats/{year,consumables}.
(hub clubPlayers = owned player count from Core; may be < Python profile count by the deferred
Legend instances = DIFFERENT-BY-DESIGN.)
- Reachability reporter (scripts/openfut-reachability.py) gates Python-hit invariants.
RESIDUAL PYTHON (still proxied; each has a concrete blocker, not ordinary difficulty):
- club/stats/{country,league,team} — RESIDUAL (nation/league/team context sub-screens). The global
MY-CLUB stat set (club/stats/{year,consumables,staff}) is now Rust (adapter club_stats.rs faithful
port of fut_club_stats.py VOCAB + counts, Core-accurate over real imported content, staging-verified
RUST route=club-stats owned=1982 players=1962). The per-nation-bucket context sub-screens still proxy
Python (low value, unrecognized atoms inert); migrate with a live context capture if ever needed.
- account/sync — launcher-facing (POST /openfut/account/sync, pre-auth); envelope has fields not in
Core (clubAbbr, established, profilePath, accountFunds cap); changing it risks the launcher. Needs
a Core account endpoint or host account logic + launcher-contract verification.
- userMassInfo — SAFE hybrid today (Rust overlays economy+squad on Python envelope). Full ownership
needs a real full-envelope capture first (missing one scaffold field breaks a hot route); deferred.
- ut/auth — ORDERING-BLOCKED: still-proxied Python routes rely on Python's session table, so auth
must stay proxied (Python mints SID, Rust OBSERVEs) until every session-needing route is Rust. Migrate LAST.
- clientdata/userHubData — BLOCKED: Core /settings supports only 2 fixed keys (difficulty,
preferred_formation); storing an arbitrary blob needs a frozen-Core change or a new host store.
Low-value UI-pref blob; kept Python per Phase 23 (no Python-by-ideology).
AUX (Rust built, deploy = operator-gated container cutover, NOT this task): blaze/roster/redirector.
POW = blocked (no crate, bodies un-reversed). Nucleus = dead (0 live hits).
-192
View File
@@ -1,192 +0,0 @@
# OpenFUT — Project State
> **Canonical source:** `../OpenFUT-Vault/06 Agent Memory/Project State.md`
> This file is a mirror. If the two disagree, the vault wins. Update the vault first.
Factual snapshot. Prefer this over the stale root `README.md`/`CLAUDE.md` status tables (FIFA 23).
Last compiled from repository evidence during context initialization.
## Working
- **FIFA 17 offline FUT stack, end-to-end.** Proven 2026-08-01: auth → Blaze login → device-trust →
the FUT hub. Brought up by `fifa17-recon/tools/openfut-fut.sh start`. Evidence: `FUT-RUNBOOK.md`,
`fifa17-recon/README.md`, the five responder scripts, gate-ladder troubleshooting table.
- **ProtoSSL cert-pin defeat** — two live `/proc/PID/mem` patches (`autopatch.py`), VAs stable
across launches. gdb-verified which gate was the wall.
- **LSX / Origin layer** — crypto handshake reversed byte-exact and confirmed against the repack's
own emu disassembly (`docs/REPACK_INTEL.md`); Origin login gates cleared.
- **Blaze redirector + Fire2/Heat2** — both hops defeated; preAuth/login/personas answered.
- **UTAS/RS4 FUT API** — `ut/auth` + boot calls + device-trust reach the hub with hand-authored JSON.
- **Persistent FIFA 17 account selection** — the launcher synchronizes one configured EA persona to
the Python backend before starting LSX/FIFA. LSX, Blaze, POW/EASFC, and UTAS then share that
identity, while FUT coins, inventory, squads, progression, and unopened packs persist in an
isolated save beneath `fifa17-recon/docker/state/accounts/<persona-id>/`. The POW level/XP/funds
shown in FIFA's general account bar are account-scoped but remain distinct from FUT club coins.
A reversible server test on 2026-08-09 verified profile switching, POW values, a 400-coin pack
debit, five awarded items, and restoration of the original profile.
- **Account-scoped FUT security compatibility** — launcher account synchronization initializes a
persisted `securityQuestion` verification record in that persona's FIFA 17 profile. The UTAS
PHISHING handler returns the complete CardsDLL trusted-console response (`changed`, `exists`,
`locked`, `trusted`), accepts only well-formed legacy setup/validate requests under `X-UT-SID`,
and never stores or logs the client-transformed answer. This is server-side emulation; the hook
and launcher do not contain an answer or add UI automation. Automated contract coverage is in
`fifa17-recon/docker/ctx/tools/test_security_question.py`; live first/repeat-launch acceptance is
partially complete: the first launch entered FUT without a security dialog on 2026-08-09; a
second fresh-process FUT entry is still required to close persistence acceptance.
- **Safe responder diagnostics** — ordinary LSX logs redact challenge/session/auth-code attributes;
ordinary Blaze logs redact auth/session keys and no longer emit raw Fire2 hex, decoded TDF, or
config values. Forensic Blaze capture remains available only with the explicit
`OPENFUT_BLAZE_DUMP_FRAMES=1` opt-in. LSX and Blaze self-tests cover the new defaults.
- **OpenFUT Core** — Rust FUT economy backend, feature-complete for its scope and tested: profiles,
clubs, coins, packs, cards, squads, chemistry styles, SBCs, objectives, matches, market (NPC),
draft, FUT Champs, seasons, statistics, achievements, events, daily check-in, division
leaderboard, market trade history. 13 migrations. Integration suite (`tests/integration_test.rs`,
96 test fns) runs against in-memory SQLite; CI (fmt/clippy/build/test) green on `openfut-core`.
## Partially implemented
- **FIFA 17 FUT hub depth** — reaching the hub is proven, but how much of FUT is fully navigable
beyond it (playing matches, pack opening, SBC submission through the *game* UI vs. spinner/error
states) is not documented as complete. The runbook's gate ladder lists failure modes still
guarded against. Treat "past the hub" as unverified.
- **Pack opening through the game UI** — proven live on 2026-08-09 with the recovered CAGE test
profile: purchase, reveal, item assignment/quick-sell, wallet refresh, and return from the reveal
all completed. The Python transaction path also passes its 446-check contract suite. FIFA's
hardcoded post-reveal `mypacks` return is supported by a short-lived active grace record for every
opened pack; it is excluded from unopened-pack counts and retired at the next hub request.
- **FIFA 17 FUT match lifecycle** — CardsDLL static analysis and isolated responder tests now cover
CREATE→READY→PLAY→END. Bare `/match` requests carrying body `matchId` are classified as PLAY
instead of accidentally allocating another match; READY returns the verified scalar `matchId`
and `opponentPersonaId` fields; END persists W/D/L, matches played, and coin rewards per account.
The implementation is deployed and `test_match_lifecycle.py` passes, but no football match has
started or completed in FIFA yet. The READY opponent `items` contract and client mode-entry gate
remain unresolved; `FUT_MODES` therefore stays off by default.
- **Core ↔ emulation integration** — the two halves exist and wiring has **started**. First
slice (2026-08-11): the My Squad owned-player search. `openfut-core` gained a semantic,
game-independent owned-inventory query (`services::inventory::{OwnedItemQuery, apply_query}`
+ a `Quality` tier) that filters (AND) → orders deterministically → paginates, wired into
`GET /collection`; `openfut-adapter-fifa17::fut::owned_query` parses the FIFA17 `/club` wire
query and resolves numeric league/nation/team ids → semantic names (unknown id = hard error,
no raw-id passthrough). Intentional fix, not parity: Python applies only `league`+`team` and
ignores `level`/`rare`/`position`/`nation`/`start`/`count` (the request-amplification bug);
Core applies all proven filters and paginates. `rare=SP` semantics UNKNOWN, unimplemented.
Slice 2 (2026-08-11): `openfut-utas-host` — the first live UTAS host. Serves `GET …/club`
from Core through the adapter and reverse-proxies every other UTAS route verbatim to the
Python oracle (`utas_server.py`); plaintext HTTP/1.1 keep-alive, classify-before-execute,
no python-fallback after a Core error. `CoreAccess` is a host-owned boundary (the adapter
stays transport-agnostic). 11 host + 22 adapter tests; 10/10 mutations killed; fmt/clippy
clean. Slice 3 (2026-08-11, `3ef3bc3`): the real `Fifa17IdentityResolver` — catalog
(card id → real asset id) + persistent `openfut-identity` store (owned instance →
stable/reversible wire int) + wire-id policy, replacing all placeholders (one
production path). Wire-id namespace is globally monotonic within `(fifa17, owned-item)`,
not per-account (Core owned ids are UUIDs → unambiguous reverse). Slice 4 (2026-08-11,
core `36abd4b`): a curated 32-card real FIFA17 dev content pack
(`data/games/fifa17/dev/cards.json`, ids `fifa17_<asset>`), loaded only via opt-in
`Config.dev_content_games`; `seed-dev` grants a `game_id=fifa17` profile+club real
`OwnedCard`s (no FIFA wire ids — the resolver mints those at request time), idempotent,
default content untouched. Slice 5 (2026-08-11, `5276dd2`): the host sends
`X-OpenFUT-Game: fifa17` so `/club` resolves the all-mapped fifa17 profile —
**composition proven live** over HTTP (real Core+host, no FIFA client): FIFA wire query
→ real `resourceId`s (catalog) + stable/reversible wire `id`s (store); 33 renderable,
gold=22, Premier League=18, pagination page1=11/page2=7/overlap=0 (clean paging, no
drops). **The only remaining gate is the live retail FIFA A/B** (no FIFA client in the
build env; runbook `openfut-utas-host/README.md`). See the vault UTAS Endpoint Map +
Known Issues (incl. "identity resolution is NOT authorization" for later mutations).
**Slice 6 (2026-08-11): `/club` RUNTIME VALIDATED on retail FIFA 17** — operator-assisted
live A/B (`.105` client → `.120` backend via a source-scoped NAT redirect into a staged
`36abd4b` Core + `openfut-utas-host`). Every checkpoint passed on the real client:
transport, per-route Python fallback, Python-negative `/club`, Core `X-OpenFUT-Game`
scoping, real card + persistent owned-item identity (incl. the two-copy fixture),
no-filter/Gold/position/nation/league/team/combined-AND filtering, retail pagination
with no amplification, card selection, identity stability across relaunch, Python
rollback, and Rust re-enable (identity store byte-identical across the cycle, 0
reallocation). Live findings: the retail client steps `start += 10` with `count=11`
(sometimes bulk `count=100`) — Core honours `offset` and terminates; the My Club UI
nests team under league; the "~1900" club counter is Python `userMassInfo`, not `/club`.
Remaining is operational only (promote the staged stack to a durable deployment).
- **Slice 7 (2026-08-12): FUT squad authority (read + write) RUNTIME VALIDATED on retail FIFA 17.**
Staged operator-assisted A/B (`.105` retail client → `.120`, source-scoped utas switch
`:8099→:8199` into `openfut-utas-host` over a staged `615c5fd` Core seeded by `seed-dev` with the
dev 33-card inventory). FIFA itself consumed the Rust squad path end-to-end: FUT boot served
`RUST_OVERLAY userMassInfo` (squad overlay) and the squad screen rendered the dev XI; a controlled
in-game squad edit issued `PUT …/squad/0` → host `squad-replace` → Core → `{"id":0}`; an in-game
formation change f442→f433 persisted to Core (canonical fingerprint changed, `position_index`
remapped 010); a FULL FIFA relaunch cold-fetched and rendered the persisted f433 squad (no client
cache). Reversibility proven on the exact client path by log presence, not response data (both
backends coincidentally hold the same dev squad — the Python oracle `fifa17_profile.json` was
seeded from the same `squad_put_f442.json` capture): disarmed `.105:8099` reached Python (absent
from host log), re-armed reached Rust (`route=club limit=Some(9)` present); the persistent identity
store survived the cycle with 0 reallocation. Non-migrated routes (`/ut/auth`, `account/sync`,
`accountinfo`, `settings`, `hub`, store txn) correctly Python-fallback. NEW startup requirement:
the Core server loads dev card defs only for games in env `OPENFUT_DEV_CONTENT_GAMES` (comma-sep);
omitting `fifa17` makes `get_collection` silently drop every owned card (empty `/collection`,
"no squad") despite a successful seed — MUST become a deployment/preflight assertion. Preceded the
same day by a staged two-process parity gate (real Core+host over HTTP, no FIFA) confirming byte-
shape parity of `userMassInfo.squad` vs the captured oracle. Next milestone: real-data Core
import / profile strategy → production Rust UTAS. Blaze deferred.
- **Slice 8 (2026-08-12): REAL-DATA staged retail A/B PASS (import + club + squad).** The real FIFA 17
profile was imported into a staged Core via the two-store protocol (`openfut-import-fifa17 --apply`:
generic transactional Core import + `openfut-identity` seeding, idempotent), then FIFA itself
consumed it end-to-end over the source-scoped utas switch (`.105``.120:8199`). Imported population
1949 of 1962 player instances (13 Legend/special assets deferred as unnameable — 9 NoName + the 4
copies of `169193`), every original Python wire id preserved (set-equal, 0 minted/dropped), each
owned instance an opaque Core UUID. On the retail client: real club rendered (1949, not the 33-card
dev XI); `/club` pagination clean (paged==full, no loop/overlap/drop); the `Special` quality filter
returned only specials (1665, 0 base leaked) and paginated the filtered set; a squad edit persisted
to Core (canonical + opaque extension atomically, fingerprint recomputed) and survived a full cold
relaunch; rollback→Python→Rust re-enable proven on the exact client path (disarm reached Python,
re-arm returned the imported club + edited squad; identity store 0 reallocation). Three real-data
fidelity gaps the base-only dev fixture had hidden were found on the live client and fixed:
(1) `e187cd4` versioned `resourceId``shape_item` emitted the base assetId as `resourceId`,
collapsing every special onto its base card art; `Fifa17Identity` now carries the versioned
`resource_id`. (2) `626c972` observed `rareflag``shape_item` hardcoded `rareflag=1`, so all
specials rendered as basic rare; `rareflag` now flows through the FIFA catalog and onto the wire
(wire distribution == source exactly). (3) `6f16a23` `rare=SP` Special filter — previously a no-op
("semantics UNKNOWN"), now grounded as `rareflag > 1` and applied host-side (Core has no rareflag).
Also `44fcf24`: nation/league/team proven to be INSTANCE metadata (not definition identity — the 4
copies of `169193` are identical but for club), so the definition-consistency gate now compares
identity only (no `--defer-conflict` needed; no majority-vote). PRODUCTION NOT READY: the 13 deferred
Legends are unnameable from the `.120` client dump (absent/placeholder in `players.json`; DLC/Legends
name tables empty) — honest names require the FIFA 17 Legends locale data from the `.105` client.
Next: resolve the 13 names → re-import to 1962/0 → full-fidelity gate → gitlink reconcile →
production Rust UTAS.
## Stubbed / planned
- **`fifa-blaze`** (Rust) — Milestone 1 capture stub only. Two TLS listeners that log packets; no
FIFA 23 component/command handlers. Its own README says IDs are unknown. Superseded in practice by
the Python FIFA 17 responders, kept as the intended FIFA 23 implementation surface.
- **`openfut-launcher` legacy controls** — core/bridge and FIFA 23 setup controls belong to a
superseded plan. The launcher now also owns the live FIFA 17 client flow: server/hook config,
account synchronization, local LSX, privileged autopatch, and game launch.
- **`tools/`** (file-watch-diff, squad-injector, exporters) — helpers for the FLE-Lua-bridge idea in
`docs/direction.md`. Not part of the live FIFA 17 path.
- **`docs/foundational-xi-injection-test.md`** — a planned (not executed) test procedure for the FLE
bridge route.
## Stubbed / blocked (FIFA 23 lineage)
- **`openfut-bridge`** — in-process `version.dll` hook on ProtoSSL. Git history: injection works but
the effort hit an "architectural wall" (async event-driven gate, not a poll). Superseded first by
the FLE-bridge pivot, then by the FIFA 17 route. Its `CLAUDE.md` task list is historical.
## Unknown / requires investigation
- Whether the FIFA 17 hub supports actually **playing a FUT match** offline and getting results back.
- Whether FUT actions beyond the now-verified pack reveal/assignment flow (submit SBC, transfer
market buy/sell, matches) round-trip correctly through `utas_server.py`.
- The exact division of FUT state ownership once Core is wired in (who is source of truth).
- Degree of FIFA 23 wire-format identity — asserted ("identical wire format") but the FIFA 23 client
has not been re-tested against these responders in this repo's evidence.
## Known technical debt / hazards
- **Root docs are stale.** `README.md`, `CLAUDE.md`, `openfut-bridge/CLAUDE.md` all describe FIFA 23
as the target and mark FIFA 23 integration as the open item — they predate the FIFA 17 success.
- **All host state is volatile** across reboot except the `/etc/hosts` line — re-run
`openfut-fut.sh start`. Requires `ptrace_scope=0` + root arming (security-relevant).
- **Whole stack rides on EAAC staying neutralized** and game updates being off; a client update can
break the memory patches (VAs) and cert bypass.
- **`fifa17-recon/tools/lsx_responder_v2.py` is currently modified in the working tree** (uncommitted).
- `33068179` / `CAGE` remains the responder fallback, but the launcher now blocks one-button launch
until an explicit persona is configured and synchronized across LSX, Blaze, POW, and UTAS.
-45
View File
@@ -1,45 +0,0 @@
# Python Retirement Readiness (post-P1)
Classification of every Python component still present. Evidence: prod-host
`owner=` dispatch log (OBSERVED), AuxServiceMap/PythonContractMap scouts,
container `entrypoint.sh`. **P2 rollback stays hot regardless** (do NOT remove
rollback-to-python-p2.sh, :p2-rollback image b1b929953f, profile 39bb3e83).
## A. LIVE PRODUCTION REQUIRED (still owns behavior in the live flow)
| Component | Role | Rust status | Retire when |
|---|---|---|---|
| oracle utas_server.py (:8199) NON-ECONOMY | **remaining**: user/club rename, non-active squad/<n>, draft/*, mode-gated leaderboards + /sbs/* | **Most non-economy migrated 2026-08-17** (account/sync, auth, userMassInfo, user, clientdata, hub, club/stats/*, settings, accountinfo, phishing, match/reset, leaderboards/options, watchList, static acks, item-defs (item/resource,defid), marketdata (+/pricelimits), and the flag-off empty reads season/tournament/champion/clubUser/user/list → `{}` — all Rust). See PRODUCTION_AUTHORITY_MATRIX | remaining tail is mutation (user/club), no-Core-model (squad/<n>), or unimplemented modes (draft/leaderboards/sbs) |
| blaze_responder_v3b.py (:42130 Blaze) | FUT Blaze transport | Rust openfut-blaze-host COMPLETE, gate-proven (Gate 10) | container cutover (operator-gated deploy) |
| blaze_responder_v3b.py (:42127 redirector TLS) | first-hop TLS redirect | Rust openfut-redirector-host COMPLETE (OpenSSL) | container cutover + cert consistency |
| roster_server.py (:8081) | roster-update XML | Rust openfut-roster-host COMPLETE (unit-only) | container cutover |
| pow_server.py (:8094/:8080) | Proof-of-Work / content | NO Rust crate; 58 templates, bodies un-reversed | needs Rust host + body RE (BLOCKED) |
## B. ORACLE ONLY (reference / differential, not production authority)
- utas_server.py economy handlers: now oracle-only (economy is Rust; Python economy hits = 0). Used by
`economy_differential.rs` as the parity oracle. KEEP.
- fut_store.py / fut_accounts.py / fut_consumables.py: reference models + Ghidra-derived taxonomy source. KEEP.
## C. MIGRATION TOOL ONLY (offline)
- scripts/*.py (seed_fifa17_cards.py, utas-observe/mutate/diff, check-*). KEEP (dev tooling).
- fifa17-recon/tools/db_dump.py etc.: table extraction. KEEP.
## D. ROLLBACK ONLY (hot, DO NOT REMOVE)
- rollback-to-python-p2.sh; image openfut-fut-backend:p2-rollback (b1b929953f) + :dev; profile 39bb3e83;
compose/env backup; tuple OPENFUT_SERVERS="blaze roster utas pow".
## E. DEAD (advertised but not followed in current FIFA17 flow)
- Nucleus /connect/token stub (:42131): 0 live hits across Gate 5-10 (client never POSTs). Keep the ADVERTISED
URL so the client can't reach real EA, but no migration needed. No Rust listener required.
- fifa-blaze (FIFA23 capture stub), openfut-bridge (FIFA23): retired lineage, not in FIFA17 flow.
## Retirement gating
1. **Mostly DONE (2026-08-17)**: account/sync, ut/auth (Rust SID mint), userMassInfo (full), clientdata,
club/stats/{country,league,team}, watchList, static acks, the flag-off empty reads
(season/tournament/champion/clubUser/user/list → `{}`), item-defs (item/resource,defid), and
marketdata (+/pricelimits, container-type-exact) migrated + deployed. **Remaining on Python**:
user/club rename (mutating; needs a Core write), non-active squad/<n> (no multi-squad Core model),
and the unimplemented modes draft/* + leaderboards + /sbs/* (need the mode logic ported, not a proxy).
2. Deploy Rust blaze/roster/redirector via container cutover (operator-gated) — then those Python responders
are class D/E only.
3. POW: build Rust host + reverse bodies (largest blocker) OR keep Python POW as class A indefinitely.
4. Only after a long stable window: consider retiring the P2 rollback (separate explicit decision — NOT now).
-293
View File
@@ -1,293 +0,0 @@
# SOLD-row A/B experiment — isolated FIFA-17 staging runbook
Operator procedure for driving the seller-facing **SOLD row** experiment with a real
FIFA 17 client against a **completely separate** staging stack, while the frozen P1
production stack keeps running untouched.
The experiment asks one question the PE cannot answer: for a `closed` trade-pile row,
does the FUT ActionScript front end treat `bidState:"highest"` or `bidState:"buyNow"`
as *"you sold this"*? Both are bit-identical to every native CardsDLL consumer, but
`bidState` is published to the movie verbatim as `YOURBID`, so only the client can
say. See `openfut-utas-host/src/sold_experiment.rs`.
---
## 1. What the two scripts do
| Script | Purpose |
| --- | --- |
| `scripts/sold-staging-up.py` | Brings up the **whole** staging stack (Core + utas-host + Blaze), seeds two identities, prints the client config block. One entry point. |
| `scripts/sold-staging-down.py` | Stops **exactly** the processes the up script recorded, proves the staging ports are free, and proves production is still alive. |
```bash
cd /home/alex/OpenFUT
# variant A — the sold row is closed / highest
python3 scripts/sold-staging-up.py --variant highest
# variant B — same state, only bidState (and optionally coinsProcessed) differ
python3 scripts/sold-staging-up.py --variant buyNow --coins-processed 1 \
--count-mode active_plus_sold
# projection disabled: identical to production behaviour (control run)
python3 scripts/sold-staging-up.py --variant off
python3 scripts/sold-staging-down.py # stop, keep dbs/logs as evidence
python3 scripts/sold-staging-down.py --purge # stop and delete the staging dir
```
To flip A → B, run `sold-staging-down.py` then `sold-staging-up.py --variant buyNow`.
The staging databases are throwaway: a fresh `up` deletes the previous ones and
reseeds, so each variant run starts from a known state. Never edit a live stack's
environment in place — the banner in `logs/utas-host.log` is the only record of which
variant produced a capture, and it is written at startup.
---
## 2. Staging port block and state
Everything lives under `/home/alex/openfut-sold-staging/` (override with `--dir` or
`OPENFUT_SOLD_STAGING_DIR`).
| Service | Bind | Notes |
| --- | --- | --- |
| staging Core | `127.0.0.1:18081` | loopback only; the client never talks to Core |
| staging utas-host | `0.0.0.0:8299` | the UTAS the client reaches |
| staging Blaze redirector | `0.0.0.0:42327` | TLS; receives EA `:10041` and `:42230` |
| staging Blaze main | `0.0.0.0:42330` | receives EA `:42127` |
| staging Blaze nucleus | `0.0.0.0:42331` | local OAuth stub |
| dead Python upstream | `127.0.0.1:8399` | **must stay unbound** (see §5) |
The redirector is on `42327`, not the "obvious" `42227`: that port is permanently
held by `openfut-redirector-host` (pid 862419).
| State file | Path |
| --- | --- |
| Core sqlite | `staging-core.db` |
| market sqlite | `staging-market.db` |
| pile sqlite | `staging-pile.db` |
| identity store | `staging-identity.json` |
| clientdata blobs | `staging-clientdata.json` |
| content pack | `content/fifa17-production-cards.json` |
| identity catalog | `content/fifa17-production-catalog.json` |
| patched Blaze | `blaze/blaze_responder_staging.py` |
| process manifest | `manifest.json` |
| logs | `logs/{core,utas-host,blaze,blaze-responder}.log` |
Seeded identities (direct SQL against the schema Core migrates for itself):
* **Seller A** — profile `prof-seller-a-cage`, club `club-seller-a-cage`, username
`CAGE`, `game_id = fifa17`, 1 000 coins, 11 starters in a canonical squad, plus
**one disposable item** (`owned-a-disposable`, card `fifa17_232273`, Nelson Atiagli
LB 51) — that is the card to list and sell during the experiment.
Seller A carries `game_id = fifa17`, so the retail client (which sends
`X-OpenFUT-Game: fifa17`) resolves to this profile and can log in as persona
`33068179`.
* **Buyer B** — profile `prof-buyer-b`, club `club-buyer-b`, 20 000 coins, parked on
its own `game_id = fifa17-buyer-b`. Core is single-profile-per-game, so this keeps
Buyer B from ever shadowing Seller A as the active `fifa17` profile while still
being reachable by club id for a settlement.
---
## 3. The client change — the only thing the operator touches
The FIFA client learns its **UTAS** base URL from Blaze, not from `openfut.cfg`:
`blaze_responder_v3b.py` builds `UTAS_BASE = "http://%s:8099/" % _ADVERTISE` with the
port **hardcoded**. The up script therefore copies the responder into the staging dir
and rewrites that literal to `:8299`. **Pointing the client at staging Blaze is
sufficient to move UTAS too** — there is no UTAS line in `openfut.cfg`.
On the FIFA client machine **10.10.0.105**, file
`"/mnt/games/FIFA 17/openfut.cfg"`:
```bash
# back it up FIRST
cd "/mnt/games/FIFA 17"
cp openfut.cfg openfut.cfg.prod
```
Set these **three** lines:
```
host=10.10.0.120
blaze_redirector_port=42327
blaze_main_port=42330
```
Leave `https_port=8443` **unchanged** — that is Bridge, which holds no economy
state. The resulting file is:
```
host=10.10.0.120
https_port=8443
blaze_redirector_port=42327
blaze_main_port=42330
```
Then **relaunch the FIFA 17 client.** A client that is already running caches its
UTAS session (SID) in memory and will not re-auth against a different stack; the
symptom is the dialog *"An error occurred downloading the FUT Squad Update"* with
**zero** requests in the staging host log. "The operator is at the main menu" is not
the same as "the client disconnected".
### Revert to production
```
host=10.10.0.120
https_port=8443
blaze_redirector_port=42127
blaze_main_port=42130
```
or simply `cp openfut.cfg.prod openfut.cfg` — then **relaunch the client again**.
These are the values the file holds today; the staging scripts never write to
10.10.0.105, so this revert is the *only* client-side change to undo.
---
## 4. Running the experiment
1. `python3 scripts/sold-staging-up.py --variant highest`
2. Apply §3 to the client and relaunch it. Confirm it reaches the FUT hub;
`logs/utas-host.log` should show `route=auth status=200 sid_opened=true`.
3. List the disposable item on the transfer market from the client.
4. Complete the sale (Buyer B side) through the real settlement path.
5. Observe the seller's Transfer List: which bucket the row lands in, what the
counter says, and which request the client issues to clear it. Capture both the
screen and `logs/utas-host.log`.
6. `python3 scripts/sold-staging-down.py`
7. `python3 scripts/sold-staging-up.py --variant buyNow`, relaunch the client, and
repeat steps 35. The two runs differ **only** in `bidState` (and
`coinsProcessed`/`count` if those flags are used), which is what makes the client's
reaction attributable.
8. When done: `sold-staging-down.py` and revert the client per §3.
`DELETE /ut/delete/game/fifa17/trade/sold` clears the sold rows (logged as
`route=market-clear-sold cleared=N`) — that is the verb the client is expected to
issue from the *Clear Sold* affordance, and seeing whether it does is part of the
observation.
---
## 5. Isolation: what guarantees production is untouched
Production frozen P1 must keep running throughout. The scripts enforce, not assume:
* **Hard port deny-list.** `8099 8199 18080 8443 42127 42130 42131 4216 8080 8081
8094` are refused before every bind and before every HTTP request the scripts make.
Each staging port is also proven free first, and bring-up aborts before launching
anything if one is not.
* **Hard path deny-list.** Every filesystem path goes through `safe_path()`, which
refuses `/home/alex/openfut-promotion/state/` (the live `prod-core.db`,
`prod-market.db`, `prod-pile.db`, `prod-identity.json`). Nothing there is ever
opened — not even read. The FIFA17 content the staging stack loads is copied from
`/home/alex/openfut-post-p1/staging/emit/content/`, a build tree outside that
directory.
* **The Python fallback fails closed.** `OPENFUT_UTAS_PYTHON_URL` points at
`http://127.0.0.1:8399`, where **nothing listens**, so a fallback to the Python
oracle surfaces as a visible connection error instead of silently serving the
production oracle on `8199`.
* **Binaries are copied into the staging dir and run from there.** A later
`cargo build` cannot change what staging is running, and — more importantly — every
staging process's `/proc/<pid>/cmdline` provably contains the staging directory,
which production's never can.
* **Teardown kills by recorded pid only, never by pattern.** `openfut-utas-host` and
`openfut-core` each name *two* live processes on this machine. `sold-staging-down.py`
reads pids from `manifest.json`, re-reads `/proc/<pid>/cmdline` and **refuses** to
signal anything whose cmdline does not contain the staging dir; it refuses the known
production pids explicitly; and it signals only the process group the up script
created (`pgid == pid`, via a new session). There is no `pkill`/`pgrep` anywhere.
* **The Blaze patch cannot silently no-op.** Six substitutions are applied to the
copy, each anchored to a whole assignment line and each required to match **exactly
once**; the file is then re-read from disk and every value re-checked, including an
explicit assertion that `UTAS_BASE` no longer contains `:8099`. A responder that
changed shape aborts bring-up rather than running half-patched and pointing at
production. The four protocol values are `REDIR_PORT`, `BLAZE_PORT`,
`NUCLEUS_PORT`, `UTAS_BASE`; the two extra ones are the responder's hardcoded
`LOG = "/tmp/blaze_responder.log"` and `RXDIR = "/tmp/blaze_rx"`, redirected into
the staging dir so a capture can never be ambiguous about which stack wrote it.
* **Production liveness is asserted** at preflight, at the end of bring-up, and after
teardown (`prod utas-host` pid 3631953, `prod Core` pid 3374264).
* **Nothing on 10.10.0.105 is written.** The scripts only *print* the config block;
the operator edits it by hand.
### Production services deliberately shared, read-only
Staging Blaze advertises the **production** auxiliary endpoints, exactly the strings
production Blaze advertises:
| Advertised to client | Value | Why sharing is safe |
| --- | --- | --- |
| roster XML / roster URL | `10.10.0.120:8081` | static roster update XML; **no economy state**. Hardcoded as `ROSTER_HOST = "%s:8081" % _ADVERTISE` in the responder. |
| `FIFA_POW_CONTENT_SERVER_URL` | `10.10.0.120:8085` | static POW content; no economy state (`POW_CONTENT_HOST`) |
| `FIFA_POW_URL` / nucleus proxy | `10.10.0.120:8094` | POW API, only emitted when `FUT_POW=1`, which staging leaves unset (`POW_HOST`) |
| LSX layer-1 responder | `:4216` | persona/session handshake for the same persona `33068179`; no economy state |
The staging stack **never connects** to any of these itself — it only hands the client
the same strings production hands it. Every service that owns economy state (UTAS,
Core, market/pile/identity) is duplicated on staging ports against staging files.
Because those aux services are shared, **do not run a staging session and a
production session simultaneously**: one client at a time.
`heat2` (TDF codec) and `fut_account` (the single source of truth for persona
`33068179` / `CAGE`, shared with LSX and the oracle) are imported read-only from
`fifa17-recon/tools/` via `PYTHONPATH` rather than copied, so the staging Blaze
asserts a **byte-identical** identity to what LSX already asserts. Cross-layer
identity consistency is the constraint; a divergent copy would break login.
---
## 6. Verifying a live stack by hand
All read-only, and none of it touches a production port:
```bash
S=/home/alex/openfut-sold-staging
# which variant is live
grep sold-experiment $S/logs/utas-host.log
# staging UTAS answers, from staging state
python3 - <<'PY'
import http.client, json
c = http.client.HTTPConnection("127.0.0.1", 8299, timeout=5)
c.request("GET", "/ut/game/fifa17/tradePile/counts",
headers={"X-OpenFUT-Game": "fifa17"})
print(c.getresponse().read().decode())
PY
# the patched Blaze points at staging UTAS, not production
grep -nE '^(UTAS_BASE|REDIR_PORT|BLAZE_PORT|NUCLEUS_PORT) = ' \
$S/blaze/blaze_responder_staging.py
# exactly six lines differ from the original responder
diff fifa17-recon/tools/blaze_responder_v3b.py \
$S/blaze/blaze_responder_staging.py
# no staging process has a handle on production state
for p in $(python3 -c "import json;print(' '.join(str(x['pid']) for x in json.load(open('$S/manifest.json'))['processes']))"); do
echo "pid $p: $(sudo ls -l /proc/$p/fd | grep -c openfut-promotion/state) prod-state handles"
done
# production is still up
ps -o pid=,cmd= -p 3631953
```
Never `curl` port 8099/8199/18080 to "compare with production" — those ports are on
the deny-list. Independence is proven structurally (separate pids, separate ports,
separate files, zero shared handles), not by poking the live stack.
---
## 7. Troubleshooting
| Symptom | Cause / fix |
| --- | --- |
| `REFUSING to start -- these staging ports are not free` | something else holds a staging port. Nothing was launched. Free it, or edit the port block at the top of `sold-staging-up.py`. |
| `a previous staging stack is STILL UP` | run `sold-staging-down.py` first. The up script never steps on a live stack. |
| `blaze patch <NAME> applied 0 times` | `blaze_responder_v3b.py` changed shape. Fix the regex in `blaze_patches()`; do **not** disable the assertion. |
| `seed card ids are not resolvable` | the emitted content in `CONTENT_SRC` no longer contains a seeded `card_id`. Re-point `CONTENT_SRC` or update the seed ids — both memberships (content pack **and** identity catalog) are required. |
| client shows *"error occurred downloading the FUT Squad Update"*, staging host log shows **no request at all** | the failure is upstream of UTAS. Almost always a client that was not relaunched after the config change (§3). Check the *absence* of requests in `logs/utas-host.log` before touching any code. |
| `<name> pid N is alive but its cmdline does NOT contain <staging dir>` | pid reuse or a stale manifest. Teardown refuses rather than guessing — identify the process by hand and stop it by pid. |
| staging host log shows connection errors to `127.0.0.1:8399` | expected and intended: a UTAS route fell through to the Python fallback, which is deliberately dead. Fix the route; do **not** point the fallback at `8199`. |
-250
View File
@@ -1,250 +0,0 @@
# OpenFUT — Direction Document
*The pivot: FUT lives in the app; FIFA 23 is the match renderer.*
*Supersedes the Blaze-backend approach as the primary plan. Last updated 2026-06-30.*
---
## 1. Goal (revised)
Deliver an **intuitive way to play a FUT-style experience with FIFA 23**, where:
- The entire **FUT experience** — cards, squads, packs, SBCs, coins, chemistry,
progression — lives in a **custom app** (web UI or desktop) built on the
already-complete OpenFUT Core economy backend.
- **FIFA 23 is demoted to a match renderer.** Its only job is to play a
single-player match using the squad the app built. No FUT mode, no online, no
Blaze, no EA servers.
This deliberately drops in-game FUT cards/UI (they live in the app) in exchange
for a project that **converges** instead of being gated behind months of
backend reverse-engineering.
### Why this replaces the backend plan
The status review confirmed the backend route (faking EA's online stack) is
blocked at an upstream in-process EbisuSDK gate, with Blaze/Fire2 unconfirmed
beyond it — realistically 36 months of expert RE that may not converge. The
app-centric route sidesteps **every** wall in that review by never making FIFA's
own FUT mode run.
---
## 2. Base mode: Career, not Kick-Off
**Career mode is the base.** Reasons:
- FLE's live-editing API (`EditDBTableField`, Freeze Lineup) is **confirmed to
work in career mode** and explicitly does NOT work in FUT/online modes.
- Career already provides the FUT-shaped scaffolding we'd otherwise fake:
persistent club, a fixture schedule, recorded results, progression across a
season.
- **Match results are written into the career DB**, making result capture a DB
read rather than a fragile live-memory grab.
**Kick-Off is the prototype sandbox.** Use it first to prove squad injection
works with nothing to corrupt (no save to break), then move the real loop onto
career. Run the foundational injection test in BOTH.
---
## 3. Core architecture: the bidirectional FLE bridge
The backbone is a **bidirectional channel between the app and a resident FLE Lua
script running inside the game.** Everything else is messages over this channel.
```
Custom App (FUT experience)
│ squad push ──────────────► ┌─────────────────────────────┐
│ │ Resident FLE Lua script │
│ ◄────────── game state │ (inside FIFA 23, career) │
│ ◄────────── match result │ - reads game state │
└────────────────────────────► │ - applies squad live │
(file-watch or local socket) │ - reads results from DB │
└─────────────────────────────┘
FIFA 23 plays the match
```
Three message types over the bridge:
1. **App → Game: squad push.** The app's chosen XI + stats applied LIVE via
`EditDBTableField`, replicating whatever DB write FLE's "Freeze Lineup"
feature performs (see `docs/foundational-xi-injection-test.md` — the exact
field(s) are found by diffing, not assumed). No restart, no
file-copy-reload. (File-load remains a fallback.)
2. **Game → App: game state.** The resident script polls the game's current
screen/menu state and reports "safe to apply" vs "not safe", driving a smart
Apply button in the app (see §5).
3. **Game → App: match result.** After full-time, the script reads the result
from the career DB and pushes score/scorers to the app, which awards
coins/progression. (Manual entry is the baseline fallback.)
The bridge transport can be a watched file the in-game Lua polls, or a local
socket — decided in build (see §7). Either way the *game keeps running*; a file,
if used, is just the message channel, not a reload.
---
## 4. Tiered mod scope
Build in tiers matched to risk. The core tier is all the SAME kind of DB write,
so it lands together once squad injection works.
### Tier 1 — Core writes (ride the same live DB-edit mechanism)
- **Squad / custom XI** — the load-bearing primitive (Freeze Lineup's
underlying write, replicated via script — see §6).
- **Player stats as "cards"** — card tiers, in-form versions, SBC upgrades all
expressed as written attribute values.
- **Chemistry as stat adjustment** — app computes FUT chemistry, applies it as
small stat bumps when writing players in (no in-game chem UI; that's in the app).
- **Appearance / identity** — kits, names, team assignment, so the club looks
like your club on the pitch.
- **Formation / tactics** — squad structure carries the app's build onto the pitch.
### Tier 2 — Confirm-then-add
- **Match difficulty per game** — to drive a Squad-Battles-style "this opponent is
World Class". Settable in-game trivially; programmatic drive needs confirming.
- **Match rules / modifiers** (half length, etc.) — for app-defined challenges.
### Tier 3 — Result capture (manual baseline + automated stretch)
- **Manual:** user enters the score in the app after the match. Zero RE, ships
first.
- **Automated:** resident script reads the career-DB result (or, for Kick-Off,
reads the in-match score from memory at full-time — precedent exists: the
CM cheat table's `export_season_stats.lua` already reads goals/cards from
memory via known offsets). Push to app → auto-award progression.
### Out of scope (stays in the app, by design)
- In-game FUT cards, FUT menus, pack-opening animation, chemistry board, FUT
presentation. The app is where it looks/feels like FUT.
---
## 5. The smart Apply button (state-aware)
Live DB edits only "stick" in safe menu states (the in-game "Edit Player" screen,
for example, overwrites edits). So the bridge reads game state and gates applying:
- Resident Lua script polls the game's current-screen value (a few Hz),
classifies **safe / not safe**, reports to the app.
- App's **Apply button is enabled only when the script confirms a safe state**
(squad hub, main menu); greyed otherwise.
- **Safe-by-default-OFF:** unknown state → button greyed → never a risky write.
Expand the known-safe list incrementally as states are confirmed.
- **v2 (more seamless):** instead of greying, the app always lets you click and
the script **queues** the apply, executing the moment a safe state is entered,
then confirms back. Greying is v1; queue-and-apply is v2.
`IsInCM()` is a confirmed state-read; the specific screen-state address + the
value→screen mapping is one-time reconnaissance (same technique as result reading).
---
## 6. What's confirmed vs what needs validating
**Confirmed (from FLE's own Lua API docs/wiki, checked 2026-06-30):**
- FLE live-edits the running career DB without restart, via `EditDBTableField`
(real signature: `EditDBTableField(cell)` where `cell = row["fieldname"]`
with `.value` mutated first — not the table/index/field/value form an
earlier draft of this doc assumed).
- FLE reads game state via `IsInCM()`.
- A `MEMORY` Lua class exists (`ReadInt`/`WriteInt`/`ReadMultilevelPointer`/
etc.) for arbitrary process memory — confirms the result-reading fallback
in §4 Tier 3 is a real, documented capability, not just cheat-table analogy.
- `GetPlayersStats()` is a documented function returning per-player
goals/assists/cards/etc. — a better confirmed path for match-result capture
than raw memory offsets.
- **Freeze Lineup** (Formation Editor → arrange XI → tick "Freeze Lineup" →
`Data → Save`) is FLE's actual documented mechanism for forcing a starting
XI in career mode. This **replaces** "selection bias" below.
- OpenFUT Core (economy) is complete and tested.
**Walked back — not actually confirmed:**
- "Selection bias forces specific players into the starting XI" — no such
field appears anywhere in FLE's documented Lua API or its own example
scripts. This was an unverified assumption carried over from general FIFA
modding precedent (other titles), not anything checked against FLE/FIFA 23.
See `docs/foundational-xi-injection-test.md` for the corrected plan, which
uses Freeze Lineup instead.
**Needs validating (the foundational tests — see §7):**
- Whether Freeze Lineup actually holds into a played match (FLE's wiki
documents the feature but not a live-match test of it).
- What DB table/field Freeze Lineup's `Data → Save` actually writes — it's
GUI-only and undocumented at that level; finding it is part of the
foundational test.
- Whether that write can be replicated by a script (`EditDBTableField`) well
enough to drive it from an EXTERNAL trigger, not just the Formation Editor
UI — required for the app↔game bridge.
- The app↔game bridge transport (file-watch vs socket) works cleanly under the
run setup.
- The screen-state address + safe/not-safe classification (FLE's `Events`
API page exists in the wiki index but its content is currently empty/
undocumented — this is more open than previously assumed).
- Result read-back from the career DB after a match.
**Standing caveat:** the whole stack rides on **EAAC staying neutralized**
(FLE's fake-launcher bypass). If a game update re-enables it, hooks fail. Keep
game updates off; confirm neutralized state each session.
---
## 7. Build order / next steps
Each is a bounded, verifiable step. Do them in order; later ones depend on
earlier answers.
1. **FOUNDATIONAL TEST — live custom XI in career.** Confirm Freeze Lineup
holds into a played match, reverse-engineer the DB write it makes, then
replicate that write from a script so it can be triggered externally
instead of through the Formation Editor UI. See
`docs/foundational-xi-injection-test.md` for the full procedure. *Done =
a script-driven write produces a match that fields the squad you
specified.* Everything rests on this.
2. **Pick the bridge transport.** Decide file-watch vs local socket for app↔game
messaging; implement the minimal app→game squad push. *Done = app sends a
squad, the resident script receives and applies it.*
3. **Game-state reader + smart Apply.** Find the screen-state address, classify
safe/not-safe, expose to the app, gate the Apply button. *Done = button greys
when you enter a match/edit screen, enables in the squad hub.*
4. **Result read-back.** Read the career-DB match result post-game, push to app,
award progression. Manual entry ships alongside as the fallback. *Done = app
updates coins from a played match.*
5. **Tier 1 breadth.** Extend the squad push to carry stats, appearance,
formation (same write mechanism). *Done = the club looks and plays like the
app's build.*
6. **Tier 2 + economy loop polish.** Difficulty drive, challenges, and the full
pack → SBC → squad → match → reward loop closed end-to-end.
### Decision still open
- **App form factor:** web UI vs desktop app. This affects the bridge transport
(a desktop app can hold a local socket more naturally; a web UI leans toward a
small local helper/file-watch). Decide before step 2.
---
## 8. Provenance
Clean-room throughout. This route relies on FLE's documented public API and the
game's own supported career mode — no EA backend, no Blaze, and nothing derived
from leaked EA source. The earlier backend RE remains clean-room and is preserved
as a spec artifact; it is simply no longer the primary path.
---
## 9. One-paragraph summary
OpenFUT becomes a **FUT companion app that uses FIFA 23 as a match engine.** The
app owns the entire FUT experience; a resident FLE Lua script in career mode
applies the app's squad live (no restart), reports game state to drive a safe
Apply button, and reads match results back to feed progression. This sidesteps
every backend wall, runs on confirmed FLE capabilities, builds on the finished
economy core, and delivers the intuitive, offline, FUT-flavored loop that is the
actual goal.
@@ -1,443 +0,0 @@
# FIFA 17 — Empty "My Packs" Client Contract (store/purchasegroup)
> **STATUS (2026-08-13): ROOT CAUSE ESTABLISHED; P2 backend compatibility workaround
> IMPLEMENTED.** Root cause: FIFA 17's Store/Scaleform path resolves the `mypacks`
> category even with zero unopened packs, and CardsDLL `FUN_1800147f0` assumes the
> resolved group is non-null (crash if absent). Backend decision: **P2** — emit an
> **active** non-openable synthetic `mypacks` placeholder (id 65534) when
> `unopenedPackIds == []` (`fifa17-recon/tools/utas_server.py` `store_catalog`; tests
> `fifa17-recon/tools/test_empty_mypacks.py`). Crash-safe + economy-safe; known UX
> limitations (fake tile, click-dialog, Browse→My-Packs nav quirk) are Scaleform-driven
> and require the client-side fix in `docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md`.
> This is a FIFA-17-specific compatibility shim, NOT an EA-authentic representation,
> and is confined to the FIFA-17 adapter/backend (NOT OpenFUT Core).
Evidence labels: **OBSERVED** (runtime capture / crash dump / already-decompiled RE
quoted in-repo), **INFERRED**, **HYPOTHESIS**, **UNKNOWN**. No server behavior is
changed by this document; it is analysis only.
Binaries (hashes verified 2026-08-13 on `.105`):
`CardsDLL_Win64_retail.dll` SHA-256 `4706a881ae1fc7b5769fd810b25a868d29d2b16a8e65a7513436327ef645573c`
(load base in the crash dump `0x00006FFFFC120000`; RE-space base `0x180000000`).
`FIFA17.exe` SHA-256 `29c31cef12b0c3c2a7305220617c7b4fa139ab76b8c857851bdbe88987962899` (packed).
## 1. Question
How must the server represent an account that owns **zero unopened packs** in
`GET /ut/game/fifa17/store/purchasegroup/all?ppInfo=true` so that FIFA 17 neither
(a) crashes nor (b) shows "The pack you've selected is currently not available",
**without granting the user a real/openable pack** and without breaking the normal
bronze/gold/special store? The current OpenFUT answer (a synthetic inactive `mypacks`
sentinel) only downgrades a crash to a dialog; it is not the correct contract.
## 2. Established experimental behavior (OBSERVED)
Profile-state ladder, all with normal packs 1/5/6/7 unchanged:
| profile `unopenedPackIds` | `mypacks` group in response | client outcome |
|---|---|---|
| `[]` (baseline) | one **inactive** sentinel pack `65534`, empty description | "pack not available" dialog → FUT Hub |
| `[70]` (Exp A) | one **active** real owned pack `70` | **store works**; My Packs visible |
| `[]` + sentinel suppressed (Exp B) | **no `mypacks` group at all** | **client CRASH** |
Captures: `store_purchasegroup_capture_2026-08-12.json` (baseline),
`…_mypacks70_2026-08-12.json` (A), `…_empty_no_sentinel_2026-08-12.json` (B).
Details in `STORE_TILE_6C.md` §14-§15.
## 3. Existing RE evidence (from `docs/plan-2026-08-05-store-subsystem.md`, decompiled)
All addresses RE-space (CardsDLL base `0x180000000`):
- **`FUN_18013af30`** — per-element pack deser; each `purchase[]` entry → a `0x158`
wire record. `displayGroup.value`(atom 0x377) → record `+0x00` (ctor default the
literal `"unknown"`). `displayGroup.priority`(0x250) → `+0x34`.
(store-subsystem §"wire record", :996.)
- **`FUN_1800150d0`** — group builder. Walks `purchase[]` in array order; for each
pack, finds-or-creates a `0x108` display group by **exact strcmp of
`displayGroup.value` against `group+0x70`** (`FUN_180014380`). New group
(`FUN_180012950`): `+0x00` = 1-based ordinal (groups-so-far+1), `+0x100` =
priority, **`+0x104` = (value == "mypacks")**, `+0x40` = vector of `0x1a8` tile
models. Pack→tile via `FUN_18002c3c0`. (:152-161, :1042-1049.)
- **`FUN_1800147f0`** — group **resolver/renderer**, called as
`FUN_1800147f0(model, screen+0x290, dataProvider, 0, 0)` from `FUN_18007dab0`.
`screen+0x290 == 0` → "list the group tiles" (`FUN_180014610`); **any other value
`FUN_180014420`, which exact-matches `group+0x00` (the ordinal) and returns NULL
on a miss, after which `FUN_1800147f0` dereferences `[RAX+0x40]` with NO guard**
("checked in raw disassembly … a real absence"). **"The only legal category values
are 0 and the ordinals 1..N."** (:163-169, :1051-1054.)
- **`FUN_180014580`** — the six-tab bar: switch 0..5 over the hardcoded lowercase
literals `mypacks, points, bronze, silver, gold, special`. `FUN_18007e5e0` gives
each panel a `PANEL_ID` = the matching group's ordinal, **or HIDES the panel** if no
matching group; `FUN_18007df60` publishes `MYPACK_/…/SPECIAL_CATEGORY_ID`.
(:202-206, :1058-1062.)
## 4. Store parser code path (OBSERVED, decompiled)
```
HTTP 200 {"purchase":[...]} (server: store_catalog / _pack_body)
→ per-element deser FUN_18013af30 → 0x158 wire records
→ FUN_1800150d0 → 0x108 display groups (by displayGroup.value),
tiles (0x1a8, FUN_18002c3c0) into group+0x40
→ render FUN_18007dab0 → FUN_1800147f0(model, screen+0x290, …)
screen+0x290 == 0 → FUN_180014610 list this group's tiles
screen+0x290 == N>0 → FUN_180014420 exact-match ordinal; NULL on miss
→ [RAX+0x40] dereference (NO NULL GUARD) ← crash site
```
## 5. My Packs group construction (OBSERVED)
A `mypacks` group exists **iff at least one `purchase[]` entry carries
`displayGroup.value == "mypacks"`** (the group is derived from packs; there is no
independent group object on the wire). Its ordinal is its 1-based creation position
in array order; `group+0x104` is set because the value is `"mypacks"`; its tiles live
in `group+0x40`. Consequently the server **cannot** emit an "empty `mypacks` group"
via `purchase[]` — removing the pack removes the group entirely.
## 6. Default / selected group logic (partly UNKNOWN)
- `FUN_1800147f0` resolves whatever category ordinal it is handed via `screen+0x290`;
legal values are `0` (list tiles) and `1..N` (existing ordinals). A value that is
not an existing ordinal (e.g. `-1` for a hidden/absent panel) → `FUN_180014420`
NULL → crash. (OBSERVED via §8 crash + RE.)
- **Whether the store defaults to / auto-resolves the `mypacks` category on open, and
why it does so even when the unopened count is 0, is UNKNOWN** — the store-screen
controller and default-tab selection are Scaleform/packed-FIFA17.exe
("Which tile the movie thinks you clicked | CLIENT | Scaleform, unread",
store-subsystem :695). Experiment B proves that in this configuration the client
DID resolve a `mypacks` ordinal that did not exist (it crashed), so the store is
reaching the My Packs category with zero owned packs. Root of "why" = UNKNOWN.
## 7. Pack availability predicate (UNKNOWN)
The predicate that turns the baseline inactive sentinel into "pack not available"
(while active pack 70 passes) is **in the packed FIFA17.exe and unread**
(`plan-2026-08-05-pack-opening.md:553`: `state/saleType/quantity/purchaseLimit/
purchaseCount/start/end` are parsed and copied to the tile, "the predicate that greys
a tile is in the packed exe and unread"). Candidate deciding fields, from the
baseline↔A diff (INFERRED, unproven): **`state` (`inactive``active`)** and/or
**`unopened` (`false``true`)**. The exact field is **UNKNOWN**.
## 8. Experiment B crash analysis (OBSERVED)
Minidump `CrashDump_…18.21.08…dmp` (the only crash in the hour; minute `:21` matches
the `00:21:07` store request; SHA-256 `fbddda18…`), parsed:
- Exception: **`0xC0000005` ACCESS_VIOLATION**, access type **READ**, **faulting VA
`0x0000000000000048`**.
- Faulting instruction: **`CardsDLL_Win64_retail.dll + 0x14882`** → RE-space
**`0x180014882`** = **`0x92` bytes into `FUN_1800147f0`** (entry `0x1800147f0`).
- Interpretation (OBSERVED crash ⋂ decompiled RE): the group pointer returned by
`FUN_180014420` was **NULL** (no `mypacks` group present with `unopenedPackIds==[]`
and the sentinel suppressed), and `FUN_1800147f0` dereferenced `[NULL+0x48]` → read
of address `0x48` → access violation. This is exactly the "no null guard" branch
the RE flagged (RE said `[RAX+0x40]`; the actual faulting offset is `+0x48`, same
member region — the group struct's `+0x40` vector accessed via a `+0x48` field).
- Note: the pre-built Ghidra project and `/tmp/fut/cardsdll.dll` were absent on `.105`
(tmp cleared); confirmation used the OBSERVED crash dump + the previously-decompiled
RE rather than a fresh (expensive) re-analysis. CardsDLL is unpacked, so this code
is statically readable if a fresh project is ever needed.
## 9. Empty My Packs client contract (the answer, as far as evidence allows)
- **The client has NO guard for a missing selected group.** If the store resolves the
`mypacks` category and no `mypacks` group exists, it null-derefs and crashes.
(OBSERVED.)
- **A `mypacks` group can only exist if a `purchase[]` entry carries
`displayGroup.value=="mypacks"`.** (OBSERVED.) There is no wire representation of an
"empty group."
- **If the `mypacks` group's selected pack is not a valid/active pack, the client
shows "pack not available".** (OBSERVED baseline vs A; deciding field UNKNOWN, §7.)
- Therefore, under the *current* client behavior, a zero-unopened-packs account is
only cleanly handled when the `mypacks` group contains a **valid active pack**
(Exp A). Whether an active-but-non-openable placeholder would also satisfy the
availability predicate is **UNKNOWN** (depends on §7).
- **The correct retail-EA representation of zero unopened packs is UNKNOWN.** It is
NOT "omit the group" (crash) and NOT "inactive placeholder" (dialog). It is most
likely one of: (i) the retail store does not auto-select My Packs when the unopened
count is 0 (a client/Scaleform decision, possibly gated by a count the server sets),
or (ii) retail sends a `mypacks` entry the client treats as an empty-but-valid state
via a field we have not identified. Neither is established.
## 10. Candidate server representations
| # | Candidate | Client evidence | Expected behavior | Confidence | Safe to test? |
|---|---|---|---|---|---|
| A | No `mypacks` pack, no `mypacks` group | Exp B crash (`0x180014882`, `[NULL+0x48]`) | **CRASH** | OBSERVED | Already tested — reject |
| B | `mypacks` group present but zero packs | Not representable via `purchase[]` (groups derive from packs, `FUN_1800150d0`) | UNKNOWN | INFERRED-not-representable | No server mechanism |
| C | Inactive placeholder pack (current sentinel) | Baseline dialog | "pack not available" → Hub | OBSERVED | Already tested — reject |
| C | **Active** placeholder pack, id absent from `PACK_CATALOG` (so open/buy handlers reject it) | Exp A shows an *active* mypacks pack works; sentinel id 65534 is already rejected by open/buy (not in `PACK_CATALOG`) | Group resolves (no crash); MIGHT pass availability (no dialog) while remaining non-openable → no free pack | HYPOTHESIS | Yes — code change, restart; economy-safe (non-openable) |
| D | Hide My Packs when unopened count == 0 | `FUN_18007e5e0` hides a panel with no group, but the store still resolved `mypacks` at count 0 (Exp B crash) | Hiding via absence CRASHES; a client count-gate is Scaleform/unknown | UNKNOWN | Not server-controllable as far as known |
| E | Different default category when count == 0 | Default-tab selection is Scaleform/packed | UNKNOWN | UNKNOWN | Not server-controllable as far as known |
| F | A count/quantities field (e.g. `ut/v2/store` `FutStorePackQuantities`, or `userInfo.unopenedPacks`) that suppresses the My Packs auto-select | eligibility gate exists (`ENDPOINT_MAP.md:60`); relationship to My Packs default UNKNOWN | UNKNOWN | HYPOTHESIS | Read-only RE first |
## 11. Recommended next controlled experiment
**Experiment C (economy-safe placeholder).** Keep `unopenedPackIds == []`; change the
synthetic sentinel `65534` ONLY in `state` (and, if needed, `unopened`) so the
`mypacks` group's single tile is **active** — but leave its id `65534` **absent from
`PACK_CATALOG`** so `store_buy`/`open_pack`/`consume_unopened_pack` still reject it
(no pack can be opened → **no free pack, no economy change**). Observe whether the
store then opens without the "pack not available" dialog (would identify `state` as
the availability field and give an economy-safe fix), or still shows the dialog
(implicating another field / packed predicate).
- Requires a temporary code change to `store_catalog` (sentinel construction) →
restart. Same experiment discipline as Experiment B (patch container copy, capture,
revert, restart). Economy-safe because the placeholder remains non-openable.
- If C still fails, escalate to read-only RE of the availability predicate / the
count-gated default-tab hypothesis (candidate F) before any further change.
## 12. Open questions
1. Exact field that flips the sentinel from "pack not available" to acceptable
(`state`? `unopened`? another). UNKNOWN — packed predicate. (Exp C targets this.)
2. Why does the store resolve/select `mypacks` with zero owned packs? Is there a
server-settable count that would stop it? UNKNOWN — Scaleform/packed.
3. Does retail FIFA 17 ever present an empty My Packs, and how? No capture on record.
4. Is an active-but-non-openable placeholder (C) accepted by the availability
predicate? HYPOTHESIS — untested.
---
## Permanent-fix requirements (Phase 11 — REPORT ONLY, not implemented)
A correct permanent fix MUST satisfy ALL of:
- Zero unopened packs must **NOT** grant the user a free pack.
- No synthetic **openable** reward may be created (any placeholder must be rejected by
`store_buy`/`open_pack`/`consume_unopened_pack`).
- Client must **not crash** (a resolvable `mypacks` group must exist, OR the client
must be kept from resolving `mypacks` when empty).
- Client must **not** show "The pack you've selected is currently not available".
- Normal Bronze/Gold/Special store categories must still work unchanged.
- When a genuine unopened pack exists, My Packs must continue to work (Exp A).
- Profile/economy semantics must remain correct (no coins/nextItemId/inventory drift).
Nothing implemented. The evidence favours investigating an **economy-safe active
placeholder (C)** and/or the **count-gated My-Packs default (F)**; it explicitly does
NOT support "grant pack 70 whenever My Packs is empty".
---
## UPDATE after Experiment C (2026-08-13) — active non-openable placeholder tested
Executed C: sentinel 65534 `state "inactive"→"active"` only; `unopenedPackIds==[]`;
65534 kept out of `PACK_CATALOG`. Full record in `STORE_TILE_6C.md` §16. Capture:
`store_purchasegroup_capture_active_placeholder_2026-08-12.json` (C-vs-baseline JSON
diff = only `65534.state`).
**Resolves §7 (availability predicate), partially:** `state` **DOES participate**
(OBSERVED). `state:"active"` removed the "pack not available" dialog while the group's
existence still prevented the crash. So the earlier §7 "deciding field UNKNOWN" is
updated: **`state` (inactive vs active) is (at least) a deciding field** for the
dialog. `unopened` was NOT varied and remains untested. The full predicate may still
involve other fields, but `state` alone flips dialog→no-dialog.
**Updated candidate table verdict:**
- **C (active placeholder, non-openable): SUPPORTED with UX caveats — best option so
far, but NOT adopted.** No crash, no dialog, store usable, and **no automatic
transaction/open for 65534** (only a routine boot `TRANSACTIONCANCEL` no-op).
Caveats (OBSERVED): (1) the placeholder renders as a **visible empty pack tile**
("0 items, 0 bronze, 0 rares", no cover) that a user could try to open (server-safe:
opening 65534 → no-op `{}`/stale `last_pack`, no value — §16.1); (2) **navigation
gate**: from the Store "Browse Packs" entry the Bronze/Gold/Special categories are
not reachable until "My Packs" is opened first (not present with a genuine owned
pack, Exp A).
- A (real active pack 70): works cleanly but grants a real openable pack → economy
risk; rejected as the permanent fix.
- Candidate **F (count-gated My-Packs default)** gains weight: C's visible-empty-tile
and Browse-Packs navigation gate suggest the client is being pushed to resolve/enter
My Packs when it should not with zero packs. If a server-settable count (e.g.
`userInfo.unopenedPacks` / `ut/v2/store` quantities) suppresses the My-Packs
default/tile, that could remove both the crash risk and the empty-tile artifact
without any placeholder. UNTESTED.
**Permanent fix: still NOT established.** Even though C is the first
crash-free/dialog-free representation, the empty-tile UX + navigation gate + the
untested "explicit placeholder selection" behavior bar adoption. Required next steps
(design/authorize separately): (a) controlled test of explicitly focusing/opening the
active placeholder; (b) investigate candidate F (count-gated My-Packs) to avoid a fake
tile entirely. Do NOT adopt `state:"active"` or "grant pack 70" as the fix on current
evidence.
---
# Candidate F — Count-Gated My Packs Navigation (READ-ONLY investigation, 2026-08-13)
Question: can the server make FIFA decide **not** to resolve/default into My Packs
when the account owns zero unopened packs (avoiding any placeholder)?
## 1. Server-sent unopened-pack signals (inventory, OBSERVED code)
| field / endpoint | source | value source | when sent | client consumer | conf |
|---|---|---|---|---|---|
| `userInfo.unopenedPacks.recoveredPacks` (via `userMassInfo`) | `utas_server.py:409-414` | `len(unopenedPackIds)` | boot massinfo; only if count>0 **or** `_UI∈{packs,full}` (default `_UI=roster` → omitted at 0) | hub unopened-pack model / My Packs badge (`FUN…vtbl[0x4e0]`, pack-opening RE) | OBSERVED (code) |
| `/user/credits` `.unopenedPacks.recoveredPacks` | `utas_server.py:3542-3545` | `len(unopenedPackIds)` | on credits fetch; **only if count>0** | My Packs badge / CentralUnclaimedPack hub tile | OBSERVED (code+capture) |
| `/hub` body | `utas_server.py:1449-1453` | — | hub load | — (**no pack count present**) | OBSERVED |
| profile `unopenedPackIds` | `fut_store.py` | account state | internal | not wire-visible directly | OBSERVED |
`pileSize`/`store quantities` (`ut/v2/store` `FutStorePackQuantities`) exist as an
eligibility gate (`ENDPOINT_MAP.md:60`) but were **never requested** in any capture
(8h logs); they carry a store-open `result`, not a My-Packs count.
## 2. Pre-store request sequence (OBSERVED, captures)
Boot → `accountinfo → /ut/auth → settings → phishing → match/reset → userMassInfo →
PUT store/transaction/0 (TRANSACTIONCANCEL→{}) → /hub → clientdata → /user/credits →
GET /store/purchasegroup/all`. The only pack-count-bearing responses **before**
`purchasegroup` are `userMassInfo`(userInfo) and `/user/credits`.
## 3. Baseline([]) vs Experiment A([70]) pre-store diff (OBSERVED, captures)
The single profile change `[] → [70]` altered exactly one pre-store wire signal:
- `/user/credits`: **`[]` → no `unopenedPacks` member** (C capture, all 3 fetches:
`{"credits":…,"currencies":[…]}`); **`[70]``"unopenedPacks":{"preOrderPacks":0,
"recoveredPacks":1}`** (A capture line 25). OBSERVED.
- `userInfo.unopenedPacks`: same pattern (present at `[70]`, omitted at `[]` with
`_UI=roster`). INFERRED from code; A-capture credits corroborates.
- **No other pre-store field changed.**
Candidate signal:
```
Candidate: unopenedPacks.recoveredPacks (count)
Endpoint: /user/credits and userMassInfo(userInfo)
Baseline([]) value: ABSENT (i.e. zero)
Experiment A([70]) value: {preOrderPacks:0, recoveredPacks:1}
Source: utas_server.py:3542-3545 / :409-414 (= len(unopenedPackIds))
Client-visible before purchasegroup?: YES
Confidence: OBSERVED that it differs; its CONTROL over My-Packs nav = see §9
```
**Key point: this count is already CORRECT** — it reports zero (absent) when the
account is empty. OpenFUT is **not** misreporting a nonzero pack count.
## 4. Navigation / client call path (OBSERVED, decompiled RE)
`FUN_18007dab0 → FUN_1800147f0(model, screen+0x290, …)`. `screen+0x290==0` lists
group tiles; else `FUN_180014420` exact-matches the group ordinal (NULL on miss →
`[NULL+0x48]` crash). `screen+0x290` is written in exactly two CardsDLL sites: the
screen ctor `FUN_18007d1a0` writes `0`, and **`FUN_18007e7f0` case `0x7551` copies
the Flash movie message field `CATEGORY_ID` verbatim** into it
(store-subsystem :172-175, :1055-1056). So the resolved category is chosen by the
**Scaleform movie**, not by any server response field.
## 5. `GOTO_STORE_MYPACK` analysis (OBSERVED, RE)
`GOTO_STORE_MYPACK` is the **destination of the hub `CentralUnclaimedPack` tile**
(tile type 0x1c); "Nothing in the chain issues a request, and no request could
exist" (pack-opening :888-890). Whether that HUB tile appears is gated by the
unopened-pack count in the hub model (`model+0x20950`) — i.e. the count DOES control
the *hub unclaimed-pack tile*, but the operator reached the store via **Browse Packs**
/ the store screen, whose category resolution is the movie-driven `CATEGORY_ID` path
(§4), not `GOTO_STORE_MYPACK`. `GOTO_STORE_MYPACK` is a UI navigation command,
**not** a server-state-gated store-category selector.
## 6. Candidate count/flag fields — verdict per field
- `unopenedPacks.recoveredPacks`: correct at 0 when empty; controls the hub badge /
CentralUnclaimedPack tile, **not** the store's category resolver. Not a viable gate
for the store My-Packs entry.
- No other server field feeds `screen+0x290` (RE §4: only ctor-0 and movie
`CATEGORY_ID`).
## 7. EA-capture evidence
No EA-origin `purchasegroup`/`credits` capture for a zero-unopened-packs account
exists in the repo (all captures are OpenFUT-generated). EA count semantics for empty
My Packs remain **UNKNOWN**.
## 8. Where My Packs selection occurs (OBSERVED)
**Before** `purchasegroup` parsing decides content, the **Scaleform movie** decides
which category to resolve and writes it to `screen+0x290` (§4). The server's role is
limited to which groups EXIST in `purchase[]`. Therefore the sentinel is compensating
for a **movie-side** decision to resolve My Packs; it is not fixing an incorrect
server count (the count is already correct).
## 9. Candidate F verdict — **F3 (CONTRADICTED)** (with an F4 residue)
My Packs selection is **not** controlled by server-sent unopened-pack state:
- OBSERVED: the server count is correctly zero/absent when empty, yet the store still
resolved My Packs (baseline dialog, Exp-B crash). A correct zero signal did not stop
it.
- OBSERVED (RE): `screen+0x290` (the resolved category) comes from the movie's
`CATEGORY_ID`, with no server-field input; default is 0.
Residue (F4): the movie's internal logic for *why* it asks for My Packs on store open
is in packed Scaleform and is not statically readable — but no server lever into it
has been found. **Conclusion: there is no server-controlled count/flag that makes FIFA
skip resolving My Packs; the server can only ensure the `mypacks` group exists.** The
"clean count-gated fix" is therefore **not achievable server-side**.
## 10. Proposed next experiment
Because F is contradicted, a count experiment is NOT recommended (the count is already
correct and does not gate the store). No single-variable server signal will make FIFA
enter Browse Packs instead of My Packs. The realistic next step is the previously
deferred **explicit active-placeholder selection test**: with the C active
non-openable placeholder in place (sentinel 65534 at baseline otherwise), have the
operator explicitly focus/open the empty My-Packs tile and observe (server-safe per
§16.1 — opening 65534 is a no-op — but UX/navigation behavior unknown). That
characterizes the best available server-side option (C) before any adoption.
- Would it change profile state? No (`unopenedPackIds=[]`).
- Would it change purchasegroup/sentinel behavior? Only `state:"active"` (as C),
reverted after.
- Code change? Yes (same one-line C patch). Restart? Yes. (Not authorized here.)
If explicit selection proves unsafe/ugly, the remaining options are all **client-side
/ out-of-scope** (the decision is in the Scaleform movie), or accepting C with its
documented UX artifacts.
**Candidate F does NOT provide the hoped-for clean fix. The active non-openable
placeholder (C) remains the best server-side representation; its empty-tile and
Browse-Packs navigation artifacts are movie-driven and not server-fixable.**
---
# Explicit Active-Placeholder Selection Test — FINAL backend-side result (2026-08-13)
With the C active placeholder in place (`unopenedPackIds=[]`, 65534 active/mypacks/
∉PACK_CATALOG), the operator explicitly opened the empty My-Packs tile once. Full
record in `STORE_TILE_6C.md` §17.
- **Outcome: S1 — pure client-side rejection.** Dialog **"This pack is no longer
available"** → back to My Packs → Hub; **no crash**, navigation stays usable.
- **No server request** on selection (no `/store/transaction`, no `/purchased/items`,
no 65534 reference); the verdict is client-side. (OBSERVED)
- **Zero economy/profile mutation:** coins/items/nextItemId/`unopenedPackIds`/
`last_pack` all unchanged; profile byte-identical (`39bb3e83…`); 65534 not
persisted. (OBSERVED)
**Active-placeholder verdict: MARGINALLY ACCEPTABLE** — crash-safe + economy-safe +
navigable, but with user-visible defects (empty fake tile; "no longer available" on
explicit click; Browse-Packs nav gate). It is a *strict improvement* over the current
inactive-sentinel baseline (which errors on store OPEN and bounces to Hub).
**Backend-side question is now fully answered.** The complete zero-unopened-packs
ladder:
```
no mypacks group -> CardsDLL null-deref CRASH (unsafe)
inactive placeholder -> "pack not available" on store open -> Hub (baseline)
active placeholder -> store loads; empty tile; "no longer available" only on
explicit click; recoverable; economy-safe (best backend option)
real active owned pack -> fully correct UI (but grants a real openable pack — economy risk)
```
**Permanent-fix recommendation: P2.** The active non-openable placeholder is the best
*safe* backend-only option, but a fully *clean* zero-pack experience is **not**
achievable server-side (Candidate F CONTRADICTED — the My-Packs resolution is
Scaleform/movie-driven). Recommend: adopt the active placeholder as an optional
backend compatibility mode (safe, strictly better than baseline) AND pursue a
client-side fix (hide the fake tile / stop the forced My-Packs resolution) for the
fully clean result. **Not implemented.** Do NOT grant a real pack.
---
## Client resolver-guard experiment (2026-08-13) — RESULT F3 (crash, confounded)
A client-side `autopatch.py` memory guard (CardsDLL `0x180014858` `JNZ``JG`, routing
category `<0` to list-all/Browse) was tested against the exact no-sentinel server condition
(sentinel 65534 suppressed; `GET /store/purchasegroup` ids `[1,5,6,7]`, no mypacks group).
The client **crashed at the identical resolver site `0x180014882`** (`[NULL+0x48]`), because
it presented a **positive** My-Packs ordinal (crash is in the `>0` resolve branch), not the
`-1` the guard diverts. **Confound:** FIFA was not relaunched after the backend flip, so it
reused stale (sentinel-present) tab state. So the negative-only guard is **insufficient for a
positive stale/invalid ordinal**, and the fresh-client case is **not yet decided** (needs a
clean re-test: fresh launch with backend already no-sentinel). Backend P2 sentinel was
restored immediately (mandatory rollback). Full record + candidate stronger guard:
`docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md` PART III.
## Fresh-process no-sentinel retest (2026-08-13) — RESULT R1 (SUCCESS)
Re-ran the above cleanly: backend entered no-sentinel mode **while FIFA was closed**, then a
**fresh** FIFA (pid 553220, new autopatch 552999, guard `85 ff 7f 0f` enforced) launched and
opened the Store. The genuine no-sentinel `/store/purchasegroup` (ids `[1,5,6,7]`, no 65534/
mypacks) is **byte-identical** to the F3 capture, so the only changed variable is client
process lifetime. Outcome: **no crash, no dialog, Store opens on Browse Packs, packs
navigable** (cosmetics only: no tabs / no cover art / "0 items" — pre-existing). A fresh
client publishes category `-1` for the absent group, which `JNZ→JG` routes to Browse/list-all
with no NULL deref. **This confirms F3 was stale-positive-ordinal contamination, and proves
Strategy A (resolver guard) on the tested build.** Backend P2 sentinel restored immediately
(`f416e71e…`, `state=active`) and remains production default. Full record:
`docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md` PART IV.
-924
View File
@@ -1,924 +0,0 @@
# Store Tile Investigation (bug 6c)
Status: **ROOT CAUSE ESTABLISHED — P2 compatibility workaround IMPLEMENTED**
(2026-08-13). Full investigation complete (§1-§17); backend fix landed in
`fifa17-recon/tools/utas_server.py` `store_catalog` with regression tests in
`fifa17-recon/tools/test_empty_mypacks.py`. The store-tiles flags are unchanged
(`FUT_STORE_DISPLAYGROUP=ON`, `FUT_STORE_GROUPID=OFF`) and the profile is unchanged.
## RESOLUTION (2026-08-13)
**ROOT CAUSE (bug 6c):** FIFA 17's Store/Scaleform path RESOLVES the `mypacks`
category even when the account owns zero unopened packs (the category is chosen
client-side from the movie's `CATEGORY_ID``screen+0x290`; no server field gates
it — Candidate F CONTRADICTED). CardsDLL `FUN_1800147f0` then dereferences the
resolved group with NO null guard, so an absent `mypacks` group crashes the client
(`CardsDLL_Win64_retail.dll+0x14882`, `[NULL+0x48]` — minidump-confirmed, §8).
**BACKEND RESULT / DECISION — P2 (compatibility workaround):** emit a synthetic,
**active**, non-openable `mypacks` placeholder (id 65534, absent from `PACK_CATALOG`)
only when `unopenedPackIds == []`. This is crash-safe AND economy-safe (explicit
selection is rejected client-side with "This pack is no longer available", sends no
backend request, and mutates nothing — §17). It is a FIFA-17 client-compatibility
shim, **NOT** an EA-authentic empty-My-Packs representation, and is confined to the
FIFA-17 adapter/backend layer (NOT OpenFUT Core).
**KNOWN UX LIMITATIONS (unfixable server-side):** a fake empty "0 items" tile; an
explicit-selection dialog "This pack is no longer available"; and a Browse-Packs →
My-Packs navigation quirk. These are Scaleform/movie-driven.
**CLEAN CLIENT FIX:** still unresolved; belongs to client-side work —
`docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md`.
Evidence labels: **OBSERVED** (running code / `docker inspect` / live log / minidump /
table), **INFERRED**, **HYPOTHESIS**, **UNKNOWN**.
### Hypotheses
- **H1 (original — subtype/definition):** *Store tiles render "unknown" because the
server emits definitions whose type/subtype the client cannot map (prime suspects
5004xxx misc {231,232,233,236}, 8010xxx league logos, FCC↔wire subtype gaps).*
**Verdict: CONTRADICTED by static store-handler evidence.** The `/store/purchasegroup`
handler emits PACK definitions only — no `cardsubtypeid`/`carddbid`/`cardassetid`
anywhere in the response (§2). Those subtype families belong to the separate
**club-item / consumable / equippable** paths (`fut_clubitems.py`, `fut_consumables`,
`/club?type=`), which are OUT OF SCOPE for this store-tile task. History preserved
in §3.3 and §8; not investigated further here.
- **H2 (revised — displayGroup token):** **HYPOTHESIS (under runtime test).** *The
"unknown" FUT Store tile is caused by one or more pack entries whose
`displayGroup.value` token is not one of the six categories FIFA 17 can render:*
`mypacks, points, bronze, silver, gold, special`. Tested against a real capture in
§4-§8.
---
## Runtime capture plan (Phase 2) — OBSERVED
- **Backend container:** `openfut-fut-backend` (logs on stdout via `log()`,
`utas_server.py:59-62`; each request logs `"<VERB> <path>"` at `:3732`, and the
response line `" -> <code> <body[:200]>"` at `:3754` — **response body truncated
to 200 bytes**, so the full JSON body is NOT in the log).
- **Endpoint / path matcher:** `GET /ut/game/fifa17/store/purchasegroup/all?ppInfo=true`
(OBSERVED historically in the log; route regex `/store/purchasegroup`,
`utas_server.py:1215`).
- **Capture marker (UTC, set immediately before the manual test):**
`2026-08-12T23:54:01Z` (saved to `/tmp/store_capture_marker.txt`; 0 log lines
after it at set-time → clean boundary).
- **Log command to isolate the manual action:**
`docker logs --since 2026-08-12T23:54:01Z --timestamps openfut-fut-backend`
then locate the first `GET .../store/purchasegroup` line after the marker plus its
following headers and `-> 200 {"purchase"...` line.
- **Full-body recovery (because the log truncates at 200 bytes):** the response is a
deterministic pure function — `store_catalog()` (`:3408`) over static `PACK_CATALOG`
(`fut_store.py:820`) + live `STORE.unopened_packs()` (from `/state` profile) under
fixed flags (`STORE_DISPLAYGROUP=ON`, `STORE_GROUPID=OFF`, `FUT_PRICE_PROBE=OFF`).
Plan: reconstruct the exact body from the running `/app` code + live `/state`
profile, then **verify** its `json.dumps(...)[:200]` byte-for-byte equals the genuine
logged 200-byte prefix. Match ⇒ the reconstruction IS the sent body. No request is
synthesized, replayed, or curl'd; the genuine log line is the ground-truth anchor.
## 1. Method
### Environment (OBSERVED)
- Running on `10.10.0.120` (dev-lxc). Client is `10.10.0.105`.
- Backend under test: Docker container `openfut-fut-backend`
(image `openfut-fut-backend:dev`), `Up 7 hours`, entrypoint `/app/entrypoint.sh`.
- `docker inspect openfut-fut-backend`: only mount is
`/home/alex/OpenFUT/fifa17-recon/docker/state -> /state (rw)`; container ENV
contains **no `FUT_STORE_*`** vars.
- `entrypoint.sh` launches `python3 -u utas_server.py` from `/app/tools` with extra
env `FUT_TRADING=1 FUT_PILESIZES=1 FUT_TRADEABLE=1 FUT_DISCARD_TABLE=1
FUT_DISCARD_SEND=1` — again **no `FUT_STORE_*`**.
- The running store code is `/app/tools/utas_server.py`. Extracted read-only via
`docker cp openfut-fut-backend:/app/tools /tmp/app-tools` and confirmed
**byte-identical** (`sha256`) to the repo copy
`fifa17-recon/tools/utas_server.py` (both 3765 lines) and
`fifa17-recon/tools/fut_clubitems.py`. All line references below are to the repo
paths and equal the running code.
### Commands (exact)
```
docker ps --format '{{.Names}}\t{{.Image}}\t{{.Command}}\t{{.Status}}'
docker inspect openfut-fut-backend --format '...CMD/ENTRYPOINT/MOUNTS/ENV...'
docker exec openfut-fut-backend cat /app/entrypoint.sh
docker cp openfut-fut-backend:/app/tools /tmp/app-tools
docker cp openfut-fut-backend:/app/data /tmp/app-data
diff /tmp/app-tools/utas_server.py fifa17-recon/tools/utas_server.py # identical
diff /tmp/app-tools/fut_clubitems.py fifa17-recon/tools/fut_clubitems.py # identical
```
### Manual test sequence
NOT executed. The manual FIFA-client store capture (2D/2E) was never reached
because the investigation blocked at 2C before any flag could be enabled. No
request was synthesized, replayed, or simulated.
---
## 2. Store handler code path (2A) — OBSERVED
Request → response for the store tile screen:
1. **Endpoint.** `GET ut/<sku>/store/purchasegroup/...``FutStoreGetPackTypes`
(client deser root `0x1801234e0`). (`utas_server.py:3408-3409` docstring.)
- Route table entry: `(re.compile(r"/store/purchasegroup"), lambda m,h:
store_catalog(h))` at `utas_server.py:1215`.
- Sibling store routes: `/store/transaction -> store_buy(h)` (`:1216`, the BUY);
bare `/store(\?|$) -> (200,{"result":"SUCCESS"})` eligibility gate (`:1221`).
2. **Handler.** `store_catalog(h)` (`utas_server.py:3408-3447`).
- Iterates `PACK_CATALOG` (non-`ownedOnly` packs) → `_pack_body(p, idx)`.
- Appends owned unopened packs from `visible_unopened_packs()` →
`STORE.unopened_packs()` + `_OPENED_PACK_GRACE` (`:51-52`, `:3423-3427`).
- If no owned packs, appends one inactive `mypacks` sentinel (id 65534) so
`GOTO_STORE_MYPACK` resolves (`:3428-3446`).
- Returns `200, {"purchase": [<pack bodies>], "timestamp": 1596326400}`
(`:3447`).
3. **Definition construction.** `_pack_body(p, idx, owned=False)`
(`utas_server.py:3268-3405`). Emitted keys (OBSERVED, `:3293-3328`):
`assetId`(=`p["id"]`), `id`(=`p["id"]`), `packType`, `description`(=`p["name"]`),
`state`, `saleType`, `limitType`, `quantity`, `purchaseLimit`, `purchaseCount`,
`isPremium`, `sortPriority`, `currencies`, `extPrice`, `packContentInfo`
(`bronze/silver/gold/rare/itemQuantity`), `unopened`, and one of:
- owned pack → `displayGroup = {"value":"mypacks","priority":idx}` (`:3336`);
- else if `STORE_DISPLAYGROUP` → `displayGroup = {"value": category}` where
`category ∈ {special, gold, silver, bronze}` chosen from
`p["specialChance"]`/`p["gold"]` (`:3337`,`:3373-3379`), and if `STORE_GROUPID`
also `displayGroupAssetId = p["id"]` (`:3403-3404`).
4. **Data source(s).**
- `PACK_CATALOG` — a hardcoded list of 3 pack dicts
(`{id,name,price,count,gold,tiers,specialChance}`) at `fut_store.py:820-841`.
There is NO card table read in this path.
- `STORE = Store()` (`fut_store.py:843`), backed by the profile JSON
(`unopened_packs()` reads `unopenedPackIds`, `fut_store.py:619-621`).
5. **Serialization → HTTP.** The dict is JSON-encoded by the server's response
writer and returned as the HTTP body.
### Fields 5 (`cardsubtypeid`/`carddbid`/`cardassetid`) — OBSERVED
**None of `cardsubtypeid`, `carddbid`, or `cardassetid` appear anywhere in the
store-tile (`purchasegroup`) response.** `_pack_body` (`:3293-3405`) emits only the
pack keys listed above; `assetId`/`id` are the PACK id `p["id"]` (e.g. 1, 5), not a
card asset id. The store catalog emits PACKS, never card definitions. (Grep of
`_pack_body` and `store_catalog` for those three field names returns zero hits.)
### Families the store handler can emit
Only **packs** (`PACK_CATALOG`: "Bronze Pack" id 1, "Gold Pack" id 5, and the third
catalog entry) plus owned reward packs and the `mypacks` sentinel. It cannot emit
any card family (players, staff, consumables, club items).
### Filtering / transformation
- `normal = [p for p in PACK_CATALOG if not p.get("ownedOnly")]` (`:3421`).
- `_pack_body` maps a pack to a category token via `specialChance>=1.0 -> special`,
else `gold -> gold`, `p.get("silver") -> silver`, else `bronze` (`:3373-3379`).
- The tile caption/category is `displayGroup.value`; `description` carries the
per-pack title.
---
## 3. Wire subtype coverage (2B)
### 3.1 Store path
The store-tile path emits **no `cardsubtypeid`** at all (see §2). So for the store
tile, "is `cardsubtypeid` the raw FCC subtype, the wire category, transformed, or
something else?" → **not present** (N/A). The store tile is selected by
`displayGroup.value` (a category-token STRING), not by any card subtype.
Per `_pack_body:3367-3372` (citing `FUN_180014580`/`FUN_180014df0`): FIFA 17's
StoreFront resolves exactly **six hard-coded category tokens** —
`mypacks, points, bronze, silver, gold, special`. Any other `displayGroup.value`
(e.g. a raw pack title) creates an "unsupported pseudo-category". The documented
cause of "unknown" store tiles is therefore a **`displayGroup` category-token**
issue on packs (absent group, or a non-canonical token), NOT a card type/subtype.
### 3.2 Club-item path (the only place wire subtypes live) — `fut_clubitems.py`
Club items are served on the **`/club?type=` route** (`utas_server.py:2250`,
`handle_club`), gated by `FUT_CLUBITEMS`, NOT by the store route. Current `FAMILIES`
(`fut_clubitems.py:61-67`), format `(family, table, art id, stat id, stat name,
UNVERIFIED cardsubtypeid)`:
| wire subtype | mapped family | table | art id | source | confidence |
|---|---|---|---|---|---|
| 9 | kits | fcc_kitcards.json | 35 | `fut_clubitems.py:65` | HYPOTHESIS (marked "UNVERIFIED", `:49`,`:30-32`) |
| 10 | stadia | fcc_stadium.json | 36 | `fut_clubitems.py:63` | HYPOTHESIS ("UNVERIFIED") |
| 11 | badges | fcc_badgecards.json| 39 | `fut_clubitems.py:64` | HYPOTHESIS ("UNVERIFIED") |
| 30 | balls | fcc_balls.json | 37 | `fut_clubitems.py:62` | HYPOTHESIS ("UNVERIFIED") |
| 31 | leaguelogos | fcc_leaguelogos.json| 40| `fut_clubitems.py:66` | HYPOTHESIS ("UNVERIFIED") |
- The prior recorded mapping (9=kits,10=stadia,11=badges,30=balls,31=logos) is
**confirmed present in current code** — but the code itself marks every one
"UNVERIFIED cardsubtypeid" and states the binary assigns family↔subtype **nowhere**
in the 149 dumped tables; cardtype-9 admits the set `{30,31,145,146,147,148,149,150}`
(`fut_clubitems.py:26-28`, `:73`). So these are server-chosen HYPOTHESIS values.
- In the club-item wire item (`_item:88-119`), `cardsubtypeid` (`:97`) is a
**server-assigned wire-category constant** (9/10/11/30/31), NOT the raw FCC
subtype: the club-item fcc tables carry **no `cardsubtype` column at all** (Task 1:
badges/stadium/kit/logos/balls have empty subtype sets). `resourceId`/`assetId`
= `carddbid`, and `cardassetid` = the family ART id (`:94-96`). So for club items:
**`cardsubtypeid` = wire category (server constant), `carddbid`/`cardassetid` =
real fcc columns.**
- By contrast, consumables (`fut_store._item` / `fut_consumables`) DO carry the raw
FCC subtype (51..341) as `cardsubtypeid`.
### 3.3 Task-1 families vs wire-subtype coverage
Families that the **store handler** can map to a wire subtype: **none** — the store
handler emits packs, which have no card subtype (by design).
Families for which a **club-item** wire subtype exists (HYPOTHESIS-grade):
kits(9), stadia(10), badges(11), balls(30), leaguelogos(31).
Task-1 card families with **no wire subtype mapping anywhere in the server**:
- **5001xxx contracts, 5002xxx fitness/healing, 5003xxx training** — served as
consumables carrying their raw FCC subtype; these are consumable overlays, not
store tiles, and not part of any store/club-item wire-category map.
- **5004xxx misc {231,232,233,236}** — **no wire subtype mapping found** in
`fut_clubitems.py` or the store path. (Grep: no `misc` family, no {231,232,233,236}
wire assignment.) They exist only as raw FCC subtypes in consumable data.
- **8010xxx league logos/stickers** — mapped in the **club-item** path as wire
subtype **31** (`fut_clubitems.py:66`), HYPOTHESIS-grade. `fcc_leaguelogostickers`
(39 rows) is NOT wired in `FAMILIES` (only `fcc_leaguelogos`, 44 rows).
- **6000xxx badges, 6200xxx stadiums, 6300/6400xxx kits, 8120xxx balls** — mapped in
the club-item path (11/10/9/30), HYPOTHESIS-grade.
- **staff (managers/coaches, subtypes 4-8)** — served by `fut_staff` on `/club?type=
manager`, not a store tile.
Note none of these belong to the **store-tile** (`purchasegroup`) response.
---
## 2C flag investigation — BLOCKED
### Store-tiles flags located (OBSERVED)
Two, both in `utas_server.py`, both module-level constants read **once at import**:
| flag | env var | line (read) | consumed | default | current running value |
|---|---|---|---|---|---|
| `STORE_DISPLAYGROUP` | `FUT_STORE_DISPLAYGROUP` | `:975` | `_pack_body:3337` | `"1"` → **ON** | **ON** (no env override) |
| `STORE_GROUPID` | `FUT_STORE_GROUPID` | `:980` | `_pack_body:3403` | `"0"` → **OFF** | **OFF** (no env override) |
- `:975` `STORE_DISPLAYGROUP = os.environ.get("FUT_STORE_DISPLAYGROUP", "1") == "1"`
- `:980` `STORE_GROUPID = os.environ.get("FUT_STORE_GROUPID", "0") == "1"`
**Current values (recorded before any change; nothing was changed):**
- `FUT_STORE_DISPLAYGROUP`: unset in container env → default `"1"` →
`STORE_DISPLAYGROUP = True` (**ON**). (INFERRED from OBSERVED env dump + OBSERVED
code default.)
- `FUT_STORE_GROUPID`: unset in container env → default `"0"` →
`STORE_GROUPID = False` (**OFF**). This is the flag "expected to be OFF".
The related club-item flag `FUT_CLUBITEMS` (`:1647-1648`) is likewise unset →
`CLUBITEMS = False` (club items not currently served), also a module-level import
constant.
### Dynamic evaluation? NO (OBSERVED)
- All three flags are top-level `os.environ.get(...)` assignments evaluated at
module import (`:975`, `:980`, `:1647`); `_pack_body` reads the resulting module
**constants** (`:3337`, `:3403`), never `os.environ` at request time.
- Repo-wide there is **no** `importlib.reload`, no `signal`/`SIGHUP` handler, and no
per-request environ re-read for these flags. (The only runtime-refreshable feature
is `FUT_ID_SWEEP`, which re-reads a *file* `SWEEP_FILE`, `:1965-1966` — unrelated.)
- `:3250` documents the intended workflow explicitly:
`# Enable for the test with: FUT_PRICE_PROBE=1 ./openfut-fut.sh restart`.
### Restart conflict → STOP
Changing either store flag requires either setting a container env var (→
`docker` recreate = restart) or editing the module (→ re-import = restart). Both
violate the no-restart / no-redeploy / no-recreate constraint. Therefore:
```
TASK 2 BLOCKED AT 2C:
Flag requires restart, conflicting with no-restart constraint.
```
No flag was enabled. 2D/2E (manual client capture + definition analysis) NOT
started. No client request generated, replayed, or simulated.
---
## 4. Captured request and response (Phase 3) — OBSERVED
Real client action (operator opened the FUT Store on `.105`, 2026-08-12). Only one
`/store/purchasegroup` request occurred after the capture marker
`2026-08-12T23:54:01Z`, so attribution is unambiguous (only the operator drives the
client). Log via `docker logs --since 2026-08-12T23:54:01Z --timestamps openfut-fut-backend`.
- **Request:** `23:54:43 GET /ut/game/fifa17/store/purchasegroup/all?ppInfo=true`
(Host `10.10.0.120:8099`, `User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)`,
`X-UT-SID` present). Preceded at 23:54:43 by `GET /ut/game/fifa17/user/credits`
→ `200 {"credits": 29876776, ...}`.
- **Response:** `200`. The server log truncates the body to 200 bytes
(`utas_server.py:3754`, `raw[:200]`), genuine prefix:
`{"purchase": [{"assetId": 1, "id": 1, "packType": "BRONZE", "description": "Bronze Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount`
- **Full body recovered** by running the exact running-container code
(`docker exec openfut-fut-backend python3 -c "import utas_server as u; u.store_catalog(None)"`)
over the live `/state` profile, under the live flags (confirmed in-process:
`STORE_DISPLAYGROUP=True`, `STORE_GROUPID=False`). Its `json.dumps(...)[:200]`
equals the genuine logged 200-byte prefix **byte-for-byte** (verified MATCH), so the
reconstruction IS the sent body (deterministic pure function + verified prefix). No
request was synthesized, replayed, or curl'd. Full body (2841 bytes) preserved
verbatim at `docs/evidence/store_purchasegroup_capture_2026-08-12.json`.
- **Operator-reported client behavior:** on opening the store, an error dialog
appeared — *"The pack you've selected is currently not available. Please select a
different pack or try again later."* — and clicking OK returned to the FUT hub. No
tiles were browsable; **no "unknown" tiles were reported**. There was **no**
`PUT /store/transaction` (no buy) and **no server error** (server answered `200`).
## 5. Definition analysis (Phase 4/5) — OBSERVED
`purchase[]` = 5 entries (`timestamp` 1596326400). No `cardsubtypeid`/`carddbid`/
`cardassetid` in any entry (packs, not cards).
| idx | id | assetId | packType | description | displayGroup.value | priority | state | dgAssetId | classification |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 1 | 1 | BRONZE | Bronze Pack | `bronze` | — | active | absent | KNOWN TOKEN |
| 1 | 5 | 5 | GOLD | Gold Pack | `gold` | — | active | absent | KNOWN TOKEN |
| 2 | 6 | 6 | GOLD | Premium Gold | `gold` | — | active | absent | KNOWN TOKEN |
| 3 | 7 | 7 | GOLD | Special Players Pack | `special` | — | active | absent | KNOWN TOKEN |
| 4 | 65534 | 65534 | GOLD | "" (empty) | `mypacks` | 1 | **inactive** | absent | KNOWN TOKEN |
Per-pack fields (idx 0-3 identical shape): `saleType:"promo"`, `limitType:"NONE"`,
`quantity:0`, `purchaseLimit:0`, `purchaseCount:0`, `isPremium:false`,
`currencies:[{"name":"coins","funds":<price>,"finalFunds":<price>}]`,
`extPrice:{finalPrice/originalPrice:{amount:<price/100>,currency:"mtx"}}`,
`packContentInfo:{...tier quantities...}`, `unopened:false`. Prices: Bronze 400,
Gold 5000, Premium Gold 15000, Special Players 25000. The sentinel (idx 4) drops
`currencies`/`extPrice`, has empty description, `state:"inactive"`.
**Unique `displayGroup.value` set emitted: `{bronze, gold, special, mypacks}`.**
Compared to the six client renderer tokens `{mypacks, points, bronze, silver, gold,
special}` (`FUN_180014580`/`FUN_180014df0`, `plan-2026-08-05-store-subsystem.md:202-206`):
**every emitted token is a KNOWN token; the set outside the renderer categories is
EMPTY.**
## 6. Unmapped definitions / suspect tokens found — OBSERVED
None. Zero SUSPECT/UNKNOWN `displayGroup.value` tokens; zero card definitions;
zero `cardsubtypeid`/`carddbid`/`cardassetid`. The only anomalous element is the
**inactive, empty-description `mypacks` sentinel** (idx 4), emitted by
`store_catalog:3428-3446` **only when the profile owns zero unopened packs**
(OBSERVED: profile `unopenedPackIds == []`).
## 7. Client-side evidence (Phase 7) — existing RE only
No new Ghidra run. Existing decompiler evidence already bounds the answer and shows
new static analysis would be unproductive:
- **The parser is not the gate** (`OPENCODE_ENDPOINT_PROMPT.md:118-120`): per-pack
epilogue `0x18013badc` pushes every parsed pack unconditionally — no drop predicate
in the parse path. So a `200` with clean JSON cannot be rejected by the deserializer.
- **Store-level "not available"** (`FUT_CatalogNotAvailable`, msg `0x7550`) comes from
downstream client gates (`OPENCODE_ENDPOINT_PROMPT.md:120-131`): (1) resolution
`GetSystemMetrics` ≤1024×768, (2) store-data-model load status `0x180013cf0`,
(3) Blaze purchase-config flags `IS_STORE_ENABLED/IS_COIN_PURCHASABLE/...` (these
are already served, `blaze_responder_v3b.py:708-719`).
- **Per-pack tile-availability predicate is in the PACKED FIFA17.exe and UNREAD**
(`plan-2026-08-05-pack-opening.md:553`): fields `state/saleType/quantity/
purchaseLimit/purchaseCount/start/end` are parsed and copied to the tile, but the
predicate that greys/blocks a tile "is in the packed exe and unread." Whether
`purchaseLimit`+`purchaseCount` greys a tile is explicitly an OPEN question
(`:644-645`). Static Ghidra on the packed exe cannot read it (decrypts only in live
memory); resolving it requires a live-memory experiment on the running FIFA process,
which is out of scope (must not touch FIFA).
- The exact operator string *"The pack you've selected is currently not available"*
is **not present** anywhere in the recon corpus (docs/tools); the documented
store/pack error strings are `FUT_CatalogNotAvailable` and
`CARDS_CB_ERR_PACK_NOT_IN_DIME` (a server-returnable code). UNKNOWN loc key.
## 8. Assessment (Phase 8) — runtime verdict
- **H1 (subtype/definition): CONTRADICTED.** The captured response contains no card
definitions and no `cardsubtypeid`/`carddbid`/`cardassetid` (OBSERVED §4-§5).
- **H2 (unknown `displayGroup.value` token): CONTRADICTED.** Every emitted token is a
KNOWN renderer category (`bronze/gold/special/mypacks`); the unsupported-token set
is EMPTY (OBSERVED §5). The store-tile "unknown" mechanism is NOT reproduced under
the current config (`STORE_DISPLAYGROUP=ON`).
Answering the Phase-8 questions:
1. **Unsupported `displayGroup.value` in the response?** No — all four tokens
(`bronze/gold/special/mypacks`) are recognized. (OBSERVED)
2. **Which pack got it?** None. (OBSERVED)
3. **Correspond to an unknown tile in FIFA?** No unknown tile was reported; the
observed symptom was a *"pack not available"* dialog, not an unknown tile. (OBSERVED)
4. **Where does the backend assign the token?** `_pack_body:3373-3379` maps each pack
to `special/gold/silver/bronze` from `specialChance`/`gold`; owned/sentinel →
`mypacks` (`:3336`). All canonical. (OBSERVED)
5. **Is `displayGroup.value` sufficient to explain the bug?** No. The response is
clean; the failure is a downstream client/packed-exe gate, not a token. (INFERRED)
6. **Is the server emitting a value FIFA demonstrably cannot understand?** No.
(OBSERVED)
7. **Narrowest likely fix (REPORT ONLY — not implemented):** The single anomalous,
server-controllable element is the **inactive empty `mypacks` sentinel** emitted
when `unopenedPackIds == []` (`store_catalog:3428-3446`). Leading HYPOTHESIS: with
no owned packs, the store's My-Packs group contains only this inactive pack, and
the client's (packed-exe) selection/availability path lands on it →
*"the pack you've selected is currently not available"* → back to hub. Narrowest
candidate fixes to TEST (each needs a controlled change, hence a future
restart-gated experiment — do NOT implement now):
(a) suppress the sentinel when there are no owned packs and instead let the store
land on a real active category (bronze/gold/special); or
(b) if My-Packs must resolve, make the sentinel non-selectable rather than an
`inactive` pack in the group.
Cheaper-to-eliminate CLIENT-side cause to check first (existing RE, no server
change): FIFA display resolution must be **>1024×768** on `.105`
(`OPENCODE_ENDPOINT_PROMPT.md:122-124`).
**Overall runtime verdict: CONTRADICTED** — neither H1 nor H2 reproduces; the
"unknown store tile" hypothesis is not the live failure. The live failure is a
distinct *pack-availability* error whose trigger is in the packed FIFA17.exe and
cannot be pinned from the (clean) server response alone.
## 9. Open questions
1. What exactly raises *"The pack you've selected is currently not available"*? The
loc key is unknown and the predicate is in the packed exe (unread). Resolving it
needs a controlled field/flag experiment or live-memory RE (both currently gated).
2. Does the store, with `unopenedPackIds == []`, land on / auto-select the inactive
`mypacks` sentinel? (HYPOTHESIS §8.7; unproven without client-side observation.)
3. Did the store render tiles successfully in earlier sessions when the profile
owned unopened packs (e.g. the 21:54-21:57 pack-opening burst)? If so, the
presence/absence of owned packs (sentinel) is implicated. (UNKNOWN — earlier logs
truncate the body; not proven.)
4. Does `purchaseLimit:0`/`purchaseCount:0` grey a tile? Existing RE lists this as an
OPEN question; would need a controlled experiment. (UNKNOWN)
5. Is bug 6c ("unknown tile") a stale symptom from before `STORE_DISPLAYGROUP` became
the default `ON`? Under the current config no unknown tile reproduces.
---
## H3 — EMPTY MY-PACKS SENTINEL (HYPOTHESIS)
When the profile owns zero unopened packs (`unopenedPackIds == []`), OpenFUT emits
synthetic **inactive** pack id **65534** in the `mypacks` display group
(`store_catalog:3428-3446`). The FIFA 17 store may treat this object as a selectable
pack whose availability predicate fails, producing *"The pack you've selected is
currently not available"* and returning the user to the FUT Hub before any
`/store/transaction`. **Status: HYPOTHESIS (untested).**
## 10. Resolution check (Phase 1) — OBSERVED
Read-only inspection of `.105` (FIFA pid 529227, not touched):
- Desktop/monitor: **2560x1440** — DRM connectors `card1-DP-2` and `card1-HDMI-A-1`
both `connected`, native mode 2560x1440; compositor KDE `kwin_wayland` (no gamescope).
- FIFA render config: `…/Games/umu/fifa17/pfx/drive_c/users/steamuser/Documents/FIFA 17/settings/overrideAutodetect.lua`
→ `ResolutionWidth = 1280`, `ResolutionHeight = 720`, `FullscreenEnabled = 0` (windowed).
- Session: Wayland (`WAYLAND_DISPLAY=wayland-0`), FIFA via XWayland (`DISPLAY=:0`,
`XAUTHORITY=/run/pressure-vessel/Xauthority` inside the game namespace).
**Is FIFA rendering above 1024x768? YES (1280x720).**
**Resolution hypothesis eliminated for this reproduction.**
## 11. Sentinel 65534 provenance (Phase 3)
1. **Basis:** **OpenFUT INVENTION** (OBSERVED). `65534` (0xFFFE) appears nowhere in
any EA capture or recon note — repo-wide it exists only in `utas_server.py`
(`store_catalog:3435-3446`) and this evidence set. The author's comment
(`:3428-3434`) states it is a workaround: "Retain an inactive zero-item sentinel so
the [`mypacks`] destination resolves… Its id is deliberately absent from
PACK_CATALOG." It is a compatibility guess, not captured behavior.
2. **Known-good EA capture of EMPTY My Packs:** **UNKNOWN** — none found. All recon
My-Packs analysis is client-side RE (`FUN_1800150d0` filters
`displayGroup.value=="mypacks"`, `plan-2026-08-05-pack-opening.md:34-36,893-895`);
no EA server response for an empty My Packs state is on record.
3. **Known-good response with ≥1 unopened pack:** **UNKNOWN** for EA. OpenFUT's own
seed grants reward pack id 70 (`_new_profile` `unopenedPackIds:[70]`,
`fut_store.py:360`), but that is an OpenFUT synthetic grant, not an EA capture.
4. **Evidence FIFA EXPECTS a sentinel/placeholder:** **NO / UNKNOWN.** Recon shows My
Packs is a client-side filter over the ordinary catalogue; the "empty-category
dialog over the wrong tab" concern behind the sentinel is the author's HYPOTHESIS,
not decompiler-confirmed. No evidence FIFA requires a placeholder object.
5. **Evidence for the `inactive` representation:** **UNKNOWN / HYPOTHESIS.** The claim
"state != active keeps it out of the visible row list" (`:3432`) is an unverified
author assumption; no RE shows `state:"inactive"` hides a pack from selection. If
FIFA does NOT hide it, the sole `mypacks` entry is a selectable inactive pack —
exactly the H3 failure mode.
## 12. Empty vs non-empty My Packs behavior (Phase 2) — OBSERVED (code) / captured
`store_catalog(h)` (`utas_server.py:3421-3447`):
- Always emits the 4 non-`ownedOnly` catalogue packs (ids 1,5,6,7) via `_pack_body`.
- For each id in `visible_unopened_packs()` (= `STORE.unopened_packs()` +
`_OPENED_PACK_GRACE`) that resolves in `PACK_CATALOG`, appends `_pack_body(owned,
idx, owned=True)`.
- **Only when `unopened_packs()` is empty (`if not owned_ids`)** appends the inactive
sentinel (`:3428-3446`).
Sentinel 65534 vs a normal active pack (Bronze, idx 0), field-by-field (from the
captured body):
| field | sentinel 65534 | Bronze Pack (active) |
|---|---|---|
| id | 65534 | 1 |
| assetId | 65534 | 1 |
| packType | GOLD | BRONZE |
| description | "" (empty) | "Bronze Pack" |
| state | **inactive** | active |
| saleType | promo | promo |
| limitType | NONE | NONE |
| quantity | 0 | 0 |
| purchaseLimit | 0 | 0 |
| purchaseCount | 0 | 0 |
| isPremium | false | false |
| sortPriority | 1 | 1 |
| currencies | **ABSENT** (popped) | `[{coins,400,400}]` |
| extPrice | **ABSENT** (popped) | `{mtx 4/4}` |
| packContentInfo | all-zero quantities | bronze 5 / item 5 |
| unopened | false | false |
| displayGroup.value | **mypacks** | bronze |
| displayGroup.priority | 1 | ABSENT |
| displayGroupAssetId | ABSENT | ABSENT |
**What changes when `unopenedPackIds` is non-empty (e.g. `[70]`):** the sentinel is
NOT emitted; instead pack 70 (Reward Special Players Pack, `ownedOnly`) appears via
the owned branch of `_pack_body` (`:3335-3336`): `displayGroup={"value":"mypacks",
"priority":idx}`, `state:"active"` (default), `unopened:true`, `currencies`/`extPrice`
popped. i.e. the `mypacks` group would hold a genuine **active** owned pack instead of
the inactive sentinel.
## 13. Proposed controlled experiments (Phase 5) — DESIGN ONLY, NOT EXECUTED
### Existing grant mechanism (Phase 4)
- **Supported profile-only method: YES** — `Store.grant_unopened_pack(pack_id)`
(`fut_store.py:635-643`): validates the id is in `PACK_CATALOG`, appends to
`unopenedPackIds`, persists; reverts via `consume_unopened_pack` (`:623-633`).
Modifies **only** profile state; cleanly reversible.
- **BUT restart IS required to take effect.** `Store.load()` caches `self._p`
(`:370-372`); **no route calls `grant_unopened_pack` or `select_account`**, and
`select_account` only re-reads on a persona *change*. So an out-of-process grant or
a raw profile-file edit writes disk but the **running server keeps serving its
cached `unopenedPackIds`** until the process reloads. There is no SIGHUP/reload
endpoint. Therefore any profile change needs a container restart to be observed.
### Experiment A — Non-empty My Packs (PREFERRED; least invasive)
- **State change:** set the profile's `unopenedPackIds` to `[70]` (edit
`…/state/accounts/33068179/fifa17_profile.json`, or call
`STORE.grant_unopened_pack(70)`), **store code/config unchanged**.
- **Restart required?** **YES** — profile cache (above). Profile-only; no code edit.
- **Rollback:** set `unopenedPackIds` back to `[]` (or `consume_unopened_pack(70)`),
restart. (Also restore `nextItemId`/coins only if a pack is actually opened — the
grant alone touches only `unopenedPackIds`.)
- **Expected `purchasegroup` difference:** sentinel 65534 GONE; instead one active
pack id 70 in the `mypacks` group (`state:active`, `unopened:true`); hub/credits
report `recoveredPacks:1`.
- **Expected client observation:** if H3 is correct, the immediate *"pack not
available"* dialog should NOT fire (or behavior changes) and My Packs should show a
real pack. If the dialog still fires identically, H3 is weakened and the cause is
elsewhere (packed-exe predicate / another field).
### Experiment B — Suppress the empty sentinel (only if A is inconclusive)
- **Change:** in `store_catalog` (`:3428-3446`), when `unopened_packs()` is empty, do
NOT append pack 65534 (emit no `mypacks` entry). **Code change ⇒ restart. NOT
authorized.** Narrowest patch: guard/remove the `if not owned_ids:` sentinel block.
- **Purpose:** distinguishes "the inactive sentinel is selected and fails" (A already
tests the inverse) from "an absent `mypacks` group causes a different failure"
(the original author's stated fear at `:3429-3431`).
### Experiment C — Alternate sentinel representation (design only)
- If evidence later shows FIFA expects an empty `mypacks` group represented
differently (e.g. present-but-not-a-pack, or `state` other than `inactive`), adjust
the sentinel shape. **DESIGN ONLY; DO NOT IMPLEMENT.** No current evidence specifies
the correct empty-group representation (see §11.4-11.5).
Note: both A and B require a restart (A for the profile cache, B for the code). A is
strictly less invasive (profile-only, clean rollback, no code change) and is the
preferred next experiment. Neither is authorized yet.
---
## 14. Experiment A — Non-empty My Packs (EXECUTED 2026-08-13) — OBSERVED
Authorized controlled test of H3: change ONLY the profile's `unopenedPackIds`
(`[] → [70]`), restart the FUT backend once, capture a genuine FIFA store request,
then roll back. No store code/config/flags/PACK_CATALOG/pack-70/sentinel-code
changed; FIFA not modified/restarted; operator drove the client.
### 14.1 Baseline profile state
- Path: `fifa17-recon/docker/state/accounts/33068179/fifa17_profile.json` (persona
33068179/CAGE; proven live: `STORE.path` in-process = `/state/accounts/33068179/
fifa17_profile.json`, coins 29876776 matching the live `/user/credits`).
- `unopenedPackIds == []`. Original SHA-256
`39bb3e833fa55287d8516815ba3a717b41c0f0c7a7c41d20503f3a55c65cc6e7`. Backup:
`/tmp/fifa17_profile.33068179.ORIG.20260813T001228Z.json` (same hash).
### 14.2 State change
- Narrow anchored edit of line 97070 only: ` "unopenedPackIds": [],` →
` "unopenedPackIds": [70],`. Diff vs backup = exactly one line; semantic diff =
only key `unopenedPackIds` (`[] → [70]`), all other 24 keys identical. Modified
SHA-256 `2b5760baa265f320904de2d23fd6ab374733efe74cb0756c3be39c083bce4ac8`.
(Used the direct edit rather than `grant_unopened_pack(70)` to avoid whole-file
reserialization; net semantic effect is identical.)
### 14.3 Restart
- `docker restart openfut-fut-backend` (Pid 1398009→1548312, StartedAt
2026-08-12T16:40:52Z → 2026-08-13T00:13:54Z). Bridge/Core and Rust hosts untouched.
This container bundles blaze/roster/utas/pow (per `entrypoint.sh`); all rebound.
- Post-restart in-process check: `STORE.load()['unopenedPackIds'] == [70]`;
`store_catalog(None)` → pack 70 present, sentinel 65534 absent.
### 14.4 Genuine FIFA capture
- Marker `2026-08-13T00:14:24Z`. Single request after it (unambiguous):
`00:14:58 GET /ut/game/fifa17/store/purchasegroup/all?ppInfo=true → 200`.
**No `/store/transaction`, no `/purchased/items`** in the window (pack 70 not opened).
- Full body recovered from the running code over the live [70] profile; its
`[:200]` matches the genuine logged 200-byte prefix byte-for-byte (verified MATCH).
Preserved at `docs/evidence/store_purchasegroup_capture_mypacks70_2026-08-12.json`.
### 14.5 Client-observed behavior (operator report)
1. Store remains open: **YES**.
2. "The pack you've selected is currently not available": **NO (gone)**.
3. My Packs category / unopened reward pack visible: **YES**.
4. Returned to FUT Hub: yes (normal navigation; not forced by an error dialog).
### 14.6 Response diff (baseline 2026-08-12 vs experiment)
Only difference across all 5 entries:
- **Removed:** id `65534` (`state:inactive`, `displayGroup:mypacks`, `description:""`).
- **Added:** id `70` (`state:active`, `displayGroup:mypacks`,
`description:"Reward Special Players Pack"`, `unopened:true`).
- Packs 1/5/6/7 byte-identical. Classification: **all EXPECTED FROM UNOPENED PACK
STATE; nothing UNEXPECTED.**
### 14.7 H3 assessment — **SUPPORTED**
65534 disappeared AND pack 70 replaced it as a real My Packs entry AND the
"pack not available" / store-exit behavior disappeared → per the pre-registered
criterion, **H3 is strongly SUPPORTED**. The failure is tied to the My Packs group
content when the profile owns zero unopened packs.
Sub-hypothesis resolution:
- **H3c (failure unrelated to My Packs): RULED OUT.** A My-Packs-only profile change
(no store code/config change) eliminated the failure.
- **H3a (the inactive sentinel object itself is the trigger) vs H3b (empty My Packs
state generally is the trigger): NOT DISTINGUISHED by Experiment A.** The change
simultaneously (i) removed the inactive sentinel and (ii) supplied a real active
owned pack. Either "presence of the inactive/empty sentinel" or "absence of any
real owned pack" could be the cause. Distinguishing them requires Experiment B
(empty `unopenedPackIds` AND suppress the sentinel so `mypacks` has no entry): if
that also fixes it → H3a (sentinel object was the problem); if it re-breaks or
changes → H3b (empty My Packs itself is the problem). Experiment B is a code change
(restart-gated) and remains unauthorized.
### 14.8 Rollback verification
- Profile restored from backup → SHA-256 `39bb3e83…` == original (byte-identical);
`unopenedPackIds == []`. Disk was unmutated during the test (still `2b5760ba…`
before rollback → store reads don't persist; pack 70 never opened).
- `docker restart openfut-fut-backend` (Pid 1549503, StartedAt 2026-08-13T00:16:56Z).
Post-restart in-process: `unopenedPackIds == []`, sentinel 65534 present again,
pack 70 absent → runtime baseline restored. Bridge/Core untouched.
**H3 status: SUPPORTED (H3c ruled out; H3a vs H3b open).** No permanent fix
implemented.
---
## 15. Experiment B — Empty My Packs Without Sentinel (EXECUTED 2026-08-13) — OBSERVED
### 15.1 Purpose
Distinguish **H3a** (the synthetic inactive sentinel 65534 itself is the trigger)
from **H3b** (FIFA cannot tolerate an empty My Packs state even without a sentinel).
Hold `unopenedPackIds == []` constant; change ONLY: sentinel 65534 emitted →
suppressed. Authorized TEMPORARY code change, reverted after test.
### 15.2 Baseline
Profile `unopenedPackIds == []` (SHA-256 `39bb3e83…`, unchanged throughout). Running
code before patch = `c89d43ea…` (host repo == container copy). Sentinel 65534 emitted.
### 15.3 Temporary patch — **TEMPORARY EXPERIMENT B PATCH, NOT A PERMANENT FIX**
Applied to the **container** copy `/app/tools/utas_server.py` only (the container
mounts `/state`, not `/app`; host repo `fifa17-recon/tools/utas_server.py` was NOT
edited — its git diff stayed empty). Single line, `store_catalog` (line 3428):
```
- if not owned_ids:
+ if False: # TEMP EXPERIMENT B PATCH -- suppress synthetic sentinel 65534 (NOT A PERMANENT FIX)
```
Semantic effect: when `unopened_packs()` is empty, append nothing (no sentinel, no
replacement object). Normal packs 1/5/6/7 (appended earlier) unchanged. Diff vs the
backed-up original = exactly this one line. Patched code SHA-256 `5bb8fca9…`.
### 15.4 Restart verification
`docker restart openfut-fut-backend` (Pid 1549503→1551026, StartedAt
2026-08-13T00:20:45Z). The writable-layer edit survived the restart; running
`/app/tools/utas_server.py` = `5bb8fca9…` (patched). In-process: `unopenedPackIds ==
[]`; `store_catalog` → 4 packs {1,5,6,7}, **no 65534, no 70, no `mypacks` entry**.
Flags unchanged (`DISPLAYGROUP=ON`, `GROUPID=OFF`). Bridge/Core/Rust untouched.
### 15.5 Genuine FIFA capture
Marker `2026-08-13T00:20:55Z`. Request sequence (operator opened the store):
`00:21:05 GET /hub` → `00:21:07 GET /user/credits` → `00:21:07 GET
/store/purchasegroup/all?ppInfo=true → 200`. **No `/store/transaction`; no further
requests** (client crashed after receiving the store body). Full body recovered from
the running patched code; `[:200]` matches the genuine logged prefix byte-for-byte
(verified MATCH). Preserved at
`docs/evidence/store_purchasegroup_capture_empty_no_sentinel_2026-08-12.json`
(4 packs {1,5,6,7}, no `mypacks` group).
### 15.6 Client behavior (operator report)
**The game CRASHED** on opening the store. Not the baseline dialog; a hard crash. No
`/store/transaction` was issued.
### 15.7 Three-way response comparison
| capture | ids present | `mypacks` group entry | client outcome |
|---|---|---|---|
| Baseline (`…_2026-08-12.json`) | 1,5,6,7,**65534** | 65534 `inactive`, desc "" | "pack not available" dialog → Hub |
| Exp A (`…_mypacks70_…json`) | 1,5,6,7,**70** | 70 `active`, "Reward Special Players Pack" | **works** — store open, My Packs visible |
| Exp B (`…_empty_no_sentinel_…json`) | 1,5,6,7 | **none** | **CRASH** |
Normal packs {1,5,6,7} identical across all three.
### 15.8 H3a / H3b verdict
- **H3a (sentinel object itself is the trigger): CONTRADICTED.** Removing the
sentinel did NOT restore the store; it produced a *worse* outcome (crash). If the
sentinel object were the sole cause, its removal would yield a working store (it
did not).
- **H3b (FIFA cannot tolerate an empty My Packs state): SUPPORTED.** Only Exp A — a
real **active** owned pack in `mypacks` — worked. Both the inactive sentinel
(graceful "pack not available" dialog) and the total absence of any `mypacks` entry
(crash) fail. The sentinel is a **load-bearing workaround** that *downgrades* the
failure from a crash to a dialog but does not fix it.
- **Pre-registered-rule nuance:** Exp B produced a *distinct* failure (crash), which
the pre-registered rules classify as **B3 (different failure)** rather than the
exact B2 dialog. Documented as such: the crash is a THIRD failure mode. It still
resolves the question — it rules out H3a and supports H3b — but the specific
outcome (crash, not the same dialog) is stronger than B2 anticipated. Not forced
into a clean binary beyond what the evidence shows.
**Does FIFA tolerate an empty My Packs without the sentinel? NO — it crashes.**
### 15.9 Rollback verification
- Container `/app/tools/utas_server.py` restored from backup → SHA-256 `c89d43ea…`
== pre-experiment (byte-identical); the `if False:` patch fully removed.
- `docker restart openfut-fut-backend` (final Pid 1551901,
StartedAt 2026-08-13T00:22:04Z). In-process: `unopenedPackIds == []`, sentinel
65534 emitted again, pack 70 absent; `DISPLAYGROUP=ON`, `GROUPID=OFF`.
- Host repo `fifa17-recon/tools/utas_server.py` never edited (git diff empty, SHA-256
`c89d43ea…`). Profile unchanged (`39bb3e83…`). Bridge/Core/Rust untouched.
### 15.10 Likely permanent fix (REPORT ONLY — not implemented)
Evidence: the store's `mypacks` group must contain a **valid, active, openable owned
pack**; both an inactive sentinel and an absent group fail (dialog / crash). The only
working configuration observed is a genuine active owned pack (Exp A). Candidate
directions (report only, each needs design + authorization):
1. Ensure the profile always owns ≥1 legitimate active unopened pack while the store
is shown (e.g. keep a real reward pack such as id 70 granted), so `mypacks` is
never empty — this matches the only known-working state but changes economy state
and needs a lifecycle policy (what happens after the user opens it).
2. Change what `store_catalog` advertises so FIFA never lands on / requires a
`mypacks` group when there are zero owned packs (client-compatible empty-store
representation) — the correct representation is UNKNOWN; neither current option
(inactive sentinel / no group) is it, so this needs new client-side RE before
implementation.
Recommendation: do NOT simply delete the sentinel (Exp B proves that crashes). No fix
implemented.
**H3 status: SUPPORTED. H3a CONTRADICTED, H3b SUPPORTED (Exp B crash = third failure
mode; empty My Packs is the root problem). Sentinel is a load-bearing workaround.**
---
## 16. Experiment C — Active Non-Openable Placeholder (EXECUTED 2026-08-13) — OBSERVED
Question: can the required `mypacks` group be kept structurally valid with an
**active** placeholder that stays impossible to open/purchase? Change exactly one
field of sentinel 65534: `state "inactive" → "active"`. Profile untouched
(`unopenedPackIds==[]`); 65534 kept absent from `PACK_CATALOG`.
### 16.1 Server-side safety proof (OBSERVED, code)
65534 cannot grant value regardless of `state` — pack resolution is by
`pack_by_id(id)` over `PACK_CATALOG` (ids 1,5,6,7,70), independent of the display
`state`:
- `store_buy` (PUT `/store/transaction`, `:3460-3465`): `pack_by_id(65534)=None` →
`if not pack: return 200, {}` (no `open_pack`, no coin change).
- `purchased_items` (POST, `:3494-3496`): `pack_by_id(65534)=None` →
`return 200, {"itemData": STORE.last_pack()}` (stale prior items only; no new
grant, no `open_pack`, no `consume_unopened_pack`).
- `open_pack`/`consume_unopened_pack` are unreachable for 65534 (pack resolves to
None first). Precondition PASSED.
### 16.2 Baseline / patch
Baseline: profile `39bb3e83…`, code `c89d43ea…` (host==container), sentinel
`inactive`/`mypacks`, 65534∉catalog, flags `DISPLAYGROUP=ON`/`GROUPID=OFF`.
Temporary container-only patch (host repo untouched), line 3444:
`empty["state"] = "inactive"` → `empty["state"] = "active"`. Diff vs original = this
one line; patched code `e1a4e1dc…`. **TEMPORARY EXPERIMENT C PATCH — NOT A PERMANENT
FIX.**
### 16.3 Restart / runtime
`docker restart openfut-fut-backend` (Pid 1556305, StartedAt 00:38:51Z). Running code
`e1a4e1dc…`; `unopenedPackIds==[]`; sentinel `65534 state=active unopened=False
dg=mypacks desc=""`; 65534∉catalog; normal packs 1/5/6/7 active; 70 absent; flags
unchanged.
### 16.4 Genuine FIFA capture
Marker `2026-08-13T00:38:53Z`. FIFA relaunched (Exp-B crash had closed it) → booted to
hub. Three genuine store fetches: `00:39:44`, `00:40:26`, `00:41:23`
(`GET /store/purchasegroup/all?ppInfo=true → 200`), reconstructed body prefix-matches
the logged 200-byte prefix (verified). Saved
`docs/evidence/store_purchasegroup_capture_active_placeholder_2026-08-12.json`.
C-vs-baseline full JSON diff = **only** `65534.state: "inactive" → "active"`.
### 16.5 UI observation (operator report)
1. **No crash** (game launched to hub).
2. **No "pack not available" dialog.**
3. Store remains open.
4. My Packs tab **not shown while inside the Store (Browse Packs)**.
5. Via the FUT-hub **My Packs** menu: **one pack tile with no cover, "0 items, 0
bronze, 0 rares"** (the placeholder renders as a visible empty pack).
6. Navigation: from the hub **My Packs** menu → Bronze/Gold/Special reachable; but
from the **Browse Packs** (Store) entry, Bronze/Gold/Special are **not reachable
until My Packs is opened first**.
### 16.6 Passive request sequence (OBSERVED)
Boot: `.../accountinfo → /ut/auth → settings → phishing → match/reset → userMassInfo
→ PUT store/transaction/0 → hub …`. The single `/store/transaction` is the routine
**boot** call with body `{"state":"TRANSACTIONCANCEL"}` → `200 {}` (no packId), fired
at 00:39:39 **before** any store fetch. Across all three store opens: **no
`/store/transaction`, no `/purchased`, and no request referencing 65534.** The active
placeholder did NOT cause FIFA to auto-submit any transaction/open.
### 16.7 Availability result / verdict
**Result C1 (strong positive) — ACTIVE PLACEHOLDER HYPOTHESIS SUPPORTED, with UX
caveats.** `state` participates materially: with `state:"active"` the group exists
(no crash, as in baseline) AND the availability path is satisfied (no
"pack not available" dialog, unlike baseline). So **C2 is refuted** — `state` is a
deciding field for the dialog. But it is **not a clean permanent fix**:
- the placeholder renders as a **visible empty pack tile** ("0 items"), i.e. a fake
pack a user could try to open (server-safe: opening → no-op `{}` / stale
`last_pack`, but confusing UX);
- **navigation caveat #6**: from Browse Packs the other categories are gated behind
opening My Packs first — an UNEXPECTED behavior not present with a genuine owned
pack (Exp A).
### 16.8 Rollback verification
Container code restored from backup → `c89d43ea…` (== host, == pre-experiment); the
`state` change removed. `docker restart` (Pid 1557831, StartedAt 00:43:04Z).
In-process: `unopenedPackIds==[]`, sentinel `state=inactive`, 65534∉catalog, 70
absent, flags `DISPLAYGROUP=ON`/`GROUPID=OFF`. Host repo `utas_server.py` never edited
(`c89d43ea…`, git diff empty). Profile `39bb3e83…` unchanged. Bridge/Core untouched.
### 16.9 Implications for the client contract
`state:"active"` satisfies the pack-availability predicate (no dialog) while the
group's existence prevents the crash — so an active non-openable placeholder is the
first representation that neither crashes nor shows the dialog. However it exposes a
**visible empty "pack"** and a **Browse-Packs navigation gate** (#5/#6), so it is NOT
adopted. **Permanent fix NOT established.** Open follow-ups: (a) what happens if the
user explicitly selects/opens the active placeholder (a later controlled test —
server-safe per §16.1 but UX-unknown); (b) whether a count-gated My-Packs default or a
representation that avoids rendering a fake tile can remove the empty-tile/navigation
artifacts. Do NOT adopt `state:"active"` as the fix on this evidence alone.
---
## 17. Explicit Active-Placeholder Selection Test (EXECUTED 2026-08-13) — OBSERVED
**Purpose:** with the C active placeholder in place, characterize what happens when
the user *explicitly opens* the empty 65534 My-Packs tile (the last open backend-side
question).
**Server safety proof (re-confirmed, code `c89d43ea`):** `pack_by_id(65534)=None`;
65534∉PACK_CATALOG∉unopenedPackIds. `store_buy`→`200 {}`; `purchased_items`→
`200 {"itemData": last_pack}` (stale). No inventory/coin/profile mutation possible.
**Temporary C state:** container-only one-line patch `65534.state "inactive"→"active"`
(patched `e1a4e1dc…`), restart (Pid 1560774). `unopenedPackIds=[]`, 65534
active/mypacks/∉catalog, normal packs unchanged, flags ON/OFF, host code + profile
unchanged. Marker `2026-08-13T00:53:09Z`.
**Manual selection behavior (operator report):** opened FUT → My Packs → the empty
placeholder tile visible → selected/opened it ONCE:
1. Dialog: **YES**. 2. Exact text: **"This pack is no longer available"**.
3. Stays in My Packs: yes. 4. After closing the dialog → returns to My Packs.
5. Then navigates back to the FUT Hub successfully. 6. **No crash.** 7. No spinner.
8. **Navigation remains fully usable afterward.**
**Genuine request sequence (OBSERVED, marker `00:53:09Z`):** boot (`…/auth →
userMassInfo → PUT store/transaction/0 {"state":"TRANSACTIONCANCEL"}→200 {} → hub →
credits → purchasegroup`) then navigation (`hub→credits→purchasegroup` ×2 for the
store/My-Packs views). **The explicit tile selection generated NO server request** —
no `/store/transaction`, no `/purchased/items`, and NO reference to 65534 anywhere.
The "no longer available" verdict is rendered **client-side**.
**Result class: S1 — pure client-side rejection.**
**Post-test profile/economy integrity (OBSERVED):** coins 29876776, nextItemId
100004837, items 1995, purchased 0, `unopenedPackIds` `[]`, `last_pack` empty — ALL
unchanged vs pre-test; 65534 not persisted in items or unopenedPackIds; profile
SHA-256 `39bb3e83…` byte-identical. **Zero mutation.**
**UX assessment:** crash-safe ✓, economy-safe ✓ (no request even sent), navigation
recoverable ✓. Blemishes: a **visible empty "0 items" tile**, a **"This pack is no
longer available" dialog on explicit click**, and (from §16.5) the **Browse-Packs
navigation gate** (must open My Packs first). Notably this is a *strict improvement*
over the inactive-sentinel baseline, which throws "pack not available" immediately on
STORE OPEN and bounces to the Hub; the active placeholder only errors if the user
deliberately clicks the empty tile, and recovers cleanly.
**Active-placeholder verdict: MARGINALLY ACCEPTABLE.** Safe (crash + economy) and
usable, but visibly imperfect (fake tile + click-dialog + browse nav gate). Not
UNACCEPTABLE (no crash/economy risk, recoverable); not fully ACCEPTABLE (user-visible
defects).
**Permanent-fix decision: P2.** The active sentinel technically works and is safe, but
its UX is poor and — per Candidate F (CONTRADICTED) — **no backend-only *clean*
solution exists** (the store's My-Packs resolution is Scaleform/movie-driven, not
server-gated). Recommendation: keep the active placeholder as an optional/temporary
backend compatibility mode (strictly better than the current inactive-sentinel
baseline) and pursue a **client-side** fix for a fully clean zero-pack experience
(hiding the fake tile / suppressing the forced My-Packs resolution). NOT implemented.
**Rollback verification:** container code restored to `c89d43ea…` (== host, ==
pre-experiment), sentinel back to `inactive`; `docker restart` (Pid 1562665, StartedAt
00:59:04Z); `unopenedPackIds=[]`, 65534 inactive/∉catalog, 70 absent, flags ON/OFF;
host `utas_server.py` never edited; profile `39bb3e83…` unchanged. Bridge/Core
untouched.
@@ -1,44 +0,0 @@
# FIFA 17 card-table provenance manifest
# Source (authoritative): 10.10.0.105:/home/alex/Documents/OpenFUT/fifa17-recon/data/tables/
# Dest (this repo): fifa17-recon/data/tables/
# Verified 2026-08-12: source and dest byte-identical (sha256), order-independent.
# Combined hash-of-hashes: 10f239add919089354d8dbff873fc9737b0a0f80f6ac41b1aa2a096c0ec8d331
# 31 fcc_*.json + 5 staff tables = 36 files. Files are DECODED tables:
# each carries {table, source, rowcount, schema[], rows[]}; card instances live in rows[].
#
0d1c9af7ae654c3e4363f18bb89bad03a0631056d36425c84b9a680fa989618c fcc_managerbonusvalues.json
0e5d5309cd1d9322476f8047fc6eaf4a88f4f19211b8ea01fe4d28ddd3733134 fcc_healingcards.json
1da80e390169ebb8ee8a6543e14b1191d9151f675797f01e23628f6c24d434c5 fcc_misccards.json
2212b0ee8d962f0fb6bd346bee35fff6566e22539e397cc2e42bb6efd4dc3ad3 fcc_textposvalues_hd.json
2a8e22ddb000b2c08f1a3e5eb47bc56ecf43f733ce6e7519498d332e4f439746 headcoachcards.json
3a323e1c0688a4068ccd21be0c9d8e88a875ab10ce2158d3aed79fc14e65f0fb fcc_chemlinkcalc.json
4a60bc4c0d8cb8d2b903e152a3dd5302348753568630818fde059b2de41f82ab fcc_leaguelogos.json
5304114078200da4564d33612c955598f12a44bdf52f18274184b98922229b8b fcc_GrandStandPlayers.json
5503291e381fee5008120615cd6d30a732b97636d4694a88f943eb14cb992741 fcc_leaguelogostickers.json
550739c124ca915fb294954afe3d9d04fb7d1faf2b0c96930b2dcdb1bfe7ac8f fcc_formationcardspositions_kc.json
5a5aabec1d40ffa21b8effb84f79e4b788cb42352aa592d71d95eba1db0d209e fcc_trainingcards.json
6476e396f166905857d2ada4f12cc37645ca42efdca121e8e74270fbf7422336 managercards.json
6546f602024973e20d04522a857c6c243473a177cab5b3be8400390651a42fab fcc_navcoords_hd.json
6b0209647383e4e940d2af2c3bbb2185a4aac7ac0e799fe6b50ae52e7625710d fcc_contractcards.json
6c52c83aafd9d9d3406e21c656762ac5cc0ba4522002f424e5593430bc5190f5 physiocards.json
765687d7f1e5c6c989adf45b174a0fdab0f65597c83132304b53b9f859c02586 fcc_stadium.json
79e50b07eecc47a0edf4d2a87782e904785e653937698cc712258a82fdf8b079 fcc_formationcardspositions_hd.json
7d36e0fbb9349eabd4267215bacbe29d78ff621deeb8cab3380dcac72c535eb9 fcc_preferredformationcalcmid.json
8122a4070901662fac97f675a3e4194dd5fd7e02194fdab204989af42676e268 fitnesscoachcards.json
828f8b90241672b9f62a9bbd3cb219a1d3bd8856bd160cc46284f2958e08f7d4 fcc_discardcoins.json
852bb82ed373881373d8610e6f4f2ca4406bac13da4bda9d6abba693d7cafc56 fcc_myclubscategories.json
974dcbbe6a46c02dc97c77df6c270c9a7f09ba23bee23005ecf95fd114ee66a7 gkcoachcards.json
9aa4b3b3f226202b21d2e9f96a1508ecce56abba64372099e090df101fce5eeb fcc_nationcalc.json
9b6797991520c05f7448b28e66d160f96afc73eefd661ed8272b0a093b6ba89f fcc_kitcards.json
9e8e6595fa8d3bcf963eb25bd9f9ea5d131aa92ed0e7e4ae3089adf5e1d55927 fcc_preferredformationcalcst.json
a4e8ac2ba0a6db45f1f59fe384fbd39a8cee8fb72a72f846e52daca44f1c7ff9 fcc_myclubs.json
bbf405e3a63b6fd03da1237b8b118764c57a40c797faf85d1e4691a1c95a840e fcc_balls.json
ca1184bd85cff3308af0104077feda4357ef483fae6335fa964922eea4e94330 fcc_navcoords_kc.json
ca3e4ab0f7892aac7473b774de4699c067c54647ca4a82cdd5fd1108c56ed894 fcc_badgecards.json
ccdda8ad0a15f73a056fa336abde8739b346d12b78cb8adbea0f0677487bb598 fcc_preferredpositioncalc.json
dbf95bddd456137e4b90a44bdd1f637458f4846ecd5c3ba6747d3f069c3f590f fcc_textposvalues_kc.json
dd8c2c860b18d999877c37e0f63dac64ef7bb57bff5a2173960cde71242f9a34 fcc_bonusvalues.json
df5b997b153941a5bb760ad5bb0fb23dc16cf8612b300559be23ac929b55eec6 fcc_leagues.json
e4619db324a7848639a8ba53f513cf3ea153eeaf698de05d71e56c319b3cc424 fcc_coinrewards.json
e6b1ca3ecb7c3923d77e73bda2e6c3f9794b9158354d566112f7979b33b4422c fcc_preferredformationcalcgk.json
f54814d61b72dd2b6186e9e414df4fbfbdbea1732da6a4622f001a1ff03bc12a fcc_preferredformationcalcback.json
@@ -1,66 +0,0 @@
# Live UTAS wire captures — 2026-08-15
Fresh sanitised FIFA 17 UTAS wire, captured during the post-P1 staging A/B on a
**real FIFA 17 client** (`10.10.0.105`) driving the isolated post-P1 candidate
backend (`10.10.0.121`, host bin `fda40d12`, Core `fbb54ea`). Production untouched.
This rebuilds the primary-capture corpus that was lost to `.gitignore` (Known Issues
#200 / "take sanitised captures on the next live FIFA run and commit them").
## Files
- `utas-requests.sanitised.txt` — 10 real client requests (`---`-separated).
- `utas-responses.sanitised.txt` — 12 responses.
Captured with `tcpdump` on the container netns (UTAS is plain HTTP on `:8099`),
reassembled + split + **sanitised** by `openfut-staging/extract_http.py`. Redacted:
`X-UT-SID`/`sid` (`<SID>`), `authCode` (`<AUTH>`), `deviceId` (`<DEV>`), `macAddress`
(`<MAC>`), and any 32+char hex token (`<TOKEN>`). The raw `.pcap` is intentionally
NOT committed (it is unsanitised).
## Route contracts captured (verbatim shapes)
### POST /openfut/account/sync (launcher control-plane, Python-served)
Request (launcher → backend):
```json
{"personaId":33068179,"personaName":"CAGE","level":1,"experience":0,
"experienceMax":1000,"accountFunds":0,"accountFundsCap":100000}
```
Response:
```json
{"account":{"personaId":33068179,"personaName":"CAGE","clubName":"OpenFUT",
"clubAbbr":"OFC","level":1,"experience":0,"experienceMax":1000,"accountFunds":0,
"accountFundsCap":100000,"profilePath":"accounts/33068179/fifa17_profile.json",
"coins":29826776,"unopenedPacks":0},"status":"OK"}
```
NOTE: `coins` here is Python's STALE profile value (29,826,776) — Core's authoritative
balance at capture time was 29,859,876. The launcher's `AccountSummary` parser is
lenient and requires only `{personaId,personaName,level,experience,accountFunds,coins,
unopenedPacks}` (clubName/clubAbbr/etc. ignored). BLOCKER for a Rust migration is NOT
the shape — it is that Python `account/sync` also *selects the active profile* the
still-Python-served userMassInfo envelope depends on, so it is coupled to the
userMassInfo hybrid and cannot migrate standalone.
### GET /ut/game/fifa17/store/purchasegroup/all?ppInfo=true (empty My-Packs sentinel)
The `mypacks` group carries the synthetic sentinel pack when My Packs is empty:
```json
{"assetId":65534,"displayGroup":{"priority":1,"value":"mypacks"},"id":65534,
"packType":"GOLD","packContentInfo":{"goldQuantity":0,"itemQuantity":0,...},
"state":"active","unopened":false}
```
Client renders empty My Packs AS Browse Packs (no crash) — the bug-6c resolver guard.
### GET /ut/game/fifa17/userMassInfo (RUST_OVERLAY hybrid, full 8 KB envelope captured)
The complete envelope is in `utas-responses.sanitised.txt`: root `pileSizeClientData` +
`userInfo` (with `clubName`, `clubAbbr`, `established`, `accountCreatedPlatformName`,
`currencies` [Core coins overlaid by Rust], `won/draw/loss`, `clubNameChangeAllowed`,
`divisionOffline/Online`, `purchased`, `feature`, `reliability`, `bidTokens`, `trophies`,
`sessionCoinsBankBalance`, `actives`, `squadList`). Field spec is authoritative in
`fifa17-recon/tools/utas_server.py::user_info()`. This is the target shape for a future
full-Rust userMassInfo (needs the `clubAbbr`/`established` account triad, which Core lacks).
### Other captured request lines
`POST /ut/auth`, `GET user/accountinfo`, `GET settings`, `GET phishing/trusteddevice`,
`PUT match/reset`, `GET userMassInfo`, `PUT clientdata/userHubData`,
`PUT /ut/v2/game/fifa17/store/transaction/0`.
See `openfut-staging/p1p-live-2026-08-15/AB_VERDICT.md` for the full A/B result.
@@ -1,117 +0,0 @@
POST /openfut/account/sync HTTP/1.1
Host: 10.10.0.121:8099
Content-Type: application/json
Content-Length: 131
Connection: close
{"personaId":33068179,"personaName":"CAGE","level":1,"experience":0,"experienceMax":1000,"accountFunds":0,"accountFundsCap":100000}
---
GET /ut/game/fifa17/user/accountinfo HTTP/1.1
Host: 10.10.0.121:8099
Connection: Close
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
Accept: application/json
Content-Type: application/json
Accept-Encoding: gzip
Easw-Session-Data-Nucleus-Id: 33068179
Accept-Encoding: gzip
---
POST /ut/auth HTTP/1.1
Host: 10.10.0.121:8099
Content-Length: 380
Connection: Close
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
Accept: application/json
Content-Type: application/json
Hash: <TOKEN>
{"isReadOnly":false,"priorityLevel":6,"sku":"FFA17PCC","nucleusPersonaPlatform":"pc","clientVersion":3,"nuc":33068179,"nucleusPersonaId":33068179,"nucleusPersonaDisplayName":"CAGE","locale":"en-US","regionCode":"US","deviceId":"<DEV>","macAddress":"<MAC>","method":"authcode","identification":{"authCode":"<AUTH>"}}
---
GET /ut/game/fifa17/settings HTTP/1.1
Host: 10.10.0.121:8099
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
Accept: application/json
Content-Type: application/json
X-UT-SID: <SID>
Accept-Encoding: gzip
---
GET /ut/game/fifa17/phishing/trusteddevice?deviceId=<DEV> HTTP/1.1
Host: 10.10.0.121:8099
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
Accept: application/json
Content-Type: application/json
X-UT-SID: <SID>
Accept-Encoding: gzip
---
PUT /ut/game/fifa17/match/reset HTTP/1.1
Host: 10.10.0.121:8099
Content-Length: 0
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
Accept: application/json
Content-Type: application/json
X-UT-SID: <SID>
---
GET /ut/game/fifa17/userMassInfo HTTP/1.1
Host: 10.10.0.121:8099
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
Accept: application/json
Content-Type: application/json
X-UT-SID: <SID>
Accept-Encoding: gzip
---
PUT /ut/v2/game/fifa17/store/transaction/0 HTTP/1.1
Host: 10.10.0.121:8099
Content-Length: 29
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
Accept: application/json
Content-Type: application/json
X-UT-SID: <SID>
{"state":"TRANSACTIONCANCEL"}
---
PUT /ut/game/fifa17/clientdata/userHubData HTTP/1.1
Host: 10.10.0.121:8099
Content-Length: 53
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
Accept: application/json
Content-Type: application/json
X-UT-SID: <SID>
{"entries":[{"key":0,"value":0},{"key":1,"value":1}]}
---
GET /ut/game/fifa17/store/purchasegroup/all?ppInfo=true HTTP/1.1
Host: 10.10.0.121:8099
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
Accept: application/json
Content-Type: application/json
X-UT-SID: <SID>
Accept-Encoding: gzip
File diff suppressed because one or more lines are too long
@@ -1,38 +0,0 @@
{
"_countdown_proof": {
"delta": -4,
"expires_after_4s": 2216,
"expires_first": 2220,
"frozen": false
},
"auctionhouse-browse": {
"container": "object",
"path": "/auctionhouse",
"status": 200
},
"marketdata-pricelimits": {
"container": "array",
"path": "/marketdata/pricelimits?defId=20801",
"status": 200
},
"trade-status-filtered": {
"container": "object",
"path": "/trade/status?tradeIds=1000000097",
"status": 200
},
"trade-status-unfiltered": {
"container": "object",
"path": "/trade/status",
"status": 200
},
"tradePile": {
"container": "object",
"path": "/tradePile",
"status": 200
},
"tradePile-counts": {
"container": "object",
"path": "/tradePile/counts",
"status": 200
}
}
@@ -1,73 +0,0 @@
{
"auctionInfo": [
{
"bidState": "none",
"buyNowPrice": 15000,
"coinsProcessed": 0,
"currentBid": 0,
"expires": 2220,
"itemData": {
"assetId": 20801,
"attributeList": [
{
"index": 0,
"value": 92
},
{
"index": 1,
"value": 92
},
{
"index": 2,
"value": 81
},
{
"index": 3,
"value": 91
},
{
"index": 4,
"value": 33
},
{
"index": 5,
"value": 80
}
],
"cardassetid": 20801,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 20801,
"discardValue": 1500,
"fitness": 99,
"id": 100000097,
"itemState": "forSale",
"itemType": "player",
"leagueId": 53,
"nation": 38,
"owners": 1,
"playStyle": 250,
"preferredPosition": "LW",
"rareflag": 1,
"rating": 94,
"resourceId": 20801,
"teamid": 243,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 1000000097,
"tradeState": "active",
"watched": false
}
],
"count": 0,
"credits": 29843976,
"duplicateItemIdList": [],
"maxAuctionsAllowed": 100,
"offered": 0,
"selling": 1,
"sold": 0,
"total": 1
}
@@ -1,61 +0,0 @@
image_base=0x180000000 sections=7
.text rva=0x00001000 vsize=0x1e3f62 rawptr=0x00000400 rawsize=0x1e4000
.rdata rva=0x001e5000 vsize=0xa4094 rawptr=0x001e4400 rawsize=0xa4200
.data rva=0x0028a000 vsize=0x65c08 rawptr=0x00288600 rawsize=0x54000
.pdata rva=0x002f0000 vsize=0x132cc rawptr=0x002dc600 rawsize=0x13400
.tls rva=0x00304000 vsize=0x9 rawptr=0x002efa00 rawsize=0x200
.rsrc rva=0x00305000 vsize=0x4e8 rawptr=0x002efc00 rawsize=0x600
.reloc rva=0x00306000 vsize=0x16bc4 rawptr=0x002f0200 rawsize=0x16c00
route table VA 0x18021df80 -> file offset 0x21d380
# field A field B
0 'ut/%s/auctionhouse' 'AUCTIONHOUSE'
1 'ut/%s/clubUser' 'CLUB_USER'
2 'ut/%s/user/list' 'CLUB_INFO'
3 'ut/%s/club' 'CLUB'
4 'ut/%s/defid' 'DREAM'
5 'ut/%s/squad' 'SQUAD'
6 'ut/delete/%s/squad' 'DELETE_SQUAD'
7 'ut/%s/leaderboards/options' 'LBOPTIONS'
8 'ut/%s/leaderboards' 'LBDEFAULT'
9 'ut/%s/activeMessage' 'PAFPRACTICE'
10 'ut/%s' 'UT'
11 'ut/%s/user' 'USER'
12 'ut/delete/%s/user' 'DELETEUSER'
13 'ut/%s/item' 'ITEMS'
14 'ut/%s/item/resource' 'ITEMS_BY_RES'
15 'ut/delete/%s/item' 'DELETEITEMS'
16 'ut/%s/match' 'MATCH'
17 'ut/%s/sbs' 'SBC'
18 'ut/%s/tournament' 'TOURNAMENT'
19 'ut/%s/tournament/user' 'TOURNAMENTUSER'
20 'ut/delete/%s/tournament/user' 'TOURNAMENTQUIT'
21 'ut/%s/season' 'SEASON'
22 'ut/%s/season/user' 'SEASONUSER'
23 'ut/%s/season/%%s/user' 'SEASONUSER_ALTER'
24 'ut/%s/season/%%s/reset' 'SEASONRESET'
25 'ut/%s/season/friendly' 'FRIENDLYSEASON'
26 'ut/%s/purchased' 'PURCHASED'
27 'ut/%s/store' 'STORE'
28 'ut/%s/watchList' 'WATCHLIST'
29 'ut/delete/%s/watchList' 'DELETEWATCHLIST'
30 'ut/%s/tradePile' 'TRADEPILE'
31 'ut/%s/trade' 'TRADE'
32 'ut/delete/%s/trade' 'DELETETRADE'
33 'ut/%s/marketdata' 'MARKETDATA'
34 'ut/%s/clientdata' 'CLIENTDATA'
35 'ut/auth' 'AUTH'
36 'ut/delete/auth' 'DELETE_AUTH'
37 'ut/%s/phishing' 'PHISHING'
38 'ut/%s/captcha' 'CAPTCHA'
39 'ut/%s/tfa' 'TFA'
40 'ut/%s/squad/mode' 'SQUADMODE'
41 'ut/%s/draft/mode' 'DRAFT'
42 'ut/%s/champion' 'CHAMPIONS'
43 'ut/v2/%s/store' 'V2STORE'
44 'ut/%s/livemessage' 'LIVEMESSAGE'
45 '' 'ADMIN'
46 '' 'DEBUG'
47 '' 'MAINTENANCE'
-- table ends after 48 rows --
@@ -1,107 +0,0 @@
=== tradeState (table walk) VA 0x180229e40 -> off 0x229240 ===
'active' = 1
'inactive' = 2
'expired' = 3
'closed' = 4
-- NULL terminator after 4 rows --
=== type VA 0x180229c30 -> off 0x229030 ===
'any' = -1
'player' = 1
'staff' = 2
'clubInfo' = 3
'training' = 4
'development' = 5
'stadium' = 6
'ball' = 7
-- NULL terminator after 8 rows --
=== zone VA 0x1802296e0 -> off 0x228ae0 ===
'goalKeeper' = 0
'defense' = 1
'midfield' = 2
'attacker' = 3
-- NULL terminator after 4 rows --
=== lev VA 0x180229a60 -> off 0x228e60 ===
'any' = 0
'gold' = 3
'silver' = 2
'bronze' = 1
-- NULL terminator after 4 rows --
=== pos VA 0x1802295c0 -> off 0x2289c0 ===
'GK' = 0
'RWB' = 2
'RB' = 3
'CB' = 5
'LB' = 7
'LWB' = 8
'CDM' = 10
'RM' = 12
'CM' = 14
'LM' = 16
'CAM' = 18
'RF' = 20
'CF' = 21
'LF' = 22
'RW' = 23
'ST' = 25
'LW' = 27
-- NULL terminator after 17 rows --
=== cat VA 0x180229ab0 -> off 0x228eb0 ===
'any' = -1
'playerGK' = 1
'playerRD' = 2
'playerRM' = 3
'playerRA' = 4
'manager' = 5
'headCoach' = 6
'GKCoach' = 7
'fitnessCoach' = 8
'physio' = 9
'badge' = 10
'kit' = 11
'leagueLogo' = 12
'playerTraining' = 13
'GKTraining' = 14
'position' = 15
'playStyle' = 16
'managerLeagueModifier' = 17
'contract' = 18
'fitness' = 19
'healing' = 20
'stadium' = 21
'ball' = 22
-- NULL terminator after 23 rows --
=== form VA 0x180229880 -> off 0x228c80 ===
'f3412' = 23
'f3421' = 24
'f343' = 25
'f352' = 27
'f41212' = 14
'f4231' = 3
'f4222' = 13
'f4312' = 6
'f4321' = 7
'f433' = 8
'f4411' = 19
'f442' = 16
'f451' = 21
'f5212' = 29
'f5221' = 30
'f532' = 31
'f541' = 32
'f41212a' = 15
'f4141' = 2
'f4231a' = 4
'f433a' = 9
'f433b' = 10
'f433c' = 11
'f433d' = 12
'f442a' = 17
'f451a' = 20
-- NULL terminator after 26 rows --
@@ -1,173 +0,0 @@
{
"gap": 1,
"test": "ACTIVE seller row under itemState=forSale",
"date": "2026-08-17",
"disposable_card": {
"wire_item_id": 100000178,
"resourceId": 212188,
"rating": 75,
"preferredPosition": "ST",
"copies_owned": 3,
"discardValue": 600,
"in_active_squad": false,
"untradeable": false
},
"pre_state": {
"club_items": 1966,
"coins": 29843976,
"tradePile_rows": 0,
"tradePile_counts": {
"count": 0,
"maxAuctionsAllowed": 100,
"offered": 0,
"selling": 0,
"sold": 0
}
},
"listing_creation_route_log": "utas-host owner=RUST route=market-list POST item_id=100000178 listed=true trade_id=1000000178",
"client_flow_observed_rust_owned": [
"PUT /ut/game/fifa17/item (club -> trade pile move)",
"POST /ut/game/fifa17/auctionhouse (ISStart) -> 200",
"GET /ut/game/fifa17/tradePile (repeated)",
"GET /ut/game/fifa17/trade/status?tradeIds=1000000178 -> requested=1 returned=1 (repeated poll)",
"GET /ut/game/fifa17/tradePile/counts"
],
"wire_active_row": {
"tradeId": 1000000178,
"tradeState": "active",
"itemState": "forSale",
"startingBid": 150,
"buyNowPrice": 200,
"currentBid": 0,
"bidState": "none",
"sellerName": "CAGE",
"sellerEstablished": 1,
"watched": false,
"coinsProcessed": 0,
"atom_count": 12,
"itemData_attributeList_entries": 6
},
"countdown_sample": {
"expires": [
3562,
3556
],
"delta_secs": 6,
"live": true
},
"trade_status_coherent_while_running": true,
"trade_status_rows": [
{
"bidState": "none",
"buyNowPrice": 200,
"coinsProcessed": 0,
"currentBid": 0,
"expires": 3556,
"itemData": {
"assetId": 212188,
"attributeList": [
{
"index": 0,
"value": 90
},
{
"index": 1,
"value": 70
},
{
"index": 2,
"value": 64
},
{
"index": 3,
"value": 78
},
{
"index": 4,
"value": 24
},
{
"index": 5,
"value": 62
}
],
"cardassetid": 212188,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 212188,
"discardValue": 600,
"fitness": 99,
"id": 100000178,
"itemState": "forSale",
"itemType": "player",
"leagueId": 19,
"nation": 21,
"owners": 1,
"playStyle": 250,
"preferredPosition": "ST",
"rareflag": 1,
"rating": 75,
"resourceId": 212188,
"teamid": 112172,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 1000000178,
"tradeState": "active",
"watched": false
}
],
"tradePile_counts_while_active": {
"count": 1,
"maxAuctionsAllowed": 100,
"offered": 0,
"selling": 1,
"sold": 0
},
"coins_unchanged": true,
"fix_a_inactive_rows": 0,
"CARD_OFFERSTATE_live_decode": {
"observed": true,
"method": "read-only /proc/<pid>/mem via fifa17-recon-derived auction_record_probe.py; slide proven by FNV control MATCH before any other read; O_RDONLY + pread only",
"client_pid": 29670,
"container": "model->vt[0x120] (trade pile), rows=1, +0x60 override=1",
"record": {
"tradeId": 1000000178,
"STATE_tradeState": "1(active)",
"YOURBID_bidState": "0(none)",
"MAX_CREDITS_buyNowPrice": 200,
"RESERVEDPRICE_startingBid": 150,
"currentBid": 0,
"expires": 3457,
"coinsProcessed": 0,
"IS_GLOW_flagB": 0,
"INBOX_flagA": 0,
"IS_WATCHED": 0
},
"item": {
"CARD_ID": 212188,
"CARD_TYPE": 1,
"tradeable": 1,
"rating": 75,
"CARD_OFFERSTATE_itemState": "5(forSale)",
"pile": 0
},
"ab_significance": "This is the direct A/B on the only changed field. Baseline `listFS` decoded to -1(<unrecognised>) in the client's own record; candidate `forSale` decodes to 5. Every other field in the record is unchanged in kind.",
"claim_boundary": "Proves the CLIENT DECODES the token correctly. Says nothing about the Flash action-gate term."
},
"ui_acceptance": {
"operator_report": "looks right",
"confirmed": [
"card renders under LISTED ITEMS (not the TRANSFER LIST section)",
"Start Price 150 and Buy Now 200 displayed correctly, Current Bid as expected",
"Time Remaining visibly counting down",
"card art renders normally; no malformed row, no duplicate, no premature Expired, no crash",
"row correctly non-actionable while active (established FIFA 17 behaviour, not a fault)"
],
"corroboration": "the client's trade-pile container populated with exactly this row (rows=1), so the Transfer List screen bound and drew it"
},
"verdict": "GAP 1 CLOSED \u2014 LIVE-CONFIRMED: an active seller auction with itemState=forSale renders correctly and is decoded by the client as CARD_OFFERSTATE=5",
"code_change_required": false
}
@@ -1,55 +0,0 @@
{"t": 1787012091, "present": true, "tradeState": "active", "expires": 3175, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787012151, "present": true, "tradeState": "active", "expires": 3115, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787012211, "present": true, "tradeState": "active", "expires": 3055, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787012271, "present": true, "tradeState": "active", "expires": 2995, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787012331, "present": true, "tradeState": "active", "expires": 2935, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787012391, "present": true, "tradeState": "active", "expires": 2875, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787012451, "present": true, "tradeState": "active", "expires": 2815, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787012511, "present": true, "tradeState": "active", "expires": 2755, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787012571, "present": true, "tradeState": "active", "expires": 2695, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787012631, "present": true, "tradeState": "active", "expires": 2635, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787012691, "present": true, "tradeState": "active", "expires": 2575, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787012751, "present": true, "tradeState": "active", "expires": 2515, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787012811, "present": true, "tradeState": "active", "expires": 2455, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787012871, "present": true, "tradeState": "active", "expires": 2395, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787012931, "present": true, "tradeState": "active", "expires": 2335, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787012991, "present": true, "tradeState": "active", "expires": 2275, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787013051, "present": true, "tradeState": "active", "expires": 2215, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787013111, "present": true, "tradeState": "active", "expires": 2155, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787013171, "present": true, "tradeState": "active", "expires": 2095, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787013231, "present": true, "tradeState": "active", "expires": 2035, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787013291, "present": true, "tradeState": "active", "expires": 1975, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787013351, "present": true, "tradeState": "active", "expires": 1915, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787013411, "present": true, "tradeState": "active", "expires": 1855, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787013471, "present": true, "tradeState": "active", "expires": 1795, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787013531, "present": true, "tradeState": "active", "expires": 1735, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787013591, "present": true, "tradeState": "active", "expires": 1675, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787013651, "present": true, "tradeState": "active", "expires": 1615, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787013711, "present": true, "tradeState": "active", "expires": 1555, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787013771, "present": true, "tradeState": "active", "expires": 1495, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787013831, "present": true, "tradeState": "active", "expires": 1435, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787013891, "present": true, "tradeState": "active", "expires": 1375, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787013951, "present": true, "tradeState": "active", "expires": 1315, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787014011, "present": true, "tradeState": "active", "expires": 1255, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787014071, "present": true, "tradeState": "active", "expires": 1195, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787014131, "present": true, "tradeState": "active", "expires": 1135, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787014191, "present": true, "tradeState": "active", "expires": 1075, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787014251, "present": true, "tradeState": "active", "expires": 1015, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787014311, "present": true, "tradeState": "active", "expires": 955, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787014371, "present": true, "tradeState": "active", "expires": 895, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787014431, "present": true, "tradeState": "active", "expires": 835, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787014491, "present": true, "tradeState": "active", "expires": 775, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787014551, "present": true, "tradeState": "active", "expires": 715, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787014611, "present": true, "tradeState": "active", "expires": 655, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787014671, "present": true, "tradeState": "active", "expires": 595, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787014731, "present": true, "tradeState": "active", "expires": 535, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787014791, "present": true, "tradeState": "active", "expires": 475, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787014851, "present": true, "tradeState": "active", "expires": 415, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787014911, "present": true, "tradeState": "active", "expires": 355, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787014971, "present": true, "tradeState": "active", "expires": 295, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787015031, "present": true, "tradeState": "active", "expires": 235, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787015091, "present": true, "tradeState": "active", "expires": 175, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787015151, "present": true, "tradeState": "active", "expires": 115, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787015211, "present": true, "tradeState": "active", "expires": 55, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787015271, "present": true, "tradeState": "expired", "expires": 0, "itemState": "forSale", "startingBid": 150, "buyNowPrice": 200, "total": 1, "credits": 29843976}
{"t": 1787015301, "present": false, "tradeState": null, "expires": null, "itemState": null, "startingBid": null, "buyNowPrice": null, "total": 0, "credits": 29843976}
@@ -1,91 +0,0 @@
{
"gap": 2,
"test": "expired -> Return to Club -> full exit/re-entry persistence",
"date": "2026-08-17",
"item": {
"wire_item_id": 100000178,
"resourceId": 212188,
"rating": 75,
"preferredPosition": "ST",
"tradeId": 1000000178
},
"natural_expiry": {
"method": "read-only sampler polling /tradePile once a minute; expiry is derived from created_at + duration, so it was OBSERVED, never forced. No timestamp was mutated at any point.",
"samples": 55,
"transitions": [
{
"t": 1787012091,
"tradeState": "active",
"expires": 3175,
"itemState": "forSale"
},
{
"t": 1787015271,
"tradeState": "expired",
"expires": 0,
"itemState": "forSale",
"note": "natural expiry reached; itemState stayed forSale through the transition"
},
{
"t": 1787015301,
"tradeState": null,
"total": 0,
"note": "row left the pile entirely \u2014 the operator's Return to Club"
}
],
"auction_duration_secs": 3600
},
"return_to_club_server_state": {
"tradePile_total": 0,
"tradePile_rows": 0,
"tradePile_counts": {
"count": 0,
"maxAuctionsAllowed": 100,
"offered": 0,
"selling": 0,
"sold": 0
},
"trade_status": {
"tradeId": 1000000178,
"tradeState": "closed",
"expires": 0
},
"club_items": 1966,
"club_row": {
"id": 100000178,
"resourceId": 212188,
"rating": 75,
"preferredPosition": "ST",
"itemState": "free",
"untradeable": false
},
"duplicate_owned_ids": 0,
"market_store": {
"listing_1000000178": "cancelled",
"active": 0,
"reserved": 0,
"all_by_state": {
"cancelled": 3
}
},
"coins": 29843976,
"fix_a_inactive_rows": 0,
"checks_passed": "18/18"
},
"status": "GAP 2 CLOSED \u2014 Return to Club is durable across repeated full FUT exit/re-entry",
"exit_reentry": {
"observed": true,
"operator_report": "yes that card is still there",
"host_log_evidence": [
"route=move-items wire=100000178 pile=club auction_cancelled=1",
"route=auth-delete",
"route=auth ... ip=10.10.0.105 persona=33068179 sid_opened=true (fresh session)",
"route=hub clubPlayers=1966 auctionCount=0",
"route=club total=1986 emitted=1966 dropped_no_asset=0",
"the full auth -> userMassInfo -> hub -> club -> tradePile -> trade/status cycle repeated for a SECOND fresh session"
],
"session_boundaries_crossed": 2,
"server_checks_after_reentry": "18/18, identical to before the re-entry"
},
"code_change_required": false
}
@@ -1,9 +0,0 @@
{
"auctionCount": 1,
"clubPlayers": 1965,
"tradePile": {
"count": 1,
"selling": 1,
"sold": 0
}
}
@@ -1,34 +0,0 @@
{
"date": "2026-08-17",
"change": "removed tradeState:'inactive' projection from /tradePile and /trade/status",
"why": "RE of the FUT front-end: the action gate is in the APT/ActionScript layer; only rows classified as auctions (getCardsInAuction/isInActiveAuction) reach PreCheckCardOptions -> handleTradeCardAction, so an 'inactive' row renders and can never be acted on. The corpus already said 'do not emit it' (plan-2026-08-06-transfer-market.md:731-733).",
"before": {
"total": 7,
"by_state": {
"expired": 1,
"inactive": 6
},
"credits": 29843976,
"trade_status_returned": 7
},
"after": {
"total": 1,
"by_state": {
"expired": 1
},
"credits": 29843976,
"trade_status_returned": 1
},
"club_invariant": {
"formerly_inactive_members_visible_in_club": 6,
"of": 6,
"stranded": 0,
"club_items": 1965
},
"not_fixed_here": {
"itemState": "listFS",
"finding": "0 occurrences in CardsDLL (md5 4de3493131d7d2ff7f8b360c5ac9b655) and 0 in 4.26 GiB of process memory; decodes to -1. FIFA 17 value is forSale (5).",
"reason_deferred": "CARD_OFFERSTATE is one of three unresolved action-gate candidates; every actionable row observed carried itemState -1, so changing it could perturb the working expired -> Return-to-Club flow. Ships alone with its own live A/B."
},
"gate_status": "Category C, STRONGLY SUPPORTED. Gate term NOT PROVEN: STATE, CARD_OFFERSTATE and RESERVEDPRICE/MAX_CREDITS all differ together in every observable row. Proof needs AVM1 disassembly of tradepile.isInActiveAuction."
}
@@ -1,129 +0,0 @@
{
"auctionInfo": [
{
"bidState": "none",
"buyNowPrice": 15000,
"coinsProcessed": 0,
"currentBid": 0,
"expires": 2674,
"itemData": {
"assetId": 158023,
"attributeList": [
{
"index": 0,
"value": 89
},
{
"index": 1,
"value": 90
},
{
"index": 2,
"value": 86
},
{
"index": 3,
"value": 96
},
{
"index": 4,
"value": 26
},
{
"index": 5,
"value": 61
}
],
"cardassetid": 158023,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 158023,
"discardValue": 1500,
"fitness": 99,
"id": 100000155,
"itemState": "listFS",
"itemType": "player",
"leagueId": 53,
"nation": 52,
"owners": 1,
"playStyle": 250,
"preferredPosition": "RW",
"rareflag": 1,
"rating": 93,
"resourceId": 158023,
"teamid": 241,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 1000000155,
"tradeState": "active",
"watched": false
},
{
"bidState": "none",
"buyNowPrice": 0,
"coinsProcessed": 0,
"currentBid": 0,
"expires": 0,
"itemData": {
"assetId": 158023,
"attributeList": [
{
"index": 0,
"value": 89
},
{
"index": 1,
"value": 90
},
{
"index": 2,
"value": 86
},
{
"index": 3,
"value": 96
},
{
"index": 4,
"value": 26
},
{
"index": 5,
"value": 61
}
],
"cardassetid": 158023,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 158023,
"discardValue": 1500,
"fitness": 99,
"id": 100000059,
"itemState": "free",
"itemType": "player",
"leagueId": 53,
"nation": 52,
"owners": 1,
"playStyle": 250,
"preferredPosition": "RW",
"rareflag": 1,
"rating": 93,
"resourceId": 158023,
"teamid": 241,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 0,
"tradeId": 1000000059,
"tradeState": "inactive",
"watched": false
}
],
"credits": 29843976,
"duplicateItemIdList": [],
"total": 2
}
@@ -1,110 +0,0 @@
{
"date": "2026-08-17",
"change": "itemData.itemState on the seller's own auction rows: listFS -> forSale",
"sites": [
"openfut-utas-host/src/market.rs handle_market_query (GET /tradePile)",
"openfut-utas-host/src/market.rs handle_market_status (GET /trade/status)"
],
"why": {
"listFS": "not a FIFA 17 token: 0 occurrences in CardsDLL_Win64_retail.dll (md5 4de3493131d7d2ff7f8b360c5ac9b655), 0 in 4.26 GiB of live client memory, decodes to -1 via FUN_180166660",
"forSale": "FIFA 17 native, value 5, from the 12-row itemState table at 0x180229cc0",
"oracle": "the Python oracle emits listFS (utas_server.py:3921); the differential asserted PARITY and so passed while both sides were wrong"
},
"supporting_evidence_the_token_is_safe": "the market SEARCH route (/transfermarket) already emitted forSale before this change, so the client has been decoding forSale=5 on a live path all along",
"wire_ab": {
"baseline": {
"tradeId": 1000000155,
"tradeState": "expired",
"bidState": "none",
"expires": 0,
"startingBid": 150,
"buyNowPrice": 15000,
"currentBid": 0,
"sellerName": "CAGE",
"sellerEstablished": 1,
"watched": false,
"coinsProcessed": 0,
"atom_count": 12,
"itemState": "listFS",
"item_id": 100000155,
"resourceId": 158023,
"untradeable": false,
"rating": 93,
"preferredPosition": "RW"
},
"candidate": {
"tradeId": 1000000155,
"tradeState": "expired",
"bidState": "none",
"expires": 0,
"startingBid": 150,
"buyNowPrice": 15000,
"currentBid": 0,
"sellerName": "CAGE",
"sellerEstablished": 1,
"watched": false,
"coinsProcessed": 0,
"atom_count": 12,
"itemState": "forSale",
"item_id": 100000155,
"resourceId": 158023,
"untradeable": false,
"rating": 93,
"preferredPosition": "RW"
},
"fields_changed": [
"itemState"
],
"fields_identical": 17,
"credits_before": 29843976,
"credits_after": 29843976,
"fix_a_inactive_rows_before": 0,
"fix_a_inactive_rows_after": 0
},
"offline_gates": {
"host_tests": "126 pass",
"adapter_tests": "214 pass",
"fmt": "clean",
"clippy": "clean",
"differential": "relabelled market-query tradePile PARITY -> DIFFERENT-BY-DESIGN; now pins oracle=listFS and rust=forSale so the divergence stays visible"
},
"status": "PROMOTED \u2014 live-confirmed on the expired path",
"live_status": {
"session": "operator FIFA 17 session, 2026-08-17, against the already-expired row 1000000155 (res 158023, 93 RW)",
"expired_row_actionable": true,
"return_to_club_offered": true,
"return_to_club_succeeded": true,
"operator_report": "the card is back in my club",
"server_verified_after": {
"tradePile_total": 0,
"tradePile_rows": 0,
"tradePile_counts": {
"count": 0,
"maxAuctionsAllowed": 100,
"offered": 0,
"selling": 0,
"sold": 0
},
"trade_status_1000000155": "closed",
"market_store_state": "cancelled",
"club_items": 1966,
"club_items_before": 1965,
"card_in_club": {
"id": 100000155,
"resourceId": 158023,
"rating": 93,
"preferredPosition": "RW",
"itemState": "free",
"untradeable": false
},
"duplicate_ids_in_club": 0,
"stale_active_listings": 0,
"inactive_rows": 0
},
"active_row_render_with_forSale": "NOT OBSERVED \u2014 no active listing existed during the session",
"full_exit_reentry_ui": "NOT DIRECTLY OBSERVED \u2014 durability established from the server state a fresh session reads (empty tradePile + card in club)"
},
"revert": "single-line: set both auction_record_as(l, \"forSale\") calls in market.rs back to \"listFS\", rebuild, hub restart prod-host",
"claim_boundary": "This validates PROTOCOL CORRECTNESS of the token only. It does NOT prove the Flash action-gate term; STATE / CARD_OFFERSTATE / RESERVEDPRICE+MAX_CREDITS remain confounded and the gate stays Category C / STRONGLY SUPPORTED / not proven.",
"narrows": "CARD_OFFERSTATE is not a gate term requiring -1: an expired row decoding 5 stayed actionable. STATE and RESERVEDPRICE/MAX_CREDITS remain confounded; the Flash gate is still Category C / not proven."
}
@@ -1,7 +0,0 @@
[
{
"defId": 20801,
"maxPrice": 15000,
"minPrice": 150
}
]
@@ -1,434 +0,0 @@
{
"auctionInfo": [
{
"bidState": "none",
"buyNowPrice": 15000,
"coinsProcessed": 0,
"currentBid": 0,
"expires": 1474,
"itemData": {
"assetId": 158023,
"attributeList": [
{
"index": 0,
"value": 89
},
{
"index": 1,
"value": 90
},
{
"index": 2,
"value": 86
},
{
"index": 3,
"value": 96
},
{
"index": 4,
"value": 26
},
{
"index": 5,
"value": 61
}
],
"cardassetid": 158023,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 158023,
"discardValue": 1500,
"fitness": 99,
"id": 100000155,
"itemState": "listFS",
"itemType": "player",
"leagueId": 53,
"nation": 52,
"owners": 1,
"playStyle": 250,
"preferredPosition": "RW",
"rareflag": 1,
"rating": 93,
"resourceId": 158023,
"teamid": 241,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 1000000155,
"tradeState": "active",
"watched": false
},
{
"bidState": "none",
"buyNowPrice": 0,
"coinsProcessed": 0,
"currentBid": 0,
"expires": 0,
"itemData": {
"assetId": 172610,
"attributeList": [
{
"index": 0,
"value": 69
},
{
"index": 1,
"value": 33
},
{
"index": 2,
"value": 47
},
{
"index": 3,
"value": 57
},
{
"index": 4,
"value": 80
},
{
"index": 5,
"value": 76
}
],
"cardassetid": 172610,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 172610,
"discardValue": 900,
"fitness": 99,
"id": 100004867,
"itemState": "free",
"itemType": "player",
"leagueId": 31,
"nation": 54,
"owners": 1,
"playStyle": 250,
"preferredPosition": "CB",
"rareflag": 1,
"rating": 80,
"resourceId": 172610,
"teamid": 54,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 0,
"tradeId": 1000004867,
"tradeState": "inactive",
"watched": false
},
{
"bidState": "none",
"buyNowPrice": 0,
"coinsProcessed": 0,
"currentBid": 0,
"expires": 0,
"itemData": {
"assetId": 163824,
"attributeList": [
{
"index": 0,
"value": 70
},
{
"index": 1,
"value": 43
},
{
"index": 2,
"value": 56
},
{
"index": 3,
"value": 62
},
{
"index": 4,
"value": 82
},
{
"index": 5,
"value": 82
}
],
"cardassetid": 163824,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 163824,
"discardValue": 900,
"fitness": 99,
"id": 100004866,
"itemState": "free",
"itemType": "player",
"leagueId": 13,
"nation": 50,
"owners": 1,
"playStyle": 250,
"preferredPosition": "CB",
"rareflag": 1,
"rating": 83,
"resourceId": 163824,
"teamid": 7,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 0,
"tradeId": 1000004866,
"tradeState": "inactive",
"watched": false
},
{
"bidState": "none",
"buyNowPrice": 0,
"coinsProcessed": 0,
"currentBid": 0,
"expires": 0,
"itemData": {
"assetId": 177003,
"attributeList": [
{
"index": 0,
"value": 74
},
{
"index": 1,
"value": 75
},
{
"index": 2,
"value": 86
},
{
"index": 3,
"value": 89
},
{
"index": 4,
"value": 72
},
{
"index": 5,
"value": 66
}
],
"cardassetid": 177003,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 177003,
"discardValue": 1500,
"fitness": 99,
"id": 100000122,
"itemState": "free",
"itemType": "player",
"leagueId": 53,
"nation": 10,
"owners": 1,
"playStyle": 250,
"preferredPosition": "CM",
"rareflag": 1,
"rating": 89,
"resourceId": 177003,
"teamid": 243,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 0,
"tradeId": 1000000122,
"tradeState": "inactive",
"watched": false
},
{
"bidState": "none",
"buyNowPrice": 0,
"coinsProcessed": 0,
"currentBid": 0,
"expires": 0,
"itemData": {
"assetId": 177003,
"attributeList": [
{
"index": 0,
"value": 74
},
{
"index": 1,
"value": 75
},
{
"index": 2,
"value": 86
},
{
"index": 3,
"value": 89
},
{
"index": 4,
"value": 72
},
{
"index": 5,
"value": 66
}
],
"cardassetid": 177003,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 177003,
"discardValue": 1500,
"fitness": 99,
"id": 100000127,
"itemState": "free",
"itemType": "player",
"leagueId": 53,
"nation": 10,
"owners": 1,
"playStyle": 250,
"preferredPosition": "CM",
"rareflag": 1,
"rating": 89,
"resourceId": 177003,
"teamid": 243,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 0,
"tradeId": 1000000127,
"tradeState": "inactive",
"watched": false
},
{
"bidState": "none",
"buyNowPrice": 0,
"coinsProcessed": 0,
"currentBid": 0,
"expires": 0,
"itemData": {
"assetId": 158023,
"attributeList": [
{
"index": 0,
"value": 89
},
{
"index": 1,
"value": 90
},
{
"index": 2,
"value": 86
},
{
"index": 3,
"value": 96
},
{
"index": 4,
"value": 26
},
{
"index": 5,
"value": 61
}
],
"cardassetid": 158023,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 158023,
"discardValue": 1500,
"fitness": 99,
"id": 100000059,
"itemState": "free",
"itemType": "player",
"leagueId": 53,
"nation": 52,
"owners": 1,
"playStyle": 250,
"preferredPosition": "RW",
"rareflag": 1,
"rating": 93,
"resourceId": 158023,
"teamid": 241,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 0,
"tradeId": 1000000059,
"tradeState": "inactive",
"watched": false
},
{
"bidState": "none",
"buyNowPrice": 0,
"coinsProcessed": 0,
"currentBid": 0,
"expires": 0,
"itemData": {
"assetId": 158023,
"attributeList": [
{
"index": 0,
"value": 89
},
{
"index": 1,
"value": 90
},
{
"index": 2,
"value": 86
},
{
"index": 3,
"value": 96
},
{
"index": 4,
"value": 26
},
{
"index": 5,
"value": 61
}
],
"cardassetid": 158023,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 158023,
"discardValue": 1500,
"fitness": 99,
"id": 100000079,
"itemState": "free",
"itemType": "player",
"leagueId": 53,
"nation": 52,
"owners": 1,
"playStyle": 250,
"preferredPosition": "RW",
"rareflag": 1,
"rating": 93,
"resourceId": 158023,
"teamid": 241,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 0,
"tradeId": 1000000079,
"tradeState": "inactive",
"watched": false
}
],
"credits": 29843976,
"duplicateItemIdList": [],
"total": 7
}
@@ -1,190 +0,0 @@
{
"auctionInfo": [
{
"bidState": "none",
"buyNowPrice": 15000,
"coinsProcessed": 0,
"currentBid": 0,
"expires": 0,
"itemData": {
"assetId": 20801,
"attributeList": [
{
"index": 0,
"value": 92
},
{
"index": 1,
"value": 92
},
{
"index": 2,
"value": 81
},
{
"index": 3,
"value": 91
},
{
"index": 4,
"value": 33
},
{
"index": 5,
"value": 80
}
],
"cardassetid": 20801,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 20801,
"discardValue": 1500,
"fitness": 99,
"id": 100000097,
"itemState": "listFS",
"itemType": "player",
"leagueId": 53,
"nation": 38,
"owners": 1,
"playStyle": 250,
"preferredPosition": "LW",
"rareflag": 1,
"rating": 94,
"resourceId": 20801,
"teamid": 243,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 1000000097,
"tradeState": "expired",
"watched": false
},
{
"bidState": "none",
"buyNowPrice": 15000,
"coinsProcessed": 0,
"currentBid": 0,
"expires": 2996,
"itemData": {
"assetId": 158023,
"attributeList": [
{
"index": 0,
"value": 89
},
{
"index": 1,
"value": 90
},
{
"index": 2,
"value": 86
},
{
"index": 3,
"value": 96
},
{
"index": 4,
"value": 26
},
{
"index": 5,
"value": 61
}
],
"cardassetid": 158023,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 158023,
"discardValue": 1500,
"fitness": 99,
"id": 100000155,
"itemState": "listFS",
"itemType": "player",
"leagueId": 53,
"nation": 52,
"owners": 1,
"playStyle": 250,
"preferredPosition": "RW",
"rareflag": 1,
"rating": 93,
"resourceId": 158023,
"teamid": 241,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 1000000155,
"tradeState": "active",
"watched": false
},
{
"bidState": "none",
"buyNowPrice": 0,
"coinsProcessed": 0,
"currentBid": 0,
"expires": 0,
"itemData": {
"assetId": 158023,
"attributeList": [
{
"index": 0,
"value": 89
},
{
"index": 1,
"value": 90
},
{
"index": 2,
"value": 86
},
{
"index": 3,
"value": 96
},
{
"index": 4,
"value": 26
},
{
"index": 5,
"value": 61
}
],
"cardassetid": 158023,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 158023,
"discardValue": 1500,
"fitness": 99,
"id": 100000059,
"itemState": "free",
"itemType": "player",
"leagueId": 53,
"nation": 52,
"owners": 1,
"playStyle": 250,
"preferredPosition": "RW",
"rareflag": 1,
"rating": 93,
"resourceId": 158023,
"teamid": 241,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 0,
"tradeId": 1000000059,
"tradeState": "inactive",
"watched": false
}
],
"credits": 29843976,
"duplicateItemIdList": [],
"total": 3
}
@@ -1,66 +0,0 @@
{
"auctionInfo": [
{
"bidState": "none",
"buyNowPrice": 15000,
"coinsProcessed": 0,
"currentBid": 0,
"expires": 2220,
"itemData": {
"assetId": 20801,
"attributeList": [
{
"index": 0,
"value": 92
},
{
"index": 1,
"value": 92
},
{
"index": 2,
"value": 81
},
{
"index": 3,
"value": 91
},
{
"index": 4,
"value": 33
},
{
"index": 5,
"value": 80
}
],
"cardassetid": 20801,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 20801,
"discardValue": 1500,
"fitness": 99,
"id": 100000097,
"itemState": "listFS",
"itemType": "player",
"leagueId": 53,
"nation": 38,
"owners": 1,
"playStyle": 250,
"preferredPosition": "LW",
"rareflag": 1,
"rating": 94,
"resourceId": 20801,
"teamid": 243,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 1000000097,
"tradeState": "active",
"watched": false
}
],
"credits": 29843976
}
@@ -1,66 +0,0 @@
{
"auctionInfo": [
{
"bidState": "none",
"buyNowPrice": 15000,
"coinsProcessed": 0,
"currentBid": 0,
"expires": 1199,
"itemData": {
"assetId": 20801,
"attributeList": [
{
"index": 0,
"value": 92
},
{
"index": 1,
"value": 92
},
{
"index": 2,
"value": 81
},
{
"index": 3,
"value": 91
},
{
"index": 4,
"value": 33
},
{
"index": 5,
"value": 80
}
],
"cardassetid": 20801,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 20801,
"discardValue": 1500,
"fitness": 99,
"id": 100000097,
"itemState": "listFS",
"itemType": "player",
"leagueId": 53,
"nation": 38,
"owners": 1,
"playStyle": 250,
"preferredPosition": "LW",
"rareflag": 1,
"rating": 94,
"resourceId": 20801,
"teamid": 243,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 1000000097,
"tradeState": "active",
"watched": false
}
],
"credits": 29843976
}
@@ -1,7 +0,0 @@
{
"count": 1,
"maxAuctionsAllowed": 100,
"offered": 0,
"selling": 1,
"sold": 0
}
@@ -1,68 +0,0 @@
{
"auctionInfo": [
{
"bidState": "none",
"buyNowPrice": 15000,
"coinsProcessed": 0,
"currentBid": 0,
"expires": 1199,
"itemData": {
"assetId": 20801,
"attributeList": [
{
"index": 0,
"value": 92
},
{
"index": 1,
"value": 92
},
{
"index": 2,
"value": 81
},
{
"index": 3,
"value": 91
},
{
"index": 4,
"value": 33
},
{
"index": 5,
"value": 80
}
],
"cardassetid": 20801,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 20801,
"discardValue": 1500,
"fitness": 99,
"id": 100000097,
"itemState": "listFS",
"itemType": "player",
"leagueId": 53,
"nation": 38,
"owners": 1,
"playStyle": 250,
"preferredPosition": "LW",
"rareflag": 1,
"rating": 94,
"resourceId": 20801,
"teamid": 243,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 1000000097,
"tradeState": "active",
"watched": false
}
],
"credits": 29843976,
"duplicateItemIdList": [],
"total": 1
}
@@ -1,242 +0,0 @@
{
"100000059": {
"assetId": 158023,
"attributeList": [
{
"index": 0,
"value": 89
},
{
"index": 1,
"value": 90
},
{
"index": 2,
"value": 86
},
{
"index": 3,
"value": 96
},
{
"index": 4,
"value": 26
},
{
"index": 5,
"value": 61
}
],
"cardassetid": 158023,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 158023,
"discardValue": 1500,
"fitness": 99,
"id": 100000059,
"itemState": "free",
"itemType": "player",
"leagueId": 53,
"nation": 52,
"owners": 1,
"playStyle": 250,
"preferredPosition": "RW",
"rareflag": 1,
"rating": 93,
"resourceId": 158023,
"teamid": 241,
"untradeable": false
},
"100000122": {
"assetId": 177003,
"attributeList": [
{
"index": 0,
"value": 74
},
{
"index": 1,
"value": 75
},
{
"index": 2,
"value": 86
},
{
"index": 3,
"value": 89
},
{
"index": 4,
"value": 72
},
{
"index": 5,
"value": 66
}
],
"cardassetid": 177003,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 177003,
"discardValue": 1500,
"fitness": 99,
"id": 100000122,
"itemState": "free",
"itemType": "player",
"leagueId": 53,
"nation": 10,
"owners": 1,
"playStyle": 250,
"preferredPosition": "CM",
"rareflag": 1,
"rating": 89,
"resourceId": 177003,
"teamid": 243,
"untradeable": false
},
"100000127": {
"assetId": 177003,
"attributeList": [
{
"index": 0,
"value": 74
},
{
"index": 1,
"value": 75
},
{
"index": 2,
"value": 86
},
{
"index": 3,
"value": 89
},
{
"index": 4,
"value": 72
},
{
"index": 5,
"value": 66
}
],
"cardassetid": 177003,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 177003,
"discardValue": 1500,
"fitness": 99,
"id": 100000127,
"itemState": "free",
"itemType": "player",
"leagueId": 53,
"nation": 10,
"owners": 1,
"playStyle": 250,
"preferredPosition": "CM",
"rareflag": 1,
"rating": 89,
"resourceId": 177003,
"teamid": 243,
"untradeable": false
},
"100004866": {
"assetId": 163824,
"attributeList": [
{
"index": 0,
"value": 70
},
{
"index": 1,
"value": 43
},
{
"index": 2,
"value": 56
},
{
"index": 3,
"value": 62
},
{
"index": 4,
"value": 82
},
{
"index": 5,
"value": 82
}
],
"cardassetid": 163824,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 163824,
"discardValue": 900,
"fitness": 99,
"id": 100004866,
"itemState": "free",
"itemType": "player",
"leagueId": 13,
"nation": 50,
"owners": 1,
"playStyle": 250,
"preferredPosition": "CB",
"rareflag": 1,
"rating": 83,
"resourceId": 163824,
"teamid": 7,
"untradeable": false
},
"100004867": {
"assetId": 172610,
"attributeList": [
{
"index": 0,
"value": 69
},
{
"index": 1,
"value": 33
},
{
"index": 2,
"value": 47
},
{
"index": 3,
"value": 57
},
{
"index": 4,
"value": 80
},
{
"index": 5,
"value": 76
}
],
"cardassetid": 172610,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 172610,
"discardValue": 900,
"fitness": 99,
"id": 100004867,
"itemState": "free",
"itemType": "player",
"leagueId": 31,
"nation": 54,
"owners": 1,
"playStyle": 250,
"preferredPosition": "CB",
"rareflag": 1,
"rating": 80,
"resourceId": 172610,
"teamid": 54,
"untradeable": false
}
}
@@ -1,308 +0,0 @@
==============================================================================
== auctionInfo deserializer 0x18013e410 — full decompilation
==============================================================================
/* WARNING: Function: __security_check_cookie replaced with injection: security_check_cookie */
undefined4 FUN_18013e410(undefined8 param_1,longlong param_2,undefined8 param_3)
{
longlong *plVar1;
undefined1 *puVar2;
undefined1 *puVar3;
undefined ***pppuVar4;
undefined ***pppuVar5;
char cVar6;
int iVar7;
int iVar8;
undefined8 uVar9;
longlong *plVar10;
undefined4 uVar11;
undefined1 auStack_138 [32];
undefined **local_118;
undefined8 local_110;
longlong local_108;
undefined8 local_100;
undefined **local_f8;
undefined1 local_f0 [32];
undefined1 local_d0;
longlong *local_c8;
undefined8 local_c0;
undefined1 local_b0 [36];
undefined4 local_8c;
undefined4 local_88;
undefined4 local_84;
undefined4 local_80;
int local_70;
undefined8 local_68;
undefined1 local_58 [8];
longlong local_50;
longlong *local_48;
int local_40;
undefined1 local_3c;
undefined1 local_3b;
undefined1 local_3a;
undefined1 local_39;
ulonglong local_38;
local_100 = 0xfffffffffffffffe;
local_38 = DAT_1802db6e0 ^ (ulonglong)auStack_138;
iVar8 = 0x38c;
FUN_18010f5f0(&local_f8);
iVar7 = FUN_1801c7f10(param_2);
while (iVar7 != 10) {
if (*(int *)(param_2 + 0xd0) == 0xb) {
iVar8 = FUN_180180d00(*(undefined8 *)(param_2 + 0xf8));
iVar7 = FUN_1801c7f10(param_2);
if (iVar7 != 6) goto LAB_18013e498;
}
else {
LAB_18013e498:
if (iVar8 < 0x2b8) {
if (iVar8 == 0x2b7) {
uVar9 = FUN_1801c7aa0(param_2);
FUN_180008120(local_b0,uVar9,0x1e);
}
else if (iVar8 < 0x117) {
if (iVar8 == 0x116) {
local_68 = FUN_1801c79d0(param_2);
}
else if (iVar8 == 0x57) {
uVar9 = FUN_1801c7aa0(param_2);
local_40 = FUN_180166380(uVar9);
}
else if (iVar8 == 0x65) {
uVar9 = FUN_1801c79d0(param_2);
local_88 = FUN_1800d7b30(uVar9);
}
else {
if (iVar8 != 0xc1) goto LAB_18013e59b;
uVar9 = FUN_1801c79d0(param_2);
local_80 = FUN_1800d7b30(uVar9);
}
}
else if (iVar8 == 0x16b) {
FUN_18013fe00(local_58,param_2,0);
}
else if (iVar8 == 0x2b6) {
uVar9 = FUN_1801c79d0(param_2);
local_8c = FUN_1800d7b30(uVar9);
}
else {
LAB_18013e59b:
FUN_180135ff0(iVar8,param_2);
}
}
else if (iVar8 == 0x2e6) {
uVar9 = FUN_1801c79d0(param_2);
local_84 = FUN_1800d7b30(uVar9);
}
else if (iVar8 == 0x2f4) {
uVar9 = FUN_1801c79d0(param_2);
local_39 = FUN_1800d7b50(uVar9);
}
else if (iVar8 == 0x331) {
local_c0 = FUN_1801c79d0(param_2);
}
else if (iVar8 == 0x335) {
uVar9 = FUN_1801c7aa0(param_2);
local_70 = FUN_180166bd0(uVar9);
}
else {
if (iVar8 != 0x380) goto LAB_18013e59b;
cVar6 = FUN_1801c7620(param_2);
local_3c = cVar6 != '\0';
}
}
iVar7 = FUN_1801c7f10(param_2);
}
if (local_70 == 4) {
local_3b = local_40 != 0;
}
else {
local_3b = (local_40 - 1U & 0xfffffffd) == 0;
}
local_3a = local_40 - 2U < 2;
local_d0 = 1;
FUN_180007f60(local_f0,param_3,1);
plVar10 = (longlong *)FUN_18011a830();
if (local_48 == (longlong *)0x0) {
local_110 = 0;
local_118 = &PTR_LAB_180214de8;
local_108 = 0;
(**(code **)(*plVar10 + 0xa00))(plVar10,&local_118,local_c0);
if (local_108 == 0) {
uVar11 = 1;
goto LAB_18013e795;
}
if ((local_58 != (undefined1 *)(local_108 + 0xa0)) &&
(plVar1 = *(longlong **)(local_108 + 0xb0), local_48 != plVar1)) {
if (local_48 != (longlong *)0x0) {
puVar2 = (undefined1 *)*local_48;
puVar3 = *(undefined1 **)(puVar2 + 8);
if (local_58 == puVar2) {
*local_48 = (longlong)puVar3;
}
else {
for (; puVar3 != (undefined1 *)0x0; puVar3 = *(undefined1 **)(puVar3 + 8)) {
if (puVar3 == local_58) {
*(undefined8 *)(puVar2 + 8) = *(undefined8 *)(puVar3 + 8);
break;
}
puVar2 = puVar3;
}
}
}
local_48 = plVar1;
if (plVar1 != (longlong *)0x0) {
local_50 = *plVar1;
*plVar1 = (longlong)local_58;
}
}
local_118 = &PTR_LAB_180214de8;
if (local_108 != 0) {
pppuVar4 = *(undefined ****)(local_108 + 0x30);
pppuVar5 = (undefined ***)pppuVar4[1];
if (&local_118 == pppuVar4) {
*(undefined ****)(local_108 + 0x30) = pppuVar5;
}
else {
for (; pppuVar5 != (undefined ***)0x0; pppuVar5 = (undefined ***)pppuVar5[1]) {
if (pppuVar5 == &local_118) {
pppuVar4[1] = pppuVar5[1];
break;
}
pppuVar4 = pppuVar5;
}
}
}
}
(**(code **)(*plVar10 + 0x9f8))(plVar10,param_1,&local_f8);
uVar11 = 10;
LAB_18013e795:
FUN_18000a3c0(local_58);
local_f8 = &PTR_FUN_18021b520;
while (local_c8 != (longlong *)0x0) {
plVar10 = (longlong *)local_c8[1];
(**(code **)(*local_c8 + 8))();
local_c8 = plVar10;
}
return uVar11;
}
==============================================================================
== Flash publisher 0x1801bf030 — full decompilation
==============================================================================
void FUN_1801bf030(longlong *param_1,longlong param_2,undefined8 param_3,undefined8 param_4)
{
longlong lVar1;
longlong *plVar2;
longlong lVar3;
int iVar4;
undefined4 local_res8 [2];
undefined4 local_res10 [2];
undefined4 local_res18 [2];
undefined4 local_res20 [2];
undefined1 *local_b0;
undefined1 *local_a8;
undefined1 *local_a0;
longlong *local_98;
undefined4 local_90;
char *local_88;
longlong local_80 [2];
longlong local_70;
longlong *local_68;
(**(code **)(*param_1 + 8))
(param_1,(*(longlong *)(param_2 + 0x38) - *(longlong *)(param_2 + 0x30)) / 0x18,*param_1
,param_4,0xfffffffffffffffe);
iVar4 = 0;
lVar3 = *(longlong *)(param_2 + 0x30);
if (lVar3 != *(longlong *)(param_2 + 0x38)) {
do {
lVar1 = *(longlong *)(lVar3 + 0x10);
FUN_1800d8fa0(*(undefined8 *)(lVar1 + 0x38),local_res10,local_res8);
(**(code **)(*param_1 + 0x20))(param_1,iVar4,"TRADEID_LOWER",local_res8[0]);
(**(code **)(*param_1 + 0x20))(param_1,iVar4,"TRADEID_UPPER",local_res10[0]);
FUN_1801a9200(local_80,*(undefined4 *)(lVar1 + 0x6c));
local_98 = (longlong *)FUN_1800d7370();
local_90 = 1;
local_88 = "FUT String";
local_b0 = &DAT_1802e13a0;
local_a8 = &DAT_1802e13a0;
local_a0 = &DAT_1802e13a1;
if (*(longlong *)(lVar1 + 0x90) - 1U < 0x20f5800) {
FUN_1801c2e50(&local_b0);
}
else {
plVar2 = (longlong *)FUN_18019e320();
(**(code **)(*plVar2 + 400))(plVar2,&local_b0,"FUT_AUCTION_EXPIRED",1);
}
(**(code **)(*param_1 + 0x30))(param_1,iVar4,"DURATION",local_b0);
(**(code **)(*param_1 + 0x20))(param_1,iVar4,"TIME_REMAINING",*(undefined4 *)(lVar1 + 0x90));
(**(code **)(*param_1 + 0x20))(param_1,iVar4,"MIN_CREDITS",*(undefined4 *)(lVar1 + 0x78));
(**(code **)(*param_1 + 0x20))(param_1,iVar4,"MAX_CREDITS",*(undefined4 *)(lVar1 + 0x70));
(**(code **)(*param_1 + 0x20))(param_1,iVar4,"RESERVEDPRICE",*(undefined4 *)(lVar1 + 0x74));
(**(code **)(*param_1 + 0x20))(param_1,iVar4,"YOURBID",*(undefined4 *)(lVar1 + 0xb8));
(**(code **)(*param_1 + 0x20))(param_1,iVar4,"STATE",*(undefined4 *)(lVar1 + 0x88));
(**(code **)(*param_1 + 0x20))(param_1,iVar4,"COINS_AWARDED",*(undefined1 *)(lVar1 + 0xbf));
FUN_1800d8fa0(*(undefined8 *)(*(longlong *)(lVar1 + 0xb0) + 8),local_res18,local_res20);
(**(code **)(*param_1 + 0x20))(param_1,iVar4,"UUID_UPPER",local_res18[0]);
(**(code **)(*param_1 + 0x20))(param_1,iVar4,"UUID_LOWER",local_res20[0]);
(**(code **)(*param_1 + 0x20))
(param_1,iVar4,"CARD_ID",*(undefined4 *)(*(longlong *)(lVar1 + 0xb0) + 0x18));
(**(code **)(*param_1 + 0x20))
(param_1,iVar4,"FIFA_ID",*(uint *)(*(longlong *)(lVar1 + 0xb0) + 0x18) & 0xffffff);
(**(code **)(*param_1 + 0x20))
(param_1,iVar4,"CARD_TYPE",*(undefined4 *)(*(longlong *)(lVar1 + 0xb0) + 0x4c));
(**(code **)(*param_1 + 0x20))
(param_1,iVar4,"CARD_OFFERSTATE",*(undefined4 *)(*(longlong *)(lVar1 + 0xb0) + 0x5c)
);
(**(code **)(*param_1 + 0x20))(param_1,iVar4,"IS_WATCHED",*(undefined1 *)(lVar1 + 0xbc));
(**(code **)(*param_1 + 0x20))(param_1,iVar4,"INBOX",*(undefined1 *)(lVar1 + 0xbe));
(**(code **)(*param_1 + 0x20))(param_1,iVar4,"IS_GLOW",*(undefined1 *)(lVar1 + 0xbd));
(**(code **)(*param_1 + 0x18))(param_1,iVar4,"TRADE_DATA_AVAILABLE",1);
if (1 < (longlong)local_a0 - (longlong)local_b0) {
if (local_b0 != (undefined1 *)0x0) {
(**(code **)(*local_98 + 0x18))(local_98,local_b0,(int)local_a0 - (int)local_b0);
}
}
if ((1 < local_70 - local_80[0]) && (local_80[0] != 0)) {
(**(code **)(*local_68 + 0x18))(local_68,local_80[0],(int)local_70 - (int)local_80[0]);
}
lVar3 = lVar3 + 0x18;
iVar4 = iVar4 + 1;
} while (lVar3 != *(longlong *)(param_2 + 0x38));
}
return;
}
==============================================================================
== strings: sold / clear / inbox / glow / collect
==============================================================================
sold 0x180228bed 'sold'
sold 0x18022f524 'sold'
Sold 0x1801efaf1 'SoldFromTradePile'
Sold 0x1802302cf 'SoldBid'
Sold 0x1802302df 'SoldBuyNow'
SOLD 0x18020a0c7 'SOLD'
clearSold ABSENT
ClearSold ABSENT
INBOX 0x180215044 'INBOX'
inbox ABSENT
IS_GLOW 0x1802151a0 'IS_GLOW'
glow ABSENT
Glow ABSENT
collect 0x1802307e0 'collector'
Collect 0x1801ec3c1 'CollectedCards'
Collect 0x1801ec3f8 'Collection'
Collect 0x1801ec422 'Collection'
Collect 0x1801ec449 'Collection'
unclaimed 0x18022fd68 'unclaimedPrizesChampionEvents'
claim 0x1802099d1 'claimedPack'
claim 0x1802099e9 'claimedPack2'
claim 0x18022fd6a 'claimedPrizesChampionEvents'
@@ -1,30 +0,0 @@
"""Q1 — what does FIFA17 do with tradeState == closed(4)?
Targets the ONE native tradeState consumer branch (cmp [rbp-0x38],0x4 at
0x18013e619) inside the auctionInfo deserializer 0x18013e410, and the Flash
publisher 0x1801bf030.
"""
print("=" * 78)
print("== auctionInfo deserializer 0x18013e410 — full decompilation")
print("=" * 78)
print(dec(0x18013E410))
print("=" * 78)
print("== Flash publisher 0x1801bf030 — full decompilation")
print("=" * 78)
print(dec(0x1801BF030))
print("=" * 78)
print("== strings: sold / clear / inbox / glow / collect")
print("=" * 78)
for needle in (b"sold", b"Sold", b"SOLD", b"clearSold", b"ClearSold",
b"INBOX", b"inbox", b"IS_GLOW", b"glow", b"Glow",
b"collect", b"Collect", b"unclaimed", b"claim"):
hits = find_all(needle, blocks=(".rdata", ".data", ".text"))
for h in hits[:14]:
s = rd_str(h, 90)
if s and len(s) > 2:
print(f" {needle.decode():12s} 0x{h:x} {s!r}")
if not hits:
print(f" {needle.decode():12s} ABSENT")
@@ -1,114 +0,0 @@
==============================================================================
== neighbourhood of SoldBid/SoldBuyNow (0x1802302cf) — read the whole table
==============================================================================
0x180230280 'auctionLostBidRejected'
0x180230298 'auctionLostOutbid'
0x1802302b0 'auctionLostOutbidSelf'
0x1802302c8 'auctionSoldBid'
0x1802302d8 'auctionSoldBuyNow'
0x1802302f0 'auctionWonBid'
0x180230300 'auctionWonBuyNow'
0x180230318 'authToken'
0x180230328 'available'
0x180230338 'awaykit'
0x180230340 'awardCount'
0x180230350 'awardedPrizes'
0x180230360 'awardItemData'
0x180230370 'awardMappings'
0x180230380 'awardType'
0x180230390 'awardSet'
0x1802303a0 'awardSetId'
0x1802303ac 'awards'
0x1802303b8 'awardValue'
0x1802303c8 'badgeDBid'
0x1802303d4 'badges'
0x1802303dc 'balls'
0x1802303e8 'BEGINNER'
0x1802303f8 'bestBuilderScore'
==============================================================================
== xrefs to each sold-ish string
==============================================================================
sold@228bed 0 xref(s)
sold@22f524 1 xref(s)
from 0x1802d3da8 DATA in ? (entry 0x0)
SoldFromTradePile 0 xref(s)
SoldBid 0 xref(s)
SoldBuyNow 0 xref(s)
SOLD@20a0c7 0 xref(s)
==============================================================================
== who reads record +0xbf (COINS_AWARDED) / +0xbe (INBOX) / +0xbd (IS_GLOW)
== i.e. the deserializer's coinsProcessed + the two computed flags
==============================================================================
-- decoder for atom 0x2f4 (coinsProcessed): FUN_1800d7b50
ulonglong FUN_1800d7b50(longlong param_1)
{
ulonglong in_RAX;
byte bVar1;
if (0 < param_1) {
bVar1 = 0xff;
if (param_1 < 0xff) {
bVar1 = (byte)param_1;
}
return (ulonglong)bVar1;
}
return in_RAX & 0xffffffffffffff00;
}
==============================================================================
== route table 0x18021df80 — all 45 rows, looking for a clear/sold route
==============================================================================
[ 0] 0x18021df80 -> 0x18021e308 'ut/%s/auctionhouse'
[ 1] 0x18021df88 -> 0x18021e320 'AUCTIONHOUSE'
[ 2] 0x18021df90 -> 0x18021e330 'ut/%s/clubUser'
[ 3] 0x18021df98 -> 0x18021e340 'CLUB_USER'
[ 4] 0x18021dfa0 -> 0x18021e350 'ut/%s/user/list'
[ 5] 0x18021dfa8 -> 0x1801fa0b8 'CLUB_INFO'
[ 6] 0x18021dfb0 -> 0x18021e360 'ut/%s/club'
[ 7] 0x18021dfb8 -> 0x1801f6438 'CLUB'
[ 8] 0x18021dfc0 -> 0x18021e370 'ut/%s/defid'
[ 9] 0x18021dfc8 -> 0x18021e37c 'DREAM'
[10] 0x18021dfd0 -> 0x18021e388 'ut/%s/squad'
[11] 0x18021dfd8 -> 0x18021e394 'SQUAD'
[12] 0x18021dfe0 -> 0x18021e3a0 'ut/delete/%s/squad'
[13] 0x18021dfe8 -> 0x18021e3b8 'DELETE_SQUAD'
[14] 0x18021dff0 -> 0x18021e3c8 'ut/%s/leaderboards/options'
[15] 0x18021dff8 -> 0x18021e3e8 'LBOPTIONS'
[16] 0x18021e000 -> 0x18021e3f8 'ut/%s/leaderboards'
[17] 0x18021e008 -> 0x18021e410 'LBDEFAULT'
[18] 0x18021e010 -> 0x18021e420 'ut/%s/activeMessage'
[19] 0x18021e018 -> 0x18021e438 'PAFPRACTICE'
[20] 0x18021e020 -> 0x18021e444 'ut/%s'
[21] 0x18021e028 -> 0x18021e44c 'UT'
[22] 0x18021e030 -> 0x18021e450 'ut/%s/user'
[23] 0x18021e038 -> 0x18021e45c 'USER'
[24] 0x18021e040 -> 0x18021e468 'ut/delete/%s/user'
[25] 0x18021e048 -> 0x18021e480 'DELETEUSER'
[26] 0x18021e050 -> 0x18021e490 'ut/%s/item'
[27] 0x18021e058 -> 0x18021e49c 'ITEMS'
[28] 0x18021e060 -> 0x18021e4a8 'ut/%s/item/resource'
[29] 0x18021e068 -> 0x18021e4c0 'ITEMS_BY_RES'
[30] 0x18021e070 -> 0x18021e4d0 'ut/delete/%s/item'
[31] 0x18021e078 -> 0x18021e4e8 'DELETEITEMS'
[32] 0x18021e080 -> 0x18021e4f8 'ut/%s/match'
[33] 0x18021e088 -> 0x1801ecc34 'MATCH'
[34] 0x18021e090 -> 0x18021e508 'ut/%s/sbs'
[35] 0x18021e098 -> 0x18021e514 'SBC'
[36] 0x18021e0a0 -> 0x18021e518 'ut/%s/tournament'
[37] 0x18021e0a8 -> 0x18021e530 'TOURNAMENT'
[38] 0x18021e0b0 -> 0x18021e540 'ut/%s/tournament/user'
[39] 0x18021e0b8 -> 0x18021e558 'TOURNAMENTUSER'
[40] 0x18021e0c0 -> 0x18021e568 'ut/delete/%s/tournament/user'
[41] 0x18021e0c8 -> 0x18021e588 'TOURNAMENTQUIT'
[42] 0x18021e0d0 -> 0x18021e598 'ut/%s/season'
[43] 0x18021e0d8 -> 0x180209fc4 'SEASON'
[44] 0x18021e0e0 -> 0x18021e5a8 'ut/%s/season/user'
[45] 0x18021e0e8 -> 0x18021e5c0 'SEASONUSER'
[46] 0x18021e0f0 -> 0x18021e5d0 'ut/%s/season/%%s/user'
[47] 0x18021e0f8 -> 0x18021e5e8 'SEASONUSER_ALTER'
@@ -1,50 +0,0 @@
"""Q2 — the sold vocabulary: SoldBid / SoldBuyNow / SoldFromTradePile / sold,
the coinsProcessed -> COINS_AWARDED path, and the route table (clear-sold?).
"""
print("=" * 78)
print("== neighbourhood of SoldBid/SoldBuyNow (0x1802302cf) — read the whole table")
print("=" * 78)
a = 0x180230280
while a < 0x180230400:
s = rd_str(a, 60)
if s and len(s) > 1 and s.isprintable():
print(f" 0x{a:x} {s!r}")
a += len(s.encode()) + 1
else:
a += 1
print()
print("=" * 78)
print("== xrefs to each sold-ish string")
print("=" * 78)
for name, sa in (("sold@228bed", 0x180228BED), ("sold@22f524", 0x18022F524),
("SoldFromTradePile", 0x1801EFAF1), ("SoldBid", 0x1802302CF),
("SoldBuyNow", 0x1802302DF), ("SOLD@20a0c7", 0x18020A0C7)):
xs = xrefs_to(sa)
print(f" {name:20s} {len(xs)} xref(s)")
for x in xs[:8]:
print(f" from 0x{x[0]:x} {x[1]} in {x[2]} (entry 0x{x[3]:x})")
print()
print("=" * 78)
print("== who reads record +0xbf (COINS_AWARDED) / +0xbe (INBOX) / +0xbd (IS_GLOW)")
print("== i.e. the deserializer's coinsProcessed + the two computed flags")
print("=" * 78)
print("-- decoder for atom 0x2f4 (coinsProcessed): FUN_1800d7b50")
print(dec(0x1800D7B50))
print()
print("=" * 78)
print("== route table 0x18021df80 — all 45 rows, looking for a clear/sold route")
print("=" * 78)
for i in range(48):
ea = 0x18021DF80 + i * 8
try:
p = qword(ea)
except Exception:
break
s = ""
if 0x180000000 < p < 0x180400000:
s = rd_str(p, 120)
print(f" [{i:2d}] 0x{ea:x} -> 0x{p:x} {s!r}")
@@ -1,123 +0,0 @@
==============================================================================
== route table continued from 0x18021e0f8 until it stops looking like one
==============================================================================
0x18021e100 -> 0x18021e600 'ut/%s/season/%%s/reset'
0x18021e108 -> 0x18021e618 'SEASONRESET'
0x18021e110 -> 0x18021e628 'ut/%s/season/friendly'
0x18021e118 -> 0x18021e640 'FRIENDLYSEASON'
0x18021e120 -> 0x18021e650 'ut/%s/purchased'
0x18021e128 -> 0x18021e660 'PURCHASED'
0x18021e130 -> 0x18021e670 'ut/%s/store'
0x18021e138 -> 0x18021e67c 'STORE'
0x18021e140 -> 0x18021e688 'ut/%s/watchList'
0x18021e148 -> 0x18021e698 'WATCHLIST'
0x18021e150 -> 0x18021e6a8 'ut/delete/%s/watchList'
0x18021e158 -> 0x18021e6c0 'DELETEWATCHLIST'
0x18021e160 -> 0x18021e6d0 'ut/%s/tradePile'
0x18021e168 -> 0x18021e6e0 'TRADEPILE'
0x18021e170 -> 0x18021e6f0 'ut/%s/trade'
0x18021e178 -> 0x18021e6fc 'TRADE'
0x18021e180 -> 0x18021e708 'ut/delete/%s/trade'
0x18021e188 -> 0x18021e720 'DELETETRADE'
0x18021e190 -> 0x18021e730 'ut/%s/marketdata'
0x18021e198 -> 0x18021e748 'MARKETDATA'
0x18021e1a0 -> 0x18021e758 'ut/%s/clientdata'
0x18021e1a8 -> 0x18021e770 'CLIENTDATA'
0x18021e1b0 -> 0x18021e780 'ut/auth'
0x18021e1b8 -> 0x18021e788 'AUTH'
0x18021e1c0 -> 0x18021e790 'ut/delete/auth'
0x18021e1c8 -> 0x18021e7a0 'DELETE_AUTH'
0x18021e1d0 -> 0x18021e7b0 'ut/%s/phishing'
0x18021e1d8 -> 0x18021e7c0 'PHISHING'
0x18021e1e0 -> 0x18021e7d0 'ut/%s/captcha'
0x18021e1e8 -> 0x18021e7e0 'CAPTCHA'
0x18021e1f0 -> 0x18021e7e8 'ut/%s/tfa'
0x18021e1f8 -> 0x18021e7f4 'TFA'
0x18021e200 -> 0x18021e7f8 'ut/%s/squad/mode'
0x18021e208 -> 0x18021e810 'SQUADMODE'
0x18021e210 -> 0x18021e820 'ut/%s/draft/mode'
0x18021e218 -> 0x18021e834 'DRAFT'
0x18021e220 -> 0x18021e840 'ut/%s/champion'
0x18021e228 -> 0x18021e850 'CHAMPIONS'
0x18021e230 -> 0x18021e860 'ut/v2/%s/store'
0x18021e238 -> 0x18021e870 'V2STORE'
0x18021e240 -> 0x18021e878 'ut/%s/livemessage'
0x18021e248 -> 0x18021e890 'LIVEMESSAGE'
0x18021e258 -> 0x18021e89c 'ADMIN'
0x18021e268 -> 0x18021e8a4 'DEBUG'
0x18021e278 -> 0x18021e8b0 'MAINTENANCE'
==============================================================================
== itemState table 0x180229cc0 (prior work: 12 rows) — dump generously
==============================================================================
[ 0] 0x180229cc0 str=0x18022a2a0 'invalid' val=0 (0x0)
[ 1] 0x180229cd0 str=0x18022a2a8 'free' val=1 (0x1)
[ 2] 0x180229ce0 str=0x18022a2b0 'WAITING_FOR_GAME' val=2 (0x2)
[ 3] 0x180229cf0 str=0x18022a2c4 'inGame' val=2 (0x2)
[ 4] 0x180229d00 str=0x18022a2d0 'forSale' val=5 (0x5)
[ 5] 0x180229d10 str=0x18022a2d8 'offered' val=6 (0x6)
[ 6] 0x180229d20 str=0x18022a2e0 'activeBadge' val=100 (0x64)
[ 7] 0x180229d30 str=0x18022a2f0 'activeHomeKit' val=101 (0x65)
[ 8] 0x180229d40 str=0x18022a300 'activeAwayKit' val=102 (0x66)
[ 9] 0x180229d50 str=0x18022a310 'activeBall' val=103 (0x67)
[10] 0x180229d60 str=0x18022a320 'activeStadium' val=104 (0x68)
[11] 0x180229d70 str=0x180221c04 'active' val=255 (0xff)
[12] 0x180229d80 str=0x0 '' val=4294967295 (0xffffffff)
[13] 0x180229d90 str=0x18022a330 'none' val=8 (0x8)
[14] 0x180229da0 str=0x18022a338 'head' val=0 (0x0)
[15] 0x180229db0 str=0x18022a340 'shoulder' val=1 (0x1)
[16] 0x180229dc0 str=0x18022a34c 'arm' val=2 (0x2)
[17] 0x180229dd0 str=0x1802009d0 'back' val=3 (0x3)
[18] 0x180229de0 str=0x18022a350 'hip' val=4 (0x4)
[19] 0x180229df0 str=0x18022a354 'leg' val=5 (0x5)
==============================================================================
== tradeState table 0x180229e40 — dump generously
==============================================================================
[ 0] 0x180229e40 str=0x180221c04 'active' val=1 (0x1)
[ 1] 0x180229e50 str=0x18022a360 'inactive' val=2 (0x2)
[ 2] 0x180229e60 str=0x18022a370 'expired' val=3 (0x3)
[ 3] 0x180229e70 str=0x18022a378 'closed' val=4 (0x4)
[ 4] 0x180229e80 str=0x0 '' val=0 (0x0)
[ 5] 0x180229e90 str=0x4e4f5f53544f4853 '' val=1380013151 (0x5241545f)
[ 6] 0x180229ea0 str=0x4653534543435553 '' val=1415531605 (0x545f4c55)
[ 7] 0x180229eb0 str=0x5345 '' val=1314017091 (0x4e524f43)
[ 8] 0x180229ec0 str=0x4548534e41454c43 '' val=5461061 (0x535445)
[ 9] 0x180229ed0 str=0x5f474e4953534150 '' val=1129465168 (0x43524550)
[10] 0x180229ee0 str=0x4547 '' val=1397968720 (0x53534f50)
[11] 0x180229ef0 str=0x45435245505f4e4f '' val=1195463758 (0x4741544e)
==============================================================================
== pointer arrays containing the sold vocabulary
==============================================================================
auctionSoldBid pointer found at: ['0x1802d2928']
auctionSoldBuyNow pointer found at: ['0x1802d2930']
auctionWonBuyNow pointer found at: ['0x1802d2940']
sold@22f524 pointer found at: ['0x1802d3da8']
sold@228bed pointer found at: NOWHERE
SoldFromTradePile pointer found at: NOWHERE
SOLD@20a0c7 pointer found at: NOWHERE
==============================================================================
== the .data array around 0x1802d3da8 (the one DATA xref to 'sold')
==============================================================================
0x1802d3d68 -> 0x1801e98ac 'shots'
0x1802d3d70 -> 0x18022f4c8 'shotsOnTarget'
0x1802d3d78 -> 0x18022f4d8 'silhouetteName'
0x1802d3d80 -> 0x18022f4e8 'silName'
0x1802d3d88 -> 0x1801ec108 'silver'
0x1802d3d90 -> 0x18022f4f0 'silverQuantity'
0x1802d3d98 -> 0x18022f500 'sizeBeforeEncode'
0x1802d3da0 -> 0x18022f518 'slotIndex'
0x1802d3da8 -> 0x18022f524 'sold' <<<
0x1802d3db0 -> 0x18022f52c 'sort'
0x1802d3db8 -> 0x18022f538 'sortPriority'
0x1802d3dc0 -> 0x18022ed30 'source'
0x1802d3dc8 -> 0x18022f548 'squad'
0x1802d3dd0 -> 0x18022f550 'squadActives'
0x1802d3dd8 -> 0x18022f560 'squadBuildingSetsClientData'
0x1802d3de0 -> 0x18022f580 'squadBuildingSetsGracePeriodMinutes'
0x1802d3de8 -> 0x18022f5a8 'squadChallenge'
0x1802d3df0 -> 0x18022f5b8 'squadId'
0x1802d3df8 -> 0x18022f5c0 'squadName'
0x1802d3e00 -> 0x18022f5d0 'squadList'
@@ -1,68 +0,0 @@
"""Q3 — full route table, the itemState/tradeState enum tables, and the pointer
arrays that reference the auctionSold* vocabulary and the bare 'sold' string.
"""
import struct
print("=" * 78)
print("== route table continued from 0x18021e0f8 until it stops looking like one")
print("=" * 78)
a = 0x18021E100
misses = 0
while a < 0x18021E400 and misses < 6:
p = qword(a)
s = rd_str(p, 120) if 0x180000000 < p < 0x180400000 else ""
if s and s.isprintable():
print(f" 0x{a:x} -> 0x{p:x} {s!r}")
misses = 0
else:
misses += 1
a += 8
print()
print("=" * 78)
print("== itemState table 0x180229cc0 (prior work: 12 rows) — dump generously")
print("=" * 78)
for i in range(20):
ea = 0x180229CC0 + i * 16
p, v = qword(ea), dword(ea + 8)
s = rd_str(p, 40) if 0x180000000 < p < 0x180400000 else ""
print(f" [{i:2d}] 0x{ea:x} str=0x{p:x} {s!r:24s} val={v} (0x{v:x})")
print()
print("=" * 78)
print("== tradeState table 0x180229e40 — dump generously")
print("=" * 78)
for i in range(12):
ea = 0x180229E40 + i * 16
p, v = qword(ea), dword(ea + 8)
s = rd_str(p, 40) if 0x180000000 < p < 0x180400000 else ""
print(f" [{i:2d}] 0x{ea:x} str=0x{p:x} {s!r:24s} val={v} (0x{v:x})")
print()
print("=" * 78)
print("== pointer arrays containing the sold vocabulary")
print("=" * 78)
for label, target in (("auctionSoldBid", 0x1802302C8),
("auctionSoldBuyNow", 0x1802302D8),
("auctionWonBuyNow", 0x180230300),
("sold@22f524", 0x18022F524),
("sold@228bed", 0x180228BED),
("SoldFromTradePile", 0x1801EFAF1),
("SOLD@20a0c7", 0x18020A0C7)):
pat = struct.pack("<Q", target)
hits = find_all(pat, blocks=(".rdata", ".data", ".text"))
print(f" {label:20s} pointer found at: {[hex(h) for h in hits] or 'NOWHERE'}")
print()
print("=" * 78)
print("== the .data array around 0x1802d3da8 (the one DATA xref to 'sold')")
print("=" * 78)
for i in range(-8, 12):
ea = 0x1802D3DA8 + i * 8
try:
p = qword(ea)
except Exception:
continue
s = rd_str(p, 50) if 0x180000000 < p < 0x180400000 else ""
mark = " <<<" if i == 0 else ""
print(f" 0x{ea:x} -> 0x{p:x} {s!r}{mark}")
@@ -1,67 +0,0 @@
"""Q4 — is the atom ID the index into the alphabetical atom-name pointer table?
Validate against the twelve KNOWN auctionInfo atoms. If all twelve agree on one
base, the table is the atom dictionary and we can read the ID of any name.
"""
import struct
KNOWN = { # atom id -> name, from the confirmed auctionInfo deserializer
0x57: "bidState", 0x65: "buyNowPrice", 0xC1: "currentBid", 0x116: "expires",
0x16B: "itemData", 0x2B6: "sellerEstablished", 0x2B7: "sellerName",
0x2E6: "startingBid", 0x2F4: "coinsProcessed", 0x331: "tradeId",
0x335: "tradeState", 0x380: "watched",
}
def str_addr(name):
"""Address of the exact NUL-terminated string `name`."""
for h in find_all(name.encode() + b"\x00", blocks=(".rdata", ".data")):
return h
return None
def ptr_addr(sa):
hits = find_all(struct.pack("<Q", sa), blocks=(".rdata", ".data"))
return hits[0] if hits else None
print("=" * 78)
print("== solve for the table base using the twelve known atoms")
print("=" * 78)
bases = {}
for aid, name in sorted(KNOWN.items()):
sa = str_addr(name)
pa = ptr_addr(sa) if sa else None
if pa is None:
print(f" 0x{aid:03x} {name:20s} string=0x{sa or 0:x} pointer=NOT FOUND")
continue
base = pa - 8 * aid
bases[base] = bases.get(base, 0) + 1
print(f" 0x{aid:03x} {name:20s} str=0x{sa:x} ptr=0x{pa:x} -> base 0x{base:x}")
print()
print(" base histogram:", {hex(b): n for b, n in sorted(bases.items(), key=lambda kv: -kv[1])})
if not bases:
raise SystemExit("no bases resolved")
BASE = max(bases, key=bases.get)
print(f" CONSENSUS BASE = 0x{BASE:x} ({bases[BASE]}/{len(KNOWN)} atoms agree)")
print()
print("=" * 78)
print("== read the atom id of every sold/counts-related name")
print("=" * 78)
for name in ("sold", "selling", "offered", "count", "maxAuctionsAllowed",
"auctionSoldBid", "auctionSoldBuyNow", "auctionWonBid",
"auctionWonBuyNow", "auctionLostOutbid", "auctionLostOutbidSelf",
"auctionLostBidRejected", "credits", "coins", "tradeIdStr",
"itemState", "offers", "bids", "watched", "expires"):
sa = str_addr(name)
pa = ptr_addr(sa) if sa else None
if pa is None:
print(f" {name:24s} ABSENT from the table")
continue
off = pa - BASE
if off % 8:
print(f" {name:24s} ptr=0x{pa:x} misaligned (off {off})")
continue
print(f" {name:24s} ptr=0x{pa:x} ATOM ID = 0x{off // 8:x} ({off // 8})")
@@ -1,80 +0,0 @@
==============================================================================
== atom ids, resolved against the table range only
==============================================================================
sold str=0x18022f524 ptr=0x1802d3da8 ATOM ID = 0x2c9 (713)
count str=0x180230abc ptr=0x1802d2d40 ATOM ID = 0xbc (188)
offered str=0x18022a2d8 ptr=0x1802d3688 ATOM ID = 0x1e5 (485)
selling str=0x18022f460 ptr=0x1802d3d20 ATOM ID = 0x2b8 (696)
maxAuctionsAllowed str=0x1802318a8 ptr=0x1802d3558 ATOM ID = 0x1bf (447)
credits str=0x180230af8 ptr=0x1802d2d60 ATOM ID = 0xc0 (192)
auctionInfo str=0x180230270 ptr=0x1802d2908 ATOM ID = 0x35 (53)
itemData str=0x180231438 ptr=0x1802d32b8 ATOM ID = 0x16b (363)
bidState str=0x180230458 ptr=0x1802d2a18 ATOM ID = 0x57 (87)
tradeState str=0x18022fb48 ptr=0x1802d4108 ATOM ID = 0x335 (821)
coinsProcessed str=0x18022f768 ptr=0x1802d3f00 ATOM ID = 0x2f4 (756)
offers str=0x180231ae4 ptr=0x1802d3690 ATOM ID = 0x1e6 (486)
duplicateItemIdList str=0x180230d18 ptr=0x1802d2ec0 ATOM ID = 0xec (236)
total str=0x18022fa4c ptr=0x1802d4088 ATOM ID = 0x325 (805)
==============================================================================
== functions that compare against >=3 of the auction-outcome atoms 0x36..0x3c
==============================================================================
scanned 13308 functions
0x180043b90 FUN_180043b90 sees ['0x36', '0x37', '0x38', '0x3c']
0x1800455b0 FUN_1800455b0 sees ['0x37', '0x38', '0x39']
0x18006b4c0 FUN_18006b4c0 sees ['0x38', '0x39', '0x3c']
0x180094ce0 FUN_180094ce0 sees ['0x36', '0x37', '0x38']
0x180097c70 FUN_180097c70 sees ['0x36', '0x37', '0x38']
0x1800cabb0 FUN_1800cabb0 sees ['0x38', '0x39', '0x3a']
0x1800cf3b0 FUN_1800cf3b0 sees ['0x36', '0x38', '0x3a', '0x3c']
0x1800e6410 FUN_1800e6410 sees ['0x36', '0x37', '0x38', '0x39']
0x1800ee7c0 FUN_1800ee7c0 sees ['0x36', '0x37', '0x38', '0x39', '0x3a', '0x3c']
0x1800fb5b0 FUN_1800fb5b0 sees ['0x38', '0x39', '0x3a', '0x3b']
0x1801071f0 FUN_1801071f0 sees ['0x36', '0x37', '0x38', '0x3a', '0x3b', '0x3c']
0x18012b370 FUN_18012b370 sees ['0x36', '0x37', '0x38', '0x39', '0x3a', '0x3b', '0x3c']
0x18012fd40 FUN_18012fd40 sees ['0x36', '0x37', '0x38', '0x3c']
0x18013fe00 FUN_18013fe00 sees ['0x36', '0x38', '0x3a', '0x3b']
0x180163770 FUN_180163770 sees ['0x36', '0x38', '0x3a']
0x180172d00 FUN_180172d00 sees ['0x38', '0x3a', '0x3c']
==============================================================================
== string neighbourhoods: SoldFromTradePile / SOLD / sold
==============================================================================
-- SoldFromTradePile
0x1801efa40 'CardsRequestStaffBonuses'
0x1801efa60 'CardsGetStaffBonuses'
0x1801efa78 'WatchListRequest'
0x1801efa90 'WatchListResultsRequest'
0x1801efaa8 'AddToWatchList'
0x1801efab8 'RemoveFromWatchList'
0x1801efad0 'RemoveFromTradePile'
0x1801efae8 'RemoveAllSoldFromTradePile'
0x1801efb08 'GetPhishingQuestion'
0x1801efb20 'SetPhishingAnswer'
0x1801efb38 'ValidatePhishingAnswer'
0x1801efb50 'RetrieveTrustedConsoleList'
0x1801efb70 'EnableLiveStreaming'
-- SOLD
0x18020a040 'D_PRIZES'
0x18020a050 'EVENT_ID'
0x18020a05c 'TEXT0'
0x18020a068 'FUT_UC_SQUAD'
0x18020a078 'TEXT1'
0x18020a080 'FUT_TOTAL_AUCTIONS'
0x18020a098 'FUT_UC_ITEMS'
0x18020a0a8 'FUT_TF_SELLING'
0x18020a0b8 'TEXT2'
0x18020a0c0 'FUT_TF_SOLD'
0x18020a0cc 'TEXT3'
0x18020a0d8 'FUT_TF_WINNING'
0x18020a0e8 'FUT_TF_OUTBID'
0x18020a0f8 'FUT_GH_TOTAL_PLAYERS_0'
0x18020a110 'FUT_GH_TOTAL_PLAYERS_1'
0x18020a128 'FUT_GH_TOP_SCORER'
-- sold@228bed
0x180228bc8 'RS4:FutISRemoveTradeServerResponse'
0x180228bec '/sold'
0x180228c00 '\\&'
0x180228c11 "'"
0x180228c28 "X'"
0x180228c38 "`'"
@@ -1,83 +0,0 @@
"""Q5 — fix the atom lookup, then find the CONSUMER of atoms 0x36..0x3c
(auctionLost*/auctionSold*/auctionWon*) and read the sold-related string
neighbourhoods.
"""
import struct
BASE = 0x1802D2760
TABLE_LO, TABLE_HI = 0x1802D2760, 0x1802D4800
def atom_id(name):
"""Every string with this exact spelling, then the pointer that lies INSIDE
the atom table. Taking the first string match is wrong: common words appear
in several unrelated tables."""
out = []
for sa in find_all(name.encode() + b"\x00", blocks=(".rdata", ".data")):
for pa in find_all(struct.pack("<Q", sa), blocks=(".rdata", ".data")):
if TABLE_LO <= pa < TABLE_HI and (pa - BASE) % 8 == 0:
out.append((sa, pa, (pa - BASE) // 8))
return out
print("=" * 78)
print("== atom ids, resolved against the table range only")
print("=" * 78)
for name in ("sold", "count", "offered", "selling", "maxAuctionsAllowed",
"credits", "auctionInfo", "itemData", "bidState", "tradeState",
"coinsProcessed", "offers", "duplicateItemIdList", "total"):
res = atom_id(name)
if not res:
print(f" {name:22s} ABSENT from the atom table")
for sa, pa, aid in res:
print(f" {name:22s} str=0x{sa:x} ptr=0x{pa:x} ATOM ID = 0x{aid:x} ({aid})")
print()
print("=" * 78)
print("== functions that compare against >=3 of the auction-outcome atoms 0x36..0x3c")
print("=" * 78)
WANT = set(range(0x36, 0x3D))
hits = {}
fi = fm.getFunctions(True)
n = 0
for f in fi:
n += 1
ent = int(f.getEntryPoint().getOffset())
body = f.getBody()
seen = set()
it = listing.getInstructions(body, True)
while it.hasNext():
ins = it.next()
m = ins.getMnemonicString()
if m not in ("CMP", "SUB", "MOV", "LEA"):
continue
for i in range(ins.getNumOperands()):
for o in ins.getOpObjects(i):
try:
v = int(o.getValue())
except Exception:
continue
if v in WANT:
seen.add(v)
if len(seen) >= 3:
hits[ent] = (f.getName(), sorted(hex(v) for v in seen))
print(f" scanned {n} functions")
for ent, (nm, vals) in sorted(hits.items()):
print(f" 0x{ent:x} {nm:28s} sees {vals}")
print()
print("=" * 78)
print("== string neighbourhoods: SoldFromTradePile / SOLD / sold")
print("=" * 78)
for label, lo, hi in (("SoldFromTradePile", 0x1801EFA40, 0x1801EFB80),
("SOLD", 0x18020A040, 0x18020A140),
("sold@228bed", 0x180228B60, 0x180228C60)):
print(f"-- {label}")
a = lo
while a < hi:
s = rd_str(a, 70)
if s and len(s) > 1 and s.isprintable():
print(f" 0x{a:x} {s!r}")
a += len(s.encode()) + 1
else:
a += 1
@@ -1,469 +0,0 @@
==============================================================================
== (a) xrefs: request names + the /sold path suffix
==============================================================================
RemoveAllSoldFromTradePile 1 xref(s)
0x1800293fe DATA in FUN_180028b50 (entry 0x180028b50)
RemoveFromTradePile 1 xref(s)
0x1800293d4 DATA in FUN_180028b50 (entry 0x180028b50)
AddToWatchList 1 xref(s)
0x180029380 DATA in FUN_180028b50 (entry 0x180028b50)
/sold suffix 1 xref(s)
0x180164813 DATA in FUN_1801647c0 (entry 0x1801647c0)
RS4:FutISRemoveTradeServerResponse 2 xref(s)
0x1801defe9 DATA in Unwind@1801defd0 (entry 0x1801defd0)
0x18016488d DATA in FUN_180164860 (entry 0x180164860)
==============================================================================
== (b) xrefs: the Transfer List caption keys
==============================================================================
FUT_TOTAL_AUCTIONS 1 xref(s)
0x1800b1ef8 DATA in FUN_1800b1dc0 (entry 0x1800b1dc0)
FUT_UC_ITEMS 4 xref(s)
0x1800fbbdc DATA in FUN_1800fbae0 (entry 0x1800fbae0)
0x1800fbe5a DATA in FUN_1800fbae0 (entry 0x1800fbae0)
0x1800b1fe7 DATA in FUN_1800b1dc0 (entry 0x1800b1dc0)
0x1800b21a9 DATA in FUN_1800b1dc0 (entry 0x1800b1dc0)
FUT_TF_SELLING 1 xref(s)
0x1800b2023 DATA in FUN_1800b1dc0 (entry 0x1800b1dc0)
FUT_TF_SOLD 1 xref(s)
0x1800b208e DATA in FUN_1800b1dc0 (entry 0x1800b1dc0)
FUT_TF_WINNING 1 xref(s)
0x1800b21e5 DATA in FUN_1800b1dc0 (entry 0x1800b1dc0)
FUT_TF_OUTBID 1 xref(s)
0x1800b2250 DATA in FUN_1800b1dc0 (entry 0x1800b1dc0)
caption publisher candidates (entry -> #caption refs): {'0x1800b1dc0': 7, '0x1800fbae0': 2}
==============================================================================
== decompiled caption publisher 0x1800b1dc0
==============================================================================
void FUN_1800b1dc0(undefined8 param_1,longlong *param_2)
{
char cVar1;
undefined8 uVar2;
longlong lVar3;
longlong *plVar4;
ushort uVar5;
longlong *local_res10;
undefined1 *local_90;
undefined1 *local_88;
undefined1 *local_80;
longlong *local_78;
undefined4 local_70;
char *local_68;
undefined1 *local_60;
undefined1 *local_58;
undefined1 *local_50;
longlong *local_48;
undefined4 local_40;
char *local_38;
uVar2 = FUN_1800d7170();
FUN_180009c80(&local_res10,uVar2);
lVar3 = (**(code **)(*local_res10 + 0x1f8))(local_res10);
local_78 = (longlong *)FUN_1800d7370();
local_70 = 1;
local_68 = "FUT String";
local_90 = &DAT_1802e13a0;
local_88 = &DAT_1802e13a0;
local_80 = &DAT_1802e13a1;
local_48 = (longlong *)FUN_1800d7370();
local_40 = 1;
local_38 = "FUT String";
local_60 = &DAT_1802e13a0;
local_58 = &DAT_1802e13a0;
local_50 = &DAT_1802e13a1;
(**(code **)(*param_2 + 0x58))(param_2,4);
(**(code **)(*param_2 + 0x78))(param_2,0,"UPDATE_TYPE",PTR_s_PANEL_UPDATE_1802a4fb0);
(**(code **)(*param_2 + 0x70))(param_2,0,"TILE_ID",0x1b0);
(**(code **)(*param_2 + 0x70))(param_2,0,"ADDON_ENUM",5);
plVar4 = (longlong *)FUN_18019e320();
(**(code **)(*plVar4 + 0xb8))(plVar4,&local_90,*(undefined4 *)(lVar3 + 0x38));
(**(code **)(*param_2 + 0x78))(param_2,0,"TEXT0",local_90);
plVar4 = (longlong *)FUN_18019e320();
(**(code **)(*plVar4 + 400))(plVar4,&local_90,"FUT_TOTAL_AUCTIONS",1);
(**(code **)(*param_2 + 0x78))(param_2,0,"TEXT1",local_90);
(**(code **)(*param_2 + 0x78))(param_2,1,"UPDATE_TYPE",PTR_s_PANEL_UPDATE_1802a4fb0);
(**(code **)(*param_2 + 0x70))(param_2,1,"TILE_ID",0x1c0);
uVar5 = *(ushort *)(lVar3 + 0x1d6);
if (0x62 < uVar5) {
uVar5 = 99;
}
(**(code **)(*param_2 + 0x70))(param_2,1,"NOTIFICATION",uVar5);
(**(code **)(*param_2 + 0x70))(param_2,1,"ADDON_ENUM");
plVar4 = (longlong *)FUN_18019e320();
(**(code **)(*plVar4 + 0xb8))(plVar4,&local_90,*(undefined2 *)(lVar3 + 0x1d4));
(**(code **)(*param_2 + 0x78))(param_2,1,"TEXT0",local_90);
plVar4 = (longlong *)FUN_18019e320();
(**(code **)(*plVar4 + 400))(plVar4,&local_90,"FUT_UC_ITEMS",1);
(**(code **)(*param_2 + 0x78))(param_2,1,"TEXT1",local_90);
plVar4 = (longlong *)FUN_18019e320();
(**(code **)(*plVar4 + 400))(plVar4,&local_90,"FUT_TF_SELLING",1);
plVar4 = (longlong *)FUN_18019e320();
(**(code **)(*plVar4 + 0xb8))(plVar4,&local_60,*(undefined2 *)(lVar3 + 0x1d2));
FUN_18000fbd0(&local_90,local_90,local_60);
(**(code **)(*param_2 + 0x78))(param_2,1,"TEXT2",local_90);
plVar4 = (longlong *)FUN_18019e320();
(**(code **)(*plVar4 + 400))(plVar4,&local_90,"FUT_TF_SOLD",1);
plVar4 = (longlong *)FUN_18019e320();
(**(code **)(*plVar4 + 0xb8))(plVar4,&local_60,*(undefined2 *)(lVar3 + 0x1d8));
FUN_18000fbd0(&local_90,local_90,local_60);
(**(code **)(*param_2 + 0x78))(param_2,1,"TEXT3",local_90);
(**(code **)(*param_2 + 0x78))(param_2,2,"UPDATE_TYPE",PTR_s_PANEL_UPDATE_1802a4fb0);
(**(code **)(*param_2 + 0x70))(param_2,2,"TILE_ID",0x1d0);
uVar5 = 99;
if (*(ushort *)(lVar3 + 0x1ce) < 99) {
uVar5 = *(ushort *)(lVar3 + 0x1ce);
}
(**(code **)(*param_2 + 0x70))(param_2,2,"NOTIFICATION",uVar5);
(**(code **)(*param_2 + 0x70))(param_2,2,"ADDON_ENUM");
plVar4 = (longlong *)FUN_18019e320();
(**(code **)(*plVar4 + 0xb8))(plVar4,&local_90,*(undefined2 *)(lVar3 + 0x1ca));
(**(code **)(*param_2 + 0x78))(param_2,2,"TEXT0",local_90);
plVar4 = (longlong *)FUN_18019e320();
(**(code **)(*plVar4 + 400))(plVar4,&local_90,"FUT_UC_ITEMS",1);
(**(code **)(*param_2 + 0x78))(param_2,2,"TEXT1",local_90);
plVar4 = (longlong *)FUN_18019e320();
(**(code **)(*plVar4 + 400))(plVar4,&local_90,"FUT_TF_WINNING",1);
plVar4 = (longlong *)FUN_18019e320();
(**(code **)(*plVar4 + 0xb8))(plVar4,&local_60,*(undefined2 *)(lVar3 + 0x1c8));
FUN_18000fbd0(&local_90,local_90,local_60);
(**(code **)(*param_2 + 0x78))(param_2,2,"TEXT2",local_90);
plVar4 = (longlong *)FUN_18019e320();
(**(code **)(*plVar4 + 400))(plVar4,&local_90,"FUT_TF_OUTBID",1);
plVar4 = (longlong *)FUN_18019e320();
(**(code **)(*plVar4 + 0xb8))(plVar4,&local_60,*(undefined2 *)(lVar3 + 0x1cc));
FUN_18000fbd0(&local_90,local_90,local_60);
(**(code **)(*param_2 + 0x78))(param_2,2,"TEXT3",local_90);
uVar2 = FUN_1800264e0();
cVar1 = FUN_180026510(uVar2);
if (cVar1 == '\0') {
(**(code **)(*param_2 + 0x78))(param_2,3,"UPDATE_TYPE",PTR_s_PANEL_UPDATE_1802a4fb0);
(**(code **)(*param_2 + 0x70))(param_2,3,"TILE_ID",0x200);
(**(code **)(*param_2 + 0x70))(param_2,3,"ADDON_ENUM");
}
if ((1 < (longlong)local_50 - (longlong)local_60) && (local_60 != (undefined1 *)0x0)) {
(**(code **)(*local_48 + 0x18))(local_48,local_60,(int)local_50 - (int)local_60);
}
if ((1 < (longlong)local_80 - (longlong)local_90) && (local_90 != (undefined1 *)0x0)) {
(**(code **)(*local_78 + 0x18))(local_78,local_90,(int)local_80 - (int)local_90);
}
(**(code **)(*local_res10 + 8))(local_res10);
return;
}
==============================================================================
== decompiled caption publisher 0x1800fbae0
==============================================================================
/* WARNING: Function: __security_check_cookie replaced with injection: security_check_cookie */
/* WARNING: Globals starting with '_' overlap smaller symbols at the same address */
uint FUN_1800fbae0(undefined8 param_1,undefined4 param_2,undefined8 *param_3)
{
undefined1 *puVar1;
longlong lVar2;
uint uVar3;
int iVar4;
longlong *plVar5;
undefined8 uVar6;
longlong *plVar7;
longlong lVar8;
size_t sVar9;
size_t sVar10;
undefined1 auStack_2e8 [32];
undefined4 local_2c8;
undefined1 local_2b8;
uint local_2b4;
undefined4 local_2b0;
undefined4 uStack_2ac;
undefined1 *local_2a8;
undefined1 *local_2a0;
undefined1 *local_298;
longlong *local_290;
undefined4 local_288;
char *local_280;
undefined1 *local_278;
undefined1 *local_270;
undefined1 *local_268;
longlong *local_260;
undefined4 local_258;
char *local_250;
undefined1 *local_248;
undefined1 *local_240;
undefined1 *local_238;
longlong *local_230;
undefined4 local_228;
char *local_220;
longlong local_218;
undefined8 local_210;
longlong local_208;
longlong *local_200;
undefined4 local_1f8;
char *local_1f0;
void *local_1e8;
longlong local_1e0;
longlong local_1d8;
longlong *local_1d0;
undefined8 local_1b8;
longlong *local_1a8;
undefined8 local_1a0;
undefined8 local_198;
undefined8 local_190;
undefined4 local_188;
undefined2 local_184;
undefined8 local_180;
undefined8 local_178;
undefined8 local_170;
undefined8 local_168;
undefined2 local_160;
undefined4 local_15c;
ulonglong local_158;
undefined8 uStack_150;
undefined4 local_148;
undefined2 local_144;
undefined4 local_140;
undefined **local_138;
undefined8 local_130;
undefined8 local_128;
undefined8 local_120;
undefined8 local_118;
undefined8 local_110;
undefined8 local_108;
undefined8 local_100;
undefined4 local_f8;
undefined2 local_f4;
undefined1 local_f2;
undefined1 local_f0 [168];
ulonglong local_48;
local_1b8 = 0xfffffffffffffffe;
local_48 = DAT_1802db6e0 ^ (ulonglong)auStack_2e8;
local_200 = (longlong *)FUN_1800d7370();
local_218 = 0;
local_208 = 0;
local_1f8 = 1;
local_1f0 = "FUT Vector";
FUN_18004fff0(0,0,local_2b8);
local_210 = 0;
local_2b0 = 0;
uVar3 = FUN_1800fb8c0(param_1,param_2,&local_218,&local_2b0);
if ((undefined1 *)*param_3 != (undefined1 *)param_3[1]) {
*(undefined1 *)*param_3 = 0;
param_3[1] = *param_3;
}
local_2b4 = uVar3;
local_260 = (longlong *)FUN_1800d7370();
lVar2 = local_218;
local_258 = 1;
local_250 = "FUT String";
local_278 = &DAT_1802e13a0;
local_270 = &DAT_1802e13a0;
local_268 = &DAT_1802e13a1;
if (uVar3 < 2) {
if (uVar3 == 1) {
local_1a8 = (longlong *)0x0;
local_1a0 = 0;
local_198 = 0;
local_190 = 0;
local_188 = 0;
local_184 = 0;
local_180 = 0;
local_178 = 0;
local_170 = 0;
local_168 = 0;
local_160 = 0x100;
lVar8 = -1;
local_15c = 0xffffffff;
local_158 = _DAT_1801f66a0;
uStack_150 = _UNK_1801f66a8;
local_148 = 0;
local_144 = 0;
local_140 = 0;
local_130 = 0;
local_138 = &PTR_LAB_1801eaac0;
local_128 = 0;
local_120 = 0;
local_118 = 0;
local_f4 = 0;
local_f2 = 0;
local_110 = 0;
local_108 = 0;
local_100 = 0;
local_f8 = 0;
memset(local_f0,0,0xa0);
local_190 = CONCAT44(local_190._4_4_,*(undefined4 *)(lVar2 + 4)) & 0xffffffff00ffffff;
FUN_1801362e0(&local_1a8);
FUN_180141660(&local_1a8);
uVar6 = FUN_1800d7170();
FUN_180009c80(&local_2b0,uVar6);
plVar5 = (longlong *)CONCAT44(uStack_2ac,local_2b0);
local_2c8 = (undefined4)local_190;
(**(code **)(*plVar5 + 0x490))(plVar5,&local_1e8,local_15c,local_158 & 0xffffffff);
sVar10 = local_1e0 - (longlong)local_1e8;
sVar9 = sVar10;
if (0 < (longlong)sVar10) {
sVar9 = 0;
}
iVar4 = memcmp(local_1e8,&DAT_1801e9caf,sVar9);
if (((iVar4 == 0) && (-1 < (longlong)sVar10)) && ((longlong)sVar10 < 1)) {
local_230 = (longlong *)FUN_1800d7370();
local_228 = 1;
local_220 = "FUT String";
local_248 = &DAT_1802e13a0;
local_240 = &DAT_1802e13a0;
local_238 = &DAT_1802e13a1;
local_290 = (longlong *)FUN_1800d7370();
local_288 = 1;
local_280 = "FUT String";
local_2a8 = &DAT_1802e13a0;
local_2a0 = &DAT_1802e13a0;
local_298 = &DAT_1802e13a1;
FUN_18000fbd0(&local_248,"AWARD_LABEL_%i",*(undefined4 *)(lVar2 + 8));
plVar7 = (longlong *)FUN_18019e320();
puVar1 = local_248;
(**(code **)(*plVar7 + 400))(plVar7,&local_2a8,local_248,1);
sVar10 = (longlong)local_2a0 - (longlong)local_2a8;
sVar9 = sVar10;
if (0 < (longlong)sVar10) {
sVar9 = 0;
}
iVar4 = memcmp(local_2a8,&DAT_1801e9caf,sVar9);
if (((iVar4 == 0) && (-1 < (longlong)sVar10)) && ((longlong)sVar10 < 1)) {
plVar7 = (longlong *)FUN_18019e320();
(**(code **)(*plVar7 + 400))(plVar7,&local_2a8,"FUT_UC_ITEMS",1);
}
FUN_18000fbd0(param_3,&DAT_18021940c,*(undefined4 *)(lVar2 + 0xc));
do {
lVar8 = lVar8 + 1;
} while (local_2a8[lVar8] != '\0');
FUN_18000f650(param_3,local_2a8,local_2a8 + lVar8);
if (1 < (longlong)local_298 - (longlong)local_2a8) {
if (local_2a8 != (undefined1 *)0x0) {
(**(code **)(*local_290 + 0x18))(local_290,local_2a8,(int)local_298 - (int)local_2a8);
}
}
if (1 < (longlong)local_238 - (longlong)puVar1) {
if (puVar1 != (undefined1 *)0x0) {
(**(code **)(*local_230 + 0x18))(local_230,puVar1,(int)local_238 - (int)local_248);
}
}
}
else {
FUN_18000fbd0(param_3,&DAT_18021940c,*(undefined4 *)(lVar2 + 0xc));
do {
lVar8 = lVar8 + 1;
} while (*(char *)((longlong)local_1e8 + lVar8) != '\0');
FUN_18000f650(param_3,local_1e8,(longlong)local_1e8 + lVar8);
}
if ((1 < local_1d8 - (longlong)local_1e8) && (local_1e8 != (void *)0x0)) {
(**(code **)(*local_1d0 + 0x18))(local_1d0,local_1e8,(int)local_1d8 - (int)local_1e8);
}
(**(code **)(*plVar5 + 8))(plVar5);
FUN_18000a3c0(&local_138);
plVar5 = local_1a8;
while (uVar3 = local_2b4, plVar5 != (longlong *)0x0) {
plVar7 = (longlong *)plVar5[1];
(**(code **)(*plVar5 + 8))();
plVar5 = plVar7;
}
}
}
else {
plVar5 = (longlong *)FUN_18019e320();
(**(code **)(*plVar5 + 400))(plVar5,&local_278,"FUT_UC_ITEMS",1);
FUN_18000fbd0(param_3,"%i %s",local_2b0,local_278);
}
if (1 < (longlong)local_268 - (longlong)local_278) {
if (local_278 != (undefined1 *)0x0) {
(**(code **)(*local_260 + 0x18))(local_260,local_278,(int)local_268 - (int)local_278);
}
}
FUN_18004fff0(lVar2,local_210,local_2b8);
if (lVar2 != 0) {
(**(code **)(*local_200 + 0x18))(local_200,lVar2,((local_208 - lVar2) / 0x38) * 0x38);
}
return uVar3;
}
==============================================================================
== (c) GetAuctionCount deserializer FUN_180163770
==============================================================================
/* WARNING: Function: __security_check_cookie replaced with injection: security_check_cookie */
undefined8 FUN_180163770(undefined8 param_1,undefined8 *param_2)
{
undefined2 uVar1;
int iVar2;
undefined4 uVar3;
undefined8 uVar4;
longlong *plVar5;
longlong lVar6;
undefined1 auStack_168 [32];
int local_148 [2];
undefined8 local_140;
undefined1 local_138 [288];
ulonglong local_18;
local_140 = 0xfffffffffffffffe;
local_18 = DAT_1802db6e0 ^ (ulonglong)auStack_168;
FUN_1801c63e0(local_138,0,0);
local_148[0] = 0x38c;
uVar4 = FUN_180008130(*param_2);
FUN_1801c8270(local_138,*param_2,uVar4,0);
FUN_1801c7f10(local_138);
FUN_1801c7f10(local_138);
plVar5 = (longlong *)FUN_18011a830();
lVar6 = (**(code **)(*plVar5 + 0x130))(plVar5);
*(undefined4 *)(lVar6 + 0x30) = 0xffffffff;
*(undefined8 *)(lVar6 + 0x34) = 0;
iVar2 = FUN_1801c7f10(local_138);
while (iVar2 != 10) {
iVar2 = FUN_180141ee0(local_148,local_138);
if (iVar2 != 6) {
if (local_148[0] == 0xbc) {
uVar4 = FUN_1801c79d0(local_138);
uVar1 = FUN_1800d7b10(uVar4);
*(undefined2 *)(lVar6 + 0x34) = uVar1;
}
else if (local_148[0] == 0x1bf) {
uVar4 = FUN_1801c79d0(local_138);
uVar3 = FUN_1800d7af0(uVar4);
*(undefined4 *)(lVar6 + 0x30) = uVar3;
}
else if (local_148[0] == 0x1e5) {
uVar4 = FUN_1801c79d0(local_138);
uVar1 = FUN_1800d7b10(uVar4);
*(undefined2 *)(lVar6 + 0x3a) = uVar1;
}
else if (local_148[0] == 0x2b8) {
uVar4 = FUN_1801c79d0(local_138);
uVar1 = FUN_1800d7b10(uVar4);
*(undefined2 *)(lVar6 + 0x36) = uVar1;
}
else if (local_148[0] == 0x2c9) {
uVar4 = FUN_1801c79d0(local_138);
uVar1 = FUN_1800d7b10(uVar4);
*(undefined2 *)(lVar6 + 0x38) = uVar1;
}
else {
FUN_180135ff0(local_148[0],local_138);
}
}
iVar2 = FUN_1801c7f10(local_138);
}
*(undefined1 *)(lVar6 + 0x28) = 1;
FUN_1801c6560(local_138);
return 1;
}
@@ -1,51 +0,0 @@
"""Q6 — the decisive reads:
(a) who builds the /sold request (clear-all-sold) and what it is called;
(b) who publishes FUT_TF_SELLING / FUT_TF_SOLD / FUT_TF_WINNING / FUT_TF_OUTBID,
i.e. which count feeds the seller's SOLD bucket;
(c) the GetAuctionCount deserializer, to pin the five counts atoms.
"""
print("=" * 78)
print("== (a) xrefs: request names + the /sold path suffix")
print("=" * 78)
for label, a in (("RemoveAllSoldFromTradePile", 0x1801EFAE8),
("RemoveFromTradePile", 0x1801EFAD0),
("AddToWatchList", 0x1801EFAA8),
("/sold suffix", 0x180228BEC),
("RS4:FutISRemoveTradeServerResponse", 0x180228BC8)):
xs = xrefs_to(a)
print(f" {label:36s} {len(xs)} xref(s)")
for x in xs[:10]:
print(f" 0x{x[0]:x} {x[1]:12s} in {x[2]} (entry 0x{x[3]:x})")
print()
print("=" * 78)
print("== (b) xrefs: the Transfer List caption keys")
print("=" * 78)
caps = (("FUT_TOTAL_AUCTIONS", 0x18020A080), ("FUT_UC_ITEMS", 0x18020A098),
("FUT_TF_SELLING", 0x18020A0A8), ("FUT_TF_SOLD", 0x18020A0C0),
("FUT_TF_WINNING", 0x18020A0D8), ("FUT_TF_OUTBID", 0x18020A0E8))
owners = {}
for label, a in caps:
xs = xrefs_to(a)
print(f" {label:20s} {len(xs)} xref(s)")
for x in xs[:10]:
print(f" 0x{x[0]:x} {x[1]:12s} in {x[2]} (entry 0x{x[3]:x})")
owners[x[3]] = owners.get(x[3], 0) + 1
print()
print(" caption publisher candidates (entry -> #caption refs):",
{hex(k): v for k, v in sorted(owners.items(), key=lambda kv: -kv[1])})
for ent in sorted(owners, key=lambda e: -owners[e])[:2]:
print()
print("=" * 78)
print(f"== decompiled caption publisher 0x{ent:x}")
print("=" * 78)
print(dec(ent))
print()
print("=" * 78)
print("== (c) GetAuctionCount deserializer FUN_180163770")
print("=" * 78)
print(dec(0x180163770))
@@ -1,178 +0,0 @@
==============================================================================
== (a) hub tradePile sub-deserializer 0x18013ead0
== atom ids of interest: count=0xbc notification=0x1da selling=0x2b8 sold=0x2c9
==============================================================================
undefined4 FUN_18013ead0(longlong param_1,longlong param_2,longlong param_3)
{
undefined2 uVar1;
int iVar2;
int iVar3;
undefined8 uVar4;
iVar3 = 0x38c;
iVar2 = FUN_1801c7f10(param_3);
do {
if (iVar2 == 10) {
*(undefined1 *)(param_1 + 0x1da) = 1;
return 10;
}
if (*(int *)(param_3 + 0xd0) == 0xb) {
iVar3 = FUN_180180d00(*(undefined8 *)(param_3 + 0xf8));
iVar2 = FUN_1801c7f10(param_3);
if (iVar2 != 6) goto LAB_18013eb2e;
}
else {
LAB_18013eb2e:
if (iVar3 == 0xbc) {
uVar4 = FUN_1801c79d0(param_3);
uVar1 = FUN_1800d7b10(uVar4);
*(undefined2 *)(param_1 + 0x1d4) = uVar1;
}
else if (iVar3 == 0x1da) {
uVar4 = FUN_1801c79d0(param_3);
uVar1 = FUN_1800d7b10(uVar4);
*(undefined2 *)(param_1 + 0x1d6) = uVar1;
}
else if (iVar3 == 0x2b8) {
uVar4 = FUN_1801c79d0(param_3);
uVar1 = FUN_1800d7b10(uVar4);
*(undefined2 *)(param_1 + 0x1d2) = uVar1;
uVar4 = FUN_1801c79d0(param_3);
uVar1 = FUN_1800d7b10(uVar4);
*(undefined2 *)(param_2 + 0x36) = uVar1;
}
else if (iVar3 == 0x2c9) {
uVar4 = FUN_1801c79d0(param_3);
uVar1 = FUN_1800d7b10(uVar4);
*(undefined2 *)(param_1 + 0x1d8) = uVar1;
}
else {
FUN_180135ff0(iVar3,param_3);
}
}
iVar2 = FUN_1801c7f10(param_3);
} while( true );
}
==============================================================================
== (b) /sold request builder 0x1801647c0
==============================================================================
/* WARNING: Function: __security_check_cookie replaced with injection: security_check_cookie */
void FUN_1801647c0(longlong param_1,undefined8 param_2,undefined4 param_3)
{
undefined1 auStack_58 [32];
undefined1 local_38;
undefined8 local_37;
undefined8 local_2f;
undefined8 local_27;
undefined4 local_1f;
undefined2 local_1b;
undefined1 local_19;
ulonglong local_18;
local_18 = DAT_1802db6e0 ^ (ulonglong)auStack_58;
local_38 = 0;
local_37 = 0;
local_2f = 0;
local_27 = 0;
local_1f = 0;
local_1b = 0;
local_19 = 0;
if (*(longlong *)(param_1 + 0x10) == 0) {
FUN_180007f80(&local_38,0x20,"/sold");
}
else {
FUN_180007f80(&local_38,0x20,"/%lld");
}
FUN_180008100(param_2,&local_38,param_3);
return;
}
==============================================================================
== (c) FutGetAuctionCount deserializer 0x180163770
==============================================================================
/* WARNING: Function: __security_check_cookie replaced with injection: security_check_cookie */
undefined8 FUN_180163770(undefined8 param_1,undefined8 *param_2)
{
undefined2 uVar1;
int iVar2;
undefined4 uVar3;
undefined8 uVar4;
longlong *plVar5;
longlong lVar6;
undefined1 auStack_168 [32];
int local_148 [2];
undefined8 local_140;
undefined1 local_138 [288];
ulonglong local_18;
local_140 = 0xfffffffffffffffe;
local_18 = DAT_1802db6e0 ^ (ulonglong)auStack_168;
FUN_1801c63e0(local_138,0,0);
local_148[0] = 0x38c;
uVar4 = FUN_180008130(*param_2);
FUN_1801c8270(local_138,*param_2,uVar4,0);
FUN_1801c7f10(local_138);
FUN_1801c7f10(local_138);
plVar5 = (longlong *)FUN_18011a830();
lVar6 = (**(code **)(*plVar5 + 0x130))(plVar5);
*(undefined4 *)(lVar6 + 0x30) = 0xffffffff;
*(undefined8 *)(lVar6 + 0x34) = 0;
iVar2 = FUN_1801c7f10(local_138);
while (iVar2 != 10) {
iVar2 = FUN_180141ee0(local_148,local_138);
if (iVar2 != 6) {
if (local_148[0] == 0xbc) {
uVar4 = FUN_1801c79d0(local_138);
uVar1 = FUN_1800d7b10(uVar4);
*(undefined2 *)(lVar6 + 0x34) = uVar1;
}
else if (local_148[0] == 0x1bf) {
uVar4 = FUN_1801c79d0(local_138);
uVar3 = FUN_1800d7af0(uVar4);
*(undefined4 *)(lVar6 + 0x30) = uVar3;
}
else if (local_148[0] == 0x1e5) {
uVar4 = FUN_1801c79d0(local_138);
uVar1 = FUN_1800d7b10(uVar4);
*(undefined2 *)(lVar6 + 0x3a) = uVar1;
}
else if (local_148[0] == 0x2b8) {
uVar4 = FUN_1801c79d0(local_138);
uVar1 = FUN_1800d7b10(uVar4);
*(undefined2 *)(lVar6 + 0x36) = uVar1;
}
else if (local_148[0] == 0x2c9) {
uVar4 = FUN_1801c79d0(local_138);
uVar1 = FUN_1800d7b10(uVar4);
*(undefined2 *)(lVar6 + 0x38) = uVar1;
}
else {
FUN_180135ff0(local_148[0],local_138);
}
}
iVar2 = FUN_1801c7f10(local_138);
}
*(undefined1 *)(lVar6 + 0x28) = 1;
FUN_1801c6560(local_138);
return 1;
}
==============================================================================
== callers of the hub tradePile sub-deserializer (which response embeds it)
==============================================================================
0x180139610 FUN_180139610
@@ -1,31 +0,0 @@
"""Q7 — close it:
(a) hub tradePile sub-deserializer 0x18013ead0: which ATOM writes +0x1d8 (the
offset the FUT_TF_SOLD caption reads)?
(b) the /sold request builder 0x1801647c0 — bulk clear, or per-trade?
(c) FutGetAuctionCount deserializer 0x180163770 — the five counts atoms.
"""
print("=" * 78)
print("== (a) hub tradePile sub-deserializer 0x18013ead0")
print("== atom ids of interest: count=0xbc notification=0x1da selling=0x2b8 sold=0x2c9")
print("=" * 78)
print(dec(0x18013EAD0))
print()
print("=" * 78)
print("== (b) /sold request builder 0x1801647c0")
print("=" * 78)
print(dec(0x1801647C0))
print()
print("=" * 78)
print("== (c) FutGetAuctionCount deserializer 0x180163770")
print("=" * 78)
print(dec(0x180163770))
print()
print("=" * 78)
print("== callers of the hub tradePile sub-deserializer (which response embeds it)")
print("=" * 78)
for a, n in callers(0x18013EAD0):
print(f" 0x{a:x} {n}")
@@ -1,25 +0,0 @@
"""Q8 — are atoms 0x36..0x3c (auctionLost*/auctionSold*/auctionWon*) real response
keys? If a deserializer dispatches on them, the server tells the client HOW an
auction concluded via its own atom, and bidState is not the carrier.
Signature of a genuine atom dispatcher: the SAME local compared against many atom
ids, with FUN_180135ff0 (value-SKIP) as the else, and FUN_1801c7f10 driving the loop.
"""
CANDIDATES = (0x18012B370, 0x1801071F0, 0x1800EE7C0, 0x1800FB5B0, 0x1800CF3B0)
for ent in CANDIDATES:
print("=" * 78)
print(f"== 0x{ent:x} callees include value-SKIP? "
f"{any(c[0] == 0x180135FF0 for c in callees(ent))}"
f" atom-loop? {any(c[0] == 0x1801C7F10 for c in callees(ent))}")
print("=" * 78)
body = dec(ent)
# Only print if it smells like an atom dispatcher, else just report.
if "FUN_180135ff0" in body or "FUN_1801c7f10" in body:
print(body[:6000])
else:
print(" NOT an atom dispatcher (no value-SKIP, no atom loop) — small-immediate noise")
print(f" size {len(body)} chars; first 400:")
print(body[:400])
print()
@@ -1,62 +0,0 @@
==============================================================================
== localisation / string keys about tax, fee, net proceeds
==============================================================================
TAX (no printable hits)
tax (no printable hits)
Tax (no printable hits)
FEE 0x18023a3a8 'FEET_ATTEMPTED'
FEE 0x18023a3c0 'FEET_COMPLETED'
_FEE 0x18023a3a7 '_FEET_ATTEMPTED'
_FEE 0x18023a3bf '_FEET_COMPLETED'
fee (no printable hits)
NET 0x180209b1c 'NETOURNAMENT_0'
NET 0x180209b3c 'NETOURNAMENT_1'
NET 0x18020dd10 'NET'
_NET 0x18020dd0f '_NET'
RECEIVE 0x18023933f 'RECEIVER'
RECEIVE 0x180239350 'RECEIVER'
RECEIVE 0x18023983f 'RECEIVER'
RECEIVE 0x18023990d 'RECEIVER'
RECEIVE 0x180239a0b 'RECEIVER'
RECEIVE 0x18023a06f 'RECEIVED'
Receive (no printable hits)
PROCEEDS (no printable hits)
COMMISSION (no printable hits)
EA_TAX (no printable hits)
FUT_TF_ 0x18020a0a8 'FUT_TF_SELLING'
FUT_TF_ 0x18020a0c0 'FUT_TF_SOLD'
FUT_TF_ 0x18020a0d8 'FUT_TF_WINNING'
FUT_TF_ 0x18020a0e8 'FUT_TF_OUTBID'
AFTER_TAX (no printable hits)
earnings (no printable hits)
Earnings (no printable hits)
==============================================================================
== float constants 0.95 / 0.05 / 0.95f / 0.05f
==============================================================================
double 0.95 ABSENT
double 0.05 ABSENT
float 0.95f ABSENT
float 0.05f ABSENT
==============================================================================
== functions using BOTH 95 (or 5) and 100 as immediates — integer fee math
==============================================================================
17 candidate(s)
0x18000b5c0 FUN_18000b5c0 immediates [5, 100]
0x180051cd0 FUN_180051cd0 immediates [5, 20, 100]
0x180057b00 FUN_180057b00 immediates [5, 100]
0x180082c30 FUN_180082c30 immediates [5, 100]
0x180088cb0 FUN_180088cb0 immediates [5, 100]
0x1800a3cb0 FUN_1800a3cb0 immediates [5, 100]
0x1800a47b0 FUN_1800a47b0 immediates [5, 100]
0x1800d5050 FUN_1800d5050 immediates [5, 20, 100]
0x1800d5450 FUN_1800d5450 immediates [5, 20, 100]
0x1801129f0 FUN_1801129f0 immediates [5, 100]
0x18013af30 FUN_18013af30 immediates [5, 20, 100]
0x18013ec10 FUN_18013ec10 immediates [5, 100]
0x18013fe00 FUN_18013fe00 immediates [5, 100]
0x180147070 FUN_180147070 immediates [5, 100]
0x180180ea0 FUN_180180ea0 immediates [5, 100]
0x1801adae0 FUN_1801adae0 immediates [5, 100]
0x1801b9e60 FUN_1801b9e60 immediates [5, 100]
@@ -1,69 +0,0 @@
"""Task B — does FIFA17.exe itself compute the transfer fee / net proceeds?
If the list-item confirmation screen shows "you will receive N", the rounding rule
is IN THE BINARY and needs no live measurement. Look for:
* localisation keys mentioning tax/fee/net/receive
* float constants 0.95 / 0.05
* integer 95 or 5 used with 100 in the same function
"""
import struct
print("=" * 78)
print("== localisation / string keys about tax, fee, net proceeds")
print("=" * 78)
for needle in (b"TAX", b"tax", b"Tax", b"FEE", b"_FEE", b"fee", b"NET", b"_NET",
b"RECEIVE", b"Receive", b"PROCEEDS", b"COMMISSION", b"EA_TAX",
b"FUT_TF_", b"AFTER_TAX", b"earnings", b"Earnings"):
hits = find_all(needle, blocks=(".rdata", ".data"))
shown = 0
for h in hits:
s = rd_str(h, 70)
if s and len(s) > 2 and s.isprintable():
print(f" {needle.decode():10s} 0x{h:x} {s!r}")
shown += 1
if shown >= 12:
break
if not shown:
print(f" {needle.decode():10s} (no printable hits)")
print()
print("=" * 78)
print("== float constants 0.95 / 0.05 / 0.95f / 0.05f")
print("=" * 78)
for label, pat in (("double 0.95", struct.pack("<d", 0.95)),
("double 0.05", struct.pack("<d", 0.05)),
("float 0.95f", struct.pack("<f", 0.95)),
("float 0.05f", struct.pack("<f", 0.05))):
hits = find_all(pat, blocks=(".rdata", ".data"))
print(f" {label:12s} {[hex(h) for h in hits[:10]] or 'ABSENT'}")
for h in hits[:6]:
xs = xrefs_to(h)
for x in xs[:4]:
print(f" used at 0x{x[0]:x} in {x[2]} (entry 0x{x[3]:x})")
print()
print("=" * 78)
print("== functions using BOTH 95 (or 5) and 100 as immediates — integer fee math")
print("=" * 78)
found = []
for f in fm.getFunctions(True):
ent = int(f.getEntryPoint().getOffset())
vals = set()
it = listing.getInstructions(f.getBody(), True)
while it.hasNext():
ins = it.next()
if ins.getMnemonicString() not in ("IMUL", "MOV", "CMP", "ADD", "SUB", "LEA", "SHL"):
continue
for i in range(ins.getNumOperands()):
for o in ins.getOpObjects(i):
try:
v = int(o.getValue())
except Exception:
continue
if v in (95, 100, 5, 20):
vals.add(v)
if 100 in vals and (95 in vals or 5 in vals):
found.append((ent, f.getName(), sorted(vals)))
print(f" {len(found)} candidate(s)")
for ent, nm, vals in found[:40]:
print(f" 0x{ent:x} {nm:26s} immediates {vals}")
@@ -1,67 +0,0 @@
{
"champion": {
"bytes": 2,
"path": "/ut/game/fifa17/champion",
"status": 200
},
"clubUser": {
"bytes": 2,
"path": "/ut/game/fifa17/clubUser",
"status": 200
},
"defid": {
"bytes": 600,
"path": "/ut/game/fifa17/defid?definitionId=200389",
"status": 200
},
"defs_resource": {
"bytes": 600,
"path": "/ut/game/fifa17/item/resource?resourceId=200389",
"status": 200
},
"draft_state": {
"bytes": 118,
"path": "/ut/game/fifa17/squad/mode/draft/state",
"status": 200
},
"marketdata": {
"bytes": 110,
"path": "/ut/game/fifa17/marketdata/pricelimits?defId=200389,200104",
"status": 200
},
"sbs_sets": {
"bytes": 537,
"path": "/ut/game/fifa17/sbs/sets",
"status": 200
},
"season": {
"bytes": 2,
"path": "/ut/game/fifa17/season",
"status": 200
},
"squad_0": {
"bytes": 7792,
"path": "/ut/game/fifa17/squad/0",
"status": 200
},
"tournament": {
"bytes": 2,
"path": "/ut/game/fifa17/tournament",
"status": 200
},
"user": {
"bytes": 751,
"path": "/ut/game/fifa17/user",
"status": 200
},
"user_list": {
"bytes": 2,
"path": "/ut/game/fifa17/user/list",
"status": 200
},
"watchList": {
"bytes": 52,
"path": "/ut/game/fifa17/watchList",
"status": 200
}
}
@@ -1 +0,0 @@
{}
@@ -1 +0,0 @@
{}
@@ -1,53 +0,0 @@
{
"itemData": [
{
"assetId": 200389,
"attributeList": [
{
"index": 0,
"value": 70
},
{
"index": 1,
"value": 70
},
{
"index": 2,
"value": 70
},
{
"index": 3,
"value": 70
},
{
"index": 4,
"value": 70
},
{
"index": 5,
"value": 70
}
],
"cardType": 0,
"cardassetid": 200389,
"cardsubtypeid": 0,
"commodityId": 200389,
"commonName": "Player",
"definitionId": 200389,
"id": 200389,
"itemState": "free",
"itemType": "player",
"lastName": "Player",
"leagueId": 0,
"name": "Player",
"nation": 0,
"playStyle": 250,
"preferredPosition": "CM",
"rareflag": 1,
"rating": 75,
"resourceId": 200389,
"teamid": 0,
"untradeable": true
}
]
}
@@ -1,53 +0,0 @@
{
"itemData": [
{
"assetId": 200389,
"attributeList": [
{
"index": 0,
"value": 70
},
{
"index": 1,
"value": 70
},
{
"index": 2,
"value": 70
},
{
"index": 3,
"value": 70
},
{
"index": 4,
"value": 70
},
{
"index": 5,
"value": 70
}
],
"cardType": 0,
"cardassetid": 200389,
"cardsubtypeid": 0,
"commodityId": 200389,
"commonName": "Player",
"definitionId": 200389,
"id": 200389,
"itemState": "free",
"itemType": "player",
"lastName": "Player",
"leagueId": 0,
"name": "Player",
"nation": 0,
"playStyle": 250,
"preferredPosition": "CM",
"rareflag": 1,
"rating": 75,
"resourceId": 200389,
"teamid": 0,
"untradeable": true
}
]
}
@@ -1,9 +0,0 @@
[
{
"gamesWonCurrentMatch": 0,
"roundsInfo": [],
"squadState": "INVALID",
"stateParam1": "INVALID",
"stateParam2": "0"
}
]
@@ -1,12 +0,0 @@
[
{
"defId": 200389,
"maxPrice": 15000,
"minPrice": 150
},
{
"defId": 200104,
"maxPrice": 15000,
"minPrice": 150
}
]
@@ -1,35 +0,0 @@
{
"categories": [
{
"categoryId": 1,
"name": "Foundations",
"priority": 1,
"sets": [
{
"awards": [],
"categoryId": 1,
"challengesCompletedCount": 0,
"challengesCount": 1,
"description": "Submit an 11-player squad.",
"endTime": 4102444800,
"hidden": false,
"name": "Bronze Challenge",
"priority": 1,
"setId": 1
},
{
"awards": [],
"categoryId": 1,
"challengesCompletedCount": 0,
"challengesCount": 1,
"description": "Get started with your first SBC.",
"endTime": 4102444800,
"hidden": false,
"name": "Simple Start",
"priority": 2,
"setId": 2
}
]
}
]
}
@@ -1 +0,0 @@
{}
@@ -1,707 +0,0 @@
{
"actives": [],
"captain": 100000001,
"changed": 0,
"chemistry": 49,
"custom": "[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,50,50,0,50,40,65,0,65,50,50,1]",
"formation": "f433",
"id": 0,
"kicktakers": [
{
"dream": false,
"id": 100000001,
"index": 0
},
{
"dream": false,
"id": 100000001,
"index": 1
},
{
"dream": false,
"id": 100000001,
"index": 2
},
{
"dream": false,
"id": 100000001,
"index": 3
},
{
"dream": false,
"id": 100000001,
"index": 4
}
],
"manager": [
{
"dream": false,
"id": 100000427
}
],
"personaId": 33068179,
"players": [
{
"index": 0,
"itemData": {
"assetId": 200389,
"attributeList": [
{
"index": 0,
"value": 83
},
{
"index": 1,
"value": 90
},
{
"index": 2,
"value": 77
},
{
"index": 3,
"value": 82
},
{
"index": 4,
"value": 50
},
{
"index": 5,
"value": 87
}
],
"cardassetid": 200389,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 200389,
"fitness": 99,
"id": 100000003,
"itemState": "free",
"itemType": "player",
"leagueId": 53,
"nation": 44,
"owners": 1,
"playStyle": 250,
"preferredPosition": "GK",
"rareflag": 1,
"rating": 87,
"resourceId": 200389,
"teamid": 240,
"untradeable": true
},
"kitNumber": 1
},
{
"index": 1,
"itemData": {
"assetId": 197445,
"attributeList": [
{
"index": 0,
"value": 86
},
{
"index": 1,
"value": 73
},
{
"index": 2,
"value": 81
},
{
"index": 3,
"value": 83
},
{
"index": 4,
"value": 83
},
{
"index": 5,
"value": 73
}
],
"cardassetid": 197445,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 197445,
"fitness": 99,
"id": 100000006,
"itemState": "free",
"itemType": "player",
"leagueId": 19,
"nation": 4,
"owners": 1,
"playStyle": 250,
"preferredPosition": "LB",
"rareflag": 1,
"rating": 87,
"resourceId": 197445,
"teamid": 21,
"untradeable": true
},
"kitNumber": 4
},
{
"index": 2,
"itemData": {
"assetId": 155862,
"attributeList": [
{
"index": 0,
"value": 78
},
{
"index": 1,
"value": 63
},
{
"index": 2,
"value": 70
},
{
"index": 3,
"value": 70
},
{
"index": 4,
"value": 87
},
{
"index": 5,
"value": 83
}
],
"cardassetid": 155862,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 155862,
"fitness": 99,
"id": 100000005,
"itemState": "free",
"itemType": "player",
"leagueId": 53,
"nation": 45,
"owners": 1,
"playStyle": 250,
"preferredPosition": "CB",
"rareflag": 1,
"rating": 89,
"resourceId": 155862,
"teamid": 243,
"untradeable": true
},
"kitNumber": 3
},
{
"index": 3,
"itemData": {
"assetId": 182521,
"attributeList": [
{
"index": 0,
"value": 45
},
{
"index": 1,
"value": 80
},
{
"index": 2,
"value": 88
},
{
"index": 3,
"value": 79
},
{
"index": 4,
"value": 69
},
{
"index": 5,
"value": 70
}
],
"cardassetid": 182521,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 182521,
"fitness": 99,
"id": 100000008,
"itemState": "free",
"itemType": "player",
"leagueId": 53,
"nation": 21,
"owners": 1,
"playStyle": 250,
"preferredPosition": "CM",
"rareflag": 1,
"rating": 88,
"resourceId": 182521,
"teamid": 243,
"untradeable": true
},
"kitNumber": 6
},
{
"index": 4,
"itemData": {
"assetId": 189332,
"attributeList": [
{
"index": 0,
"value": 93
},
{
"index": 1,
"value": 69
},
{
"index": 2,
"value": 75
},
{
"index": 3,
"value": 83
},
{
"index": 4,
"value": 81
},
{
"index": 5,
"value": 75
}
],
"cardassetid": 189332,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 189332,
"fitness": 99,
"id": 100000007,
"itemState": "free",
"itemType": "player",
"leagueId": 53,
"nation": 45,
"owners": 1,
"playStyle": 250,
"preferredPosition": "LB",
"rareflag": 1,
"rating": 86,
"resourceId": 189332,
"teamid": 241,
"untradeable": true
},
"kitNumber": 5
},
{
"index": 5,
"itemData": {
"assetId": 158023,
"attributeList": [
{
"index": 0,
"value": 89
},
{
"index": 1,
"value": 90
},
{
"index": 2,
"value": 86
},
{
"index": 3,
"value": 96
},
{
"index": 4,
"value": 26
},
{
"index": 5,
"value": 61
}
],
"cardassetid": 158023,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 158023,
"fitness": 99,
"id": 100000002,
"itemState": "free",
"itemType": "player",
"leagueId": 53,
"nation": 52,
"owners": 1,
"playStyle": 250,
"preferredPosition": "RW",
"rareflag": 1,
"rating": 93,
"resourceId": 158023,
"teamid": 241,
"untradeable": true
},
"kitNumber": 9
},
{
"index": 6,
"itemData": {
"assetId": 183907,
"attributeList": [
{
"index": 0,
"value": 79
},
{
"index": 1,
"value": 50
},
{
"index": 2,
"value": 72
},
{
"index": 3,
"value": 68
},
{
"index": 4,
"value": 90
},
{
"index": 5,
"value": 85
}
],
"cardassetid": 183907,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 183907,
"fitness": 99,
"id": 100000004,
"itemState": "free",
"itemType": "player",
"leagueId": 19,
"nation": 21,
"owners": 1,
"playStyle": 250,
"preferredPosition": "CB",
"rareflag": 1,
"rating": 90,
"resourceId": 183907,
"teamid": 21,
"untradeable": true
},
"kitNumber": 2
},
{
"index": 7,
"itemData": {
"assetId": 183277,
"attributeList": [
{
"index": 0,
"value": 90
},
{
"index": 1,
"value": 81
},
{
"index": 2,
"value": 82
},
{
"index": 3,
"value": 91
},
{
"index": 4,
"value": 32
},
{
"index": 5,
"value": 64
}
],
"cardassetid": 183277,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 183277,
"fitness": 99,
"id": 100000009,
"itemState": "free",
"itemType": "player",
"leagueId": 13,
"nation": 7,
"owners": 1,
"playStyle": 250,
"preferredPosition": "LM",
"rareflag": 1,
"rating": 88,
"resourceId": 183277,
"teamid": 5,
"untradeable": true
},
"kitNumber": 7
},
{
"index": 8,
"itemData": {
"assetId": 176580,
"attributeList": [
{
"index": 0,
"value": 82
},
{
"index": 1,
"value": 90
},
{
"index": 2,
"value": 79
},
{
"index": 3,
"value": 87
},
{
"index": 4,
"value": 42
},
{
"index": 5,
"value": 79
}
],
"cardassetid": 176580,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 176580,
"fitness": 99,
"id": 100000010,
"itemState": "free",
"itemType": "player",
"leagueId": 53,
"nation": 60,
"owners": 1,
"playStyle": 250,
"preferredPosition": "ST",
"rareflag": 1,
"rating": 92,
"resourceId": 176580,
"teamid": 241,
"untradeable": true
},
"kitNumber": 10
},
{
"index": 9,
"itemData": {
"assetId": 188545,
"attributeList": [
{
"index": 0,
"value": 81
},
{
"index": 1,
"value": 87
},
{
"index": 2,
"value": 74
},
{
"index": 3,
"value": 85
},
{
"index": 4,
"value": 38
},
{
"index": 5,
"value": 82
}
],
"cardassetid": 188545,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 188545,
"fitness": 99,
"id": 100000025,
"itemState": "free",
"itemType": "player",
"leagueId": 19,
"nation": 37,
"owners": 1,
"pile": "club",
"playStyle": 250,
"preferredPosition": "ST",
"rareflag": 1,
"rating": 90,
"resourceId": 188545,
"teamid": 21,
"untradeable": true
},
"kitNumber": 11
},
{
"index": 10,
"itemData": {
"assetId": 20801,
"attributeList": [
{
"index": 0,
"value": 92
},
{
"index": 1,
"value": 92
},
{
"index": 2,
"value": 81
},
{
"index": 3,
"value": 91
},
{
"index": 4,
"value": 33
},
{
"index": 5,
"value": 80
}
],
"cardassetid": 20801,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 20801,
"fitness": 99,
"id": 100000001,
"itemState": "free",
"itemType": "player",
"leagueId": 53,
"nation": 38,
"owners": 1,
"playStyle": 250,
"preferredPosition": "LW",
"rareflag": 1,
"rating": 94,
"resourceId": 20801,
"teamid": 243,
"untradeable": true
},
"kitNumber": 8
},
{
"index": 11,
"itemData": {
"dream": false,
"id": 0
},
"kitNumber": 0
},
{
"index": 12,
"itemData": {
"dream": false,
"id": 0
},
"kitNumber": 0
},
{
"index": 13,
"itemData": {
"dream": false,
"id": 0
},
"kitNumber": 0
},
{
"index": 14,
"itemData": {
"dream": false,
"id": 0
},
"kitNumber": 0
},
{
"index": 15,
"itemData": {
"dream": false,
"id": 0
},
"kitNumber": 0
},
{
"index": 16,
"itemData": {
"dream": false,
"id": 0
},
"kitNumber": 0
},
{
"index": 17,
"itemData": {
"dream": false,
"id": 0
},
"kitNumber": 0
},
{
"index": 18,
"itemData": {
"dream": false,
"id": 0
},
"kitNumber": 0
},
{
"index": 19,
"itemData": {
"dream": false,
"id": 0
},
"kitNumber": 0
},
{
"index": 20,
"itemData": {
"dream": false,
"id": 0
},
"kitNumber": 0
},
{
"index": 21,
"itemData": {
"dream": false,
"id": 0
},
"kitNumber": 0
},
{
"index": 22,
"itemData": {
"dream": false,
"id": 0
},
"kitNumber": 0
}
],
"rating": 90,
"squadName": "OpenFUT",
"squadType": "REGULAR_SQUAD",
"starRating": 90
}
@@ -1 +0,0 @@
{}
@@ -1,54 +0,0 @@
{
"userInfo": {
"accountCreatedPlatformName": "pc",
"actives": [],
"bidTokens": {
"count": 0,
"updateTime": 0
},
"clubAbbr": "OFC",
"clubName": "OpenFUT",
"clubNameChangeAllowed": false,
"currencies": [
{
"active": true,
"finalFunds": 29876776,
"funds": 29876776,
"name": "coins"
},
{
"active": true,
"finalFunds": 0,
"funds": 0,
"name": "points"
}
],
"divisionOffline": 10,
"divisionOnline": 10,
"draw": 0,
"established": "2026",
"feature": {},
"loss": 0,
"personaId": 33068179,
"purchased": false,
"reliability": {
"matchUnfinishedTime": 0,
"reliability": 100
},
"sessionCoinsBankBalance": 0,
"squadList": {
"squad": [
{
"chemistry": 49,
"formation": "f433",
"id": 0,
"rating": 89,
"squadName": "OpenFUT",
"squadType": "REGULAR_SQUAD"
}
]
},
"trophies": 0,
"won": 0
}
}
@@ -1 +0,0 @@
{}
@@ -1,5 +0,0 @@
{
"auctionInfo": [],
"credits": 29876776,
"total": 0
}
@@ -1,114 +0,0 @@
{
"env": {
"OPENFUT_FIFA17_SOLD_COINS_PROCESSED": "0",
"OPENFUT_FIFA17_SOLD_EXPERIMENT": "highest"
},
"surfaces": {
"counts": {
"body": {
"count": 0,
"maxAuctionsAllowed": 100,
"offered": 0,
"selling": 0,
"sold": 1
},
"sha256": "766b1fcc4ca47442e6f570c81583840b6ae8df9cbeae52473b3c100ca881e206",
"status": 200
},
"tradePile": {
"body": {
"auctionInfo": [
{
"bidState": "highest",
"buyNowPrice": 150,
"coinsProcessed": 0,
"currentBid": 150,
"expires": 0,
"itemData": {
"assetId": 212188,
"id": 100000178,
"itemState": "forSale",
"preferredPosition": "ST",
"rating": 75,
"resourceId": 212188,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 900500150,
"tradeState": "closed",
"watched": false
}
],
"credits": 1143,
"duplicateItemIdList": [],
"total": 1
},
"sha256": "caecc67a615ddd0f209b36c7c8ea6a04e0002552d9a42b8eeca20df84feedf3c",
"status": 200
},
"trade_status_filtered": {
"body": {
"auctionInfo": [
{
"bidState": "highest",
"buyNowPrice": 150,
"coinsProcessed": 0,
"currentBid": 150,
"expires": 0,
"itemData": {
"assetId": 212188,
"id": 100000178,
"itemState": "forSale",
"preferredPosition": "ST",
"rating": 75,
"resourceId": 212188,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 900500150,
"tradeState": "closed",
"watched": false
}
],
"credits": 1143
},
"sha256": "0107bf70809cce10fa5fa0f7f4e196f7ffdd6def1b9f63407eeffc7e66f35855",
"status": 200
},
"trade_status_unfiltered": {
"body": {
"auctionInfo": [
{
"bidState": "highest",
"buyNowPrice": 150,
"coinsProcessed": 0,
"currentBid": 150,
"expires": 0,
"itemData": {
"assetId": 212188,
"id": 100000178,
"itemState": "forSale",
"preferredPosition": "ST",
"rating": 75,
"resourceId": 212188,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 900500150,
"tradeState": "closed",
"watched": false
}
],
"credits": 1143
},
"sha256": "0107bf70809cce10fa5fa0f7f4e196f7ffdd6def1b9f63407eeffc7e66f35855",
"status": 200
}
}
}
@@ -1,114 +0,0 @@
{
"env": {
"OPENFUT_FIFA17_SOLD_COINS_PROCESSED": "0",
"OPENFUT_FIFA17_SOLD_EXPERIMENT": "buyNow"
},
"surfaces": {
"counts": {
"body": {
"count": 0,
"maxAuctionsAllowed": 100,
"offered": 0,
"selling": 0,
"sold": 1
},
"sha256": "766b1fcc4ca47442e6f570c81583840b6ae8df9cbeae52473b3c100ca881e206",
"status": 200
},
"tradePile": {
"body": {
"auctionInfo": [
{
"bidState": "buyNow",
"buyNowPrice": 150,
"coinsProcessed": 0,
"currentBid": 150,
"expires": 0,
"itemData": {
"assetId": 212188,
"id": 100000178,
"itemState": "forSale",
"preferredPosition": "ST",
"rating": 75,
"resourceId": 212188,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 900500150,
"tradeState": "closed",
"watched": false
}
],
"credits": 1143,
"duplicateItemIdList": [],
"total": 1
},
"sha256": "0b6e5cc5ab58fdbdd76586b1cff685a562e669fd37750cbd1397db9ee9e97407",
"status": 200
},
"trade_status_filtered": {
"body": {
"auctionInfo": [
{
"bidState": "buyNow",
"buyNowPrice": 150,
"coinsProcessed": 0,
"currentBid": 150,
"expires": 0,
"itemData": {
"assetId": 212188,
"id": 100000178,
"itemState": "forSale",
"preferredPosition": "ST",
"rating": 75,
"resourceId": 212188,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 900500150,
"tradeState": "closed",
"watched": false
}
],
"credits": 1143
},
"sha256": "fab8d43b7ac0f0e09d6902155f7c5f6d91e57b4b595ec8f6093a5a9dcb02bcdb",
"status": 200
},
"trade_status_unfiltered": {
"body": {
"auctionInfo": [
{
"bidState": "buyNow",
"buyNowPrice": 150,
"coinsProcessed": 0,
"currentBid": 150,
"expires": 0,
"itemData": {
"assetId": 212188,
"id": 100000178,
"itemState": "forSale",
"preferredPosition": "ST",
"rating": 75,
"resourceId": 212188,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 900500150,
"tradeState": "closed",
"watched": false
}
],
"credits": 1143
},
"sha256": "fab8d43b7ac0f0e09d6902155f7c5f6d91e57b4b595ec8f6093a5a9dcb02bcdb",
"status": 200
}
}
}
-114
View File
@@ -1,114 +0,0 @@
{
"env": {
"OPENFUT_FIFA17_SOLD_COINS_PROCESSED": "0",
"OPENFUT_FIFA17_SOLD_EXPERIMENT": "highest"
},
"surfaces": {
"counts": {
"body": {
"count": 0,
"maxAuctionsAllowed": 100,
"offered": 0,
"selling": 0,
"sold": 1
},
"sha256": "766b1fcc4ca47442e6f570c81583840b6ae8df9cbeae52473b3c100ca881e206",
"status": 200
},
"tradePile": {
"body": {
"auctionInfo": [
{
"bidState": "highest",
"buyNowPrice": 150,
"coinsProcessed": 0,
"currentBid": 150,
"expires": 0,
"itemData": {
"assetId": 212188,
"id": 100000178,
"itemState": "forSale",
"preferredPosition": "ST",
"rating": 75,
"resourceId": 212188,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 900500150,
"tradeState": "closed",
"watched": false
}
],
"credits": 1143,
"duplicateItemIdList": [],
"total": 1
},
"sha256": "caecc67a615ddd0f209b36c7c8ea6a04e0002552d9a42b8eeca20df84feedf3c",
"status": 200
},
"trade_status_filtered": {
"body": {
"auctionInfo": [
{
"bidState": "highest",
"buyNowPrice": 150,
"coinsProcessed": 0,
"currentBid": 150,
"expires": 0,
"itemData": {
"assetId": 212188,
"id": 100000178,
"itemState": "forSale",
"preferredPosition": "ST",
"rating": 75,
"resourceId": 212188,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 900500150,
"tradeState": "closed",
"watched": false
}
],
"credits": 1143
},
"sha256": "0107bf70809cce10fa5fa0f7f4e196f7ffdd6def1b9f63407eeffc7e66f35855",
"status": 200
},
"trade_status_unfiltered": {
"body": {
"auctionInfo": [
{
"bidState": "highest",
"buyNowPrice": 150,
"coinsProcessed": 0,
"currentBid": 150,
"expires": 0,
"itemData": {
"assetId": 212188,
"id": 100000178,
"itemState": "forSale",
"preferredPosition": "ST",
"rating": 75,
"resourceId": 212188,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 900500150,
"tradeState": "closed",
"watched": false
}
],
"credits": 1143
},
"sha256": "0107bf70809cce10fa5fa0f7f4e196f7ffdd6def1b9f63407eeffc7e66f35855",
"status": 200
}
}
}
-114
View File
@@ -1,114 +0,0 @@
{
"env": {
"OPENFUT_FIFA17_SOLD_COINS_PROCESSED": "1",
"OPENFUT_FIFA17_SOLD_EXPERIMENT": "highest"
},
"surfaces": {
"counts": {
"body": {
"count": 0,
"maxAuctionsAllowed": 100,
"offered": 0,
"selling": 0,
"sold": 1
},
"sha256": "766b1fcc4ca47442e6f570c81583840b6ae8df9cbeae52473b3c100ca881e206",
"status": 200
},
"tradePile": {
"body": {
"auctionInfo": [
{
"bidState": "highest",
"buyNowPrice": 150,
"coinsProcessed": 1,
"currentBid": 150,
"expires": 0,
"itemData": {
"assetId": 212188,
"id": 100000178,
"itemState": "forSale",
"preferredPosition": "ST",
"rating": 75,
"resourceId": 212188,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 900500150,
"tradeState": "closed",
"watched": false
}
],
"credits": 1143,
"duplicateItemIdList": [],
"total": 1
},
"sha256": "6e228a34b5d7fac6bf70710cbd4ca35b58740e04ae455ffeee04b6ce7faab8e5",
"status": 200
},
"trade_status_filtered": {
"body": {
"auctionInfo": [
{
"bidState": "highest",
"buyNowPrice": 150,
"coinsProcessed": 1,
"currentBid": 150,
"expires": 0,
"itemData": {
"assetId": 212188,
"id": 100000178,
"itemState": "forSale",
"preferredPosition": "ST",
"rating": 75,
"resourceId": 212188,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 900500150,
"tradeState": "closed",
"watched": false
}
],
"credits": 1143
},
"sha256": "c1557013bf20285d0512ca0d055a16df8036260559d640da11102a2379ac5986",
"status": 200
},
"trade_status_unfiltered": {
"body": {
"auctionInfo": [
{
"bidState": "highest",
"buyNowPrice": 150,
"coinsProcessed": 1,
"currentBid": 150,
"expires": 0,
"itemData": {
"assetId": 212188,
"id": 100000178,
"itemState": "forSale",
"preferredPosition": "ST",
"rating": 75,
"resourceId": 212188,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 900500150,
"tradeState": "closed",
"watched": false
}
],
"credits": 1143
},
"sha256": "c1557013bf20285d0512ca0d055a16df8036260559d640da11102a2379ac5986",
"status": 200
}
}
}
@@ -1,509 +0,0 @@
{
"counts_states": {
"S1_zero_active_one_sold": {
"count": 0,
"maxAuctionsAllowed": 100,
"offered": 0,
"selling": 0,
"sold": 1
},
"S2_one_active_one_sold_count_active": {
"count": 1,
"maxAuctionsAllowed": 100,
"offered": 0,
"selling": 1,
"sold": 1
},
"S2_one_active_one_sold_count_membership": {
"count": 2,
"maxAuctionsAllowed": 100,
"offered": 0,
"selling": 1,
"sold": 1
}
},
"diffs": {
"orthogonal_coinsprocessed": [
{
"a": 0,
"b": 1,
"kind": "VALUE_MISMATCH",
"path": "auctionInfo[0].coinsProcessed"
}
],
"primary_bidstate": [
{
"a": "highest",
"b": "buyNow",
"kind": "VALUE_MISMATCH",
"path": "auctionInfo[0].bidState"
}
]
},
"generated": "2026-08-18T02:19:25Z",
"settlement": {
"buyer": 19850,
"fee": 7,
"instances": 1,
"owner": "club-buyer-b",
"seller": 1143
},
"variants": {
"A_highest": {
"env": {
"OPENFUT_FIFA17_SOLD_COINS_PROCESSED": "0",
"OPENFUT_FIFA17_SOLD_EXPERIMENT": "highest"
},
"surfaces": {
"counts": {
"body": {
"count": 0,
"maxAuctionsAllowed": 100,
"offered": 0,
"selling": 0,
"sold": 1
},
"sha256": "766b1fcc4ca47442e6f570c81583840b6ae8df9cbeae52473b3c100ca881e206",
"status": 200
},
"tradePile": {
"body": {
"auctionInfo": [
{
"bidState": "highest",
"buyNowPrice": 150,
"coinsProcessed": 0,
"currentBid": 150,
"expires": 0,
"itemData": {
"assetId": 212188,
"id": 100000178,
"itemState": "forSale",
"preferredPosition": "ST",
"rating": 75,
"resourceId": 212188,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 900500150,
"tradeState": "closed",
"watched": false
}
],
"credits": 1143,
"duplicateItemIdList": [],
"total": 1
},
"sha256": "caecc67a615ddd0f209b36c7c8ea6a04e0002552d9a42b8eeca20df84feedf3c",
"status": 200
},
"trade_status_filtered": {
"body": {
"auctionInfo": [
{
"bidState": "highest",
"buyNowPrice": 150,
"coinsProcessed": 0,
"currentBid": 150,
"expires": 0,
"itemData": {
"assetId": 212188,
"id": 100000178,
"itemState": "forSale",
"preferredPosition": "ST",
"rating": 75,
"resourceId": 212188,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 900500150,
"tradeState": "closed",
"watched": false
}
],
"credits": 1143
},
"sha256": "0107bf70809cce10fa5fa0f7f4e196f7ffdd6def1b9f63407eeffc7e66f35855",
"status": 200
},
"trade_status_unfiltered": {
"body": {
"auctionInfo": [
{
"bidState": "highest",
"buyNowPrice": 150,
"coinsProcessed": 0,
"currentBid": 150,
"expires": 0,
"itemData": {
"assetId": 212188,
"id": 100000178,
"itemState": "forSale",
"preferredPosition": "ST",
"rating": 75,
"resourceId": 212188,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 900500150,
"tradeState": "closed",
"watched": false
}
],
"credits": 1143
},
"sha256": "0107bf70809cce10fa5fa0f7f4e196f7ffdd6def1b9f63407eeffc7e66f35855",
"status": 200
}
}
},
"B_buyNow": {
"env": {
"OPENFUT_FIFA17_SOLD_COINS_PROCESSED": "0",
"OPENFUT_FIFA17_SOLD_EXPERIMENT": "buyNow"
},
"surfaces": {
"counts": {
"body": {
"count": 0,
"maxAuctionsAllowed": 100,
"offered": 0,
"selling": 0,
"sold": 1
},
"sha256": "766b1fcc4ca47442e6f570c81583840b6ae8df9cbeae52473b3c100ca881e206",
"status": 200
},
"tradePile": {
"body": {
"auctionInfo": [
{
"bidState": "buyNow",
"buyNowPrice": 150,
"coinsProcessed": 0,
"currentBid": 150,
"expires": 0,
"itemData": {
"assetId": 212188,
"id": 100000178,
"itemState": "forSale",
"preferredPosition": "ST",
"rating": 75,
"resourceId": 212188,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 900500150,
"tradeState": "closed",
"watched": false
}
],
"credits": 1143,
"duplicateItemIdList": [],
"total": 1
},
"sha256": "0b6e5cc5ab58fdbdd76586b1cff685a562e669fd37750cbd1397db9ee9e97407",
"status": 200
},
"trade_status_filtered": {
"body": {
"auctionInfo": [
{
"bidState": "buyNow",
"buyNowPrice": 150,
"coinsProcessed": 0,
"currentBid": 150,
"expires": 0,
"itemData": {
"assetId": 212188,
"id": 100000178,
"itemState": "forSale",
"preferredPosition": "ST",
"rating": 75,
"resourceId": 212188,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 900500150,
"tradeState": "closed",
"watched": false
}
],
"credits": 1143
},
"sha256": "fab8d43b7ac0f0e09d6902155f7c5f6d91e57b4b595ec8f6093a5a9dcb02bcdb",
"status": 200
},
"trade_status_unfiltered": {
"body": {
"auctionInfo": [
{
"bidState": "buyNow",
"buyNowPrice": 150,
"coinsProcessed": 0,
"currentBid": 150,
"expires": 0,
"itemData": {
"assetId": 212188,
"id": 100000178,
"itemState": "forSale",
"preferredPosition": "ST",
"rating": 75,
"resourceId": 212188,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 900500150,
"tradeState": "closed",
"watched": false
}
],
"credits": 1143
},
"sha256": "fab8d43b7ac0f0e09d6902155f7c5f6d91e57b4b595ec8f6093a5a9dcb02bcdb",
"status": 200
}
}
},
"C_cp0": {
"env": {
"OPENFUT_FIFA17_SOLD_COINS_PROCESSED": "0",
"OPENFUT_FIFA17_SOLD_EXPERIMENT": "highest"
},
"surfaces": {
"counts": {
"body": {
"count": 0,
"maxAuctionsAllowed": 100,
"offered": 0,
"selling": 0,
"sold": 1
},
"sha256": "766b1fcc4ca47442e6f570c81583840b6ae8df9cbeae52473b3c100ca881e206",
"status": 200
},
"tradePile": {
"body": {
"auctionInfo": [
{
"bidState": "highest",
"buyNowPrice": 150,
"coinsProcessed": 0,
"currentBid": 150,
"expires": 0,
"itemData": {
"assetId": 212188,
"id": 100000178,
"itemState": "forSale",
"preferredPosition": "ST",
"rating": 75,
"resourceId": 212188,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 900500150,
"tradeState": "closed",
"watched": false
}
],
"credits": 1143,
"duplicateItemIdList": [],
"total": 1
},
"sha256": "caecc67a615ddd0f209b36c7c8ea6a04e0002552d9a42b8eeca20df84feedf3c",
"status": 200
},
"trade_status_filtered": {
"body": {
"auctionInfo": [
{
"bidState": "highest",
"buyNowPrice": 150,
"coinsProcessed": 0,
"currentBid": 150,
"expires": 0,
"itemData": {
"assetId": 212188,
"id": 100000178,
"itemState": "forSale",
"preferredPosition": "ST",
"rating": 75,
"resourceId": 212188,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 900500150,
"tradeState": "closed",
"watched": false
}
],
"credits": 1143
},
"sha256": "0107bf70809cce10fa5fa0f7f4e196f7ffdd6def1b9f63407eeffc7e66f35855",
"status": 200
},
"trade_status_unfiltered": {
"body": {
"auctionInfo": [
{
"bidState": "highest",
"buyNowPrice": 150,
"coinsProcessed": 0,
"currentBid": 150,
"expires": 0,
"itemData": {
"assetId": 212188,
"id": 100000178,
"itemState": "forSale",
"preferredPosition": "ST",
"rating": 75,
"resourceId": 212188,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 900500150,
"tradeState": "closed",
"watched": false
}
],
"credits": 1143
},
"sha256": "0107bf70809cce10fa5fa0f7f4e196f7ffdd6def1b9f63407eeffc7e66f35855",
"status": 200
}
}
},
"D_cp1": {
"env": {
"OPENFUT_FIFA17_SOLD_COINS_PROCESSED": "1",
"OPENFUT_FIFA17_SOLD_EXPERIMENT": "highest"
},
"surfaces": {
"counts": {
"body": {
"count": 0,
"maxAuctionsAllowed": 100,
"offered": 0,
"selling": 0,
"sold": 1
},
"sha256": "766b1fcc4ca47442e6f570c81583840b6ae8df9cbeae52473b3c100ca881e206",
"status": 200
},
"tradePile": {
"body": {
"auctionInfo": [
{
"bidState": "highest",
"buyNowPrice": 150,
"coinsProcessed": 1,
"currentBid": 150,
"expires": 0,
"itemData": {
"assetId": 212188,
"id": 100000178,
"itemState": "forSale",
"preferredPosition": "ST",
"rating": 75,
"resourceId": 212188,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 900500150,
"tradeState": "closed",
"watched": false
}
],
"credits": 1143,
"duplicateItemIdList": [],
"total": 1
},
"sha256": "6e228a34b5d7fac6bf70710cbd4ca35b58740e04ae455ffeee04b6ce7faab8e5",
"status": 200
},
"trade_status_filtered": {
"body": {
"auctionInfo": [
{
"bidState": "highest",
"buyNowPrice": 150,
"coinsProcessed": 1,
"currentBid": 150,
"expires": 0,
"itemData": {
"assetId": 212188,
"id": 100000178,
"itemState": "forSale",
"preferredPosition": "ST",
"rating": 75,
"resourceId": 212188,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 900500150,
"tradeState": "closed",
"watched": false
}
],
"credits": 1143
},
"sha256": "c1557013bf20285d0512ca0d055a16df8036260559d640da11102a2379ac5986",
"status": 200
},
"trade_status_unfiltered": {
"body": {
"auctionInfo": [
{
"bidState": "highest",
"buyNowPrice": 150,
"coinsProcessed": 1,
"currentBid": 150,
"expires": 0,
"itemData": {
"assetId": 212188,
"id": 100000178,
"itemState": "forSale",
"preferredPosition": "ST",
"rating": 75,
"resourceId": 212188,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 900500150,
"tradeState": "closed",
"watched": false
}
],
"credits": 1143
},
"sha256": "c1557013bf20285d0512ca0d055a16df8036260559d640da11102a2379ac5986",
"status": 200
}
}
}
}
}
@@ -1,850 +0,0 @@
{
"captured": "2026-08-18T02:21:49Z",
"phase": "0 \u2014 staging validated, sold row present, awaiting live client",
"settlement": {
"clubs": {
"club-buyer-b": 19850,
"club-seller-a-cage": 1143
},
"disposable_owner": [
"club-buyer-b"
],
"fee": 7,
"gross": 150,
"instances": 1,
"proceeds": 143
},
"sold_row": {
"bidState": "highest",
"buyNowPrice": 150,
"coinsProcessed": 0,
"currentBid": 150,
"expires": 0,
"itemData": {
"assetId": 232273,
"attributeList": [
{
"index": 0,
"value": 62
},
{
"index": 1,
"value": 28
},
{
"index": 2,
"value": 33
},
{
"index": 3,
"value": 49
},
{
"index": 4,
"value": 53
},
{
"index": 5,
"value": 50
}
],
"cardassetid": 232273,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 232273,
"discardValue": 150,
"fitness": 99,
"id": 100000012,
"itemState": "forSale",
"itemType": "player",
"leagueId": 32,
"nation": 117,
"owners": 1,
"playStyle": 250,
"preferredPosition": "LB",
"rareflag": 1,
"rating": 51,
"resourceId": 232273,
"teamid": 112487,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 900500150,
"tradeState": "closed",
"watched": false
},
"surfaces": {
"club": {
"body": {
"itemData": [
{
"assetId": 20801,
"attributeList": [
{
"index": 0,
"value": 97
},
{
"index": 1,
"value": 97
},
{
"index": 2,
"value": 86
},
{
"index": 3,
"value": 96
},
{
"index": 4,
"value": 38
},
{
"index": 5,
"value": 85
}
],
"cardassetid": 20801,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 83906881,
"discardValue": 1500,
"fitness": 99,
"id": 100000001,
"itemState": "free",
"itemType": "player",
"leagueId": 53,
"nation": 38,
"owners": 1,
"playStyle": 250,
"preferredPosition": "LW",
"rareflag": 11,
"rating": 99,
"resourceId": 83906881,
"teamid": 243,
"untradeable": false
},
{
"assetId": 158023,
"attributeList": [
{
"index": 0,
"value": 94
},
{
"index": 1,
"value": 95
},
{
"index": 2,
"value": 91
},
{
"index": 3,
"value": 99
},
{
"index": 4,
"value": 31
},
{
"index": 5,
"value": 66
}
],
"cardassetid": 158023,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 84044103,
"discardValue": 1500,
"fitness": 99,
"id": 100000002,
"itemState": "free",
"itemType": "player",
"leagueId": 53,
"nation": 52,
"owners": 1,
"playStyle": 250,
"preferredPosition": "RW",
"rareflag": 11,
"rating": 98,
"resourceId": 84044103,
"teamid": 241,
"untradeable": false
},
{
"assetId": 167495,
"attributeList": [
{
"index": 0,
"value": 94
},
{
"index": 1,
"value": 95
},
{
"index": 2,
"value": 99
},
{
"index": 3,
"value": 94
},
{
"index": 4,
"value": 64
},
{
"index": 5,
"value": 96
}
],
"cardassetid": 167495,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 84053575,
"discardValue": 1500,
"fitness": 99,
"id": 100000003,
"itemState": "free",
"itemType": "player",
"leagueId": 19,
"nation": 21,
"owners": 1,
"playStyle": 250,
"preferredPosition": "GK",
"rareflag": 11,
"rating": 97,
"resourceId": 84053575,
"teamid": 21,
"untradeable": false
},
{
"assetId": 176580,
"attributeList": [
{
"index": 0,
"value": 85
},
{
"index": 1,
"value": 93
},
{
"index": 2,
"value": 82
},
{
"index": 3,
"value": 90
},
{
"index": 4,
"value": 45
},
{
"index": 5,
"value": 82
}
],
"cardassetid": 176580,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 117617092,
"discardValue": 1500,
"fitness": 99,
"id": 100000004,
"itemState": "free",
"itemType": "player",
"leagueId": 53,
"nation": 60,
"owners": 1,
"playStyle": 250,
"preferredPosition": "ST",
"rareflag": 22,
"rating": 95,
"resourceId": 117617092,
"teamid": 241,
"untradeable": false
},
{
"assetId": 177003,
"attributeList": [
{
"index": 0,
"value": 78
},
{
"index": 1,
"value": 79
},
{
"index": 2,
"value": 90
},
{
"index": 3,
"value": 93
},
{
"index": 4,
"value": 76
},
{
"index": 5,
"value": 70
}
],
"cardassetid": 177003,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 151171947,
"discardValue": 1500,
"fitness": 99,
"id": 100000005,
"itemState": "free",
"itemType": "player",
"leagueId": 53,
"nation": 10,
"owners": 1,
"playStyle": 250,
"preferredPosition": "CM",
"rareflag": 24,
"rating": 93,
"resourceId": 151171947,
"teamid": 243,
"untradeable": false
},
{
"assetId": 164240,
"attributeList": [
{
"index": 0,
"value": 77
},
{
"index": 1,
"value": 60
},
{
"index": 2,
"value": 76
},
{
"index": 3,
"value": 76
},
{
"index": 4,
"value": 93
},
{
"index": 5,
"value": 82
}
],
"cardassetid": 164240,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 134381968,
"discardValue": 1500,
"fitness": 99,
"id": 100000006,
"itemState": "free",
"itemType": "player",
"leagueId": 16,
"nation": 54,
"owners": 1,
"playStyle": 250,
"preferredPosition": "CB",
"rareflag": 23,
"rating": 92,
"resourceId": 134381968,
"teamid": 73,
"untradeable": false
},
{
"assetId": 182493,
"attributeList": [
{
"index": 0,
"value": 69
},
{
"index": 1,
"value": 52
},
{
"index": 2,
"value": 69
},
{
"index": 3,
"value": 67
},
{
"index": 4,
"value": 92
},
{
"index": 5,
"value": 83
}
],
"cardassetid": 182493,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 151177437,
"discardValue": 1500,
"fitness": 99,
"id": 100000007,
"itemState": "free",
"itemType": "player",
"leagueId": 53,
"nation": 60,
"owners": 1,
"playStyle": 250,
"preferredPosition": "CB",
"rareflag": 24,
"rating": 92,
"resourceId": 151177437,
"teamid": 240,
"untradeable": false
},
{
"assetId": 168651,
"attributeList": [
{
"index": 0,
"value": 71
},
{
"index": 1,
"value": 89
},
{
"index": 2,
"value": 91
},
{
"index": 3,
"value": 87
},
{
"index": 4,
"value": 64
},
{
"index": 5,
"value": 71
}
],
"cardassetid": 168651,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 84054731,
"discardValue": 1500,
"fitness": 99,
"id": 100000008,
"itemState": "free",
"itemType": "player",
"leagueId": 53,
"nation": 10,
"owners": 1,
"playStyle": 250,
"preferredPosition": "CM",
"rareflag": 11,
"rating": 92,
"resourceId": 84054731,
"teamid": 241,
"untradeable": false
},
{
"assetId": 182521,
"attributeList": [
{
"index": 0,
"value": 48
},
{
"index": 1,
"value": 83
},
{
"index": 2,
"value": 91
},
{
"index": 3,
"value": 82
},
{
"index": 4,
"value": 72
},
{
"index": 5,
"value": 73
}
],
"cardassetid": 182521,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 134400249,
"discardValue": 1500,
"fitness": 99,
"id": 100000009,
"itemState": "free",
"itemType": "player",
"leagueId": 53,
"nation": 21,
"owners": 1,
"playStyle": 250,
"preferredPosition": "CM",
"rareflag": 23,
"rating": 91,
"resourceId": 134400249,
"teamid": 243,
"untradeable": false
},
{
"assetId": 197445,
"attributeList": [
{
"index": 0,
"value": 90
},
{
"index": 1,
"value": 77
},
{
"index": 2,
"value": 85
},
{
"index": 3,
"value": 87
},
{
"index": 4,
"value": 87
},
{
"index": 5,
"value": 77
}
],
"cardassetid": 197445,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 151192389,
"discardValue": 1500,
"fitness": 99,
"id": 100000010,
"itemState": "free",
"itemType": "player",
"leagueId": 19,
"nation": 4,
"owners": 1,
"playStyle": 250,
"preferredPosition": "LB",
"rareflag": 24,
"rating": 91,
"resourceId": 151192389,
"teamid": 21,
"untradeable": false
},
{
"assetId": 121939,
"attributeList": [
{
"index": 0,
"value": 70
},
{
"index": 1,
"value": 58
},
{
"index": 2,
"value": 84
},
{
"index": 3,
"value": 86
},
{
"index": 4,
"value": 88
},
{
"index": 5,
"value": 66
}
],
"cardassetid": 121939,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 100785235,
"discardValue": 1500,
"fitness": 99,
"id": 100000011,
"itemState": "free",
"itemType": "player",
"leagueId": 19,
"nation": 21,
"owners": 1,
"playStyle": 250,
"preferredPosition": "RB",
"rareflag": 21,
"rating": 90,
"resourceId": 100785235,
"teamid": 21,
"untradeable": false
}
]
},
"sha256": "0a916e8e129a9c05294784738c305d651beedd58ebd914a6abfa94bd0a59abc7",
"status": 200
},
"counts": {
"body": {
"count": 0,
"maxAuctionsAllowed": 100,
"offered": 0,
"selling": 0,
"sold": 1
},
"sha256": "766b1fcc4ca47442e6f570c81583840b6ae8df9cbeae52473b3c100ca881e206",
"status": 200
},
"tradePile": {
"body": {
"auctionInfo": [
{
"bidState": "highest",
"buyNowPrice": 150,
"coinsProcessed": 0,
"currentBid": 150,
"expires": 0,
"itemData": {
"assetId": 232273,
"attributeList": [
{
"index": 0,
"value": 62
},
{
"index": 1,
"value": 28
},
{
"index": 2,
"value": 33
},
{
"index": 3,
"value": 49
},
{
"index": 4,
"value": 53
},
{
"index": 5,
"value": 50
}
],
"cardassetid": 232273,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 232273,
"discardValue": 150,
"fitness": 99,
"id": 100000012,
"itemState": "forSale",
"itemType": "player",
"leagueId": 32,
"nation": 117,
"owners": 1,
"playStyle": 250,
"preferredPosition": "LB",
"rareflag": 1,
"rating": 51,
"resourceId": 232273,
"teamid": 112487,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 900500150,
"tradeState": "closed",
"watched": false
}
],
"credits": 1143,
"duplicateItemIdList": [],
"total": 1
},
"sha256": "c792ebcf179a5c617ffe02859ad39eba830a4d5b5224a0953ecef774ee4cf75b",
"status": 200
},
"trade_status_filtered": {
"body": {
"auctionInfo": [
{
"bidState": "highest",
"buyNowPrice": 150,
"coinsProcessed": 0,
"currentBid": 150,
"expires": 0,
"itemData": {
"assetId": 232273,
"attributeList": [
{
"index": 0,
"value": 62
},
{
"index": 1,
"value": 28
},
{
"index": 2,
"value": 33
},
{
"index": 3,
"value": 49
},
{
"index": 4,
"value": 53
},
{
"index": 5,
"value": 50
}
],
"cardassetid": 232273,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 232273,
"discardValue": 150,
"fitness": 99,
"id": 100000012,
"itemState": "forSale",
"itemType": "player",
"leagueId": 32,
"nation": 117,
"owners": 1,
"playStyle": 250,
"preferredPosition": "LB",
"rareflag": 1,
"rating": 51,
"resourceId": 232273,
"teamid": 112487,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 900500150,
"tradeState": "closed",
"watched": false
}
],
"credits": 1143
},
"sha256": "db79d9fcfc47ae680b44311598e82918b8afd3284160143c33e525adf693f30e",
"status": 200
},
"trade_status_unfiltered": {
"body": {
"auctionInfo": [
{
"bidState": "highest",
"buyNowPrice": 150,
"coinsProcessed": 0,
"currentBid": 150,
"expires": 0,
"itemData": {
"assetId": 232273,
"attributeList": [
{
"index": 0,
"value": 62
},
{
"index": 1,
"value": 28
},
{
"index": 2,
"value": 33
},
{
"index": 3,
"value": 49
},
{
"index": 4,
"value": 53
},
{
"index": 5,
"value": 50
}
],
"cardassetid": 232273,
"cardsubtypeid": 0,
"contract": 7,
"definitionId": 232273,
"discardValue": 150,
"fitness": 99,
"id": 100000012,
"itemState": "forSale",
"itemType": "player",
"leagueId": 32,
"nation": 117,
"owners": 1,
"playStyle": 250,
"preferredPosition": "LB",
"rareflag": 1,
"rating": 51,
"resourceId": 232273,
"teamid": 112487,
"untradeable": false
},
"sellerEstablished": 1,
"sellerName": "CAGE",
"startingBid": 150,
"tradeId": 900500150,
"tradeState": "closed",
"watched": false
}
],
"credits": 1143
},
"sha256": "db79d9fcfc47ae680b44311598e82918b8afd3284160143c33e525adf693f30e",
"status": 200
}
},
"topology": {
"advertises_utas": "http://<host>:8299/",
"blaze": [
42327,
42330,
42331
],
"core": 18081,
"utas_host": 8299
},
"variant": "A_highest (bidState=highest, coinsProcessed=0, countMode=active)"
}
@@ -1,199 +0,0 @@
{
"purchase": [
{
"assetId": 1,
"id": 1,
"packType": "BRONZE",
"description": "Bronze Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 1,
"currencies": [
{
"name": "coins",
"funds": 400,
"finalFunds": 400
}
],
"extPrice": {
"finalPrice": {
"amount": 4,
"currency": "mtx"
},
"originalPrice": {
"amount": 4,
"currency": "mtx"
}
},
"packContentInfo": {
"bronzeQuantity": 5,
"silverQuantity": 0,
"goldQuantity": 0,
"rareQuantity": 0,
"itemQuantity": 5
},
"unopened": false,
"displayGroup": {
"value": "bronze"
}
},
{
"assetId": 5,
"id": 5,
"packType": "GOLD",
"description": "Gold Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 2,
"currencies": [
{
"name": "coins",
"funds": 5000,
"finalFunds": 5000
}
],
"extPrice": {
"finalPrice": {
"amount": 50,
"currency": "mtx"
},
"originalPrice": {
"amount": 50,
"currency": "mtx"
}
},
"packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 0,
"goldQuantity": 7,
"rareQuantity": 7,
"itemQuantity": 7
},
"unopened": false,
"displayGroup": {
"value": "gold"
}
},
{
"assetId": 6,
"id": 6,
"packType": "GOLD",
"description": "Premium Gold",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 3,
"currencies": [
{
"name": "coins",
"funds": 15000,
"finalFunds": 15000
}
],
"extPrice": {
"finalPrice": {
"amount": 150,
"currency": "mtx"
},
"originalPrice": {
"amount": 150,
"currency": "mtx"
}
},
"packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 0,
"goldQuantity": 11,
"rareQuantity": 11,
"itemQuantity": 11
},
"unopened": false,
"displayGroup": {
"value": "gold"
}
},
{
"assetId": 7,
"id": 7,
"packType": "GOLD",
"description": "Special Players Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 4,
"currencies": [
{
"name": "coins",
"funds": 25000,
"finalFunds": 25000
}
],
"extPrice": {
"finalPrice": {
"amount": 250,
"currency": "mtx"
},
"originalPrice": {
"amount": 250,
"currency": "mtx"
}
},
"packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 0,
"goldQuantity": 11,
"rareQuantity": 11,
"itemQuantity": 11
},
"unopened": false,
"displayGroup": {
"value": "special"
}
},
{
"assetId": 65534,
"id": 65534,
"packType": "GOLD",
"description": "",
"state": "inactive",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 1,
"packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 0,
"goldQuantity": 0,
"rareQuantity": 0,
"itemQuantity": 0
},
"unopened": false,
"displayGroup": {
"value": "mypacks",
"priority": 1
}
}
],
"timestamp": 1596326400
}
@@ -1,199 +0,0 @@
{
"purchase": [
{
"assetId": 1,
"id": 1,
"packType": "BRONZE",
"description": "Bronze Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 1,
"currencies": [
{
"name": "coins",
"funds": 400,
"finalFunds": 400
}
],
"extPrice": {
"finalPrice": {
"amount": 4,
"currency": "mtx"
},
"originalPrice": {
"amount": 4,
"currency": "mtx"
}
},
"packContentInfo": {
"bronzeQuantity": 5,
"silverQuantity": 0,
"goldQuantity": 0,
"rareQuantity": 0,
"itemQuantity": 5
},
"unopened": false,
"displayGroup": {
"value": "bronze"
}
},
{
"assetId": 5,
"id": 5,
"packType": "GOLD",
"description": "Gold Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 2,
"currencies": [
{
"name": "coins",
"funds": 5000,
"finalFunds": 5000
}
],
"extPrice": {
"finalPrice": {
"amount": 50,
"currency": "mtx"
},
"originalPrice": {
"amount": 50,
"currency": "mtx"
}
},
"packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 0,
"goldQuantity": 7,
"rareQuantity": 7,
"itemQuantity": 7
},
"unopened": false,
"displayGroup": {
"value": "gold"
}
},
{
"assetId": 6,
"id": 6,
"packType": "GOLD",
"description": "Premium Gold",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 3,
"currencies": [
{
"name": "coins",
"funds": 15000,
"finalFunds": 15000
}
],
"extPrice": {
"finalPrice": {
"amount": 150,
"currency": "mtx"
},
"originalPrice": {
"amount": 150,
"currency": "mtx"
}
},
"packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 0,
"goldQuantity": 11,
"rareQuantity": 11,
"itemQuantity": 11
},
"unopened": false,
"displayGroup": {
"value": "gold"
}
},
{
"assetId": 7,
"id": 7,
"packType": "GOLD",
"description": "Special Players Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 4,
"currencies": [
{
"name": "coins",
"funds": 25000,
"finalFunds": 25000
}
],
"extPrice": {
"finalPrice": {
"amount": 250,
"currency": "mtx"
},
"originalPrice": {
"amount": 250,
"currency": "mtx"
}
},
"packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 0,
"goldQuantity": 11,
"rareQuantity": 11,
"itemQuantity": 11
},
"unopened": false,
"displayGroup": {
"value": "special"
}
},
{
"assetId": 65534,
"id": 65534,
"packType": "GOLD",
"description": "",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 1,
"packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 0,
"goldQuantity": 0,
"rareQuantity": 0,
"itemQuantity": 0
},
"unopened": false,
"displayGroup": {
"value": "mypacks",
"priority": 1
}
}
],
"timestamp": 1596326400
}
@@ -1 +0,0 @@
{"purchase": [{"assetId": 1, "id": 1, "packType": "BRONZE", "description": "Bronze Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 1, "currencies": [{"name": "coins", "funds": 400, "finalFunds": 400}], "extPrice": {"finalPrice": {"amount": 4, "currency": "mtx"}, "originalPrice": {"amount": 4, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 5, "silverQuantity": 0, "goldQuantity": 0, "rareQuantity": 0, "itemQuantity": 5}, "unopened": false, "displayGroup": {"value": "bronze"}}, {"assetId": 5, "id": 5, "packType": "GOLD", "description": "Gold Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 2, "currencies": [{"name": "coins", "funds": 5000, "finalFunds": 5000}], "extPrice": {"finalPrice": {"amount": 50, "currency": "mtx"}, "originalPrice": {"amount": 50, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 0, "silverQuantity": 0, "goldQuantity": 7, "rareQuantity": 7, "itemQuantity": 7}, "unopened": false, "displayGroup": {"value": "gold"}}, {"assetId": 6, "id": 6, "packType": "GOLD", "description": "Premium Gold", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 3, "currencies": [{"name": "coins", "funds": 15000, "finalFunds": 15000}], "extPrice": {"finalPrice": {"amount": 150, "currency": "mtx"}, "originalPrice": {"amount": 150, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 0, "silverQuantity": 0, "goldQuantity": 11, "rareQuantity": 11, "itemQuantity": 11}, "unopened": false, "displayGroup": {"value": "gold"}}, {"assetId": 7, "id": 7, "packType": "GOLD", "description": "Special Players Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 4, "currencies": [{"name": "coins", "funds": 25000, "finalFunds": 25000}], "extPrice": {"finalPrice": {"amount": 250, "currency": "mtx"}, "originalPrice": {"amount": 250, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 0, "silverQuantity": 0, "goldQuantity": 11, "rareQuantity": 11, "itemQuantity": 11}, "unopened": false, "displayGroup": {"value": "special"}}], "timestamp": 1596326400}
@@ -1,173 +0,0 @@
{
"purchase": [
{
"assetId": 1,
"id": 1,
"packType": "BRONZE",
"description": "Bronze Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 1,
"currencies": [
{
"name": "coins",
"funds": 400,
"finalFunds": 400
}
],
"extPrice": {
"finalPrice": {
"amount": 4,
"currency": "mtx"
},
"originalPrice": {
"amount": 4,
"currency": "mtx"
}
},
"packContentInfo": {
"bronzeQuantity": 5,
"silverQuantity": 0,
"goldQuantity": 0,
"rareQuantity": 0,
"itemQuantity": 5
},
"unopened": false,
"displayGroup": {
"value": "bronze"
}
},
{
"assetId": 5,
"id": 5,
"packType": "GOLD",
"description": "Gold Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 2,
"currencies": [
{
"name": "coins",
"funds": 5000,
"finalFunds": 5000
}
],
"extPrice": {
"finalPrice": {
"amount": 50,
"currency": "mtx"
},
"originalPrice": {
"amount": 50,
"currency": "mtx"
}
},
"packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 0,
"goldQuantity": 7,
"rareQuantity": 7,
"itemQuantity": 7
},
"unopened": false,
"displayGroup": {
"value": "gold"
}
},
{
"assetId": 6,
"id": 6,
"packType": "GOLD",
"description": "Premium Gold",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 3,
"currencies": [
{
"name": "coins",
"funds": 15000,
"finalFunds": 15000
}
],
"extPrice": {
"finalPrice": {
"amount": 150,
"currency": "mtx"
},
"originalPrice": {
"amount": 150,
"currency": "mtx"
}
},
"packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 0,
"goldQuantity": 11,
"rareQuantity": 11,
"itemQuantity": 11
},
"unopened": false,
"displayGroup": {
"value": "gold"
}
},
{
"assetId": 7,
"id": 7,
"packType": "GOLD",
"description": "Special Players Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 4,
"currencies": [
{
"name": "coins",
"funds": 25000,
"finalFunds": 25000
}
],
"extPrice": {
"finalPrice": {
"amount": 250,
"currency": "mtx"
},
"originalPrice": {
"amount": 250,
"currency": "mtx"
}
},
"packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 0,
"goldQuantity": 11,
"rareQuantity": 11,
"itemQuantity": 11
},
"unopened": false,
"displayGroup": {
"value": "special"
}
}
],
"timestamp": 1596326400
}
@@ -1 +0,0 @@
{"purchase": [{"assetId": 1, "id": 1, "packType": "BRONZE", "description": "Bronze Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 1, "currencies": [{"name": "coins", "funds": 400, "finalFunds": 400}], "extPrice": {"finalPrice": {"amount": 4, "currency": "mtx"}, "originalPrice": {"amount": 4, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 5, "silverQuantity": 0, "goldQuantity": 0, "rareQuantity": 0, "itemQuantity": 5}, "unopened": false, "displayGroup": {"value": "bronze"}}, {"assetId": 5, "id": 5, "packType": "GOLD", "description": "Gold Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 2, "currencies": [{"name": "coins", "funds": 5000, "finalFunds": 5000}], "extPrice": {"finalPrice": {"amount": 50, "currency": "mtx"}, "originalPrice": {"amount": 50, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 0, "silverQuantity": 0, "goldQuantity": 7, "rareQuantity": 7, "itemQuantity": 7}, "unopened": false, "displayGroup": {"value": "gold"}}, {"assetId": 6, "id": 6, "packType": "GOLD", "description": "Premium Gold", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 3, "currencies": [{"name": "coins", "funds": 15000, "finalFunds": 15000}], "extPrice": {"finalPrice": {"amount": 150, "currency": "mtx"}, "originalPrice": {"amount": 150, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 0, "silverQuantity": 0, "goldQuantity": 11, "rareQuantity": 11, "itemQuantity": 11}, "unopened": false, "displayGroup": {"value": "gold"}}, {"assetId": 7, "id": 7, "packType": "GOLD", "description": "Special Players Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 4, "currencies": [{"name": "coins", "funds": 25000, "finalFunds": 25000}], "extPrice": {"finalPrice": {"amount": 250, "currency": "mtx"}, "originalPrice": {"amount": 250, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 0, "silverQuantity": 0, "goldQuantity": 11, "rareQuantity": 11, "itemQuantity": 11}, "unopened": false, "displayGroup": {"value": "special"}}], "timestamp": 1596326400}
@@ -1,199 +0,0 @@
{
"purchase": [
{
"assetId": 1,
"id": 1,
"packType": "BRONZE",
"description": "Bronze Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 1,
"currencies": [
{
"name": "coins",
"funds": 400,
"finalFunds": 400
}
],
"extPrice": {
"finalPrice": {
"amount": 4,
"currency": "mtx"
},
"originalPrice": {
"amount": 4,
"currency": "mtx"
}
},
"packContentInfo": {
"bronzeQuantity": 5,
"silverQuantity": 0,
"goldQuantity": 0,
"rareQuantity": 0,
"itemQuantity": 5
},
"unopened": false,
"displayGroup": {
"value": "bronze"
}
},
{
"assetId": 5,
"id": 5,
"packType": "GOLD",
"description": "Gold Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 2,
"currencies": [
{
"name": "coins",
"funds": 5000,
"finalFunds": 5000
}
],
"extPrice": {
"finalPrice": {
"amount": 50,
"currency": "mtx"
},
"originalPrice": {
"amount": 50,
"currency": "mtx"
}
},
"packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 0,
"goldQuantity": 7,
"rareQuantity": 7,
"itemQuantity": 7
},
"unopened": false,
"displayGroup": {
"value": "gold"
}
},
{
"assetId": 6,
"id": 6,
"packType": "GOLD",
"description": "Premium Gold",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 3,
"currencies": [
{
"name": "coins",
"funds": 15000,
"finalFunds": 15000
}
],
"extPrice": {
"finalPrice": {
"amount": 150,
"currency": "mtx"
},
"originalPrice": {
"amount": 150,
"currency": "mtx"
}
},
"packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 0,
"goldQuantity": 11,
"rareQuantity": 11,
"itemQuantity": 11
},
"unopened": false,
"displayGroup": {
"value": "gold"
}
},
{
"assetId": 7,
"id": 7,
"packType": "GOLD",
"description": "Special Players Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 4,
"currencies": [
{
"name": "coins",
"funds": 25000,
"finalFunds": 25000
}
],
"extPrice": {
"finalPrice": {
"amount": 250,
"currency": "mtx"
},
"originalPrice": {
"amount": 250,
"currency": "mtx"
}
},
"packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 0,
"goldQuantity": 11,
"rareQuantity": 11,
"itemQuantity": 11
},
"unopened": false,
"displayGroup": {
"value": "special"
}
},
{
"assetId": 70,
"id": 70,
"packType": "GOLD",
"description": "Reward Special Players Pack",
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": 1,
"packContentInfo": {
"bronzeQuantity": 0,
"silverQuantity": 0,
"goldQuantity": 11,
"rareQuantity": 11,
"itemQuantity": 11
},
"unopened": true,
"displayGroup": {
"value": "mypacks",
"priority": 1
}
}
],
"timestamp": 1596326400
}
-108
View File
@@ -1,108 +0,0 @@
# FIFA 23 PC Startup Flow (Offline / Proton)
Observed via FLE log, hook log, and file inspection on 2026-06-26.
## Launch chain
```
umu-run / Steam → FIFA23.exe (via Proton/Wine)
├─ DLL load order (before entry point)
│ ntdll.dll, kernel32.dll, ws2_32.dll …
│ version.dll ← our hook DLL slot (loads here)
│ FIFALiveEditor.DLL ← injected by FLE launcher after ~100 ms
├─ anadius / LSX emulator (anadius64.dll)
│ Fakes EA App / Origin session
│ Reads HKLM\SOFTWARE\Wow6432Node\Origin\ClientPath
│ Writes AppData\Local\anadius\LSX emu\achievement-*.xml
│ Provides fake PersonaId=1144668899 / UserId=1000200030000
├─ EA Anti-Cheat (EAAntiCheat.GameServiceLauncher.exe)
│ Spawns as child; checks EAAntiCheat.cfg
│ Not active in offline/cracked builds (FakeEAACLauncher present)
└─ FIFA23.exe entry point
Frostbite engine init (BuildDate 2023-07-05, changelist 5417699)
Reads Data\initfs_Win32 ← Frostbite package manifest
Reads Data\layout.toc ← file-system layout
Reads Patch\initfs_Win32 ← patches on top of base
Reads Documents\FIFA 23\fifasetup.ini ← display settings
Reads Data\locale.ini ← language table
Reads Data\db_meta.xml (via FLE) ← DB schema for all tables
```
## Phase timing (observed, single machine)
| Phase | Time after launch | Trigger |
|------------------------------|-------------------|----------------------------------|
| DLL load + FLE injection | 0 0.3 s | OS loader |
| Engine + DirectX init | 0.3 5 s | FIFA23 entry point |
| "Press any key" splash | ~5 s | First rendered frame |
| Main menu | ~25 s | After key press |
| FUT mode entry (attempted) | user-driven | User selects FUT tile |
| Network calls to EA services | at FUT entry | DirtySDK / EAWebKit |
## Files read at startup (observed)
| File | Format | Purpose |
|------|--------|---------|
| `Data/initfs_Win32` | Frostbite pkg | Base asset manifest |
| `Data/layout.toc` | Frostbite TOC | File layout index |
| `Patch/initfs_Win32` | Frostbite pkg | Patch layer |
| `Data/locale.ini` | INI | String localisation |
| `Data/db_meta.xml` | XML | DB schema (loaded by FLE) |
| `Data/id_map.json` | JSON | Player/team ID→name map |
| `Data/char_conv.json` | JSON | Character conversion table |
| `Documents/FIFA 23/fifasetup.ini` | INI | Display/audio settings |
| `AppData/Local/Temp/FIFA 23/_replay0.bin` | binary | Replay buffer |
| `anadius.cfg` | VDF | Fake EA persona config |
| `AppData/Local/anadius/LSX emu/achievement-*.xml` | XML | Achievement state |
## Files written during a session (observed)
| File | When written | Content |
|------|-------------|---------|
| `Documents/FIFA 23/settings/Settings*` | Main menu reached | FBCHUNKS — controller/display prefs |
| `Documents/FIFA 23/settings/ProfileOptions` | Profile load | FBCHUNKS — 1.5 MB profile blob |
| `Documents/FIFA 23/filesystemcache/survey.state` | Startup | Empty state file |
| `Documents/FIFA 23/filesystemcache/atlPlayTimeJson/playtime_*.json` | Ongoing | Playtime tracking |
| `FIFA 23 Live Editor/config.json` | FLE ready | FLE settings (rewritten each session) |
| `Logs/log_DD-MM-YYYY.txt` | Throughout | FLE debug log |
## Save file formats
### FBCHUNKS (Frostbite chunk container)
- Magic: `46 42 43 48 55 4E 4B 53` (`FBCHUNKS`)
- Byte 8: version (01 seen)
- Offset 0x12: null-terminated label string (e.g. "Personal Settings 1", "Career - Player Progress 1")
- Remainder: compressed/binary chunk data — no public spec; requires Frostbite tooling to fully parse
- Tools: [Frosty Tool Suite](https://github.com/CadeEvs/FrostyToolSuite) can read/write these
### fifasetup.ini
- Plain `KEY = VALUE` ini, fully human-readable
- Safe to edit (display resolution, locale, vsync)
## Network calls at FUT entry (observed with iptables redirect)
Traffic pattern captured before changing strategy:
- Multiple TLS connections to port 443 (destination: EA servers, resolved as various EA IPs)
- TLS 1.3, AES-256-GCM (DirtySDK's copy of ProtoSSL, inline in FIFA23.exe)
- No SNI sent (DirtySDK does not set `server_name` extension)
- Connections originate from Wine/Proton network stack via Linux kernel TCP
Specific EA hostnames used (from openfut-bridge captures, not decoded from TLS):
- `fut.ea.com` (FUT API)
- `accounts.ea.com` (auth)
- `gateway.ea.com` (entitlements)
- `pin-river.data.ea.com` (telemetry)
## Key FLE Lua API hooks
FLE injects `FIFALiveEditor.DLL` and exposes a Lua engine that can:
- Read any in-memory DB table via `GetDBTableRows(tableName)`
- Write any cell via `EditDBTableField`
- Query career mode state via `IsInCM()`
- Get player/team names via `GetPlayerName`, `GetTeamName`
This is the primary safe integration path (see `fut-integration-options.md`).
-191
View File
@@ -1,191 +0,0 @@
# Foundational test — live custom XI via Freeze Lineup
**Status: PENDING — test has not yet been run.**
This is build-order step 1 from `docs/direction.md`: the test everything else
in the direction pivot depends on.
## What changed since the first draft of this doc
The first version of this test guessed at a "selection bias" DB field and a
candidate squad/lineup table name, based on general FIFA-modding precedent
that turned out not to hold for FLE's documented API — no such field appears
anywhere in FLE's actual Lua API docs or its own example scripts. While
researching an unrelated hotkey issue, a **confirmed, FLE-documented**
mechanism for forcing a starting XI turned up instead: the **Formation
Editor's "Freeze Lineup" feature** (FLE wiki, `Formation-Editor.md`):
> This feature can be used in player career mode if you want to manage the
> starting lineup of your team. Can be also used in manager career mode to
> manually manage your next opponent's starting lineup.
Steps (GUI, no scripting): open Formation Editor for a team → arrange players
on the pitch → tick **Freeze Lineup**`Data → Save`.
This is real and documented, but it's GUI-only — there is no Lua function for
it, and what DB write it actually performs under the hood is undocumented.
This test is now two phases: confirm the GUI feature works at all, then
reverse the DB write it makes so it can be replicated programmatically
(required for the app→game bridge in build-order step 2, which needs this
driven from outside the game, not from a person clicking checkboxes).
Also fixed in this pass: `EditDBTableField`'s real signature, confirmed from
FLE's own docs and `lua/scripts/99ovr_99pot.lua`, is
`EditDBTableField(cell)` where `cell` is `row["fieldname"]` with `.value`
mutated in place — **not** `EditDBTableField(table, row_index, field, value)`
as originally (incorrectly) written into the first draft of the injector
script.
## What this test settles
Whether a *specific, externally-chosen* 11 players can be forced into a
career (or Kick-Off) match's starting lineup, live, with no restart — and
whether the mechanism that does it (Freeze Lineup's underlying DB write) can
be driven by a script instead of a person clicking through the Formation
Editor UI.
If Freeze Lineup itself doesn't actually hold under match start (the wiki
doesn't show it being tested against a live match, only "you should be able
to see... when you play against them"), the whole bridge architecture in
`docs/direction.md` §3 needs rethinking — there is no other documented
mechanism for forcing a lineup.
## Prerequisites
- FIFA 23 launched normally (FLE injected, EAAC neutralized — same baseline
as `track-c-fut-table-test.md`)
- A career save loaded (Freeze Lineup is documented for career mode
specifically — confirm separately whether it does anything in Kick-Off,
don't assume it does)
- Note 11 player IDs from your club (`tools/squad-exporter/export_squad.lua`
output, `playerid` field) that are NOT currently your starting XI
## Phase 1 — confirm Freeze Lineup actually holds into a match
This has zero scripting and should be done first since everything else is
wasted effort if it fails.
1. Open the Live Editor overlay (F9, or `Windows → Settings` from the
overlay's own menu bar if the hotkey isn't registering — see the umu/Wine
hotkey note below).
2. `Features → Teams` → find your team → `Edit`.
3. `Team → Formation` to open the Formation Editor.
4. Swap players around on the pitch so the XI differs from your current
actual starting XI in some checkable way (e.g. swap two outfield players'
positions, or bench/start a specific player).
5. Tick **Freeze Lineup**.
6. `Data → Save`.
7. Hide Live Editor (F9), save your career **on a new slot** (don't overwrite
your main save in case this corrupts something), exit to main menu, reload
that save, and check the team's lineup screen / play a match and watch who
starts.
**Record in the Results table below whether the frozen lineup actually took
the pitch.** If not, stop here — Phase 2 is moot.
## Phase 2 — find the underlying DB write
Only proceed if Phase 1 confirmed Freeze Lineup works.
1. In FLE's Lua Engine, run `tools/squad-injector/snapshot_lineup_tables.lua`.
This dumps every DB table whose name contains `squad`, `lineup`,
`formation`, `tactic`, `teamsheet`, `selection`, `players`, or `teams` to
`C:\FIFA 23 Live Editor\openfut_snapshot_<timestamp>.json`. Note this
filename — this is your **before** snapshot.
2. Without restarting or reloading, repeat the Formation Editor steps from
Phase 1 (steps 26 only — open Formation Editor, change the lineup, tick
Freeze Lineup, `Data → Save`). Don't save/reload the career between
snapshot and this step — keep it to a single live session so the diff
isn't polluted by other state changes.
3. Run `snapshot_lineup_tables.lua` again. This is your **after** snapshot.
4. Copy both JSON files out of the Wine prefix (same path pattern as
`track-c-fut-table-test.md`: `~/Games/umu/.../drive_c/FIFA 23 Live
Editor/`) and run:
```bash
python3 tools/squad-injector/diff_snapshots.py before.json after.json
```
5. The output shows exactly which table(s) and field(s) changed. This is the
real, confirmed write Freeze Lineup performs — record it in the Results
table below.
## Phase 3 — replicate the write via script
1. Open `tools/squad-injector/apply_lineup_write.lua` and fill in
`TARGET_TABLE` and `TARGET_FIELDS` using Phase 2's diff output.
2. Edit `C:\FIFA 23 Live Editor\openfut_test_xi.json`:
```json
{
"team_id": 12345,
"xi": [
{ "player_id": 111111, "position": 0 },
{ "player_id": 222222, "position": 5 }
]
}
```
Use 11 entries. Position codes are **confirmed numeric 027**
(`GK=0, SW=1, RWB=2, RB=3, RCB=4, CB=5, LCB=6, LB=7, LWB=8, RDM=9, CDM=10,
LDM=11, RM=12, RCM=13, CM=14, LCM=15, LM=16, RAM=17, CAM=18, LAM=19,
RF=20, CF=21, LF=22, RW=23, RS=24, ST=25, LS=26, LW=27`) — from
`lua/scripts/export_season_stats.lua`'s `get_pos_name` table in FLE's own
repo, not a guess.
3. Run `apply_lineup_write.lua` from FLE's Lua Engine.
4. Repeat the save-to-new-slot / reload / check-lineup verification from
Phase 1, but this time without ever opening the Formation Editor — the
write was made entirely from the script.
## Classification criteria
### "Confirmed — full mechanism works"
Phase 1 holds, Phase 2 finds a clean diff, Phase 3's scripted write produces
the same in-match result as the manual GUI path.
**Verdict:** Build-order step 1 done. Proceed to step 2 (bridge transport) in
`docs/direction.md`.
### "GUI works, script doesn't"
Phase 1 holds but Phase 3's replicated write doesn't stick, even though the
diffed fields matched what changed in Phase 2.
**Verdict:** Freeze Lineup likely does more than a single DB field write
(e.g. an internal engine call beyond `EditDBTableField`'s reach, or a second
write the diff missed because it happened in a table outside the `KEYWORDS`
filter in `snapshot_lineup_tables.lua` — widen the filter and redo Phase 2).
### "Freeze Lineup doesn't hold at all"
Phase 1 fails — the lineup reverts to the game's own AI-picked XI regardless.
**Verdict:** No confirmed mechanism exists for forcing a lineup. This kills
the bridge architecture as designed in `direction.md` §3 and needs a return
to first principles — there is no fallback documented anywhere in FLE's wiki
for this specific case.
## A note on the umu/Wine F9/F11 hotkey issue
If FLE's F9 (hide/show) hotkey isn't registering under umu, this is plausibly
a Wine keyboard-hook limitation (FLE's global hotkey detection likely uses a
low-level hook that doesn't translate cleanly through Wine's input layer) —
not something documented anywhere in FLE's own troubleshooting docs, which
don't mention Linux/Wine at all. F11 specifically has **no documented FLE
function** — F9 is the only documented toggle. Workaround: click directly
into the FLE overlay window (it should still be visible/clickable even if the
hotkey doesn't fire) and use its own menu bar instead of relying on the
hotkey.
## Results
*(To be filled in after the test is run.)*
| Field | Value |
|---|---|
| Date run | — |
| Phase 1: Freeze Lineup holds into a match? | — |
| Phase 2: table(s)/field(s) changed | — |
| Phase 3: scripted write reproduces Phase 1 result? | — |
| **Classification** | **PENDING** |
-136
View File
@@ -1,136 +0,0 @@
# FUT Integration Options
How to connect FIFA 23 to the OpenFUT local simulator, ranked by safety and feasibility.
## Option A — FLE Lua scripting (RECOMMENDED)
**What it does:** Use FIFA Live Editor's in-memory Lua API to read and write the game's
database tables at runtime. FLE is already injected; no additional hooking needed.
**Why it's the right path:**
- Fully offline, no EA servers touched
- FLE is already trusted by the user (it's the launch mechanism)
- `GetDBTableRows` / `EditDBTableField` expose the full Frostbite DB in memory
- Scripts run inside the game process; no IPC complexity
- Same mechanism used by modders for career mode edits today
**Integration design:**
```
openfut-core (SQLite)
│ HTTP REST (localhost)
openfut-bridge (port 8080, plain HTTP, no TLS)
│ pulls club/squad/player data as JSON
FLE Lua bridge script
│ calls GetDBTableRows, EditDBTableField
FIFA 23 in-memory DB (Frostbite)
```
The Lua script polls openfut-core's REST API at intervals (or on FUT menu entry)
and writes simulator data (coins, items, squad) into the appropriate DB tables.
**Tables likely involved (to verify with export_squad.lua):**
| Table | Expected FUT content |
|-------|---------------------|
| `players` | Player attributes (OVR, potential, stats) |
| `teams` | Club identity, stadium, colors |
| `fut_clubs` | FUT club record (if in memory when FUT loads) |
| `fut_items` | Card inventory (if in memory) |
| `fut_squads` | Active squad (if in memory) |
**Steps to implement:**
1. Run `tools/squad-exporter/export_squad.lua` from FLE Lua Engine while in FUT to discover which tables are live
2. Map openfut-core's data model to the discovered table fields
3. Write a Lua polling script that fetches `/api/v1/club`, `/api/v1/squad`, etc. from openfut-core and calls `EditDBTableField` to populate them
4. Optionally add a small HTTP client to the Lua script using LuaSocket (FLE ships with Lua 5.4)
**Limitations:**
- Changes are in-memory only; they reset on game restart (acceptable for a simulator)
- Only works while FLE is running (always true in our setup)
- FUT tables may only be populated when the FUT hub is loaded; test with the exporter
---
## Option B — Local save file injection (career mode proxy)
**What it does:** Generate or modify offline career mode save files that contain FUT-like
squad/player data, using Frostbite's FBCHUNKS format.
**Feasibility:** Medium
- FBCHUNKS format is not publicly documented but has been partially reverse-engineered by the Frosty Tool Suite project
- Career saves are 16 MB — large and complex
- Changes take effect only after a game restart
**Best use:** Pre-populating a career club with the same players as the FUT simulator squad, so offline Squad Battles use "your" players.
**Steps:**
1. Use Frosty Tool Suite to open a career save and map the schema
2. Build a Python exporter that writes a valid FBCHUNKS save with simulator squad data
3. Test: replace the career save, launch FIFA, verify squad is correct
---
## Option C — Local companion web UI
**What it does:** The user manages their FUT simulator entirely in a web browser (openfut-core already has this). A button exports the current squad/club state to a format that a Lua script or file injector can consume.
**This is already implemented** — openfut-core serves the FUT simulator REST API. The missing piece is the Lua bridge script (Option A) that reads from it.
---
## Option D — Local proxy for non-secured local calls only
**What it does:** Intercept FIFA 23's calls to `localhost:*` or a known local endpoint (not EA servers) and respond with simulator data.
**Feasibility:** Low value in isolation
- FIFA 23 does not make calls to localhost in normal operation (except EA App on port 10853)
- All FUT API calls go to EA's servers over TLS
- Intercepting those would require the approach we explicitly ruled out
**Not recommended as a primary path.** Could be combined with Option A if the Lua script exposes a local socket that a coordinator process writes to.
---
## Option E — Memory bridge (Cheat Engine / FLE offsets)
**What it does:** Use known memory offsets (FLE's `offset_cache.json`) to read/write FUT state directly in FIFA23.exe's heap.
**Feasibility:** Medium — FLE already does this for career mode
- FLE's `offset_cache.json` contains addresses for many game structures
- FUT in-memory structs are separate from career structs and may not be mapped yet
- This is fragile (offsets change with game updates)
**Not recommended** unless Options A and B both fail — too brittle.
---
## Recommendation
**Start with Option A (FLE Lua scripting).**
1. Run `tools/squad-exporter/export_squad.lua` in-game to discover which DB tables exist in FUT mode
2. Use `tools/file-watch-diff/watch.sh` to snapshot file state entering FUT and identify any new local files
3. Use `tools/network-metadata-logger/netlog.sh` to log which EA hosts FIFA contacts at FUT entry (metadata only, no decryption)
4. Map findings back to openfut-core's data model
5. Implement the Lua bridge script that calls openfut-core's REST API and writes to discovered tables
If FUT tables are not exposed by FLE's DB API (they may not be — FUT data lives server-side in online mode), fall back to **Option B** (career save injection) to provide a squad that mirrors the simulator's club.
---
## Safety boundary
The following are out of scope and must not be implemented:
- Decrypting or inspecting EA's TLS traffic
- Spoofing EA domain names or impersonating EA servers
- Sending modified clients to EA's production services
- Bypassing EA App login or account verification
- Anything that could constitute online cheating or violate EA's ToS for online play
All integration must remain local/offline/single-player.
@@ -1,523 +0,0 @@
# FIFA 17 — Clean Empty-My-Packs client fix (DESIGN / RESEARCH ONLY)
Status: **design only — no client binary/movie changes made.** This is the client-side
follow-up to bug 6c. The backend already ships a compatibility workaround (P2, active
non-openable sentinel 65534; see `docs/evidence/STORE_TILE_6C.md` §17 and
`FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md`). This plan describes what a *client-side*
fix would need to change so the backend shim can eventually become unnecessary for
patched clients.
Do NOT patch the executable, DLLs, or Scaleform movies in this task.
## 1. Established client-side evidence
Binary: `CardsDLL_Win64_retail.dll`
SHA-256 `4706a881ae1fc7b5769fd810b25a868d29d2b16a8e65a7513436327ef645573c`
(dump load base `0x00006FFFFC120000`; RE-space base `0x180000000`). `FIFA17.exe`
(`29c31cef…`) is Denuvo-packed (decrypts only in live memory).
Store category pipeline (all decompiled; see `docs/plan-2026-08-05-store-subsystem.md`):
- `FUN_1800150d0` — builds display groups from `purchase[]`; a `mypacks` group exists
iff some pack has `displayGroup.value=="mypacks"`. `group+0x104=(value=="mypacks")`,
tiles in `group+0x40`, ordinal in `group+0x00` (1-based creation order).
- `FUN_18007dab0``FUN_1800147f0(model, screen+0x290, …)` — renders/resolves a
category. `screen+0x290==0` lists group tiles (`FUN_180014610`); otherwise
`FUN_180014420` exact-matches the ordinal and **returns NULL on a miss**, after
which `FUN_1800147f0` dereferences `[RAX+0x48]` with **no null guard**
**crash at `0x180014882`** (`ACCESS_VIOLATION` read of `0x48`, minidump-confirmed).
- `screen+0x290` is written in exactly two CardsDLL sites: ctor `FUN_18007d1a0`
writes `0`; **`FUN_18007e7f0` case `0x7551` copies the Flash movie message field
`CATEGORY_ID` verbatim** into it. So the category is chosen by the Scaleform movie.
- `FUN_18007e5e0` binds the six store tabs (`FUN_180014580`: `mypacks, points, bronze,
silver, gold, special`) to `PANEL_ID` = matching group ordinal, or hides the panel.
- Unopened-pack count signals (server, already correct at 0 when empty):
`userInfo.unopenedPacks.recoveredPacks` and `/user/credits .unopenedPacks`. The hub
`CentralUnclaimedPack` tile (destination `GOTO_STORE_MYPACK`) is gated by this count
in the hub model (`model+0x20950`). **Candidate F (a server count gating the STORE's
My-Packs resolution) was CONTRADICTED**: the count is correct at 0 yet the store
still resolves My Packs, because the decision is movie-side.
## 2. Desired clean client behavior
```
unopened-pack count == 0:
Store defaults to Browse Packs (e.g. a real category such as bronze/gold)
My Packs is NOT selected/resolved
no synthetic placeholder tile is required from the server
unopened-pack count > 0:
existing My Packs behavior unchanged
```
## 3. Candidate insertion points (ranked)
Ranking favors fixing the UX (not merely preventing the crash) and the smallest,
lowest-risk change that achieves it.
### Rank 1 (preferred, best UX) — Scaleform / category-selection layer
Prevent the movie from emitting `CATEGORY_ID == mypacks` (and from defaulting the
store into My Packs) when the unopened-pack count is 0; default to Browse Packs
instead.
- **Where:** the FUT Store Scaleform movie / ActionScript (`StoreFront`,
`CATEGORY_ID`/`ACTION_GET_PACKLIST`, `GOTO_STORE_MYPACK`), which the packed exe hosts
and which reads the hub model (it already knows the count for the
`CentralUnclaimedPack` tile).
- **Behavior changed:** the store's initial/selected category when empty.
- **Scope:** movie asset edit (client-side), no native-code patch.
- **Risk:** medium — Scaleform RE/editing is fiddly; must find where the default
`CATEGORY_ID` is chosen and gate it on the count without breaking the count>0 path.
- **Compatibility:** per-client asset change; does not touch protocol or other clients.
- **Fixes UX or just crash?** **UX** — no fake tile, correct default; the crash also
disappears because `mypacks` is never resolved when absent.
- **Evidence:** `screen+0x290 ← CATEGORY_ID` (`FUN_18007e7f0` case `0x7551`); count
already available client-side (hub model / `unopenedPacks`).
### Rank 2 — Native Store resolver fallback (CardsDLL)
Make `FUN_1800147f0`/`FUN_180014420` fall back to a safe category (e.g. list-tiles
`N==0`, or the first existing group) when the requested ordinal misses, instead of
dereferencing NULL.
- **Behavior changed:** category-miss handling for ALL categories, not just mypacks.
- **Scope:** small, localized CardsDLL binary patch near `0x180014420`/`0x180014882`.
- **Risk:** medium — alters native store behavior globally; could mask other
legitimate misses; the movie may still believe it is in My Packs (empty/odd view).
- **Compatibility:** binary patch to the shipped DLL (client-side).
- **Fixes UX or just crash?** Crash + partial UX (no crash, but the empty-My-Packs
view may still be awkward).
- **Evidence:** the no-guard deref at `0x180014882`; `FUN_180014420` returns NULL on
miss.
### Rank 3 (cheapest, crash-only) — CardsDLL null guard
Insert a null check before the `[RAX+0x48]` dereference in `FUN_1800147f0` (a single
`TEST/JZ` around the deref) so a NULL group is skipped/returned safely.
- **Behavior changed:** only the crash path.
- **Scope:** minimal (a few bytes) binary patch at `~0x180014882`.
- **Risk:** low — smallest change; but purely crash-prevention. With no `mypacks`
group the resulting empty view is unverified (could be a blank/empty-category state).
- **Compatibility:** binary patch (client-side).
- **Fixes UX or just crash?** Crash only.
- **Evidence:** minidump faulting instruction `CardsDLL+0x14882`, `[NULL+0x48]`.
## 4. Recommended long-term outcome
Rank 1 (Scaleform default-category gating) is the clean fix: with count 0 the store
opens on Browse Packs, no `mypacks` resolution, no fake tile — and the **backend
sentinel 65534 can be dropped for patched clients** (the server would simply omit the
`mypacks` group when empty, which is safe once the client no longer resolves it).
Rank 3 (null guard) is a cheap universal crash-safety net that could ship alongside.
Until a client-side fix exists, the backend P2 sentinel remains the required
compatibility behavior for unpatched retail clients.
## 5. Open questions / next research (no execution here)
- Locate the Store movie's default/initial `CATEGORY_ID` selection and confirm it can
read the unopened count (Rank 1 feasibility).
- Confirm, via a guarded-resolver experiment, what the empty-My-Packs view degrades to
if the `mypacks` group is simply absent + a null guard is present (Rank 2/3).
- Determine whether the Browse-Packs→My-Packs navigation gate (observed with the
active sentinel) also resolves under Rank 1.
---
# PART II — Native client-fix design (RE-backed, 2026-08-13)
Investigation-and-design phase (no client binary/movie changed, no backend changed,
no new live Store experiment). CardsDLL was re-analysed in Ghidra on `.105`; the
in-repo decompiled addresses were reconfirmed against a freshly-built project. Every
claim below is labelled **ESTABLISHED** (read from this build's binary / crash dump),
**PROPOSED** (design, not yet implemented), or **UNKNOWN**.
## 6. Binary + environment verification (ESTABLISHED)
Hashes re-verified on `.105` (`/mnt/games/FIFA 17/`) — identical to the recorded RE:
- `CardsDLL_Win64_retail.dll` SHA-256 `4706a881ae1fc7b5769fd810b25a868d29d2b16a8e65a7513436327ef645573c`,
size 3179952, PE `TimeDateStamp` 1497050156 (2017-06-09T23:15:56Z), `SizeOfImage`
`0x31d000`, image base `0x180000000` (RE-space). **Unpacked → statically analysable.**
- `FIFA17.exe` SHA-256 `29c31cef12b0c3c2a7305220617c7b4fa139ab76b8c857851bdbe88987962899`,
size 224639408, **Denuvo-packed** → the Scaleform/StoreFront ActionScript that
*decides* to emit `CATEGORY_ID` is NOT statically readable. This is why a
pure-Scaleform edit (old "Rank 1") is not the practical vehicle; the fix is taken
at the readable native boundary in CardsDLL instead.
- Ghidra project rebuilt at `.105:/tmp/ghidra_fut/cardsdll` (headless import+analysis
succeeded). Tooling: `fifa17-recon/tools/ghidra_env.py` run under `~/.venv`
(`PYTHONPATH=/opt/ghidra/Ghidra/Features/PyGhidra/pypkg/src:/usr/lib/python3.14/site-packages`;
`jpype1` reinstalled offline from pip cache). RVAs below = static VA `0x180000000`.
## 7. Category-selection path (ESTABLISHED — decompiled this build)
Store message dispatch `FUN_18007d880` (RVA `0x7d880`) routes Flash message ids:
`0x753f → FUN_18007dab0` (render), `0x278a → FUN_18007df60` (publish category ids),
and the input handler `FUN_18007e7f0` (RVA `0x7e7f0`) case **`0x7551`** copies the
movie field `CATEGORY_ID` verbatim into `screen+0x290` (the only non-ctor writer;
ctor `FUN_18007d1a0` writes 0).
**Store render `FUN_18007dab0` (RVA `0x7dab0`), decompiled verbatim, is the decision
point:**
```c
iVar1 = *(int *)(param_1 + 0x290); // requested CATEGORY_ID (screen+0x290)
iVar6 = FUN_180014580(store, 1); // the *points* category id (see tab map)
if (iVar1 == iVar6) { // requested category is POINTS (real-money)
if (region_check() == 0) { post "REGION_MISMATCH"; return; }
if (FUN_180014de0(store) != 0) return; // points group present → handled
FUN_180014b60(store, dp); // else points render
} else {
FUN_1800147f0(store, iVar1, dp, 0, 0); // EVERY other category, incl. My Packs
}
```
- `param_1` (RCX) = the store-screen object; `+0x290` is the requested category.
- **Tab→id map `FUN_180014580(store, n)` (RVA `0x14580`): `0=mypacks, 1=points,
2=bronze, 3=silver, 4=gold, 5=special`.** Each returns the group's **1-based
ordinal** (via caption compare `FUN_180014380`) or **`-1`** if that group is absent.
So category ids are DYNAMIC ordinals, not fixed constants. The tab publisher
`FUN_18007df60` pushes `MYPACK_/BRONZE_/…_CATEGORY_ID` to the movie from these
lookups; the movie echoes one back as `CATEGORY_ID`.
- The **points** tab is the only one special-cased (commerce/region gate). **My Packs
is NOT special-cased — it falls into the `else` and is resolved by
`FUN_1800147f0`.**
**Resolver `FUN_1800147f0` (RVA `0x147f0`) — the crash (ESTABLISHED, instruction
level):**
```
0x14856: 85 ff TEST EDI,EDI ; EDI = category ordinal (param_2)
0x14858: 75 0f JNZ 0x14869 ; ==0 → list-all (Browse), else resolve
0x1485a: … CALL 0x14610 ; FUN_180014610 list ALL group tiles
0x14867: eb 29 JMP 0x14892
0x14869: 8b d7 MOV EDX,EDI
0x1486b: e8 … CALL 0x14420 ; FUN_180014420(store, ordinal) → RAX (group|NULL)
0x14870: 48 8d 50 40 LEA RDX,[RAX + 0x40] ; RDX = group+0x40 (=0x40 when RAX=NULL)
0x14878: 48 3b c2 CMP RAX,RDX
0x1487b: 74 15 JZ 0x14892
0x14882: 4c 8b 42 08 MOV R8,[RDX + 0x8] ; <-- FAULT: read [0x40+0x8]=0x48 when NULL
0x14886: 48 8b 12 MOV RDX,[RDX] ; [0x40]
```
`FUN_180014420` (RVA `0x14420`) exact-matches `group+0x00` (ordinal), stride `0x108`,
**returns NULL on a miss, with no guard in the caller** → faulting read of VA `0x48`
at `0x180014882`. This is byte-for-byte the Experiment-B minidump
(`0xC0000005` READ `0x48` at `CardsDLL+0x14882`).
- `param_2 == 0` → `FUN_180014610` lists **all** group tiles = the safe "Browse Packs"
view. `param_2 == existing ordinal` → resolves. `param_2 == a non-existent ordinal`
(e.g. `-1`, which `MYPACK_CATEGORY_ID` becomes when the group is absent) → NULL → crash.
**Why it crashes with zero packs (ESTABLISHED):** with `unopenedPackIds==[]` and no
sentinel, no `mypacks` group exists, so `FUN_180014580(store,0) = -1`,
`MYPACK_CATEGORY_ID = -1`, the movie still selects My Packs and echoes `CATEGORY_ID =
-1`, and `FUN_1800147f0(store, -1, …)` → `FUN_180014420(-1)=NULL` → crash. The active
sentinel (65534) works only because it makes a real `mypacks` ordinal exist to resolve.
## 8. Zero-pack state client-side (ESTABLISHED)
The client already holds the correct unopened-pack count in a **data-manager
singleton** (the same one the store resolver uses):
- Obtain: `seed = FUN_1800d7170()` then `FUN_180009c80(&p, seed)` → `p` (release with
`p->vtbl[0x08](p)`). This exact accessor already runs inside `FUN_180014420` and
`FUN_1800147f0`, so any store-category hook can reach it.
- **Read count: `p->vtbl[0x4d8](p)` → int. Write: `p->vtbl[0x4e0](p, n)`.** Confirmed
in `FUN_180019780`, which reads slot `0x4d8`, adds the number of set booleans in a
pack response, and writes slot `0x4e0` (it also fetches `FutGetPurchasedItems`).
- Representation: plain `int`; **0 = no unopened packs**, `>0` = count. Lifetime: the
singleton persists for the session; updated on pack acquire/open.
- No dedicated "hasUnopenedPacks" boolean helper was found; `count != 0` is the
predicate. (The hub `CentralUnclaimedPack` tile is gated by this same count via
`model+0x20950`, written by `FUN_18010cdc0`/`FUN_18011e120` — the hub mirror, not the
store gate.)
## 9. Implementation vehicle (ESTABLISHED — reuse, do not build a new loader)
OpenFUT **already ships a client hook framework**: `openfut-launcher/openfut-hook`
(`crate-type=["cdylib"]`) builds **`version.dll`**, a proxy DLL placed in the game dir
(`/mnt/games/FIFA 17/version.dll`, present & active; log `~/.wine/drive_c/openfut_hook.log`).
- Load path: Wine/Windows loads `version.dll` from the app dir at process start →
`DllMain(DLL_PROCESS_ATTACH)` → `install_hooks()`.
- Existing hooks (`lib.rs`): `getaddrinfo` (IAT via `iat::resolve`), `connect`
(inline detour), `WSAConnect`, `WSAIoctl`/ConnectEx, origin_spy registry/mutex,
crypt32 `CertVerifyCertificateChainPolicy`, **and in-memory byte-patching of the
loaded (packed) main exe + EAWebKit** (`ssl_patch`: `GetModuleHandleA` → scan for a
unique prologue → `VirtualProtect`+`copy_nonoverlapping`).
- Inline-hook primitive (`connect_hook`): `write_hook(target, dest)` lays a 14-byte
`FF 25 00000000 <abs64>` JMP; `restore_original` restores saved bytes
(unhook → call real → rehook, avoiding trampoline relocation).
- Config: `openfut.cfg` beside the DLL (`host`/ports today; a `store_mypacks_fix`
flag would be added there).
- **Suitability for the Store fix: direct.** The DLL is in-process with full access
to the loaded `CardsDLL_Win64_retail.dll`; the store fix is a NEW module
(`store_hook.rs`) installed from `install_hooks`, reusing the `ssl_patch`
signature-scan and the `connect_hook` inline-detour patterns. No new loader, no ASI,
no separate injector.
## 10. Three strategies re-evaluated against the RE (Task 4)
### A. Category-selection redirect — **PREFERRED** (best UX, native, targeted)
Hook `FUN_18007dab0` (RVA `0x7dab0`) at entry; before the original runs, redirect a
zero-pack My-Packs request to Browse Packs:
```
cat = *(int*)(store + 0x290)
mypacks_id = FUN_180014580(store, 0) // -1 when the group is absent
if (cat == mypacks_id) { // movie asked for My Packs (incl. cat==-1==id)
if (unopened_count() == 0) // singleton vtbl[0x4d8]
*(int*)(store + 0x290) = 0; // 0 = FUN_180014610 list-all = Browse Packs
}
// then call the original FUN_18007dab0(store)
```
- Uses the real count? **Yes** (singleton `vtbl[0x4d8]`). Removes the fake 65534 tile?
**Yes** (server can omit the group). Removes the click-dialog? **Yes** (no placeholder
to click). Removes the Browse→My-Packs nav gate? **Yes** (store lands on Browse, not
an empty My-Packs). Preserves count>0? **Yes** (`cat==mypacks_id` with count>0 is left
untouched → normal My Packs). Affects other categories? **No** (`cat!=mypacks_id`
path is unmodified; points/bronze/… unchanged).
- Prevents the crash as a side effect (My Packs is never resolved when its group is
absent). This is the old "Rank 1" INTENT, implemented at the readable native boundary
instead of in packed Scaleform.
### B. Resolver fallback — acceptable safety net, less targeted
In `FUN_1800147f0` (or right after the `CALL 0x14420` at RVA `0x1486b`): if the
resolved group is NULL, fall back to list-all (`param_2=0`) instead of dereferencing.
- Prevents crash? **Yes.** Fixes default nav / removes fake tile? **Partially** — the
movie still believes it is in My Packs, so the view may be an empty/odd My-Packs
rather than a clean Browse. Leaves other lookups unchanged? **It changes miss-handling
for ALL categories** — a generic NULL fallback that could mask a genuine
missing-category protocol bug. Higher risk than A for that reason; keep as a
belt-and-braces guard, not the primary UX fix. The resolver does NOT know *why*
`mypacks` is missing, which is exactly the concern the task flags.
### C. Null-guard only — weakest (crash-only)
Insert `TEST RAX,RAX; JZ 0x14892` immediately after `CALL 0x14420` (RVA `0x1486b`),
before `LEA RDX,[RAX+0x40]`. Needs a trampoline (no inline slack).
- Converts the crash into whatever an empty tile-vector renders (unverified; likely a
blank/empty category). Does **not** remove the fake tile or fix the default category;
the sentinel would still be needed for acceptable UX. Verified as expected-weakest.
## 11. Concrete hook target for strategy A (Task 6, PROPOSED)
```
module: CardsDLL_Win64_retail.dll (GetModuleHandleA)
function: FUN_18007dab0 (store render / message 0x753f)
RVA: 0x7dab0 (static VA 0x18007dab0)
calling conv: Microsoft x64 fastcall; single arg store-screen ptr in RCX
screen offset: store+0x290 = requested CATEGORY_ID (int)
helpers to call: FUN_180014580 (RVA 0x14580) tab→ordinal, arg0=RCX store, arg1=EDX index(0=mypacks)
count singleton: FUN_1800d7170 (0xd7370-seed) + FUN_180009c80 (0x9c80), read vtbl[0x4d8]
redirect target: set store+0x290 = 0 (FUN_180014610 list-all → Browse Packs)
original behavior: zero packs → resolves absent mypacks ordinal → FUN_180014420 NULL → crash at 0x14882
desired behavior: zero packs + mypacks requested → store+0x290 forced to 0 → Browse Packs; no crash/dialog/tile
```
Hook mechanics (reuse `connect_hook`): lay a 14-byte `FF 25` JMP at `base+0x7dab0` to a
Rust `hooked_store_render(store)`; inside: apply the redirect, unhook, call real
`FUN_18007dab0(store)`, rehook, return its value. Intercepting only the entry means the
minimum interception is the 14 JMP bytes; the first instructions of `FUN_18007dab0`
(`MOV RAX,RSP; MOV [RAX+8],RCX; PUSH …`) are a standard prologue safe to save/restore.
Alt insertion point (earlier): `FUN_18007e7f0` case `0x7551`, where `CATEGORY_ID` is
written to `screen+0x290` — redirect there instead of at render. Entry-hook of
`FUN_18007dab0` is preferred (single, well-typed arg; runs once per store render).
Thread/context: the store screen runs on the client's UI/update thread; the hook reads
one int and (rarely) writes one int on the same object the callee immediately reads —
no new synchronization needed. Called for categories other than My Packs? The FUNCTION
is, but the redirect body only fires when `cat==mypacks_id`, so other tabs are
untouched.
## 12. Version / build safety (Task 7, PROPOSED)
FIFA17-specific compat code MUST validate the client before hooking, and MUST no-op on
any other build (the same `version.dll` is also used for FIFA23):
1. **Module gate:** only proceed if `GetModuleHandleA("CardsDLL_Win64_retail.dll")`
resolves (FIFA23 has no such module → auto-skip).
2. **Build gate (both, belt-and-braces):**
- Exact hash/PE gate: on-disk SHA-256 == `4706a881…`, or PE `SizeOfImage==0x31d000`
&& `TimeDateStamp==1497050156` (cheap in-memory check).
- Signature scan + validation: locate `FUN_18007dab0` by a unique prologue/byte
window rather than trusting the RVA, and assert the known bytes at the branch
(`85 ff 75 0f` region) and at the resolver `CALL 0x14420` site match before
installing. Recommend **both**: hash to reject the wrong game fast, signature to
confirm the exact patch site.
3. **Failure behavior:** any check fails (unknown/updated build) → **do NOT patch**,
log, and leave the **backend P2 active-sentinel (65534) as the fallback**. Never
patch or crash an unrecognised build.
## 13. First controlled client experiment (Task 8, PROPOSED — not executed here)
Goal: prove a patched client sends zero-pack Store entry to Browse Packs with **no**
active placeholder.
- Build `openfut-hook` with strategy-A `store_hook`, gated behind `openfut.cfg`
`store_mypacks_fix=1` (opt-in; default off preserves today's behavior).
- Test profile: `unopenedPackIds == []`.
- Sequence (each variable changed alone; operator drives FIFA; read-only capture):
1. Deploy patched `version.dll`; confirm `openfut_hook.log` shows the store hook
installed + build gate PASSED.
2. **Backend test mode (LATER, separately authorized — NOT in this task):** switch the
backend to *empty-no-sentinel* (the Exp-B config that crashed the UNPATCHED client)
so the patched client must handle a genuinely-absent `mypacks` group.
3. Operator opens Store. **Predicted (patched + zero packs + no sentinel):** Store
opens, defaults to Browse Packs, no `mypacks` resolve, **no crash, no dialog, no
fake tile**.
4. Set `unopenedPackIds=[70]`; reopen. **Predicted:** My Packs works normally
(hook body skipped because count>0).
5. Revert backend to the active sentinel.
- **Backend change eventually required for this experiment: YES** — a controlled
empty-no-sentinel test mode to force the absent group. It is NOT performed in this
phase and MUST be separately authorized (same experiment discipline: patch the
container copy, capture, revert, restart; never synthesize a client request).
- **Client rollback:** flip `store_mypacks_fix=0` (hook not installed) or restore the
original `version.dll`; the game reverts to depending on the backend sentinel. No FIFA
binaries/movies/config are modified on disk — the hook is in-memory only, so rollback
is a file/flag swap.
## 14. Interaction with the backend 65534 fallback (ESTABLISHED + PROPOSED)
- **Keep the backend sentinel deployed** until strategy A is implemented AND verified.
It remains the required behavior for unpatched retail clients and for any client whose
build gate fails.
- Once strategy A is verified, the server MAY, **for patched clients only**, omit the
`mypacks` group when empty (the safe representation the client will then handle) —
but only behind explicit detection/opt-in; do NOT drop the sentinel globally, since
unpatched clients still crash without it.
## 15. ESTABLISHED / PROPOSED / UNKNOWN summary
- **ESTABLISHED:** binary hashes/build; the full native category path and addresses
(`FUN_18007d880/18007dab0/18007e7f0/1800147f0/180014420/180014580/180014610`); the
instruction-level crash (`0x14882`, `[NULL+0x48]`); tab→ordinal map; that My Packs is
not special-cased and funnels through `FUN_1800147f0`; the unopened-count singleton
and its `vtbl[0x4d8]/[0x4e0]` accessors, reachable from store code; the
`openfut-hook`/`version.dll` vehicle and its hook/patch primitives.
- **PROPOSED (not implemented):** the strategy-A entry hook and its redirect logic; the
build-guard scheme; the opt-in config flag; the first experiment and its backend
test-mode requirement; the per-patched-client server relaxation.
- **UNKNOWN:** exactly why the packed Scaleform movie selects My Packs on store open
(Denuvo-packed, unread) — not needed for strategy A, which intercepts the native
result; the precise rendered appearance of `category==0` list-all in this empty
configuration (to be observed in the experiment); whether any non-store path also
drives `screen+0x290` to a My-Packs ordinal (none found; `FUN_18007e7f0` case `0x7551`
and the ctor are the only writers).
---
# PART III — Final no-sentinel resolver experiment (2026-08-13) — RESULT F3 (CRASH), CONFOUNDED
Vehicle change: the resolver guard was implemented as an **`autopatch.py` memory patch**
(the live FIFA-17 client-patch mechanism), NOT the `version.dll` proxy — Proton loads its
builtin `version.dll`, so the earlier `store_hook`/`version.dll` prototype was inert and
has been rolled back. Guard: at CardsDLL `0x180014858`, `JNZ 0x14869` (`75 0f`) →
`JG 0x14869` (`7f 0f`), orig-verified; routes category `< 0` (and `== 0`) to the safe
list-all/Browse path (`FUN_180014610`), category `> 0` to the existing resolver.
`TEST EDI,EDI` at `0x180014856` is the flag source (OF cleared ⇒ `JG` = signed `> 0`).
## Setup (verified)
- CLIENT: guard active/enforced — live bytes `85 ff 7f 0f` at `0x180014856` (FIFA pid 547843,
autopatch pid 547621; log `ENFORCED guarded store patch @ … (JNZ->JG)`, orig `75 0f` matched).
- BACKEND: sentinel 65534 suppressed by a one-line `if not owned_ids:` → `if False:` in the
container copy only (committed source `f42279f` untouched; backup `/tmp/utas_server.EXP_ORIG.py`).
Genuine `GET /store/purchasegroup` (02:44:39Z) → ids `[1,5,6,7]`, **no 65534, no mypacks group**,
normal packs unchanged (evidence: `docs/evidence/store_purchasegroup_capture_client_guard_no_sentinel_2026-08-13.json`).
- PROFILE: `unopenedPackIds=[]`, coins 29,876,776, sha `39bb3e83…` — unchanged throughout.
## Result — F3 (CRASH)
Minidump `CrashDump_2026.08.12_20.44.40.302.dmp` (preserved `/tmp/expF_crash.dmp`, sha `4dcb0cb7…`):
`0xC0000005` READ of VA `0x48` at `ExceptionAddress 0x6ffffc224882` → **RE `0x180014882`** —
the **identical** resolver crash instruction as Experiment B (`FUN_1800147f0`,
`MOV R8,[RDX+0x8]` with the group ptr NULL).
**Mechanism (decisive):** `0x14882` lives in the *resolve* branch, which the guard's `JG`
reaches **only when category `> 0`**. Since the guard was verified in place, the client
presented a **positive** My-Packs ordinal that no longer resolves (no mypacks group) →
`FUN_180014420` returned NULL → crash. The guard's design assumption — *absent mypacks ⇒
category `-1`* — did NOT hold on this path.
## Confound (uncontrolled variable)
FIFA was **not relaunched** after the backend flipped to no-sentinel; the client carried
**stale store/tab state** from the sentinel-present safe stage, where the mypacks group
existed at a *positive* ordinal `N` (`MYPACK_CATEGORY_ID = N`). Reopening the Store reused
that stale positive ordinal rather than the `-1` a **fresh** launch publishes
(`FUN_18007df60 → FUN_180014580(store,0) = -1` when absent). So the intended clean A/B (client
only ever sees the no-sentinel response) was not achieved — the category that reached the
resolver was a stale `>0`, exactly the case the negative-only guard does not divert.
## Conclusion / strategy status
- **The guard as-written does NOT handle a positive, now-invalid My-Packs ordinal** — proven
by this crash. Diverting only `category < 0` is insufficient when the client presents a
stale/positive ordinal for an absent group.
- **Not falsified for the fresh-client case.** Whether a fresh no-sentinel launch presents
`-1` (guard diverts → Browse, no crash) or still a positive ordinal is **UNKNOWN** and needs
a **clean re-test**: launch FIFA fresh with the backend already in no-sentinel mode so the
client never sees a mypacks group. That is the proper equivalent of Experiment B.
- **Candidate stronger guard** (design only, not implemented): divert to list-all when the
resolved group is NULL for *any* category (guard `FUN_180014420`'s NULL return at the
`0x14870`/`0x14882` site), not merely when `category < 0`. This covers the positive-invalid
ordinal too, at the cost of being a generic miss-fallback (the higher-risk Rank-2 behavior).
Do NOT implement without authorization and a clean re-test first.
**Strategy A / resolver guard status: NOT PROVEN.** Crash-guard installs and is build-validated
and dormant-safe with the sentinel present, but the first no-sentinel test CRASHED at the
resolver via a positive stale ordinal (confounded by no relaunch). Backend P2 active-sentinel
was restored immediately (mandatory rollback; source `f416e71e…`, sentinel `state=active`),
and remains the production safety net. Guard left in `autopatch.py` (dormant) pending the
clean re-test decision; `autopatch.py.pre-storeguard.bak` available to remove it.
---
# PART IV — Fresh-process no-sentinel retest (2026-08-13) — RESULT R1 (SUCCESS)
Corrects PART III's confound. This time the mandatory ordering was enforced: the backend
entered no-sentinel mode **while FIFA was closed**, then FIFA launched **fresh** (new pid,
new autopatch) so the process never saw a sentinel-present Store response.
## Setup (verified, clean A/B)
- BACKEND set no-sentinel at 02:55:09Z with FIFA down; genuine `GET /store/purchasegroup`
(02:58:45Z) served to the fresh client = ids `[1,5,6,7]`, **no 65534, no mypacks group**,
packs 1/5/6/7 present. This body is **byte-identical** to the PART III (F3) no-sentinel
capture — the ONLY changed variable vs F3 is the client process lifetime.
Evidence: `docs/evidence/store_purchasegroup_capture_freshretest_no_sentinel_2026-08-13.json`.
- CLIENT: NEW FIFA pid 553220, NEW autopatch pid 552999; guard ENFORCED (orig `75 0f`
matched → `85 ff 7f 0f` = `TEST EDI,EDI; JG`). Process never saw a sentinel response
(0 purchasegroup responses containing 65534 after the no-sentinel restart).
- PROFILE unchanged throughout (`39bb3e83…`, `[]`, coins 29,876,776).
## Result — R1 (operator-observed)
- **No crash** (FIFA 553220 alive after the test; no new minidump), **no dialog**, **Store
stays open**, opens on **Browse Packs**, Bronze/Gold/Special packs visible and navigable.
- Cosmetic-only imperfections (pre-existing, NOT caused by the guard): the six-tab bar is
unbound (no tabs), packs render without cover art, and tiles show "0 items". These match
the known store tab-bind / list-all rendering quirks (`plan-2026-08-05-store-subsystem.md`
§2.1) and are independent of the resolver guard.
## Causal conclusion (decisive A/B)
```
server response (no sentinel, no mypacks group) == byte-identical across F3 and R1
client original JNZ + this response -> CRASH 0x180014882 (Experiment B)
client JG (stale positive ordinal) -> CRASH 0x180014882 (PART III F3, contaminated)
client JG (FRESH, category = -1) -> NO CRASH, Browse Packs (PART IV R1) ✅
```
A **fresh** client publishes `MYPACK_CATEGORY_ID = FUN_180014580(store,0) = -1` for the absent
group; the movie echoes `-1`; `TEST EDI,EDI; JG` does **not** take the resolve branch, so the
client runs the list-all/Browse path (`FUN_180014610`) — no `FUN_180014420(NULL)` deref, no
crash. **PART III's F3 is confirmed as stale-positive-ordinal contamination** (FIFA not
relaunched across the sentinel→no-sentinel flip), not a guard failure.
## Strategy status
**Strategy A / resolver guard: PROVEN ON THE TESTED FIFA 17 BUILD** (CardsDLL
`4706a881…`) for the clean process-lifetime case — it safely routes the absent My-Packs
category to Browse Packs with no crash and no dialog, needing **no** backend sentinel. Scope
caveats: (1) tested build only; (2) the negative-only guard does NOT cover a stale/positive
invalid ordinal (PART III) — only arises if the client's Store state predates a sentinel→
no-sentinel change within one process, which does not happen on a normal launch; a NULL-return
guard at `FUN_180014420` would additionally cover that, deferred/not implemented; (3) UX still
has the pre-existing no-tabs/no-art/"0 items" cosmetics.
Backend P2 active-sentinel was restored immediately after capture (mandatory rollback; source
`f416e71e…`, sentinel `state=active`) and **remains production default**. The clean UX is only
safe to serve when the server knows the client is patched — see PART II §12 rollout options
(recommend B: suppress the sentinel only when client patch-capability is known; keep the
sentinel universal by default). Guard retained in `autopatch.py` (dormant with the sentinel).
## INVARIANT — empty-My-Packs capability MUST be session-stable
F3 vs R1 establish a hard operational invariant for any deployment (sentinel or client
guard): **the server MUST NOT switch a running FIFA client between sentinel-present and
sentinel-absent for the My Packs group within a single FIFA process lifetime.**
Rationale: the client resolves and caches the My-Packs group **ordinal** (positive when a
group — real or sentinel — is present; `-1` when absent) from the `purchasegroup` response
seen at Store-subsystem init. The resolver guard only reclassifies the ordinal *sign*
(`≤0` → Browse). If a client that already cached a **positive** ordinal later receives a
no-sentinel topology, the stale positive ordinal still takes the resolve branch and
`FUN_180014420` returns NULL → crash at `0x180014882` (exactly F3). A **fresh** process that
only ever sees the no-sentinel topology caches `-1` and is routed to Browse safely (R1).
Practical rules:
- Choose the My-Packs representation (sentinel-present vs sentinel-absent) **before** a client
starts its session, and hold it for that session.
- The future patch-capability handshake (PART II §12) MUST therefore be decided at
login/session start, not toggled mid-session.
- A NULL-return guard at `FUN_180014420` (deferred) is the only thing that would make a
mid-session flip crash-safe; until then, session stability is mandatory.
@@ -1,358 +0,0 @@
# FIFA 17 — verified patched-client capability negotiation
Goal: let the FIFA 17 backend suppress the synthetic My-Packs sentinel (id 65534)
**only when the current FIFA process has positively verified that the CardsDLL
resolver guard is active** (JNZ→JG at RVA `0x14858`). Unpatched / unsupported /
unknown / failed-patch clients keep receiving the existing P2 active sentinel.
Core principle: **the capability is not "this launcher supports the patch"; it is
"the resolver guard was verified in *this particular FIFA process*."**
This document is the design + the cross-component contract. It is deliberately
additive: the P2 active-sentinel path (`docs/evidence/FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md`)
remains the default and the universal fallback.
---
## 1. Architecture inventory (as-built, verified by reading the code)
Data flow today (launch of one FIFA process):
```
LauncherApp::launch_game (openfut-launcher/src/app.rs:450)
-> account_sync::sync POST /openfut/account/sync (:8099) [REQUIRED; launch is gated on it]
-> ensure_local_services() spawn LSX, then autopatch.py --launcher-pid <launcher_pid>
-> game_launch::launch umu-run FIFA17.exe (grandchild; launcher never learns FIFA PID)
FIFA process
-> autopatch.py self-discovers FIFA by comm=='FIFA17.exe'; patches /proc/<pid>/mem each tick
-> FIFA -> backend POST /ut/auth (login) ; GET /store/purchasegroup ; ... (:8099)
```
Facts that shape the design:
- **Launcher ↔ autopatch IPC = one-way stdout only.** `local_services::spawn`
(openfut-launcher/src/local_services.rs:279-296) pipes autopatch stdout/stderr
into the launcher `LogBuffer` line-by-line as `[autopatch] <line>`. There is no
socket / named pipe / status-file readback. `--launcher-pid` is the *launcher's*
own pid (local_services.rs:66), used for liveness, not to identify FIFA.
- **Launcher ↔ backend = exactly one control call:** `account_sync::sync`
(openfut-launcher/src/account_sync.rs:43) — a tiny stdlib-HTTP `POST
/openfut/account/sync` on `openfut_account_sync_port` (default 8099), sent once
per launch, *before* FIFA starts, and **launch is blocked unless it succeeds**
(utas_server.py:1204). This is the reliable per-FIFA-process session boundary.
- **Backend is single-account, stateless-per-request, threaded.** `SID` is a fixed
module constant shared by all clients (utas_server.py:32); account identity is one
global `ACCOUNT` singleton. There is **no per-session identity** in requests. The
only per-connection discriminator available at every handler is
`self.client_address[0]` (peer IP), currently unused. Server is
`ThreadingHTTPServer` (utas_server.py:3784); module is import-safe (server under
`if __name__ == "__main__"`).
- **No bridge/proxy in the FIFA-17 path.** FIFA reaches the Python backend's
published `:8099` directly (client-side DNAT/hosts redirect); the openfut-bridge is
legacy FIFA-23. Docker's iptables DNAT preserves the source IP for external LAN
clients. The launcher and FIFA run on the **same** client machine, so the backend
observes them under the **same** peer IP regardless of NAT.
## 2. Capability transport — options and choice
Ranked against the as-built architecture:
**autopatch → launcher (chosen: structured stdout line).**
1. **Structured stdout line (CHOSEN).** Reuses the existing one-way pipe the
launcher already reads. It is *live* (only the current autopatch child's stdout),
inherently child-bound, and carries **zero stale-file risk** — a previous
launch's capability cannot leak because nothing is persisted. Smallest possible
change. Format is a machine-readable token (§4).
2. Status file in `$XDG_RUNTIME_DIR` keyed by launcher-pid+FIFA-pid+version+timestamp
— works but needs explicit staleness handling and cleanup; more moving parts.
3. Unix-domain socket — most capable but overkill; there is no bidirectional need.
**launcher → backend (chosen: sibling HTTP endpoint on the account-sync port).**
- A. **Existing session-init channel (CHOSEN).** Add `POST /openfut/fifa17/capability`
next to the existing `/openfut/account/sync` (same port 8099, same tiny stdlib-HTTP
client). It cannot ride *inside* account_sync because the capability is only known
*after* autopatch verifies (which happens after account_sync + FIFA start), so it is
a separate, later call — but on the same proven transport.
- B. Blaze/login metadata — rejected: no OpenFUT-owned field is available without
risking a field FIFA depends on, and Blaze runs in a separate responder.
- C. New local IPC + backend side-channel — unnecessary; A already exists.
- D. Server-wide "assume patched" config — dev/testing fallback only; cannot
distinguish patched vs unpatched clients, so never the production mechanism.
## 3. The capability (name + version + VERIFIED semantics)
- Name: **`fifa17.empty_mypacks_resolver`**, integer version, current **`1`**.
- **VERIFIED (v1) means, for THIS FIFA process:** the CardsDLL tested build was
recognised AND the live bytes at RVA `0x14858` are `7f 0f` (`JG`) **after
autopatch enforcement** — i.e. `guarded_action` returned `"patch"` (was `75 0f`,
written, re-read as `7f 0f`) **or** `"noop"` (already `7f 0f`).
- It explicitly does **NOT** mean any of: "autopatch.py contains the guard code",
"the launcher build is new enough", or "a config flag is set". The signal
represents **observed runtime enforcement on the specific process**, nothing less.
## 4. autopatch verification state + emitted line
Per-FIFA-pid guard status (fail-closed; never loosens the existing byte guard):
| state | meaning |
|---|---|
| `NOT_ATTEMPTED` | CardsDLL not yet mapped / guard not evaluated for this pid |
| `VERIFIED` | live bytes == `7f 0f` after enforcement (from `patch` or `noop`) |
| `UNSUPPORTED_BUILD` | live bytes are neither the known original nor patched (`guarded_action``skip`) |
| `WRITE_FAILED` | `/proc/<pid>/mem` write raised |
| `VERIFY_FAILED` | post-write re-read != `7f 0f` |
Only `VERIFIED` advertises capability. On transition to `VERIFIED`, autopatch emits
**once per FIFA pid** on stdout:
```
[store-guard] verified capability fifa17.empty_mypacks_resolver=1 fifa_pid=<pid>
```
Any non-verified terminal state emits an explicit, non-advertising status line, e.g.:
```
[store-guard] guard status=UNSUPPORTED_BUILD fifa_pid=<pid> (no capability advertised)
```
## 5. Launcher per-process capability state
```rust
pub struct Fifa17ClientCapabilities { pub empty_mypacks_resolver: Option<u32> }
```
- Starts **UNKNOWN** (`None`) at each launch.
- Becomes `Some(1)` when the launcher parses a valid capability line from the
**current** autopatch child's stdout (`parse_capability_line`).
- **Discarded** when autopatch stops / FIFA exits / launcher exits / next launch. It
is never persisted and never reused for a later FIFA process — staleness is
structurally impossible.
On first `Some(v)`, the launcher registers the capability with the backend (§6) once.
## 6. Launcher → backend registration + binding
`POST /openfut/fifa17/capability` (port = `openfut_account_sync_port`, 8099), body:
```json
{"capability":"empty_mypacks_resolver","version":1,"personaId":<id>,"fifaPid":<pid>}
```
- **Binding key = source IP** (`self.client_address[0]`). The registration arrives
from the client machine's IP; FIFA's `/store/purchasegroup` requests arrive from
the **same** IP (same machine). `personaId`/`fifaPid` are for logging only (the
backend is single-account, so persona cannot discriminate clients).
- Concurrency: distinct client machines → distinct peer IPs → independent decisions
(no global state). Two FIFA processes on **one** machine share an IP — an accepted
limitation (the backend is single-account anyway); documented in §Trust.
## 7. Backend session-stable decision
Per-IP record (guarded by a lock; threaded server):
```
_FIFA17_STORE[ip] = {"resolver": Option[int], "mode": Option[str]} # mode: None|"sentinel"|"clean-v1"
```
- **Reset (session boundary):** `/openfut/account/sync` from `ip` sets
`{resolver: None, mode: None}`. This is the launcher's required per-launch call, so
every new FIFA process starts from a clean, unfrozen record — no cross-process leak.
- **Register:** `/openfut/fifa17/capability` from `ip` sets `resolver = version`. If
`mode` is already frozen, it is logged as late and **ignored for this session**.
- **Freeze point = first `/store/purchasegroup`** from `ip` (§9): if `mode is None`,
set `mode = "clean-v1"` iff `resolver == 1` else `"sentinel"`, and log once.
Thereafter `mode` is immutable for the session.
- **Default / fail-closed:** an IP with no record (no account-sync, no capability),
an unknown resolver version, a late capability, or a disappeared capability all
resolve to (or remain) `"sentinel"`.
## 8. Freeze point rationale
Freeze at **first `/store/purchasegroup`**, not at login/account-sync. account-sync
fires *before* FIFA starts and *before* autopatch can verify, so freezing there would
always be `sentinel`. First Store request is the earliest moment at which a genuine
capability can already be registered (autopatch verifies at process start; the user
opens the Store later), while still being a single, well-defined topology commit for
the session. Once Store topology is served, it must not change (the F3 experiment
proved a mid-session flip can leave a stale positive ordinal that crashes even the
sign-only guard — see `FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md` PART III/IV and the
SESSION-STABLE invariant).
## 9. Store behaviour (additive switch)
At `store_catalog`, only the zero-owned-packs branch changes:
```
if not owned_ids:
if fifa17_empty_mypacks_mode(client_ip) == "clean-v1":
pass # patched client: emit NO mypacks group; guard routes -1 to Browse
else:
<append active 65534 sentinel exactly as today> # P2 fallback (unchanged)
```
Untouched: real owned-pack rendering, `PACK_CATALOG`, pack 70, normal packs 1/5/6/7,
profile state, all store env flags. Default remains sentinel. This lives in the FIFA-17
Python backend only — **never** in game-independent OpenFUT Core.
| client | zero packs | real unopened pack |
|---|---|---|
| verified v1 | **no sentinel** (clean) | genuine My Packs, no sentinel |
| no / unknown capability | **active 65534 sentinel** | genuine My Packs, no sentinel |
## 10. Trust model (Task 14)
This is **not** anti-cheat / attestation. OpenFUT assumes the user controls the
launcher/client machine and the server is a private preservation environment. The
verification exists to prevent *accidents*: a stale capability, an unsupported
CardsDLL build, a failed autopatch, the wrong process, or an unpatched client
receiving no sentinel and crashing. No signatures / PKI / remote attestation.
Isolation across *distinct client machines* relies on the backend observing distinct
peer IPs (source-IP-preserving publish; Docker's default for external LAN via iptables
DNAT). Two FIFA processes on one machine cannot be distinguished by IP — accepted,
since the backend is single-account. The single-client production case is unaffected
by NAT because launcher and FIFA share one IP.
## 11. Fail-closed matrix (Task 15) — every failure ⇒ sentinel
autopatch missing / not run · guard `UNSUPPORTED_BUILD` / `WRITE_FAILED` /
`VERIFY_FAILED` · launcher cannot parse the line · registration POST fails ·
account-sync never called · unknown capability version · capability arrives after
freeze · capability disappears after a sentinel freeze — **all resolve to the active
65534 sentinel.** Asserted by tests (matrix AJ) and this document.
## 12. P2 retained (Task 16)
The active-sentinel implementation is **not** removed. It is the else-branch of the
switch and the universal default for unpatched clients, unsupported builds, failed
patches, unknown launchers, and late capabilities. The clean path is purely additive.
---
## 13. Session binding (hardening — supersedes the per-IP prototype)
**History.** The first implementation keyed the backend capability/store-mode by
**source IP alone** (§7 as originally written). That was rejected before deployment:
two FIFA processes that share a source IP — concurrent, or a relaunch — would share
the key, so an *unverified* process could inherit a *verified* one's `clean-v1`
topology and crash on the empty-My-Packs resolver. Source IP is now **auxiliary only**
(logging, a fail-closed sid/ip sanity check, and the pending hand-off key). This
history is retained deliberately; do not treat per-IP as the design.
**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 `OPENFUT-SID-…0001`); the
client echoes it on every later call, and it is **live-confirmed present on real
`/store/purchasegroup` requests**. The SID uniquely identifies one FIFA process/login:
a relaunch re-auths → new SID; two concurrent logins → two SIDs. The legacy constant
is still accepted by the retired security-question gate only, and is **never** used to
grant `clean-v1`. A store request whose SID was opened on a different source IP is
fail-closed to sentinel (sid/ip sanity check).
**Why not persona alone:** the backend is single-account, so `personaId` cannot
distinguish two sessions, and a relaunch keeps the same persona — persona alone would
leak a prior session's mode. Persona is used only (with IP) to key the pending hand-off.
### State machine (per session, keyed by SID)
```
Capability : Unknown | ResolverV1
StoreMode : Unfrozen | Sentinel | CleanV1
/ut/auth (new SID) : Capability=Unknown, StoreMode=Unfrozen, record {ip,persona}
+ consume any pending (ip,persona) -> Capability=ResolverV1
capability registered : bind to the one live Unfrozen/Unbound session for (ip,persona)
-> Capability=ResolverV1 ; else stage single-use pending ;
else (a session exists but is frozen/ambiguous) -> ignored-late
first /store/purchasegroup : Unfrozen + ResolverV1 -> freeze CleanV1
Unfrozen + otherwise -> freeze Sentinel (consume pending first)
late capability : StoreMode already frozen -> unchanged (ignored-late, not staged)
capability lost/cleared : after a CleanV1 freeze -> stays CleanV1 (mode is cached)
session idle > TTL / reaped: session discarded (a later store with that SID -> Sentinel)
```
### Registration order + pending hand-off
The verified capability is known only after the FIFA process exists, CardsDLL is
loaded, and autopatch confirms the JG bytes — which may land before or after
`/ut/auth`, but reliably before the user opens the Store. The launcher cannot know
the SID, so its registration is matched to a session by (source_ip, persona) as a
**single-use, short-TTL pending** (`FIFA17_PENDING_TTL = 120s`) that is consumed by
exactly one session, at whichever of these happens first for that session: its
`/ut/auth` (pending predates login), the registration itself (session already live —
bound directly), or its first store request (lazy). If the Store is reached before a
capability binds, the session freezes **Sentinel** (fail-closed); a later capability
does not change it.
### Session cleanup (Task 10)
- **creation:** at `/ut/auth`.
- **last activity:** bumped on every `/store/purchasegroup` for the session.
- **freeze:** first `/store/purchasegroup`.
- **expiry:** lazy sweep on every session op removes sessions idle for
`FIFA17_SESSION_TTL = 3600s` and pendings older than `FIFA17_PENDING_TTL`. Explicit
Blaze/UTAS teardown is not reliably observable at this handler, so a conservative
activity-based TTL is used instead. Reaping only removes *expired* entries and never
affects another live session from the same IP/persona (keyed by distinct SIDs).
### Residual limitation (documented, fail-closed)
FIFA carries no launcher-controllable per-process token, so two **simultaneous** logins
from the **same (ip, persona)** cannot be disambiguated at the instant a capability is
registered while *both* are Unfrozen/Unbound. That ambiguous case resolves to
`ignored-late`**both freeze Sentinel** (safe: an unverified process is never granted
clean). The normal one-launcher-per-FIFA and sequential-relaunch flows bind correctly
(proven by matrix K/L/M). This is a UX conservativeness, never a safety hole.
---
## 14. Deployment candidate & controlled A/B (overnight reconciliation 2026-08-13)
**Launcher lineage reconciliation.** The two divergent launcher histories (merge
base `87241ac`) were reconciled by a real merge — **not** a rebase/squash/rewrite —
in a clean worktree:
- `feat/launcher-arming` `13339c1` (client arming + FIFA-17 capability reporting)
- `feat/sbc-hook-tracing` `958ff24` (openfut-hook SBC request tracing / RE probes)
Merged commit **`ca7ce26`** on branch `integration/fifa17-launcher-capability-sbc`
retains **both** ancestors (`git merge-base --is-ancestor` true for both `958ff24`
and `13339c1`). The only conflict was `src/process.rs` (launcher-arming deleted it +
dropped `mod process`; SBC only incidentally tidied it) — resolved **keep-deleted**
(orphan module; the SBC feature lives entirely in `openfut-hook/*`). The two features
are in disjoint crates/processes (launcher-crate Rust host vs `openfut-hook` Windows
DLL) and share no stdout readers, child handles, or lifecycle — no integration code
was needed.
**Gitlink status — DEFERRED (morning blocker).** The superproject gitlink still
records the pre-reconciliation `958ff24`. It was **not** bumped to `ca7ce26` because
the live submodule checkout carries uncommitted `openfut-hook/*` WIP that overlaps the
merged hook content; a non-destructive `git checkout ca7ce26` is refused ("local
changes would be overwritten"), and no `-f`/`reset`/`clean` is permitted. The user
must first reconcile that WIP against the merged `openfut-hook`, then the gitlink can
bump. Preservation artifact: `/tmp/openfut-launcher-overnight-tracked.patch`
(sha256 `8e65de2c…`).
**Validated deployment-candidate tuple** (reproducible from git except the deferred
gitlink):
```
superproject HEAD a82407c (backend per-session + docs)
backend guard b0d5e04 fix(fifa17): guard missing store category resolution
client proof fc29c2e docs(fifa17): record no-sentinel client resolver proof
autopatch report 1c396dd feat(fifa17): report verified client patch capability
backend negotiate b25761e feat(fifa17): negotiate clean empty My Packs mode
session binding 805d754 fix(fifa17): isolate patched-client capability per session
launcher merged HEAD ca7ce26 merge: reconcile launcher capability and SBC tracing
(ancestors 13339c1 capability + 958ff24 SBC)
launcher gitlink (super) 958ff24 <-- to become ca7ce26 once WIP reconciled
```
Local build artifacts (NOT deployed): launcher `target/release/openfut-launcher`
(sha256 `a390c61d…`); backend image `openfut-fut-backend:candidate-overnight`
(`84d280be…`, ships `utas_server.py` `33e0ef3…`). Live `:dev` image and the running
container were left untouched.
### Controlled A/B sequence (execute only in a later authorized deploy task)
**A — patched client:** fresh FIFA process → autopatch verifies the JG guard →
launcher parses the verified line and registers → `/ut/auth` mints a fresh `X-UT-SID`
→ capability binds to that SID → first `/store/purchasegroup` freezes `clean-v1`
backend omits 65534 → Store opens on Browse Packs, no crash.
**B — unpatched client, same machine/IP, NEW session:** new `X-UT-SID`, no verified
capability → first store freezes `sentinel` → backend emits active 65534 → no crash.
Proves same-IP isolation + fail-closed fallback.
**C — failed patch (optional):** autopatch reports `UNSUPPORTED_BUILD`/`VERIFY_FAILED`
→ launcher never registers → `sentinel`.
Production remains the P2 active-sentinel universal default until this A/B passes.
-208
View File
@@ -1,208 +0,0 @@
# OpenFUT Status Review
*Generated 2026-06-30 — read-only stocktake, no code changed.*
---
## Executive Summary
OpenFUT has a mature offline FUT economy backend (Core, 25 phases, fully functional in
isolation) and a sophisticated hook DLL that loads into FIFA 23, redirects EA hostnames
to loopback, and bypasses TLS certificate verification. The Blaze/ProtoSSL layer is
structurally ready: framing code exists, a TLS listener runs, cert-verify is patched.
However the project is currently blocked before any Blaze traffic is ever seen.
The fundamental problem is that FIFA 23 submits `GoOnline` to EbisuSDK and then
**waits for an asynchronous ONLINE_STATUS_EVENT push** from the EA-app LSX server —
a push that current code never sends. Every approach tried so far (flipping poll
return values, forcing the state flags, read-only probes) confirms the gate is
event-driven, not poll-driven. The Blaze captures directory contains six empty files.
No Fire2 frame from FIFA 23 has ever been decoded. Until the ONLINE_STATUS_EVENT push
is synthesized and delivered correctly, Milestones 27 are all waiting on the same
single wall.
---
## 1. Proven vs Assumed
| Claim | Status | Evidence |
|---|---|---|
| FIFA 23 uses DirtySDK / ProtoSSL | **Proven** | String scan hit `ProtoSSLSend`, `ProtoSSLRecv`, `gosredirector` in FIFA23.exe memory (Task 1) |
| `version.dll` loads and runs hook code | **Proven** | `hook.log` written at DLL_PROCESS_ATTACH |
| `getaddrinfo` IAT hook redirects EA domains to loopback | **Proven** | Hook log records every EA `getaddrinfo` call; connect_hook log confirms port redirects |
| ProtoSSL cert-verify prologue found and patched (FIFA23.exe) | **Proven** | ssl_patch.rs prologue confirmed at file offset 0xf0c850; hook log "ssl: main exe cert-verify patched" |
| ProtoSSL cert-verify patched in EAWebKit.dll | **Proven** (if loaded) | Lazy patch fires on first EA getaddrinfo call; hook log message confirms |
| Gate is upstream of DirtySDK — no DNS/connect fires on FUT entry | **Proven** | getaddrinfo, connect, WSASend/Recv hooks all show zero external traffic during "connecting to EA Servers" |
| `GoOnline` is called by the game | **Proven** | Read-only detour on `anadius64.dll+0x2BB90` confirmed hit |
| anadius returns GoOnline success | **Proven** | Handler observed returning successfully; game still retries every ~7 s |
| Gate is downstream of GoOnline | **Proven** | GoOnline called + returns success; no Blaze connect follows |
| Connection-state function: `GetInternetConnectedState @ anadius64.dll+0x27790` | **Proven** | Located via anadius LSX command-registration table; two-flag branch decoded (`+0xCAB1A`, `+0xCAB1B`) |
| Gate is event-driven (game waits for async push, not a poll return) | **Proven** | Forced both state flags AND GoOnline return to "1"; game kept retrying; worker-thread stack scan confirms handler runs on anadius IOCP thread, not FIFA's thread |
| GoOnline runs on anadius worker thread, not FIFA's call thread | **Proven** | Stack scan from inside detour found zero FIFA23.exe frames, sp ~2.4 KB from thread stack top |
| `protossl-scan` live toolkit is exhausted for finding GoOnline in FIFA23.exe | **Proven** | No `"GoOnline"` string in image; worker-thread call stack has no FIFA frames; jmpscan yields ~3875 hits (overwhelmingly data false positives) |
| FIFA 23 redirector config references `Authorization:` header (Nucleus token) | **Proven** | Found in FIFA23.exe .rdata pointer table @ `+0x83FC858` |
| openfut-core REST API complete and tested | **Proven** | 25 phases, 15 migrations, passing integration tests |
| Bridge LSX server starts and handles request-response | **Proven** (code) | `openfut-bridge/src/lsx.rs` + `main.rs` — server starts on 127.0.0.1:3216 |
| Bridge LSX server ACTUALLY receives FIFA's LSX connections | **UNCONFIRMED** | anadius may intercept the same calls in-process before the TCP connection reaches the bridge |
| Bridge LSX server `GetInternetConnectedState → connected="1"` unblocks the gate | **UNCONFIRMED (known to fail in-process)** | Flipping the value via anadius in-process failed; bridge path not yet confirmed working |
| ONLINE_STATUS_EVENT push XML format | **UNKNOWN** | No capture; format not derived |
| Fire2 framing is correct for FIFA 23 | **UNCONFIRMED** | Implemented based on post-2012 EA convention; all blaze captures are empty (0 bytes) |
| Blaze component / command IDs for FIFA 23 | **UNKNOWN** | Zero captures; dispatch table entirely empty placeholders |
| ProtoSSL recv-injection convention (non-blocking return values etc.) | **UNCONFIRMED** | Never reached M4; recv_hook module removed from active install path |
| FUT REST endpoint paths in mapper.rs | **SPECULATIVE** | Based on community knowledge of older FIFA titles; the one actual capture in `captures/` is an early GET from before the Blaze strategy |
| FLE Lua API exposes FUT DB tables in memory | **UNKNOWN** | `export_squad.lua` has never been run; FUT data may only exist server-side in online mode |
---
## 2. Milestone Status
| Milestone | Status | Blocker | Depends on unconfirmed assumption? |
|---|---|---|---|
| **M1** — Locate connection-state decision point | ✅ Done | — | No |
| **M2** — Flip gate, force "connected" | ⛔ Blocked | Game waits for async ONLINE_STATUS_EVENT push; no current code sends it | Yes — unknown event XML format |
| **M3** — First ProtoSSL plaintext on Blaze connection | 🔲 Not started | Depends on M2 | Yes — Fire2 framing unconfirmed |
| **M4** — Answer redirector + decode first Fire2 frame | 🔲 Not started | Hard wall: Fire2 framing, recv-injection convention, component/command IDs all unconfirmed | Yes — all three unknown |
| **M5** — Blaze preauth / login / postauth | 🔲 Not started | Depends on M4 | Yes — Blaze auth TDF body layout unknown |
| **M6** — FUT entry + hub load | 🔲 Not started | Depends on M5; also requires FUT REST response shapes confirmed | Yes — endpoint paths speculative |
| **M7** — Squad Battles (AI FUT) | 🔲 Not started | Depends on M6 | Yes |
**Note on roadmap.md wording:** Under M2M4, roadmap.md uses `**Done (observable):**` bullets. These describe the *success criterion* for each milestone, not an achieved state. The authoritative status is in `connection-gate-findings.md` (M2 attempts failed; M3/M4 never started). The roadmap has not been updated to reflect M2 failure.
### M4 is the first hard wall in detail
Even assuming M2 is solved, M4 requires three unconfirmed things simultaneously:
1. **Fire2 framing** — the 12-byte header layout is assumed; if FIFA 23 uses an older Fire variant or a custom delta, the codec will misparse every packet.
2. **ProtoSSL recv-injection** — delivering responses to the game via recv hook requires knowing what return values and buffer conventions ProtoSSL expects; recv_hook.rs exists but is not installed.
3. **Blaze component/command IDs** — the dispatch table is entirely empty; we cannot answer any request until IDs are known from captures.
All three are resolved by getting one real captured frame. M4 is primarily a capture problem, not a decoding problem — once bytes exist, the framing and IDs are immediately readable.
---
## 3. Blockers, Risks, Unknowns
### Blockers (stop progress now)
1. **ONLINE_STATUS_EVENT push not synthesized** *(M2 wall)*
The game calls GoOnline, gets success, then waits indefinitely for a push event on the LSX socket that never arrives. This is the single gate blocking all Blaze work. Options: (a) trace the event format via Ghidra on FIFA23.exe (xref `ONLINE_STATUS_EVENT` string + the game's EbisuSDK listener), (b) RE anadius's LSX event-send path (find what it would push in an "online" scenario), (c) brute-force push candidate event XMLs and observe whether the game advances.
2. **Bridge LSX server delivery unconfirmed** *(architectural risk converted to blocker)*
The hook passes port 3216 connections through, assuming the bridge LSX server on the Linux host receives them. If anadius's in-process hooks intercept the winsock calls before they reach the TCP stack, the bridge server is never reached. This must be confirmed by checking `openfut_hook.log` for a getaddrinfo on the LSX host, or by observing the bridge server's accept logs.
### Risks (could derail later)
3. **Fire2 framing wrong** *(M4 risk)*
If FIFA 23 uses Fire (pre-2012) or a modified frame layout, the codec misparses. Mitigation: the server has a `Raw` fallback mode for capturing raw bytes when framing fails.
4. **Secondary auth-token gate** *(M5 risk)*
`connection-gate-findings.md` noted the redirector request carries an `Authorization:` header. M1's final conclusion said `GetAuthCode` returns a fake token that appears accepted — but this was inferred, not confirmed by seeing the redirector request actually constructed with that token.
5. **EAAC not fully neutralized** *(persistent risk)*
`FakeEAACLauncher` bypasses the anticheat launcher. The hook DLL is unsigned. If EAAC is ever active (e.g., after a game update re-enables it), all hooks fail silently. Marked as "not active in offline/cracked builds" — assumed, not confirmed on every launch.
6. **FUT REST response shapes wrong** *(M6 risk)*
The 61 endpoint mappings in mapper.rs and the shaper stubs in shaper.rs are based on community guesses about older FIFA FUT APIs, not FIFA 23 captures. Response JSON shapes may differ enough to cause the client to fail silently or crash.
### Unknowns (open questions)
7. **ONLINE_STATUS_EVENT XML format** — exact tag names, field order, sender attribute, and any nonces/tokens required.
8. **GoOnline event sequence** — whether ONLINE_STATUS_EVENT alone is sufficient or a sequence of events (e.g., PROFILE_EVENT, LOGIN_EVENT, COMMERCE_EVENT) is expected.
9. **Whether FLE exposes FUT DB tables** — FUT card inventory and squad data likely live server-side in online mode; FLE may not surface them for in-process editing.
10. **Blaze component/command IDs for FIFA 23** — entirely unknown; no captures.
11. **openfut_hook.log current content** — we have the code but no log output in any document. Whether the current hook (with connect, ssl_patch, tls_bypass, WSAIoctl, origin_spy all installed) fires correctly and what it observes is unverified in this review.
---
## 4. Track Comparison
### Track A — Full EA-backend fake (M1M7, playable FUT vs AI)
**What it delivers:** The FIFA 23 FUT hub loads from OpenFUT Core; Squad Battles matches play and reward economy items.
**Effort:** Research-grade. Minimum path: synthesize ONLINE_STATUS_EVENT (unknown format, 12 weeks of RE), then capture Fire2 frames (days once M2 is solved), then implement Blaze auth handlers (weeks), then implement FUT entry (weeks), then Squad Battles (weeks). Realistic minimum: 36 months of expert RE work.
**Proven support:** Hook loads and redirects correctly. TLS bypass patched. Core economy backend complete. Blaze framing code and TLS listener exist.
**Assumed:** Fire2 framing correct; component/command IDs discoverable from captures; FUT REST shapes close enough to community guesses; no additional undiscovered gates.
**Evidence for:** Architecture is coherent. The M1 finding (gate precisely named and decoded) was achieved cleanly. The in-process hook approach is validated.
**Evidence against:** M2 was attempted and failed with the in-process approach. The event-driven architecture adds a full EbisuSDK emulation layer before even one Blaze byte is seen. The live toolkit is exhausted (Path A verdict); Ghidra-level work on a 505 MB binary is required. Six capture files with zero bytes.
---
### Track B — Clean-room spec deliverable (M1M5 documented)
**What it delivers:** A documented map of the connection gate, LSX event sequence, Blaze auth surface (transport, framing, gate conditions, component IDs, TDF schemas). Valuable as an archival/community artifact even if Track A stalls.
**Effort:** Medium. M1 is done. M2M5 documentation emerges as a by-product of engineering work. The spec itself (writing) is lightweight; the engineering to produce the captures is the cost.
**Proven support:** M1 complete and documented. connection-gate-findings.md is already a high-quality spec artifact.
**Assumed:** Same as Track A for the unconfirmed values, but the spec can mark them `TODO/CONFIRM` rather than needing to implement them.
**Evidence for:** The clean-room constraint means a spec is the only artifact that can be safely published. connection-gate-findings.md shows this approach produces real value. B finishes even if A is never fully playable.
**Evidence against:** Track B alone doesn't produce a playable FUT; it is a foundation, not an end-user product.
---
### Track C — FLE Lua bridge (local-match path, skip the backend gate)
**What it delivers:** FIFA 23 career mode or Kick-Off with an OpenFUT club's players and squad loaded via FLE's in-memory DB API. No online gate, no Blaze, no TLS. Fully offline from day one.
**Effort:** Low-to-medium. FLE is already loaded in the normal launch path. Tools exist (`tools/squad-exporter/`, `tools/profile-exporter/`). Primary unknown is whether FUT-relevant DB tables are accessible.
**Proven support:** FLE Lua API exposes `GetDBTableRows` / `EditDBTableField` for career mode. `fifa23-startup-flow.md` confirms FLE injects at load. `fut-integration-options.md` documents the integration path in detail and rates this as the recommended option.
**Assumed:** FUT card/club/squad data has in-memory DB table representations that FLE can write. If FUT data is purely server-side (loaded from EA servers, not from the Frostbite DB layer), Track C produces no FUT simulation at all — only career mode player stats.
**Evidence for:** Career mode already works with FLE edits (community precedent). Tools are present and designed for this path. No infrastructure work needed.
**Evidence against:** FUT in FIFA 23 uses server-side data. The cards in a player's FUT club, the coins, the squad — these are fetched from `fut.ea.com` REST APIs, not from the Frostbite embedded DB. FLE's `GetDBTableRows` likely exposes base player stats tables but not FUT item tables. The crucial test (run `export_squad.lua` while in FUT mode) has never been done.
---
### Recommendation
**Start Track C immediately as a parallel, low-cost validation.**
Run `export_squad.lua` in FLE while inside the FUT hub (or attempting to enter it). If FUT tables appear in the export, Track C is viable and is the fastest path to something a user can interact with. This test takes one session and costs nothing.
Simultaneously, **continue Track A/B with the next concrete RE step:** synthesize the ONLINE_STATUS_EVENT push. The most actionable option is to run `origin_spy` logs from the current hook to see what LSX events fire during a session, then attempt to push candidate event XMLs via the bridge LSX server and watch whether the game advances. This is bounded, testable work that either unblocks M2 or produces the spec value for Track B.
**Do not abandon Track A/B for Track C** — they are complementary. Core is already built; the bridge is mostly built. The gap is purely the RE wall at M2.
---
## 5. Architecture and Provenance Sanity-Check
### Hook + Brain coherence
The CLAUDE.md bridge architecture diagram (hook intercepts ProtoSSL → plain localhost TCP → blaze_brain → Core) remains coherent. The M1/M2 findings revealed one additional layer (EbisuSDK LSX event) that must precede the Blaze connection. The bridge has been updated to handle LSX directly. The overall design is sound; the M2 blocker is an implementation gap (event synthesis), not an architectural flaw.
**One inconsistency to flag:** The hook's `lsx.rs` contains a complete in-process LSX emulator (AES-128-ECB, CRandom, all response builders), but the recv/send hooks that activate it are explicitly removed (`lib.rs`: "recv/send hooks removed — LSX is now handled by the native openfut-bridge LSX server"). This is dead code. The bridge's LSX server is the current path. The in-process lsx.rs should either be deleted or documented as a fallback; its presence is confusing.
### Clean-room status
No evidence of EA leaked source anywhere in the tree. All RE work is derived from:
- Running the shipping binary and observing behavior (function return values, network traffic patterns)
- Memory scanning of the live process (string search, xref, disasm of observed addresses)
- Reading anadius's own compiled output (its exported symbols, its LSX XML format — which is anadius's own implementation, not EA's)
- Community FUT API knowledge (mapper.rs endpoint paths — plausible but speculative)
The Blaze framing in `fifa-blaze/crates/blaze-proto/src/frame.rs` cites "Fire2 used by ME3, BF3, and most post-2012 titles" — this is sourced from public community documentation of those older titles, not from any leaked EA source. **Clean-room intact.**
The `AES_KEY` in the hook's lsx.rs (`[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]`) is a placeholder key used for the LSX session encryption. The real session key is derived from the challenge seed via CRandom — this algorithm was RE'd from anadius's own binary. No EA source required.
---
## 6. If You Read Only This
- **The project is blocked at M2.** FIFA 23 submits `GoOnline`, gets success, then waits for an async `ONLINE_STATUS_EVENT` push on the LSX socket that no current code ever sends. All six Blaze capture files are empty (0 bytes). No Fire2 frame has ever been decoded.
- **M1 is the only completed milestone.** The gate function (`GetInternetConnectedState @ anadius64.dll+0x27790`) is precisely named and its two-flag branch decoded. Everything after M1 is either blocked or not started.
- **The next concrete action** is synthesizing the ONLINE_STATUS_EVENT push XML and testing whether the bridge's LSX server can deliver it to the game. This is the single thing that unblocks all Blaze work.
- **Track C (FLE Lua) is untested but cheap to validate.** Run `export_squad.lua` while in FUT to find out if FUT DB tables are accessible. If yes, it is the fastest path to user-visible results. If no, it is ruled out with one session.
- **openfut-core is complete and ready** — 25 phases, 15 migrations, full economy REST API, passing tests. It is not blocking anything; it is waiting for the bridge to connect to it.
-93
View File
@@ -1,93 +0,0 @@
# Track C — FUT DB table viability test
**Status: PENDING — test has not yet been run.**
## What this test settles
Track C ("FLE Lua bridge") would inject OpenFUT club data directly into FIFA 23's
in-memory Frostbite DB tables at runtime, bypassing the entire backend/Blaze stack.
It is only viable for FUT (not just career mode) if FUT-specific tables — card
inventory, squad composition with FUT fields, coins — are accessible in memory when
the game is in the FUT area.
FUT data in online mode is fetched server-side from `fut.ea.com`. It is not known
whether FIFA 23 mirrors any of this into the Frostbite in-memory DB that FLE
can read/write. This test settles that question directly.
## Test procedure
**Prerequisites:**
- FIFA 23 launched normally via umu-run/Steam
- FLE (FIFA Live Editor) injected and active (normal launch path)
- EAAC in offline/neutralized state
- Game navigated as deep into FUT as possible (FUT hub if reachable; otherwise the
furthest FUT screen before the gate blocks it)
**Run the exporter:**
1. In FLE's Lua Engine, open and run `tools/squad-exporter/export_squad.lua`
(full path on the Windows side: `C:\<game>\openfut_squad_export.json`)
2. Wait for the MessageBox "Done! N players, M teams." or "ERROR writing..."
3. Retrieve the output file from the Wine prefix:
`~/Games/umu/fifa23-tools/drive_c/FIFA 23 Live Editor/openfut_squad_export.json`
(or wherever `C:\FIFA 23 Live Editor\` maps in the active prefix)
**What to inspect in the output:**
- `all_db_tables` array — the complete list of table names visible to FLE right now
- `fut_tables` object — any table whose name contains `fut`, `club`, `pack`, `item`, or
`market` (the script auto-extracts these)
- `is_career_mode` — confirms whether FUT or career mode was active
## Classification criteria
### "FUT tables present"
`fut_tables` is non-empty AND contains FUT-specific fields beyond base player stats:
- e.g., `fut_items` with card-type / rating / chemistry fields
- e.g., a squad table with FUT formation / chemistry / loan-flag fields
- e.g., a coins or points balance field
**Verdict:** Track C is viable for FUT. Fastest path to user-visible results.
### "only base player tables"
`fut_tables` is empty (no `fut_*` / `club_*` / `item_*` / `market_*` table names found
in `all_db_tables`), OR those tables exist but contain only base player attributes
(OVR, potential, position, pace, …) — the same fields visible in career mode.
**Verdict:** Track C cannot produce FUT. It could at most provide a custom Kick-Off or
career-mode match with players sourced from OpenFUT Core. FUT items and coins exist
only on EA's servers (not in the in-memory DB in offline mode).
### "FUT area unreachable to test"
The connection gate blocked entering FUT deeply enough for FUT tables to be populated.
Record which tables were visible and at what screen the test was run.
**Verdict:** Retest after M2 is unblocked, OR test with `TLS_ENABLED=false` bridge
handling the entry check stub.
## Results
*(To be filled in after the test is run.)*
| Field | Value |
|---|---|
| Date run | — |
| FIFA screen at test time | — |
| `is_career_mode` | — |
| Total tables in `all_db_tables` | — |
| FUT-specific table names found | — |
| Key FUT fields present | — |
| **Classification** | **PENDING** |
## Honest prior
`fut-integration-options.md` rates this as the recommended path and lists `fut_clubs`,
`fut_items`, `fut_squads` as "expected" tables. However those expectations are based on
analogy with career mode (which does store club/squad in the DB). FUT's data model is
architecturally different — it is account-bound server-side. The expectation may be
wrong. This test is the oracle.
The `export_squad.lua` script checks `GetDBTablesNames()` exhaustively (not just
assumed names), so it will surface any FUT tables that actually exist, regardless of
what name they use.