137 Commits

Author SHA1 Message Date
OpenFUT Agent 5020137050 docs(fifa17): record retail economy route grammar (purchased/items, tradePile) 2026-08-14 00:50:03 +00:00
OpenFUT Agent cf05ab2a9e test(fifa17): replay retail purchased/items BUY + route matrix via dispatch
- pure_economy_routes (NEVER-BOTH + no-fallback) gains POST/GET purchased/items,
  lowercase tradepile, tradePile/counts -> all asserted Rust-owned, proxy 0.
- retail_purchased_items_buy_debits_core_through_dispatch: the exact round-2
  live failure -> POST /purchased/items now debits Core, reveal shows minted
  items, repeat reveal idempotent, Python proxy count 0.
2026-08-14 00:50:03 +00:00
OpenFUT Agent a6416a3f1d fix(fifa17): classify full retail economy route shapes
Round-2 live staging (candidate 47ced22) showed the CONFIRMED retail Store BUY
uses POST /ut/game/fifa17/purchased/items (reveal GET .../purchased/items),
which the exact-tail 'purchased' match missed -> Python (Core coins unchanged).
Comprehensive audited fix in classify_economy:
- is_purchased_tail: 'purchased' AND 'purchased/items' (POST->PackOpen,
  GET->PackReveal); bounded (rejects purchasedfoo, purchased/items/extra).
- is_tradepile_tail: 'tradePile' family CASE-INSENSITIVE incl 'tradePile/counts'
  (hub tile polls lowercase; oracle routes via re.I); allocation-free.
- (kept) v1/v2 prefix normalization + store/transaction[/<digits>].
Adds retail_route_matrix unit test = the machine-auditable route contract gate
(all economy shapes + negative near-misses). Host lib 75.
2026-08-14 00:50:03 +00:00
OpenFUT Agent 47ced228de docs(route-authority): record v1/v2 economy URL prefix contract
Accepted prefixes /ut/game/<sku>/ and /ut/v2/game/<sku>/ for every economy
route; StoreBuy accepts store/transaction and store/transaction/<txn-id>.
2026-08-13 23:53:44 +00:00
OpenFUT Agent b8beeba98d test(fifa17): cover retail v2 Store route shapes
Through real handle_with_ip dispatch against live Core:
- pure_economy_routes gains the retail v2 Store family (transaction/0,
  purchasegroup, purchased GET/POST) so NEVER-BOTH + no-fallback assert them
  Rust-owned (Python proxy count 0) and fail-closed 503 on dead Core.
- Part-7 repro: PUT /ut/v2/game/fifa17/store/transaction/0 returns a Rust
  createPackResponse + debits Core (NOT the Python TRANSACTIONCANCEL no-op).
- retail_v2_store_flow_matches_v1_through_dispatch: v1 and v2 BUY of the same
  pack debit + mint identically; v2 purchasegroup + reveal Rust-owned.
2026-08-13 23:53:44 +00:00
OpenFUT Agent df6994c957 fix(fifa17): classify retail v2 economy routes
Live staging (S2) showed the retail FIFA17 client issues the Store family
under /ut/v2/game/<sku>/... (PUT /ut/v2/game/fifa17/store/transaction/0),
which escaped Rust economy authority to Python. Fix classify_economy:
- ut_tail() normalizes both /ut/game/<sku>/ and /ut/v2/game/<sku>/ to the
  same tail (generic sku, never hard-coded fifa17); delete family likewise
  accepts /ut/v2/delete/game/.
- StoreBuy matches store/transaction and store/transaction/<digits> via a
  bounded is_store_transaction_tail (never store/transactions, ...foo, or
  .../<id>/extra), mirroring the Python bare /store/transaction regex.
Adds table-driven ut_tail + is_store_transaction_tail + classify_economy v2
unit tests (lib 74).
2026-08-13 23:53:44 +00:00
OpenFUT Agent d74e86c065 docs(fifa17): record Rust economy authority proof (E1 cutover ready)
Final per-route authority table: every economy route owner=Rust, Python
proxy=NO (userMassInfo the one hybrid: Python envelope + Rust economy/squad
overlay). Barrier 93a46d4; from_config 43917a0. Proofs: differential 15 PARITY
+ 1 DIFFERENT-BY-DESIGN, concurrency 8x50, failure 10 (complete-sale SAFE, no
E3), importer 5-step, from_config E2E, NEVER-BOTH / no-fallback / stale-reader.
Python source byte-unchanged; oracle suite 32/32.
2026-08-13 22:44:12 +00:00
OpenFUT Agent 76512f6048 test(fifa17): prove post-barrier economy authority (never-both / no-fallback / stale-reader)
barrier_never_both_no_fallback_and_stale_reader drives the REAL post-barrier
handle_with_ip against a live in-process Core + a mock Python upstream that
counts every request and answers with a coins=111 marker:
- STALE READER: credits + userMassInfo show the Core balance, never 111
  (userMassInfo proxies the Python envelope but the Rust economy overlay wins).
- NEVER BOTH (Core up): every pure economy route returns a Rust body (no
  __python__ marker) and the Python proxy call-count stays 0.
- NO FALLBACK: a server pointed at a dead Core port (built without probing Core:
  empty catalog + empty pool) fails closed (credits/match -> 503) and STILL never
  proxies to Python (call-count unchanged).

Also parametrizes build_econ_server's pass URL so the mock upstream can be
injected. host 71 lib + 4 integration + differential + concurrency + failure +
24 host_test all green; clippy -D warnings + fmt clean.
2026-08-13 22:39:45 +00:00
OpenFUT Agent 93a46d4de7 feat(fifa17): cut over FUT economy authority to Rust
The economy authority barrier. `handle_with_ip` now dispatches every
economy-touching route to Rust/Core via `try_handle_economy` BEFORE consulting
`classify()`, so a migrated route can never also reach the Python passthrough
(NEVER BOTH). With economy services wired (production `from_config`) an economy
route ALWAYS returns Some — fail-closed 503 on any Core error — so there is no
Python economy fallback. `userMassInfo` stays a hybrid by design: Python
supplies the non-economy envelope; Rust overlays BOTH the squad and the economy
fields (coins + unopened-pack count from Core), so no stale Python economy value
is visible.

Routing only — no handler/test changes buried here. Routes now Rust-owned:
/user/credits, /store/purchasegroup, /store/transaction, POST+GET /purchased,
PUT /item, item DELETE forms, /match (ut/delete), /auctionhouse, /tradePile,
/trade, ut/delete trade; plus the userMassInfo economy overlay.
2026-08-13 22:39:36 +00:00
OpenFUT Agent 3ba24a0faf test(import): verify durable FIFA17 economy migration
openfut-import-fifa17/tests/durable_import.rs drives the REAL import pipeline
(analyze -> Report dry-run; emit_content; plan_apply -> GenericImportRequest +
deterministic identity mappings + watermark; the staging/preflight/seed/
post-validate gates over a real JsonIdentityStore; openfut_core::services::
import::apply_profile_import in one Core SQLite tx) against a disposable
temp-file Core DB, from a small sanitized in-test fixture (750000 coins, 3
resolvable base players, unopenedPackIds [70,70,101], one squad).

Five ordered steps on one durable target, all green:
  A dry-run: report exposes persona/coins/inventory/unopened/fingerprint; ZERO
    DB mutation (all Core tables COUNT=0, identity store empty).
  B apply: coins=750000 exact, owned=3, packs=3 (opened=0), squad_players=2,
    deterministic owned ids, import_fingerprint recorded, identities reverse-
    resolve both ways, watermark=100000600.
  C restart: close+reopen the SAME sqlite file -> identical state.
  D re-apply same source -> AlreadyImported (fingerprint), no doubling.
  E conflict (coins 750000->750001 flips the fingerprint for the same game)
    -> apply fails closed ('different source'); DB unchanged.

Fingerprint = FNV-1a-64 hex of the source snapshot, carried into
ProfileImportRequest.source_fingerprint = Core profiles.import_fingerprint, the
per-game rerun-identity key. dev-deps added to openfut-import-fifa17
(openfut-core path, tokio, sqlx). No production/live data.
2026-08-13 22:31:01 +00:00
OpenFUT Agent 6926bb9528 test(fifa17): add Python-oracle economy differential coverage
economy_differential.rs boots the REAL Python oracle (fifa17-recon/tools/
utas_server.py) as an isolated subprocess (env FUT_PROFILE/FUT_ACCOUNT_PATH/
FUT_PORT into a temp dir + loopback port; no production container/port/save;
killed on Drop) AND the real Rust stack (seeded in-process Core + a real Server
with EconomyServices), seeds a semantically-aligned fixture on both, and drives
16 ops through the REAL surfaces (oracle over HTTP; Rust via
Server::try_handle_economy on the off-runtime thread).

Result: 15 PARITY, 1 DIFFERENT-BY-DESIGN.
- PARITY: credits, userMassInfo economy, purchasegroup (pack70/sentinel/clean-v1
  incl. the real SessionStore capability handshake), Store BUY, POST /purchased
  open, GET /purchased reveal (VERIFIED: durable single-profile purchased pile
  on BOTH — the hypothesised per-SID cache does NOT exist, so PARITY not
  DIFFERENT-BY-DESIGN), quick-sell (both forms), move, match WIN (+400 byte
  shape), market list/query/cancel.
- DIFFERENT-BY-DESIGN: market second-buy. First buy debits buyNowPrice + closes
  on both. Rust's MarketStore is a crash-consistent single-debit ledger (second
  buy of a sold listing = no-op, pinned by assertion); the oracle's buyable
  market is a stateless PACK_POOL sample that re-debits on repeat. Compat impact
  NONE (buy-now is one-shot); Rust is a strict correctness improvement.

No Python source changes; no classifier changes. Deterministic (3 runs).
2026-08-13 22:30:47 +00:00
OpenFUT Agent b1643309f6 test(fifa17): prove host economy concurrency and failure rollback
Two real host-dispatch test files (no fakes) driving Server::try_handle_economy
against a live in-process Core over the real blocking client + durable
MarketStore/PileStore + JsonIdentityStore, each racer its own OS thread
(off-runtime pattern).

economy_concurrency.rs — 8 races x 50 iterations:
  A two BUYs (coins for one) -> exactly one 200 + one 461, final 0, one debit.
  B duplicate owned-pack open -> one redemption, +11 once, entitlement once.
  C duplicate quick-sell -> one sell + one credit + one removal.
  D two market buyers -> one win, one debit, one mint, sold once.
  E reward+BUY -> no lost update (Core relative UPDATE under BEGIN IMMEDIATE).
  F move+quick-sell / G list+quick-sell -> one coherent transition.
  H 1000 concurrent mints -> unique + reversible wire ids, monotonic watermark.

economy_failure.rs — 10 fault-injection sub-cases, all fail-closed:
  BUY/open-redeem/generator/pile/identity, quick-sell, move, market
  reserve/purchase/complete. CRITICAL complete-sale-after-commit = SAFE: the
  listing is left `reserved` (not active), so the active->reserved reserve CAS
  can never win again -> not buyable, exactly one debit + one mint. No E3.

Fault injection uses test-file CoreEconomy/ExternalIdentityStore doubles plus a
NARROW, inert-by-default `StoreFault` seam in market_store.rs + pile_store.rs
(the concrete stores have no trait boundary; 3 `tripped()` checks + a field,
zero behaviour unless a test arms it). `parking_lot` promoted to a normal dep
(the seam's Mutex is used at lib scope). Classifier/ROUTE_AUTHORITY/Python
untouched. host lib 71/71; both new tests pass.
2026-08-13 22:30:35 +00:00
OpenFUT Agent 43917a0051 feat(fifa17): attach economy services in Server::from_config
Wire the PRODUCTION constructor so the economy authority is not test-only.
Server::from_config now builds one process-lifetime AsyncBridge, opens the
durable MarketStore + PileStore (paths from config), shares one HttpCoreClient
as both CoreAccess and CoreEconomy, builds the content pool from Core, and
attaches EconomyServices via with_economy. Stores/bridge are host-lifetime, never
per request.

- config.rs: required OPENFUT_MARKET_DB / OPENFUT_PILE_DB (durable file paths;
  must survive host restart — no temp defaults).
- Fail-closed startup: a bridge/store that cannot initialize returns Err from
  from_config (host refuses to start) — NEVER a silent omission or a Python
  economy fallback.

Test: from_config_constructs_and_serves_economy — builds the Server via the REAL
from_config (disposable config: temp market/pile/identity + a catalog file
derived from seeded content + the real tables dir) against a live Core, drives
credits / purchasegroup / Store BUY / market list-query-buy through it, then
rebuilds from the SAME config after a Core restart and asserts the balance
persisted. host 71 lib + 3 integration + 24 host_test green; clippy/fmt clean.
2026-08-13 21:59:29 +00:00
OpenFUT Agent 1fac71e3ef docs(route-authority): market resourceId mapping + reveal contract landed
Records fe72f0d (resourceId->Core card_id reverse mapping; listings carry both
identities; full Core+store restart E2E) and 747cc23 (GET /purchased reveal =
durable purchased pile, idempotent). Both pre-barrier correctness gaps closed.
Remaining: Python differential, host concurrency matrix, failure injection,
importer, from_config attachment, then the classifier barrier + reachability.
2026-08-13 21:51:52 +00:00
OpenFUT Agent 747cc234c1 fix(fifa17): preserve owned-pack reveal state for GET /purchased
Closes the reveal contract gap: POST /purchased opens a pack and returns
metadata; the client then polls GET /purchased for the opened items. Store BUY
returns items inline, but owned reward-pack (e.g. pack 70) opens had no reveal
read path, so a real FIFA session would show nothing after opening.

Faithful to the Python oracle (fut_store.last_pack / purchased pile): the reveal
is the set of owned items currently in the FIFA "purchased" pile — durable,
idempotent on repeat GET, cleared per-item when a card is moved to the club, and
appended-to by each open. Not a replay cache; presentation state derived from
the durable pile store + Core inventory.

- pile_store.rs: `list_by_pile(pile) -> Vec<core_item_id>` (reveal membership).
- economy_store.rs: `PurchasedPileSink` trait + optional `StoreDeps.purchased`;
  handle_store_buy/handle_pack_open record each minted item into the "purchased"
  pile. `shape_purchased_reveal` (pure): filter Core inventory to the purchased
  pile, shape with the SAME `shape_club_response` /club uses. Grants nothing,
  consumes no entitlement, allocates no id, moves no coins.
- lib.rs: EconomyRoute::PackReveal + classify_economy (GET purchased);
  BridgedPurchasedSink (records via the runtime bridge from the sync dispatch
  thread); dispatch reads the pile async + Core inventory sync + pure-shapes.

Scoping: single fifa17 profile/club (like the Python oracle), so all sessions
share one purchased pile — DIFFERENT-BY-DESIGN vs a per-SID cache, matching the
oracle's single-profile model.

Tests: pile_store::list_by_pile_filters_and_reflects_moves; and the dispatch E2E
now opens pack 70 (entitlement seeded via the Core economy API) and asserts GET
/purchased reveals the opened items and is idempotent on repeat. host 71 lib +
2 integration + 24 host_test green; clippy -D warnings + fmt clean.
2026-08-13 21:51:12 +00:00
OpenFUT Agent fe72f0def2 fix(fifa17): map market resource ids to authoritative Core card ids
Closes the market correctness gap: handle_market_list recorded listing.card_id
from the raw FIFA wire resourceId, so a synthetic buy minted a card_id Core
could not resolve — it survived the immediate response but Core's content
preflight rejected it on reboot.

- catalog.rs: keep the by_resource reverse index (was built then discarded) and
  expose `card_id_for_resource(resource_id) -> Option<&str>` — exact reverse of
  the card_id->asset catalog, no heuristics, unknown => None.
- lib.rs: `impl MarketCardResolver for Fifa17IdentityResolver` delegates to the
  same catalog /club shaping uses; Core never sees a FIFA resource id.
- market_store.rs: listings now carry BOTH `card_id` (authoritative Core content,
  what a buy MINTS) and `wire_resource_id` (the FIFA wire id, echoed in the
  auction record). New column; create_listing takes both; row/Listing updated.
- market.rs: `MarketCardResolver` trait; handle_market_list resolves resourceId
  -> Core card_id and fails closed (persists nothing) on an unmappable resource;
  auction_record emits `resourceId` from wire_resource_id. Dispatch passes the
  resolver.

Tests: list_unknown_resource_fails_closed_no_listing (B),
list_persists_core_card_and_wire_resource_across_reopen (C), catalog reverse
lookup; and the dispatch E2E now RESTORES the full Core+store restart
(economy_full_sequence_through_dispatch_and_restart) — the synthetic buy mints a
real reverse-mapped card_id, so Core's content preflight passes on reboot (A+D).
market 23 lib + catalog 15 + 2 integration green; clippy -D warnings + fmt clean.
2026-08-13 21:43:48 +00:00
OpenFUT Agent 884ecbba64 docs(route-authority): async bridge + dispatch wiring + real E2E landed (580d80a)
Records the AsyncBridge + classify_economy + try_handle_economy dispatch
(unrouted) and the economy_full_sequence_through_dispatch E2E, the
reqwest-blocking-in-async fix (off_runtime), and narrows Remaining to: Python
differential, host concurrency matrix, failure injection, importer, then the
from_config attachment + classifier barrier + reachability proofs. Flags the
two market-handler gaps (resourceId->card_id mapping; GET /purchased reveal
cache).
2026-08-13 21:30:56 +00:00
OpenFUT Agent 580d80a86e feat(fifa17): wire async economy handlers into the host via a runtime bridge (unrouted)
Bridges the synchronous thread-per-connection host to the async
transfer-market/pile handlers WITHOUT flipping the classifier. classify()
is untouched; production still proxies every economy route to Python. The
new dispatch is exercised only by the integration harness via
Server::try_handle_economy — handler wiring, not authority cutover.

async_bridge.rs: AsyncBridge owns ONE process-lifetime multi-threaded Tokio
runtime, shared by every connection via Arc. block_on() runs a future from
the sync dispatch thread; if invoked from within an ambient runtime it
offloads onto its own runtime + a std channel instead of panicking
("cannot start a runtime from within a runtime"). 4 unit tests incl. the
nested-runtime-safety case and concurrent multi-thread drivers.

lib.rs: EconomyRoute + classify_economy (mirrors the Python route table:
credits, purchasegroup, store/transaction, purchased, item DELETE/PUT,
ut/delete match/item/trade, auctionhouse/transfermarket, tradePile, trade).
EconomyServices (Core econ transport + durable MarketStore/PileStore + the
bridge + the pack-content pool), attached via Server::with_economy (kept out
of `new`/`from_config` so existing tests build a DB-less Server; production
from_config attachment is the barrier step). Server::try_handle_economy
dispatches: sync handlers (credits/purchasegroup/store-buy/pack-open/
quick-sell/match) inline; async handlers (market list/query/buy/cancel,
move) on the bridge via owned `async move` blocks. build_content_pool
derives the resolvable FIFA∩Core candidate pool from Core content.

market.rs: FIX the load-bearing hazard the FakeEconomy tests missed — the
async market handlers call the BLOCKING reqwest Core client, which panics
(reqwest::blocking::wait::enter) when run while a Tokio runtime is entered.
off_runtime() hops each Core call to a fresh OS thread with no runtime
entered, so blocking is legal. handle_move_items resolver gains `+ Sync`
(future must be Send for the bridge).

tests/economy_integration.rs: economy_full_sequence_through_dispatch drives
the WHOLE cluster through the REAL Server dispatch + bridge against a live
in-process Core (seeded with fifa17 dev content: 100k coins + owned cards),
on a plain OS thread (direct bridge path), over the real blocking
HttpCoreClient — no fakes: Store BUY (pool draw + shape + debit 400 + mint 5),
credits, quick-sell (reverse-resolve + credit), match WIN (+400), market
list->query->buy->query(sold)->second-buy-fails(no double debit), cancel
(cancelled not buyable), move-items, then reopen the durable market/pile
stores from disk (sold + pile persist). Deterministic. start_core_seeded
loads fifa17 dev content so /collection renders real definitions.

Tests: host 68 lib (+4 bridge) + 2 economy_integration + 24 host_test, all
green; adapter unchanged-green; clippy -D warnings + fmt clean.
2026-08-13 21:30:10 +00:00
OpenFUT Agent 0e2ca5a7c3 docs(route-authority): record landed Store/Market/pack handlers (unrouted)
Update cutover progress: Store BUY/pack-open/quick-sell, market
list/query/cancel/buy + move-items, durable listing/pile stores, and the
pack-content generator are implemented + tested (4d2b8b9) but NOT routed.
Remaining before the single classifier barrier: sync<->async Server wiring +
classify() routes, host<->Core writer E2E, Python differential, host
concurrency matrix, importer restart/idempotency, then the barrier + no-fallback
proofs.
2026-08-13 20:48:39 +00:00
OpenFUT Agent 4d2b8b9be3 economy(fifa17): land Store + Market writer handlers + pack generator (unrouted)
Implements the FIFA17 economy WRITER cluster on top of the landed Core
economy authority + host CoreEconomy client + identity/item-shaper infra.
Handlers are pub, unit-tested, and NOT yet routed: classify() and
ROUTE_AUTHORITY are untouched — the classifier barrier is a later single
coherent flip. No stubs; real Core-backed behavior; fail-closed on CoreError.

Pack generator (adapter fut/pack_content.rs):
  generate_pack_contents(&PackDef, &mut impl Rng, &[GeneratedCandidate])
  -> Vec<GeneratedCard>. Pure, seeded (deterministic), gold-tier split +
  special_chance gate as documented OPENFUT PLACEHOLDER policy (Python
  open_pack/_pack_body parity note inline). Fail-closed empty on empty pool.

Store/item writers (host economy_store.rs), matching oracle wire shapes:
  - handle_store_buy   PUT /store/transaction -> purchase_items (debit+mint N)
    -> createPackResponse; cancel/unknown/owned_only -> 200 {}; insufficient
    -> 461 {reason,credits}; CoreError -> 503.
  - handle_pack_open   POST /purchased -> owned_only consumes the unopened
    entitlement (redeem_entitlement, consume-once); normal packs debit+mint.
  - handle_quick_sell{_path,_body}  DELETE .../item/<id> + POST /ut/delete/.../item
    -> reverse-resolve wire->Core id (SquadWireResolver) -> sell_item ->
    {items:[{id}],totalCredits}; not-owned skipped.
  Production OwnedItemLookup = CoreItemLookup over CoreAccess.

Market (host market_store.rs / pile_store.rs / market.rs), synthetic-seller:
  - MarketStore over sqlx SQLite (WAL-once + busy_timeout=5s + BEGIN IMMEDIATE
    for writes, mirroring openfut-core::db). listings(active/reserved/sold/
    cancelled), owner-checked cancel, CAS reserve/complete_sale/rollback.
    Typed errors NotFound/Sold/Cancelled/WrongOwner/Conflict.
  - PileStore: durable pile/location metadata keyed by Core item id.
  - handle_market_{list,query,cancel,buy} + handle_move_items. Buy-now =
    reserve (CAS) -> balance precheck (461) -> Core purchase_item (mint+debit)
    -> complete_sale; any Core failure rolls the reservation back active.
    Two concurrent buyers -> exactly one sale + one debit.

Deps (additive): rand 0.8 (adapter+host), sqlx 0.7 sqlite/runtime-tokio (host).
Tests: adapter +7 (pack_content), host +43 (economy_store 20, market/store 23
incl two_reservers_exactly_one_wins, two_buyers_exactly_one_sale_one_debit,
state_survives_reopen, move_persists_across_reopen). All green; clippy
-D warnings clean; rustfmt clean.
2026-08-13 20:47:57 +00:00
OpenFUT Agent 0b31abe1d1 test(fifa17): run economy harness on a real multi-connection Core pool
The fresh-DB write-lock race is fixed (core fbb54ea: BEGIN IMMEDIATE writes),
so the E2E+restart harness now uses max_connections=5; 10/10 deterministic.
2026-08-13 20:20:58 +00:00
OpenFUT Agent 6b652cb0a2 chore(core): BEGIN IMMEDIATE write transactions (75b1830 -> fbb54ea) 2026-08-13 20:20:21 +00:00
OpenFUT Agent 3507c5714d feat(fifa17): add purchase_items to host CoreEconomy client
Complete the transport contract: purchase_items (atomic debit + mint N) on the
CoreEconomy trait + HttpCoreClient (POST /economy/purchase-items) + FakeEconomy.
This is the open-on-buy primitive the Store BUY handler will use (createPackResponse
returns the minted itemList). Fail-closed like the rest of the client.
2026-08-13 20:06:54 +00:00
OpenFUT Agent 4f78b9a875 test(fifa17): keep economy harness serialized; document multi-conn blocker
WAL-establish-once + busy_timeout (core 75b1830) reduced but did not eliminate a
brand-new-DB multi-connection warm-up 'database error'; the E2E+restart harness
stays on a single serialized connection for determinism, and the residual
multi-connection concurrency issue is documented as the remaining Part-T blocker.
2026-08-13 20:05:34 +00:00
OpenFUT Agent f3aebafcc7 chore(core): serialize WAL establishment (75b1830) 2026-08-13 20:03:40 +00:00
OpenFUT Agent 1df0bc4f00 test(fifa17): make economy integration harness deterministic
Serialize Core access with a single pooled connection (the harness drives Core
sequentially via the blocking client) to avoid a WAL-mode-establishment race
across connections warming up on a brand-new DB file, and raise the readiness
ceiling for heavy parallel test-binary load. 10/10 deterministic. (Fixed
alongside a real Core robustness fix: per-connection pragmas + busy_timeout,
core 0360135.)
2026-08-13 19:57:53 +00:00
OpenFUT Agent 7d5d0cff06 chore(core): sqlite busy_timeout + per-connection pragmas (bcc4f51 -> 0360135) 2026-08-13 19:55:13 +00:00
OpenFUT Agent 8d752cb0e4 test(fifa17): real host<->Core economy integration harness
Spawn Core (axum) on an ephemeral loopback port backed by a disposable temp-file
SQLite, seed a fifa17 profile via the real Core HTTP API, then drive the HOST's
REAL transport (HttpCoreClient: CoreEconomy) + handlers against it — no fakes:
credits reads Core balance; match-reward writer credits via Core grant_reward;
purchasegroup full-gen renders the owned pack from a Core entitlement (no
sentinel); userMassInfo overlay derives coins from the same Core state
(credits==massinfo==Core invariant). Restart phase reboots Core from the same
on-disk DB and proves coins + entitlements persist. Temp dir + 127.0.0.1:0 only;
no prod DB/ports/containers/.105. dev-deps: openfut-core, tokio, axum.
2026-08-13 19:50:42 +00:00
OpenFUT Agent 96e80ab293 feat(import-fifa17): seed unopenedPackIds as Core entitlements
Carry unopenedPackIds through the importer: Profile model -> Report ->
ApplyPlan. GenericImportRequest now emits entitlements[] (one definition_id
per unopened pack instance, order+duplicates preserved), which Core's import
seeds as unconsumed packs rows in the same transaction. Closes the economy
import gap so a migrated profile's unopened packs become Core entitlements
(feeding purchasegroup/credits/userMassInfo). Idempotency unchanged (import
fingerprint). +1 test; 26 pass, clippy -D warnings clean.
2026-08-13 19:38:04 +00:00
OpenFUT Agent 49c5185ae3 docs(utas-host): update economy cutover progress (readers + match writer landed)
Core API + purchase_items + entitlement import + host reader handlers
(credits/purchasegroup/userMassInfo) + match reward writer landed and tested;
classifier still unflipped (barrier pending BUY/pack-open/quick-sell item shaping,
market listing state, differential/concurrency/restart).
2026-08-13 19:32:45 +00:00
OpenFUT Agent 181bd94341 feat(fifa17): Rust purchasegroup, userMassInfo economy, match reward handlers
All Core-backed, fail-closed (503, never Python), NOT yet classifier-routed
(coherent barrier pending full cluster + Core seed):
- handle_purchasegroup: full Rust body from Core entitlements + StoreMode via
  the oracle-fixture-tested build_purchasegroup (no Python body dependency).
- overlay_massinfo_economy: set userInfo.currencies coins + unopenedPacks
  recoveredPacks from Core, preserving all other fields.
- handle_match_end + build_match_reward_body: derive outcome from endReason,
  credit via Core grant_reward, oracle-shaped destroy_match_body.
Invariant test: credits == userMassInfo == purchasegroup all read one Core state.
7 new host tests (+ FakeEconomy write methods honor fail flag).
2026-08-13 19:31:23 +00:00
OpenFUT Agent 09675a9f7f feat(fifa17): economy policy mappers (match reward, pack price)
Pure FIFA17 policy: match_result_coins/match_reward_total (oracle MATCH_COINS
won 400/draw 200/loss 100 + participation 0), result_from_end_reason (endReason
enum -> outcome, draw default), pack_price (catalogue buy-now, None for
unknown/owned-only). 3 unit tests.
2026-08-13 19:31:23 +00:00
OpenFUT Agent 56c364e4c9 chore(core): purchase_items + entitlement import (d32dc6e -> bcc4f51) 2026-08-13 19:27:06 +00:00
OpenFUT Agent 3021a4e761 docs(utas-host): record economy cutover progress in route-authority gate
Core economy HTTP API + host CoreEconomy client (fail-closed) + credits reader
landed; classifier not yet flipped (coherent barrier pending full cluster + Core
seed).
2026-08-13 19:14:39 +00:00
OpenFUT Agent d240a61157 feat(fifa17): host Core economy client + credits reader vertical
Add CoreEconomy transport (trait + HttpCoreClient impl over Core /economy/*):
balance, entitlements, purchase_entitlement, redeem_entitlement, sell_item,
grant_reward, purchase_item. Fail-closed by contract: any transport/status/parse
error surfaces a controlled error and NEVER falls back to Python (a fallback
would be a second writer).

Add the credits reader vertical: build_credits_body (byte-shape-identical to the
Python oracle: credits + currencies[].funds/finalFunds + optional
unopenedPacks.recoveredPacks) and handle_credits (coins = Core balance,
recoveredPacks = Core entitlement count; 503 fail-closed on Core error). Not yet
classifier-routed: the coins cluster flips as one coherent barrier once every
writer+reader moves together and Core is seeded. FakeEconomy double + 3 tests
(oracle shape, Core-backed read, fail-closed).
2026-08-13 19:14:13 +00:00
OpenFUT Agent d7c5307045 chore(core): expose economy HTTP API (c8269d0 -> d32dc6e)
Advance openfut-core gitlink to d32dc6e: generic /economy/* HTTP routes over
services::economy, server-side club resolution (game-scoped active profile, no
client-supplied club id), + list_unopened_entitlements reader. This is the
transport the FIFA17 economy cutover binds to. Core matrix 43 lib + 115
integration green; clippy clean; boundary audit clean.
2026-08-13 19:11:04 +00:00
OpenFUT Agent 838d76f95e docs(utas-host): add economy route-authority cutover gate
Machine-auditable ownership table for every FIFA17 UTAS route touching the
economy cluster (coins/inventory/entitlements), plus the writer->Core-primitive
map and the single-writer rule. Grounded in the Python economy writer audit:
4 coin-mutation routes (match reward, pack BUY, quick-sell, market buy-now),
synthetic-seller market (no sale-credit/expiry/fee), dead grant_coins/
grant_unopened_pack, no points writer. This is the deployment gate: no proxied
Python route may touch Core-owned state before R1.
2026-08-13 18:59:46 +00:00
OpenFUT Agent 9791eee67b chore(core): add generic purchase_item economy primitive (ee2caa0 -> c8269d0)
Advance openfut-core gitlink to c8269d0, which adds services::economy::purchase_item
(atomic debit + mint) — the generic Core primitive the FIFA17 synthetic-seller
transfer market needs. Evidence: the Python economy audit proved market buy-now
mints a new item with no real counterparty, so debit+mint (not two-party transfer)
is the correct generic model. Core matrix + clippy green.
2026-08-13 18:58:49 +00:00
OpenFUT Agent 5d119f5555 chore(core): reconcile Core to validated trunk + generic economy
Advance the openfut-core gitlink 3084a46 -> ee2caa0. This does two things:

1. Reconciliation: moves the canonical Core lineage onto the committed,
   validated migration trunk (66c88fb: game-scoped opaque extension,
   inventory service, squad-ext routes, content-pack loader, generic
   transactional profile-import service). The divergent local Core refactor
   that was dirtying the eab522a checkout is preserved verbatim on branch
   wip/core-local-development (f70cf44) for separate reconciliation; nothing
   is lost.

2. Economy foundation: ee2caa0 adds services::economy, a generic atomic
   profile-economy authority (currency/inventory/entitlements over the
   existing durable tables, single-transaction compound ops, fail-closed).
   The FIFA17 adapter economy engine sits on top of these primitives.

Core builds green; full matrix 41 lib + 111 integration + 16 = all pass;
clippy clean.
2026-08-13 18:45:21 +00:00
funman300 46e5f612c8 feat(fifa17): add authoritative economy engine + fut_profile importer
Adds openfut-adapter-fifa17 fut::economy — the single-writer FIFA 17 economy engine
the eventual cluster cutover needs: coins + unopened-pack entitlements + owned
inventory + stable item ids, with all-or-nothing transactional mutations faithfully
ported from the Python oracle's fut_store.Store primitives.

Atomic ops: debit (fail-closed), credit, grant_pack/consume_pack (consume-once),
allocate_item_id (unique/monotonic), add_item, and composed transactions buy_pack,
open_pack, quick_sell, market_buy_now, grant_reward. Fail-closed everywhere; the
65534 sentinel can never be bought/granted/opened (defense at the grant primitive,
mirroring grant_unopened_pack rejecting non-catalogue ids). from_fut_profile importer
round-trips coins/unopenedPackIds/items/nextItemId and floors nextItemId past the
highest existing id so re-import cannot mint a duplicate. 10 unit tests (atomicity,
sentinel safety, consume-once, quick-sell, item-id uniqueness, import round-trip).

NOT wired (R3): the live coin balance is one indivisible writer set spanning Store
BUY, pack-open, quick-sell, match rewards AND the transfer market, all in
fut_profile.json; and the generic home (OpenFUT Core) is a preserved-dirty/frozen
submodule. So a safe single-writer cutover cannot be wired yet — this engine +
importer is the coherent prerequisite. No dual-write introduced. No deployment.
2026-08-13 18:26:09 +00:00
funman300 41494bd18f feat(fifa17): port Store pack catalog + purchasegroup wire model (oracle parity)
Adds openfut-adapter-fifa17 fut::store_catalog — a pure, faithful Rust port of the
Python oracle's PACK_CATALOG + _pack_body + store_catalog assembly at production
flag defaults (FUT_STORE_DISPLAYGROUP=1, GROUPID=0, PRICE_PROBE=0):

- PackDef + PACK_CATALOG (ids 1/5/6/7/70; economy numbers are OpenFUT PLACEHOLDER,
  wire shape is oracle-verified; 65534 deliberately absent).
- pack_body() (_pack_body port), sentinel_body() (id-65534 compatibility shim),
  build_purchasegroup(unopened_ids, StoreMode) mirroring store_catalog(3627).
- Differential parity: fixtures generated from the Python oracle
  (tests/fixtures/purchasegroup_{zero_sentinel,zero_clean,pack70}.json); Rust output
  matches semantically (6 tests). Adapter 140 tests, host 24, fmt/clippy clean,
  Python A-R oracle green.

PURE wire shaping — NOT wired into the live host. Serving purchasegroup from Rust
requires an authoritative Rust owner of unopenedPackIds, which is blocked on the
economy-authority prerequisite (R3): coins are one shared balance written by many
Python-oracle routes (BUY spend, quick-sell credit, SBC/match/objective rewards)
persisted to fut_profile.json, so no single coin-touching route can move without a
whole-cluster migration. No dual-write introduced; no production deployment.
2026-08-13 18:16:47 +00:00
funman300 40ebf7c1e7 feat(fifa17): own UTAS auth/capability/purchasegroup session vertical in Rust
openfut-utas-host now classifies and owns three routes, wiring the landed
adapter store_session state machine while keeping the Store economy Python's:

- POST /ut/auth: proxy to Python (which mints X-UT-SID, adopts persona, refreshes
  save), OBSERVE the returned sid, and open a Rust session bound to peer IP +
  configured persona. Account/economy authority stays Python.
- POST /openfut/fifa17/capability: Rust-owned, no proxy — validate + register into
  SessionStore (bound/pending/ignored-late); fail-closed 400 on unsupported.
- GET .../store/purchasegroup: proxy to Python for the authoritative economy body,
  then overlay ONLY the empty-My-Packs topology from the frozen session mode —
  strip the 65534 sentinel for a verified clean-v1 SID, keep it otherwise. Rust
  never writes economy state.

Session state (Arc<Mutex<SessionStore>> + monotonic clock) lives on Server; new()
and from_config() initialise it (signatures unchanged). handle() gains a peer-IP
variant (handle_with_ip) threaded from handle_conn. Strict never-both routing is
preserved. Pure helpers (observe_sid, parse_capability_request, overlay_empty_mypacks)
+ classifier are unit-tested; adapter+host tests + Python A-R oracle all pass.

STOP-GATE: Store BUY / coins / unopenedPackIds NOT migrated — Rust has no
authoritative FIFA17 economy-mutation path (Python fut_profile.json is the source;
Core's economy is separate/unwired), so moving BUY would split store authority.
That cluster migration is the remaining R2 gap. No production deployment.
2026-08-13 17:38:59 +00:00
funman300 c7609252d2 feat(fifa17): add Rust FUT session/capability state machine (empty My Packs)
Ports the novel per-session empty-My-Packs capability negotiation — proven live
on staging and currently Python-only (fifa17-recon/tools/utas_server.py) — into
the production Rust FIFA17 adapter as a pure, dependency-free state machine
(openfut-adapter-fifa17 fut::store_session).

It owns: per-login X-UT-SID session table, single-use (ip,persona) launcher
capability hand-off (pending), capability binding (bound/pending/ignored-late),
the once-per-session clean-v1 vs sentinel freeze, TTL reaping, and fail-closed
rules (unknown/expired/ambiguous/late/cross-session/sid-ip-mismatch -> sentinel).
The clock and SID entropy are injected so it is fully unit-testable.

The full Python capability-negotiation matrix A-R is ported as Rust unit tests
(21 pass). Python remains the behavioural oracle. The delicate _pack_body UTAS
wire shaping, /ut/auth persona-adoption, /store/purchasegroup catalogue assembly
and the store BUY path are deliberately NOT ported here (documented gap); wiring
the three routes into openfut-utas-host without splitting store authority is the
remaining bounded slice toward full Rust authority. No production deployment.
2026-08-13 16:52:13 +00:00
funman300 4cf388dd3b chore: reconcile openfut-launcher submodule
Point the launcher gitlink at the reconciled merge ca7ce26
(integration/fifa17-launcher-capability-sbc), which retains BOTH launcher lineages:
  - 13339c1  FIFA 17 verified patched-client capability reporting
  - 958ff245 openfut-hook SBC request tracing / RE instrumentation

The previously-uncommitted openfut-hook WIP that blocked this move is preserved on the
submodule branch wip/openfut-hook-local (commit 4e44a37) + /tmp/openfut-hook-wip-preserved.patch
(sha256 8e65de2c…) + the untracked server.rs copy. Gitlink-only change; no other superproject
dirt staged. Backend/production unchanged.
2026-08-13 15:11:18 +00:00
funman300 00d85aa6e4 docs(fifa17): finalize capability deployment candidate
Record the overnight launcher-lineage reconciliation (merge ca7ce26 retaining both
feat/launcher-arming 13339c1 and feat/sbc-hook-tracing 958ff24; only src/process.rs
conflict, resolved keep-deleted), the deferred superproject gitlink bump (blocked by
uncommitted openfut-hook WIP overlapping the merged hook content), the validated
deployment-candidate commit tuple + local build artifacts, and the controlled A/B/C
deployment sequence. Production stays P2 active-sentinel until the A/B passes.
2026-08-13 05:18:14 +00:00
funman300 d9e80a774a test(fifa17): add explicit per-SID topology-freeze regression
Case R makes the F3 session-topology invariant explicit alongside the A-Q matrix:
for a single X-UT-SID the frozen empty-My-Packs mode never flips in either
direction (Sentinel stays Sentinel even if a capability later appears; Clean stays
Clean even if the capability is wiped), while a fresh SID from the same IP decides
independently. Complements F/G/K.
2026-08-13 05:18:13 +00:00
funman300 a82407c686 docs(fifa17): harden patched-client session binding
Record the per-IP -> per-session correction: why source-IP-only was unsafe (two
FIFA processes share an IP), the authoritative per-login X-UT-SID key with IP and
persona as auxiliary, the Capability/StoreMode state machine, the single-use
short-TTL launcher->session pending hand-off, activity-based session cleanup, and
the documented fail-closed residual for genuinely simultaneous same-(ip,persona)
logins. Design history is retained; the per-IP prototype is marked superseded.
2026-08-13 04:39:53 +00:00
funman300 805d754dc8 fix(fifa17): isolate patched-client capability per session
Harden the empty-My-Packs capability binding so a verified FIFA process can never
enable clean/no-sentinel Store topology for another unverified process that merely
shares its source IP. The prototype keyed the decision by source IP alone; two FIFA
processes (concurrent, or a relaunch) share an IP, so an unpatched process could
inherit a patched one's clean-v1 mode and crash. Source IP is now auxiliary only.

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

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

Tests: matrix A-Q incl. same-IP concurrent (K), same-IP+persona relaunch (L),
same-IP failed-patch (M), late-registration-vs-frozen-sessions (N), TTL expiry (O),
duplicate/idempotent registration (P), and register-before-login pending (Q).
2026-08-13 04:39:53 +00:00
funman300 d4c3811665 docs(fifa17): document patched-client store negotiation
Design + cross-component contract for verified patched-client capability
negotiation: architecture inventory, transport choice (autopatch stdout ->
launcher, sibling /openfut/fifa17/capability endpoint), the versioned capability
and its VERIFIED semantics, autopatch verification states, launcher per-process
state, source-IP binding, the session-stable freeze point, the additive store
switch, the trust model (local preservation, not attestation), the fail-closed
matrix, and P2 retention.
2026-08-13 04:03:37 +00:00
funman300 b25761ea31 feat(fifa17): negotiate clean empty My Packs mode
Backend side of the handshake: suppress the synthetic 65534 My-Packs sentinel
ONLY for a session whose client has registered a verified resolver-guard
capability. Additive; the P2 active-sentinel path is retained as the else-branch
and the universal default. Fail-closed everywhere.

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

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

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

The capability = "the guard was verified in THIS FIFA process", never merely
"the code is present". Design: docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md.
2026-08-13 04:03:37 +00:00
funman300 fc29c2eb9b docs(fifa17): record no-sentinel client resolver proof
Land the client-side empty-My-Packs resolver evidence and the session-stability
invariant established by the F3/R1 experiments.

- PART III (F3, CONFOUNDED CRASH): a mid-process sentinel -> no-sentinel flip
  left a stale POSITIVE My-Packs ordinal that still took the resolve branch and
  crashed at 0x180014882. Preserved verbatim (not a guard failure).
- PART IV (R1, SUCCESS): backend set no-sentinel first, then a FRESH FIFA
  process; genuine purchasegroup response ids [1,5,6,7] is byte-identical to the
  F3 capture, so client process lifetime is the only changed variable. Store
  opens on Browse Packs, no crash, no dialog. Guard PROVEN on the tested build.
- New INVARIANT: empty-My-Packs capability MUST be session-stable -- the server
  must not switch a running client between sentinel-present and sentinel-absent
  for the My Packs group within one FIFA process, because the client caches the
  group ordinal and a stale positive ordinal still crashes the resolver.
- Both no-sentinel captures kept: client_guard (F3) and freshretest (R1).

Backend P2 active-sentinel (65534) remains production default; no capability
handshake is implemented yet.
2026-08-13 03:13:40 +00:00
funman300 b0d5e04bb9 fix(fifa17): guard missing store category resolution
Port the PROVEN empty-"My Packs" resolver crash-guard into the canonical
autopatch.py /proc-mem patcher. When no `mypacks` purchase group exists, a
fresh FIFA 17 client resolves category id -1; CardsDLL FUN_1800147f0 at RVA
0x14858 (`JNZ 0x14869`, bytes 75 0f) treats every non-zero category as
resolvable, calls FUN_180014420, gets NULL, and dereferences [NULL+0x48] at
0x180014882 (0xC0000005). Rewriting JNZ->JG (7f 0f) preserves positive-category
resolution (EDI>0) while routing zero/negative categories to the existing
Browse/list-all path -> no NULL lookup, no crash, Store opens on Browse Packs.

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

Proven on the tested build (CardsDLL 4706a881...) by a clean fresh-process
no-sentinel A/B (R1). Dormant while the backend active-sentinel is present.
See docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md PART IV.
2026-08-13 03:13:39 +00:00
funman300 6746c75302 docs(fifa17): RE-backed native client-fix design for empty My Packs
Investigation + design only (no client/backend/binary changes, no live
Store experiment). Reconfirmed CardsDLL_Win64_retail.dll (4706a881..,
unpacked) against a freshly rebuilt Ghidra project on .105; FIFA17.exe
(29c31cef..) is Denuvo-packed so the Scaleform decision is unreadable.

PART II added to docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md:
- Native category path traced: FUN_18007dab0 (store render, RVA 0x7dab0)
  reads screen+0x290; My Packs funnels through FUN_1800147f0 (0x147f0) ->
  FUN_180014420 (0x14420, NULL on ordinal miss) -> crash MOV [RDX+0x8] at
  0x14882 ([NULL+0x48]), matching the Exp-B minidump. Tab->ordinal map
  FUN_180014580 (0=mypacks..5=special); category 0 = list-all (Browse).
- Unopened-pack count is a data-manager singleton (vtbl[0x4d8] get /
  [0x4e0] set), reachable from the store resolver.
- Vehicle: existing openfut-hook -> version.dll proxy (already deployed);
  reuse ssl_patch signature-scan + connect_hook inline detour. No new loader.
- Preferred strategy A: entry-hook FUN_18007dab0; when the requested
  category is My Packs and unopened count==0, force screen+0x290=0 (Browse).
  Removes crash + fake 65534 tile + dialog + nav gate; count>0 untouched.
- Ranked B (resolver NULL fallback, higher risk) and C (null-guard, crash-only).
- Build guard: module gate + SHA/PE + signature scan; unknown build -> no
  patch, backend sentinel remains fallback.
- First experiment design (needs a later, separately-authorized backend
  empty-no-sentinel test mode) + client rollback (config flag / dll swap).
- Keep backend 65534 sentinel deployed until strategy A is verified.

Describes the FIFA 17 client/data model only; not OpenFUT Core assumptions.
2026-08-13 01:48:58 +00:00
funman300 b2697b13dc docs(fifa17): establish verified card taxonomy
Single source of truth for FIFA 17 FUT card families, reconciled against
the authoritative shipped fcc_*.json + staff tables (verified byte-identical
between .105 and this repo, 36/36 sha256).

- docs/CARD_TAXONOMY.md: family -> table/rowcount/subtype/carddbid/cardassetid,
  with OBSERVED/INFERRED/HYPOTHESIS/UNKNOWN labels. Corrects four superseded
  claims (chem styles are 250-273 not 91-136; 6300/6400xxx are kits not badges;
  5004xxx misc and 8010xxx league logos exist). Manager-league precision kept
  distinct: shipped table 300-340 (41 rows) vs client enum 300-341 (341 defined,
  unshipped). Club-item wire subtype->family mapping preserved as UNKNOWN.
- docs/evidence/fifa17-recon/table-hashes.sha256: 36-file provenance manifest
  (31 fcc_*.json + 5 staff tables), combined hash 10f239ad...

Describes the FIFA 17 data/client model only; not OpenFUT Core assumptions.
2026-08-13 01:31:22 +00:00
funman300 e8ee6c34e7 docs(fifa17): record empty My Packs client contract
Full investigation record for bug 6c: baseline + Experiments A/B/C', Candidate F (contradicted), the explicit active-placeholder selection test, the minidump-confirmed CardsDLL crash, and the P2 decision. Marks ROOT CAUSE ESTABLISHED and documents the known UX limitations and the client-side follow-up.

Files: docs/evidence/STORE_TILE_6C.md, docs/evidence/FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md, the four genuine /store/purchasegroup captures (baseline, mypacks70, empty_no_sentinel, active_placeholder), and docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md (client-side design/research).
2026-08-13 01:08:47 +00:00
funman300 f42279f869 fix(fifa17): keep empty My Packs group client-safe
When the account owns zero unopened packs, store_catalog() emits a synthetic `mypacks` group placeholder (id 65534, absent from PACK_CATALOG). Change its state from "inactive" to "active".

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

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

Adds regression tests (test_empty_mypacks.py): empty -> one active 65534 placeholder (absent from PACK_CATALOG); non-empty [70] -> no placeholder, genuine pack shown; economy safety; normal packs 1/5/6/7 untouched.
2026-08-13 01:08:37 +00:00
funman300 54ad9e8f79 docs(state): Slice 8 — real-data staged retail A/B PASS
Records the operator-assisted live A/B on the REAL imported club (1949/1962, 13
Legends deferred): real club render, clean pagination, Special filter (1665, 0
base leaked), squad edit persistence + cold relaunch, and rollback->Python->Rust
re-enable. Documents the three real-data fidelity fixes (versioned resourceId
e187cd4, rareflag 626c972, rare=SP filter 6f16a23) and the nation/league/team
model correction (44fcf24). Marks PRODUCTION NOT READY pending .105 Legends name
data for the 13 deferred assets. Aggregate counts only; no private identity.
2026-08-12 21:37:42 +00:00
funman300 6f16a231fc fix(fifa17): implement rare=SP 'Special' club filter via rareflag
The club search 'Quality = Special' sends rare=SP, which was a deliberate no-op
('semantics UNKNOWN'), so it returned every card — base golds included. The
rareflag work now grounds it: a special is rareflag > 1 (base rare = 1),
evidence-backed by the FIFA17 taxonomy + the observed profile (base Ronaldo/Messi
rareflag 1; their informs 11/24).

rareflag lives in the FIFA catalog, not Core, so Core cannot filter it:
- map_to_core: rare=SP now sets CoreOwnedQuery.special (host-applied), not
  'unsupported'; any OTHER rare value stays unsupported. special is NEVER a Core
  /collection param. is_special_rareflag(rf)=rf>1 lives in the adapter.
- handle_club special path: fetch all items matching the OTHER filters (offset/
  limit stripped), shape (resolves rareflag), then special_filter_page() keeps
  rareflag>1 and paginates the FILTERED set locally (start/count over specials,
  not Core's unfiltered page) — no base leakage, no post-pagination drops.

Verified live on the real staged club: rare=SP -> 1665 items (=1949-284 base),
rareflag distribution all >1, zero base leaked; pagination page0==full[0:50],
page1==full[50:100], no overlap. Tests: adapter map rare=SP->special, unknown
rare stays unsupported, is_special_rareflag predicate; host special_filter_page
filter+paginate. adapter 113 + host 25 + importer 25 green; clippy -D clean.
2026-08-12 21:25:18 +00:00
funman300 626c972232 fix(fifa17): carry observed rareflag so special cards render as specials
shape_item hardcoded rareflag=1, so all 1949 cards shaped as basic rare gold
regardless of type; informs/specials lost their card art. The dev fixture is
base-only, so this was invisible until the real profile (10 distinct rareflag
values) exposed it on .105.

rareflag is definition-level FIFA identity metadata OBSERVED from the profile
(the raw wire integer, never a guessed marketing label), so it lives in the
FIFA catalog like asset_id/version, not in generic Core:
- ObservedDefinition.rareflag + emitted into the production catalog entry.
- Fifa17CardCatalog RawCard/Fifa17CardIdentity gain rareflag (default 1 when a
  base-only catalog omits it, preserving prior wire behaviour).
- Fifa17Identity.rareflag; host resolver populates it from the catalog.
- shape_item emits id.rareflag instead of a hardcoded 1.

Verified on the real staged /club: wire rareflag distribution == source exactly
(0 per-item mismatches across 1949; e.g. rareflag 3 x591, 24 x302, 21 x256).
adapter 111 + host 24 + importer 25 tests green; clippy -D clean. rareflag lives
in the host catalog, not Core, so no re-import was needed.
2026-08-12 21:13:51 +00:00
funman300 44fcf24d92 fix(import-fifa17): nation/league/team are instance metadata, not definition identity
Evidence (resourceId 169193): its 4 owned copies are IDENTICAL in asset/rating/
position/all attributes and differ ONLY in nation/team/league (and those resolve
inconsistently, e.g. team 240 'Atletico Madrid' under league 16 'Ligue 1'). A
player's club affiliation is an instance-time snapshot, not part of the card
DEFINITION identity.

Correct the model (not a special-case): the definition-consistency gate now
compares a DefIdentity projection (asset_id/version/rating/position/attrs/
rareflag) and EXCLUDES nation/league/team. A club-only difference between copies
of one resourceId is no longer a conflict; a real identity disagreement
(rating/position/attrs/asset) still trips it. The definition's display
nation/league/club use the first-observed copy (deterministic; display-only,
never identity). No --defer-conflict allowlist entry is needed for 169193 now.

On the real profile: conflicts 1->0, 169193 reclassified conflict->NoName
(still deferred, unnameable), supported still 1681, deferred instances still 13,
BLOCKERS none without any --defer-conflict flag. 2 new tests (club-only diff is
not a conflict; rating diff still is). crate suite 25 green; clippy -D clean.
2026-08-12 20:58:33 +00:00
funman300 e187cd49a2 fix(fifa17): preserve versioned resourceId on the wire (no special->base collapse)
shape_item emitted resourceId/definitionId = asset_id (base), collapsing every
versioned (special) card onto its base definition on the /club and squad wire.
The dev 32-card fixture is base-only (version 0), so Slice 7 never exposed it;
the real profile (1531 versioned cards) did.

Fifa17Identity now carries resource_id (= (version<<24)|asset_id, == asset_id
for a base card). shape_item emits resourceId/definitionId from resource_id and
assetId/cardassetid from asset_id — versioned and base stay distinct. The host
resolver populates resource_id from the catalog's reconstructed resource_id
(the catalog already parsed version; it was dropped before shaping).

Regression test: versioned 117617092 (v7 of asset 176580) shapes resourceId/
definitionId=117617092, assetId/cardassetid=176580.

Verified on the real staged /club: 1949 items, wire-id set exact, 0
wire->resourceId mismatches, 0 duplicate-multiplicity mismatches vs the source
manifest. adapter 111 + host 24 tests green; clippy -D warnings clean.
2026-08-12 20:46:48 +00:00
funman300 1631d3b1a2 feat(import-fifa17): --apply — recoverable two-store real-profile import
FIFA17-specific orchestration that installs the real profile across BOTH durable
stores (openfut-identity + Core SQLite) recoverably and idempotently, handing
Core only a GENERIC request (all FIFA17 semantics stay in this adapter layer).

apply module:
- owned_item_id(persona, wire) = deterministic UUIDv5 from a private namespace;
  identical in BOTH stores (identity core_id AND Core owned_cards.id), so the
  running host's wire->owned reverse lookup resolves exactly what Core stored.
- plan_apply(report, raw_profile, fp): pure translation to a GenericImportRequest
  (card_id = fifa17_<resourceId>) + the preserved (owned_item_id <-> source wire)
  mappings + watermark. Canonical squad + Fifa17SquadExtensionV1 are built by the
  SAME adapter code (parse_squad_put + build_squad_write) the retail-validated
  live squad path uses. Refuses if the report has blockers.
- Two-store protocol (apply): staging gate -> local Core-preflight mirror ->
  identity dry-preflight -> idempotent seed (insert_existing_mapping per instance
  + set_watermark) -> ONE generic Core import transaction (spawned binary) ->
  cross-store post-validation -> completion record. A crash after identity
  seeding re-converges on re-run (idempotent mappings + Core already_imported):
  no cleanup, no reminting.
- gate_staging: deferred players are ABSENT from an import; allowed only for a
  staged run behind --allow-deferred-players-for-staging (never a silent default;
  prints an INCOMPLETE banner). Production requires zero deferred instances.

CLI: --apply (with --emit-content, --core-bin, --core-db, --core-data,
--identity-store, --allow-deferred-players-for-staging). Depends on
openfut-adapter-fifa17 + openfut-identity + uuid(v5).

Proven end-to-end on the real 33068179/CAGE profile (staged): first apply
imports 1949 supported instances (293 base + versioned), 11/11 f433 squad +
opaque extension, coins 28,112,944, fingerprint 8dc5582d2414af28; re-run is an
idempotent no-op (already_imported, DB unchanged); staging-not-default refuses
before any write; every OwnedItemId is an opaque UUID; identity wire-id set ==
source supported set exactly (0 minted, 0 dropped); 13 deferred instances leak 0.

10 new apply tests (determinism, request/mapping/squad translation, blocker
refusal, staging gate both ways, local preflight, identity seed/dry/postvalidate/
idempotency, conflict detection, graceful spawn failure). clippy -D warnings
clean; crate suite 23 tests green.
2026-08-12 20:36:34 +00:00
funman300 c71c2a8d33 feat(import): --emit-content (production pack + host catalog + private manifest)
Fold entity resolution (nation/league/club id->name via committed tables,
mirroring seed_fifa17_cards.py) and quality-tier rarity into the analysis, so
the supported set is honest about unresolved entities too. Add an explicit
--defer-conflict <rid> allowlist: a reviewed conflict (169193) defers, any NEW
conflict still hard-fails (defer never becomes a silent conflict suppressor).

--emit-content writes three files, PUBLIC content separated from PRIVATE account
state: fifa17-production-cards.json (Core CardDefinition[] keyed fifa17_<resourceId>,
base+versioned, tier rarity, profile-derived, no promo labels), a versioned host
identity catalog {card_id:{asset_id,version}}, and a private import manifest
(supported instances' wire ids + deferred set with reasons + preserved watermark
+ target profile + snapshot fingerprint). Emit refuses while blockers exist.

Real profile (33068179/CAGE): 1681 supported defs (150 base + 1531 versioned),
9 NoName deferred, 1 approved-deferred conflict (169193, 4 copies), 1949
importable instances, watermark 100004617 -> next 100004617, active squad f433
11/11 supported. fmt + clippy -D warnings clean; 13 tests.
2026-08-12 19:41:17 +00:00
funman300 a51947562c feat(import): identity import API + FIFA17 real-profile dry-run importer
openfut-identity:
- insert_existing_mapping(game,kind,core_id,external_id): preserve an existing
  external wire id instead of minting; idempotent for an identical mapping,
  rejects conflicting forward/reverse with IdError::Conflict, persists atomically.
- persisted per-scope allocator watermark (set_watermark/watermark_for) so a
  future mint continues past the source high-water even across burned-id gaps;
  next id = max(base_floor, live_max+1, watermark). Backward-compatible on-disk
  format (legacy bare [Row] still loads). +4 tests (10 total).

openfut-import-fifa17 (new): read-only dry-run analysis of a real FIFA17 Python
profile for a faithful Core import. Enforces disjoint item-class balance;
proposes profile-derived CardDefinitions keyed fifa17_<resourceId> (base vs
versioned never collapse) with a resourceId-group consistency gate (hard-fail on
disagreement, never pick a winner) and honest buildability (roster name +
version formula + metadata, never fabricated); plans owned-instance identity
(preserve Python wire ids, preserve nextItemId watermark); checks active-squad
coverage. --apply/--emit-content refuse to write in this phase. 11 tests.

Real profile (33068179/CAGE) dry-run: 1982 items balance (1962 players + 17
consumables + 3 staff); 1681 supported defs (155 base + 1535 versioned), 9
NoName unsupported, 1 hard conflict (resourceId 169193: one of 4 copies has a
divergent nation/team/league); 1949 importable player instances, watermark
100004617 -> first new alloc 100004617; active squad f433 fully supported.
fmt + clippy -D warnings clean.
2026-08-12 19:23:19 +00:00
funman300 63f02c4fb1 docs(state): Slice 7 — FUT squad read+write retail-validated on FIFA 17
Record the staged retail A/B: FIFA consumed the Rust squad path end-to-end
(userMassInfo overlay, in-game swap -> squad-replace {"id":0}, formation
f442->f433 persisted to Core, cold relaunch returned the persisted squad),
with Python rollback / Rust re-enable proven by host-log presence. Note the
OPENFUT_DEV_CONTENT_GAMES=fifa17 startup requirement (silent empty /collection
if omitted) as a needed deployment/preflight assertion.
2026-08-12 18:41:24 +00:00
funman300 4fd5ee2608 redirector: a port probe must not forge the signature of a TLS fault
"TLS HANDSHAKE FAILED: ... unexpected EOF" is exactly how the certificate
mismatch presented -- the defect that cost three live gate attempts and was
invisible everywhere else. It is the one line this project has learned to
treat as serious.

A reachability probe forges it for free: TcpStream::connect followed by a
drop opens the connection and closes without sending a byte, and the
acceptor reports that as "unexpected EOF". The launcher's preflight makes
two such probes per run. On 2026-08-12 they produced ten of these lines and
sent a whole session diagnosing a client-side fault that did not exist --
autopatch, ptrace_scope and client_arm.sh were all investigated before the
pairing of the timestamps gave it away.

Classify before the acceptor sees the connection: peek one byte, and treat
EOF-before-any-byte as a probe with its own quiet line. A timeout is
deliberately NOT a probe -- a slow or broken client must still reach the
acceptor and produce a real diagnostic, since misclassifying a fault as
benign would defeat the point.

The counter exists because the test needs it. A probe produces no response
either way, so a test written against client-visible behaviour passes with
the classification deleted; asserting on a count is what makes the mutation
detectable. Verified: all three mutations (drop the classification, treat
undetermined as a probe, treat a speaking client as a probe) are killed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 17:25:21 +00:00
funman300 c7d4b9f753 style(adapter): rustfmt catalog test assertions
Trailing rustfmt reflow of two catalog unit-test assertions (no logic change); clears the adapter dirty state so verify-build-identity.sh passes for the UTAS A/B.
2026-08-12 17:15:14 +00:00
funman300 37c2e5d7ee feat(utas-host): serve GET /squad/active from Core
Migrate the active-squad READ off the Python oracle to the existing Core-backed projector, completing the squad authority (read + write + /squad/list + userMassInfo overlay) on one projector.

- classify: GET /ut/game/<t>/squad/active -> Route::SquadActive. Numeric GET /squad/<n> stays on Python (no Core multi-squad model yet).
- handle_squad_active returns the projector object via user_mass_info_squad(v, persona) — byte-identical to userMassInfo.squad; degrades to an empty overlay on stale/missing/Core-error, never falls back to Python.
- persona: new REQUIRED OPENFUT_PERSONA_ID (non-zero) on HostConfig, injected not baked, must match LSX/Blaze/POW/UTAS identity.
- tests: squad_active parity test; classify updated; README config table + A/B command.

fmt + clippy -D warnings + tests (24 host + adapter) green.
2026-08-12 17:10:04 +00:00
funman300 7dbd878398 test: update mutation-battery anchors after rustfmt reflow
Formatting reflowed two mutation target lines onto multiple lines; update
the battery anchors (adapter #14 kit lookup, host #19 Content-Length push)
to the new unique substrings. Both batteries kill all mutants again
(adapter 14/14, host 20/20).
2026-08-12 04:01:37 +00:00
funman300 c2e2e0d8f2 style: rustfmt squad host + adapter files
Formatting-only. Runs the project formatter over the files authored/edited
this session (host lib+tests, adapter item/squad_ext/squad_projection/mod +
projection test). The intentionally-preserved dirty catalog.rs and
pre-existing /club-era host drift beyond these files are out of scope.
2026-08-12 03:59:15 +00:00
funman300 afc909fd3b fix(fixtures): sanitize lab subnet from committed UTAS captures
The capture sanitizer redacted tokens/device ids but left the lab Host
address (10.10.0.x) in three committed UTAS fixtures, tripping
scripts/check-no-lab-addresses.sh. Replace with an RFC 5737 TEST-NET
address (192.0.2.120) per the guard's own doctrine. Host header is
plaintext metadata only (tests decode body_b64), so no test is affected.
Pre-existing leak (fixtures committed at 0b66662/eb8311a); no lab address
was introduced this session.
2026-08-12 03:59:15 +00:00
funman300 b607ff28cb chore: bump openfut-core gitlink to rustfmt'd squad-ext routes (3084a46) 2026-08-12 03:58:51 +00:00
funman300 cf86d4e425 test(utas-host): squad host mutation battery (20 mutants, all killed)
mutation-battery.sh injects each of the 20 required wrong behaviours into
the committed host (or the adapter it composes) source, runs the one host
test that must catch it, and requires a non-zero exit (killed), reverting
via git after each. Adds a duplicate-definition host round-trip test.

Kills: authz-skipped, authz-loop-empty, failure-masked-as-success,
PUT-as-partial-diff, extension-dropped, stale-accepted, missing-fabricated,
overlay-clobbers-{userInfo,settings,pile}, list-separate-shaping,
captain-as-resourceId, kit-by-slot, client-eval-dropped, host-trusts-fp,
per-slot-N+1, squad/active-to-Rust, duplicate-collapse, stale-content-length,
python-squad-after-failure. 20/20 killed.
2026-08-12 03:11:25 +00:00
funman300 85761390a8 feat(utas-host): FIFA17 squad authority — PUT, /squad/list, userMassInfo overlay
Extend the UTAS migration host to own the squad slice, reusing the exact
production identity path (Fifa17CardCatalog + ExternalIdentityStore) that
/club uses, so /club, /squad/list, userMassInfo and PUT all agree on
wire<->owned identity.

Routing (classified ONCE, no try-Rust-then-Python):
  PUT  …/squad/<n>   -> Rust (numeric id; …/squad/active stays Python)
  GET  …/squad/list  -> Rust
  GET  …/userMassInfo-> Python proxy, ONLY .squad overlaid
  everything else    -> Python verbatim

CoreAccess gains read_squad_ext / replace_squad / all_owned (HTTP to the new
Core /squad/ext + /squad/replace routes).

PUT pipeline: parse -> build_squad_write (reverse-resolve every wire id;
refuse unresolved/duplicate) -> AUTHORIZE every resolved owned item against
the active club (identity resolution is NOT authorization; a valid wire id
owned by another profile is rejected before any mutation) -> Core atomic
replace+extension -> exactly {"id":0}. No Python fallback on failure; no
host-side second extension store; no fingerprint recomputation.

Read path assembles ONE projection input (one read_squad_ext + one batch
all_owned; no per-slot lookup) and runs the single adapter projector. Both
/squad/list and userMassInfo.squad derive from it. Fresh projects; Stale is
never applied; Missing is never fabricated — both are prominent integrity
failures, never served from Python (no split authority). The overlay
replaces only .squad and preserves userInfo/settings/userData/
pileSizeClientData, fixing Content-Length.

Tests: routing, full-replacement + exact ack, unknown/foreign/duplicate item
rejection with Core unchanged, idempotent repeat PUT, coupled read-after-
write (list + userMassInfo agree, real resourceIds + stable wire ids),
overlay field preservation, bounded no-N+1 reads, Stale/Missing integrity.
2026-08-12 03:04:24 +00:00
funman300 4d30d8b3e8 chore: bump openfut-core gitlink to squad-ext HTTP routes (9b2c6b8)
Exposes GET /squad/ext and PUT /squad/replace so the UTAS host can read
and atomically persist the FIFA17 squad canonical+extension state over
HTTP. Core commit sits atop the frozen contract 615c5fd (branch
rust-migration/squad-ext-routes); no domain change. The preserved dirty
openfut-core worktree (eab522a) is intentionally left untouched, so root
status still shows 'M openfut-core' as before.
2026-08-12 02:47:36 +00:00
funman300 0e30980632 style(adapter): elide redundant lifetime in squad projection test helper 2026-08-12 02:31:13 +00:00
funman300 46a81e7a07 test(adapter): squad mutation battery (14 mutants, all killed)
mutation-battery.sh injects each of the 14 required wrong behaviours into
the committed source, runs the one invariant test that must catch it, and
requires a non-zero exit (mutant killed), reverting via git after each.

Kills: kit-by-slot, captain-as-resourceId, chemistry-reconciled,
custom-regenerated, index-derived, stale-accepted, missing-fabricated,
faked-asset-id, duplicate-instance-collapse, PUT-as-slot-diff,
wire-id-in-canonical, projector-bypasses-shared-shaper, schema-version-
ignored, player-state-keyed-by-definition. 14/14 killed.
2026-08-12 02:30:38 +00:00
funman300 e09344490f feat(adapter): single FIFA17 squad projector + fixture round-trip tests
fut::squad_projection is the ONE projector for every squad read shape.
project_squad(canonical squad + Fresh extension + owned items) -> the FIFA
17 squad wire object; user_mass_info_squad and squad_list are envelope-only
wrappers over the same output (no per-endpoint domain model).

Design guarantees exercised by tests:
  - purity / no N+1: consumes a host-assembled input (read_squad_with_ext +
    one batch owned-cards fetch + in-memory card defs); no per-slot lookup
  - shared shaper: every occupied slot is shaped by fut::item::shape_item,
    so squad items and /club items cannot drift
  - Fresh -> full projection; Stale -> never applied (verdict surfaced);
    Missing -> explicit, never fabricated
  - captain projects as the resolved WIRE id (never resourceId); index and
    formation round-trip verbatim; kit follows the player; two owned copies
    of one definition stay distinct

Adds committed sanitized fixtures decoded from the squad session capture
(swap, f433, persisted userMassInfo.squad read, squad/list) and
tests/squad_projection.rs: baseline / swap / formation-change / persisted
read-after-write round-trips asserted by ownership class (canonical,
extension, shadow, derived identity), plus one-projector no-divergence.
2026-08-12 02:28:05 +00:00
funman300 80a8bc4520 feat(adapter): FIFA17 squad extension v1 + full-replacement PUT builder
Add fut::squad_ext::Fifa17SquadExtensionV1 — the versioned, adapter-owned
payload Core stores opaquely alongside the canonical squad. Carries the
FIFA-only wire state that is not Core-canonical:
  - custom[]        opaque 33-int string, round-tripped verbatim
  - squad_type      observed FIFA token
  - kit_numbers     keyed by owned_card_id (kit follows the PLAYER, proven
                    by the swap/formation captures), never by slot/definition
  - manager         opaque item ref (not a squad player; not shaped)
  - kicktakers      opaque role refs; relationship to captain UNKNOWN, so
                    preserved verbatim and never normalized to the captain
  - client_reported chemistry/rating/starRating shadow, never authoritative
from_payload enforces the payload schema version first (distinct from Core's
DB schema); an unknown version is rejected, never coerced.

build_squad_write turns a parsed PUT + host wire->owned resolver into a
canonical ProposedSquad + extension, refusing on unresolved ids or a
duplicate owned item. Identity resolution is explicitly NOT authorization.

Refactor the 550a59d parser scaffold: ProposedSquad is now pure canonical
(FIFA-only + shadow fields moved to the extension); the canonical formation
is the FIFA wire token verbatim (drop the lossy f442->"4-4-2" map that
could not even represent f433) so formation and index round-trip exactly
with no derivation. Bench split is the fixed 23-slot array convention.
2026-08-12 02:19:22 +00:00
funman300 b50e0359f7 feat(adapter): extract shared FIFA17 FUT item-shaping primitive
Move the per-item card shaper (CoreOwnedItem, Fifa17Identity,
ItemIdentityResolver, ShapeStats, shape_item) out of club_response into
fut::item so /club and the upcoming squad projection emit byte-identical
items from one source of truth. club_response keeps only the /club
{itemData:[...]} envelope and re-exports the moved types for API
stability. shape_item is now pub; no behavior change (all /club and
oracle-parity tests unchanged and green).

Adds item-shaper tests: full-field identity mapping and the duplicate
owned-copy invariant (two instances of one definition keep distinct wire
ids, share one asset id).
2026-08-12 02:14:41 +00:00
funman300 58a300c7f4 core: game-scoped opaque squad extension + atomic fingerprint write (submodule 615c5fd) 2026-08-12 01:40:13 +00:00
funman300 eb8311a5ee evidence: FIFA17 squad controlled retail capture (swap/formation/relaunch) sanitized fixtures 2026-08-12 01:16:46 +00:00
funman300 550a59d12c feat(adapter): FIFA17 squad full-replacement wire parser + reverse-map scaffolding (unrouted) 2026-08-12 00:53:46 +00:00
funman300 8c1d1ed958 docs(mirror): /club RUNTIME VALIDATED on retail FIFA 17 2026-08-12 00:36:26 +00:00
funman300 fc00b0c6f9 docs(mirror): /club composition proven live (slice 5) 2026-08-11 23:05:29 +00:00
funman300 5276dd2066 feat(utas-host): send X-OpenFUT-Game to Core + end-to-end /club composition test 2026-08-11 23:00:08 +00:00
funman300 88da16a11e feat(fifa17): curated dev content pack generator + Core dev seed (submodule 36abd4b) 2026-08-11 22:57:02 +00:00
funman300 3ef3bc32ec feat(utas-host): real Fifa17IdentityResolver (catalog + store + policy), drop placeholders 2026-08-11 22:33:40 +00:00
funman300 36fe1caa3f feat(fifa17): deterministic base-card seed generator + full identity catalog
scripts/seed_fifa17_cards.py: deterministic pipeline from committed FIFA17 data
(pool.json + roster.json + leagues/nations/teams tables) -> the card-definition
identity catalog. CardDefinitionId is opaque + deterministic (fifa17_<asset>),
version 0 (base cards only; resource_id == asset_id). --check mode diffs against
committed output (drift-detection mutation-proven). Provenance embedded.

Generated openfut-adapter-fifa17/data/fifa17-card-identities.json: all 17,563
base assets. Semantic definition coverage (to /tmp, not committed here): 17,547
resolvable; 16 skipped for missing roster name (reported, never fabricated).

Adapter loads the committed catalog (test: 17,563 entries, Ronaldo fifa17_20801
-> asset 20801 v0). Phase commit 3/5. NOT owned inventory: this is 'which cards
exist', not 'which the user owns'. Core content seeding + dev-owned set next.
2026-08-11 22:19:57 +00:00
funman300 f55c401b6c feat(fifa17): card-definition identity catalog + owned-item wire-id policy
fut::catalog — Fifa17CardCatalog maps a semantic CardDefinitionId to a FIFA 17
render identity (resource_id = (version<<24)|asset_id; version 0 => resource==
asset). Versioned JSON (schema_version=1, game=fifa17); validates schema/game,
rejects asset_id > 24 bits, and rejects two card ids claiming one resource_id.
Unknown definitions resolve to None (callers drop, never fabricate).
Fifa17WireItemIdPolicy carries the owned-item namespace (base 100_000_000,
first id 100_000_001, per the oracle) supplied to the generic store.

Adds serde derive to the adapter. 8 catalog tests; 3/3 mutations killed
(resourceId-drops-version, conflict-detection-off, asset-range-off).
Phase commit 2/5. No card->asset DATA shipped: the synthetic Core catalogue is
unmappable (see seed plan); the loader + format land now, population later.
2026-08-11 21:59:50 +00:00
funman300 b8037b9b22 feat(identity): generic game-scoped external-identity store
openfut-identity: durable, reversible (game_id, entity_kind, core_id) <->
external wire id mapping. Game-independent infrastructure (adapters supply the
numeric policy via base_floor; the store guarantees stable/unique/reversible/
game-scoped/persistent/atomic/explicit). JSON-file backed behind an
ExternalIdentityStore trait (SQLite can drop in later); parking_lot-guarded,
atomic temp+rename persist, rejects a torn reverse-duplicate on open.

Core never learns FIFA integers; only the host/adapter that owns a game
boundary uses this. 6 tests, 4/4 mutations killed (same-id-for-two-items,
lost-on-restart, broken-reverse, dropped-game-scope). Phase commit 1/5.
2026-08-11 21:57:15 +00:00
funman300 c0a3f68ded feat(utas): FIFA17 UTAS migration host + /club adapter mappings
openfut-utas-host: the first live UTAS host. Serves GET /ut/game/<title>/club
from OpenFUT Core via the FIFA17 adapter and reverse-proxies every other UTAS
route verbatim to the Python oracle. Plaintext HTTP/1.1 keep-alive (no TLS);
route classification before execution; a Core error on /club degrades to an
empty page and never falls back to Python. CoreAccess is a host-owned boundary
(the adapter stays transport-agnostic).

openfut-adapter-fifa17::fut: owned_query (wire parse + FIFA id->name mapping,
unknown id = hard error), entities (id<->name from committed tables), and
club_response (FIFA _item shaping; drops items lacking a real FIFA asset id,
never fabricates one).

openfut-core submodule advanced to the reconciled trunk (6acae54 = 8c8a4116
multi-game + eab522a replace_squad/SquadRules + the /club semantic query).
11 host tests + adapter fut tests; 10/10 host mutations killed. rare=SP UNKNOWN.
Retail rendering of Core inventory still blocked on the Core-card->asset-id
identity decision (next phase).
2026-08-11 21:40:15 +00:00
funman300 04c5043aba utas: My Squad filter corpus — every filter identified, root cause measured
Controlled retail capture, one criterion at a time, cleared between each.
47 transactions. Every filter the My Squad picker sends is now known from
the wire rather than guessed.

Route: GET /ut/game/fifa17/club -- the picker hits UTAS and reuses the
general club-inventory route.

  level=any|gold        quality      lowercase, ALWAYS present
  rare=SP               "Special"    uppercase, OMITTED when off
  position=ST           position     uppercase, omitted when off
  nation=52             entity id    numeric
  league=13             entity id    numeric
  team=5                entity id    numeric, NESTED under league
  sort=desc                          client constant; the UI has no sort control
  start=/count=11       pagination

Two encoding families: short string enums, and numeric FIFA ids. The ids
must never reach Core. Filters compose as plain ANDs in one query --
string and id filters alike -- so each maps independently.

THE ROOT CAUSE IS SELF-AMPLIFYING.

club_route honours type, team and league; it never reads start, count,
level, sort or year. Because start is ignored, every page returns the
same full set, so the client concludes the page was full and asks for the
next one. One scroll produced 22 requests and 6.2 MB, stopping at
start=200 only because the client gave up -- against a filtered set of 32
items that should have been three pages.

That also explains why the bug reads as erratic rather than broken:
league=13&position=ST returns every Premier League player instead of
Premier League strikers. Plausible, wrongly sized, hard to notice.

Measured filtered sets, from the real cluttered club -- these are the
acceptance test for the fix:

  unfiltered            1962
  league=13              350
  league=13&team=5        32

Two client behaviours worth carrying forward: the picker fires a query
per highlighted entry, not per selection (two requests for one club
pick), and parameter ORDER is not stable, so parsing must be key-value.

FIXTURE SIZE: bodies over 4 KB are truncated in the committed fixture,
with body_full_len and body_full_sha256 retained, because the same 1.1 MB
club response repeats ~25 times and its hash already proves identity.
13.3 MB -> 247 KB. The raw .ofcap keeps every byte, privately and
gitignored. Truncation is recorded per transaction so a trimmed fixture
is never mistaken for a whole response.

Audited across all three identifier surfaces -- headers, JSON bodies,
query strings -- before and after the size change: no leaks. 6/6
sanitiser mutations still killed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:32:57 +00:00
funman300 0b66662525 utas: first real corpus, and two sanitiser gaps the audit caught
24 transactions across 11 connections from a retail session: login,
hub, one pack open, two squad saves, a quick-sell, with before/after
state manifests. Raw .ofcap stays gitignored at 0600; the sanitized
corpus is committed as adapter fixtures.

TWO GAPS FOUND BY AUDITING THE OUTPUT, NOT BY TRUSTING THE SANITISER.

1. `POST /ut/auth` carries `macAddress` and `deviceId`. Session tokens
   were being redacted correctly and these were not. A committed fixture
   is a published fixture.

2. Then, with those fixed, the audit fired AGAIN on the file about to be
   committed: `GET .../phishing/trusteddevice?deviceId=...` puts the id in
   the QUERY STRING. Three input surfaces carry identifiers -- headers,
   JSON bodies, and query strings -- and the sanitiser knew about two.

Both fixed in the tool rather than by editing the file, with a
regression test and a mutation for the query path.

AND A THIRD ARTEFACT MIX-UP, in the mutation harness itself. It reported
the query-redaction mutation as SURVIVED while a hand-run of the same
mutation killed it. Cause: the harness pointed at a stale scratchpad copy
of the test that pre-dated the query assertion, so it was faithfully
testing the mutated tool against a test that could not detect the
mutation. That is the same class as the build guard checking the wrong
binary and cargo reusing a binary compiled from mutated source -- the
third instance today of measuring the wrong artifact. The harness now
resolves ROOT from its own location and runs the COMMITTED test; the
stale copy is deleted.

Harness committed as scripts/mutate-utas-observe.py so this is repeatable
rather than a thing that happened once in a scratch directory. 6/6 killed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:33:59 +00:00
funman300 cdea85e214 utas: standalone recording proxy that tees rather than rebuilds
UTAS needs a real request/response corpus before any Rust is written: it
is where protocol shape and FUT state start being coupled, so guessing is
worse here than it was for Blaze. The oracle truncates logged bodies at
~200 chars, and raising that cap would mean editing the behavioural
specification to make it easier to copy -- backwards. A proxy gets the
same evidence and leaves the oracle untouched.

THE DESIGN RULE: TEE, DO NOT REBUILD.

UTAS is plaintext HTTP/1.1 on ThreadingHTTPServer, so keep-alive,
pipelining and chunked transfer are all live. A proxy that parses a
request and re-emits it can corrupt the traffic it exists to observe --
and that corruption would present as a UTAS bug, pointing the
investigation in exactly the wrong direction. So bytes are copied
verbatim in both directions and a second copy goes to disk; transactions
are reconstructed later, offline, from that copy. A parser bug therefore
spoils the record and never the session.

Standalone, NOT in the container, so the same tool can later sit in front
of a Rust UTAS host and replay an identical captured request against both.

Two layers, as with the Blaze captures: raw/*.ofcap is exact bytes at mode
0600 and gitignored; sanitized/transactions.jsonl is the committed
artefact. Bodies are preserved EXACTLY and sanitised second -- only
known-secret headers and JSON keys are replaced, structure is never
reshaped, and every redaction is recorded in the transaction so a reader
knows what was touched.

Captured per transaction: connection id, sequence, relative and wall
time, elapsed ms, method, path, query, HTTP version, headers IN RECEIVED
ORDER as pairs (a dict would drop duplicates and ordering), raw body and
length for both directions, status, and observed keep-alive.

Verified as two independent properties, because they fail differently:
transparency (bytes through the proxy identical to bytes direct, Date
masked, with the mask asserted to have fired) and fidelity (parsed
transactions match what was sent, including a dechunked response and a
300-byte POST body). 5/5 mutations killed, including "record but do not
forward", "drop the last byte of every chunk" and "stop redacting".

scripts/test-utas-observe.py is committed alongside it: a capture tool
nobody can re-verify is not evidence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:13:06 +00:00
funman300 05f6147433 http: extract the shared body drain; fix a flaky test race it exposed
Queued cleanup, run only AFTER the roster gate closed in both directions,
so the live A/B changed exactly one thing.

The two `drain_body` implementations were character-for-character
identical, so the extraction is a move. What it guards is not cosmetic:
answering while the client is still sending leaves unread data in the
receive queue and Linux turns the close into an RST rather than a FIN --
invisible in any comparison of the response, and worth two live gate
attempts to find. Behaviour that must be identical across hosts gets one
implementation, the same reasoning that produced openfut-tls.

SCOPE IS DELIBERATELY NARROW. Only the byte-identical part moved. The two
head-reading loops are NOT identical and stay where they are:

              redirector   roster
  head cap    65536        16384
  read chunk  4096         1024
  on error    abort        proceed if any bytes arrived

Those differences are probably accidental, but each host is gate-proven
with the values it has. Unifying them would be a behaviour change wearing
a refactor's clothes -- exactly the mistake this project has already paid
for. They converge later as their own change with their own gate, or not
at all.

Purity shown, not asserted: every existing test in both hosts still
passes (426 workspace tests), and 7/7 mutations are killed, including
three in the SHARED crate that must break both hosts at once and one per
host that skips the drain call.

Three test cases neither host had now exist, because the extracted code
finally had somewhere to be tested directly: a malformed Content-Length,
an unterminated head, and a lookalike header. That last one matters --
`X-Original-Content-Length: 99` would drain 99 bytes that were never sent
if the match were `contains` rather than `starts_with`, and a mutation
confirms the test catches it.

Also fixes a race this run exposed in openfut-tls's own tests: keypair()
returned early if the certificate file existed, but wrote the certificate
BEFORE the key, so a parallel test could observe a cert whose key had not
landed. It failed one run and passed the next -- the kind of flake that
gets rerun instead of fixed. Now generated once per process via OnceLock,
key written first, and the suite was repeated five times to confirm.

Nothing deployed and nothing restarted: the running redirector and roster
are still the gate-proven binaries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:04:08 +00:00
funman300 8f3b659c33 lifecycle: one host-lifecycle helper; roster.sh; ban pkill -f
redirector.sh and the coming roster.sh needed the same five rules, each
of which cost something to learn:

  * resolve /proc/PID/exe; never match a command line. `pkill -f` /
    `pgrep -f` match any shell whose ARGUMENTS mention the name, including
    the shell running the command. That has killed this session's own
    shell twice, and is now banned in migration tooling -- the helper
    contains no `-f` matching and the header says why.
  * `readlink`, not `readlink -f`. After a rebuild the link reads
    "<path> (deleted)" and -f resolves it to nothing, so the orphan check
    goes blind to exactly the long-lived processes it exists to find. Two
    orphans hid there, one serving the wrong certificate.
  * stop PROVES the process is gone and the port free.
  * an ambiguous binary is an error for start/verify but NOT for
    stop/status: rollback must never be blocked by a question about the
    build tree.
  * verify the RUNNING process's commit, not the artifact on disk, which
    a rebuild can silently advance past.

Copying those into a second script would have been the same mistake as
copying the TLS setup. Instead scripts/host-lifecycle.sh owns them and a
service supplies four facts: name, crate, executable, port variable.
redirector.sh goes from 178 lines to 26 and roster.sh is 24, with no
behaviour change -- the refactored redirector.sh still sees the live
armed process (pid 830736, port 42227) and still refuses correctly
because HEAD has moved past it.

Paths are unchanged (rundir, pidfile, portfile, commit stamp, log), so
the currently running redirector stays manageable across this refactor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:45:15 +00:00
funman300 c9ae914910 roster-host: transport host for the FUT roster update, lifecycle-matched
Second consumer of openfut-tls, and the reason it was extracted first.
This host contains no roster content and no cipher choice: the adapter
owns the 67 bytes and the observed TLS profile, openfut-tls owns the
acceptor, and this crate owns accept/read/drain/write/close.

Lifecycle was MEASURED, not inherited. The obvious mistake here would
have been copying the redirector's 300ms dwell because the other host has
one. A probe against the oracle says otherwise:

    dwell after responding   0 ms      (redirector: 300 ms)
    request body             drained   POST answered only once it arrives
    close                    clean FIN, never RST
    keep-alive               none      one request per connection

The probe ran against a REPLICA of roster_server.py loaded from its own
source, not against :8081 -- http.server.HTTPServer is single-threaded
and FIFA was mid-session, so holding a connection open to measure the
close would have stalled the game's poll and could have surfaced as the
squad-update error. The replica was then confirmed byte-identical to the
live oracle under masking, the 1-byte delta being the container's Python
version in the Server header.

Differential against the live oracle, every field identical, with the
Server header compared UNMASKED:

    GET  230B   HEAD 163B   POST 163B
    drained=True  reset=False  answered_before_body=False
    keepalive: second request accepted by the socket, never answered

Testing follows the redirector's hard-won rule: where a property is
visible both to the client and inside the host, it is asserted inside the
host via ConnOutcome. A client-side check cannot tell "drained" from "not
drained" -- it reads the buffered response either way -- and that exact
mistake let a mutation survive once already.

9 parity tests, 6 unit tests, 5/5 mutations killed, including "answer
before draining", "hold the connection open like the redirector" and
"inherit the redirector's 300ms default".

drain_body is duplicated from the redirector deliberately. Unifying it
means editing the redirector, and the roster A/B must change exactly one
thing. Extraction is scheduled for after the roster gate closes.

Not deployed and not switched: Python still serves :8081.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:41:11 +00:00
funman300 84e81f2037 tls: extract a shared listener; move FIFA 17's profile into its adapter
The redirector was the only host that spoke TLS, so its TLS lived inside
it. The roster host needs the same listener, and that made the choice
explicit: share this code or copy it.

Copying it is what already went wrong. On 2026-08-11 the Rust redirector
served one certificate while the container served another. ProtoSSL
caches the server certificate per backend, so the redirector -- the first
TLS connection of a session -- decided what the client expected, and
every later service failed its handshake. Silently: Python's socketserver
swallows ssl.SSLError as OSError. Three gates went to it. One place to
configure TLS is the structural fix, so it exists before the second host
does rather than after.

Split along the line the architecture already draws:

  openfut-tls               how to build an acceptor. Game-independent.
                            Knows nothing about which suites any client
                            offers.
  adapter-fifa17::tls       what FIFA 17 was OBSERVED to offer: the six
                            enabled suites, the two refused, the TLS 1.2
                            window, the EA SNI. Plain strings, so the
                            adapter keeps its lean dependencies -- reading
                            a card table should not build OpenSSL.
  redirector-host           joins the two. Chooses no cipher of its own.

Behaviour is unchanged, and shown to be:

* tests/fifa17_tls_profile.rs carries over every case from the deleted
  module -- FIFA's eight suites negotiate AES256-GCM-SHA384, each enabled
  suite works alone, RC4-only is refused, ECDHE-only is refused. Deleting
  a module must not quietly delete its evidence.
* one test pins the composed values literally against the host as it was
  when gates 1-14 passed. A "pure refactor" that cannot fail is not a
  claim, it is an assumption.
* the rebuilt binary self-tests to the same TLSv1.2 / AES256-GCM-SHA384
  the retail client negotiated at 17:09 today.

Two improvements fall out of having one place to look:

* the startup banner now prints cert_sha256. The mismatch above raised no
  error at startup and broke the client much later with nothing logged;
  it is now the first line of the log.
* tls_min/tls_max print as TLSv1.2 rather than SslVersion(771). This line
  is gate evidence and gets read by people.

Nothing deployed and nothing restarted: FIFA is mid-session on the
running redirector, which is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:30:01 +00:00
funman300 696386a9c1 client_arm: verify the hosts entry by resolution, not by presence
The old check was `grep easw /etc/hosts && echo ok`. It passed on ANY
matching line -- including a line that shadows ours. glibc returns the
first match, and the sed above only deletes lines this script wrote
(`# openfut`), so a foreign entry earlier in the file wins forever and
re-running the script never helps.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:19:52 +00:00
funman300 aa2679162d redirector.sh: never leave it ambiguous which binary is under test
Two defects, both found by the guards misfiring rather than by reading:

1. BIN preferred target/debug and fell back to release only when debug was
   absent. `cargo build --release` therefore produced a correct binary while
   the script kept inspecting a stale debug one, and the build guard refused
   with a message naming a commit nobody was trying to run. The guard was
   right that something was stale — it just pointed at the wrong artifact.
   Disagreement between the two is now an explicit refusal naming both, with
   OPENFUT_REDIRECTOR_BIN as the deliberate override.

   The refusal is recorded at load and raised only by `verify` and `start`.
   `stop` and `status` must work in any build-tree state: rollback can never
   be blocked by a question about which artifact would have been started.

2. `verify-running` read the stamp file without checking the process still
   existed. The stamp outlives the process, so after a stop it reported on a
   corpse — either "identity OK" or a REFUSAL naming a commit, both implying
   something was running when nothing was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:20:16 +00:00
funman300 096d1c882f switch: fix the unquoted python string that broke status with no --name
`cmd_status` has two paths. The `--name` path filters on an exact tag and
works. The no-name path — the "show me every switch on this box" survey,
which is how an orphan switch under a different name would be found —
built its python with shell quote-juggling and never closed the string
literal, so it died with a SyntaxError every time.

It failed loudly (rc=1, a traceback) rather than reporting "no rules", so
it never lied about the state. But it also meant the survey path had
never once run, which is the more useful lesson: every branch of a safety
tool needs exercising, not just the branch the happy path takes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:16:10 +00:00
funman300 ca63095786 lifecycle: stop the orphan check going blind when the binary is rebuilt
`list_procs` matched on `readlink -f /proc/PID/exe`. Once the binary is
rebuilt -- which happens constantly here, `cargo test` alone is enough -- the
link reads "<path> (deleted)" and -f resolves it to something that matches
nothing. The scan then finds zero processes, so `start`'s orphan check passes
and a second instance can be launched alongside a stray.

Not theoretical. Two orphans were running undetected tonight:

  pid 592731  :42327  a stale-cert redirector left from testing check-tls-parity,
                      still serving F9:16:1A -- the exact certificate whose
                      mismatch cost three live gates
  pid 542693  :42230  a Blaze sidecar debug build from 03:12

Neither was in a client path, so neither was doing harm, but a stray listener
serving the known-bad certificate is precisely what should never sit around
unnoticed.

Fixed by using plain readlink and stripping the " (deleted)" suffix. Shown both
ways: with the bug `status` reports no processes at all for a live pid; with the
fix it reports 604454. The pidfile path was unaffected, which is why `stop` kept
working and hid this.
2026-08-11 05:25:33 +00:00
funman300 c65e9c54ce observe: correct a stale comment calling the catch-all 'other'
It is a TOTAL -- every packet reaching the chain counts there, including ones
already counted by a named-port rule. The old wording invited reading the number
as a remainder, which is how 15 unexplained attempts got misread earlier.
2026-08-11 05:20:24 +00:00
funman300 b1bc7a764e adapter: port the FUT roster-update response, held to the live oracle's bytes
Next component in the migration order (Roster -> LSX -> UTAS). Adapter layer
only: no host, no runtime replacement, nothing armed.

The response is shaped as much by http.server.BaseHTTPRequestHandler as by the
oracle's handler code, so it is captured over the wire rather than reasoned
about:

  * HTTP/1.0 status line -- protocol_version is left at its default, so the
    reply is 1.0 even though the client asks for 1.1
  * send_response injects Server: and Date: BEFORE the handler's own headers
  * POST answers with headers only: the handler writes the body `if method ==
    "GET"`, so a POST advertises Content-Length: 67 and then sends nothing

That last one is preserved, not corrected. It looks like a bug, but "obviously a
bug" has been the wrong call before in this port, and a test now asserts it so a
future cleanup has to argue with something.

Date and Server are volatile and are MASKED in the fixture rather than dropped,
so their presence and position are still asserted. Server is additionally
recorded verbatim: it carries the container's Python version, so a drift away
from roster::ORACLE_SERVER fails a test instead of silently changing every byte
we emit.

generate_roster.py --check FAILS when it cannot reach the oracle rather than
passing, and mutation-testing the mutation harness itself caught two "surviving"
mutations that were really sed no-ops. With application verified, all four
mutations (header order, Content-Length, XML body, Connection) are killed.
2026-08-11 05:19:29 +00:00
funman300 d7c0a5521d switch: refuse to arm at a dead target; watchdog: use a pidfile
Three times now the same sequence has broken the client path: a build guard
correctly refuses to start the Rust replacement, and the `switch on` that
follows in the same script arms anyway, because it never checked whether
anything was listening. The redirect then lands on a closed socket and the
working Python service is bypassed for no benefit.

`on` now refuses unless the target port is listening. ALLOW_DEAD_TARGET=1
overrides it for arming ahead of a service that is about to start, but that has
to be deliberate. Verified both ways: rc=2 and nothing installed against a dead
port, rc=0 and two rules with the override.

The watchdog now writes a pidfile. Stopping it by command-line match is unsafe
-- any shell whose arguments merely mention the script name matches too, which
has now killed the wrong process twice here (once via `pkill -f`, once via a
/proc/*/cmdline substring loop).
2026-08-11 05:13:40 +00:00
funman300 2ae90b1ea9 tooling: watchdog that rolls an armed switch back when the service stops answering
`openfut-switch.sh on` prints "the service MUST stay up" -- true, and useless
when nobody is at the terminal. An armed switch pointing at a dead port means
the client hits a closed socket with no fallback.

This turns the documented rollback into an automatic one, failing toward the
Python oracle. The worst case of a spurious trip is a gate needing re-arming;
it can never leave the client broken.

It only ever REMOVES a switch. It does not install one, restart the Rust
service, or touch Python, and it does not re-arm after tripping -- an
unexplained rollback should be a finding to read, not something hidden by
flapping the switch back on.

The probe goes through the switch and speaks TLS, because a bare TCP connect
would succeed against a process wedged mid-handshake.

Tested both directions, not just the happy path: quiet for 45s against a
healthy service, and against a stopped one it failed 3/3 in 9s, rolled back,
and left Python serving -- verified by re-reading all four tables and by which
implementation's log grew.
2026-08-11 05:11:41 +00:00
funman300 cfb0435d96 redirector.sh: verify the RUNNING process's commit, not just the binary on disk
`verify` inspects `$BIN --identity`, which is the file on disk. That is not
necessarily what is serving. Caught during gate 14 setup: the live process had
been started from 5bc39e9, then `cargo test` re-ran build.rs (the branch ref
moved when an unrelated script was committed) and restamped the on-disk binary
to fc411bb. `verify` then reported "build identity OK" about an artifact that
was not the running service.

`start` now records the stamped commit to $RUNDIR/redirector.commit, and
`verify-running` compares THAT against HEAD, refusing when they differ. The
existing on-disk check stays -- it is the right gate for "may I start this" --
but only the recorded stamp answers "is the thing currently serving the thing I
think it is", which is the question a live gate's evidence depends on.
2026-08-11 05:03:47 +00:00
funman300 fc411bb6f1 scripts: require one certificate across the whole FIFA-facing TLS stack
Two live gates were lost to a second variable I had been asked to eliminate.
The Rust redirector was pointed at the repo's fifa17-recon/tools/redir_cert.pem
(fingerprint F9:16:1A...), while the running container serves a different cert
baked into its image (E7:F9:46...) which the Python redirector, roster and the
rest of the stack all share. So the A/B compared TLS implementation AND
certificate identity at once.

FIFA 17's ProtoSSL caches the server certificate for a backend. The redirector
is the first TLS connection of a session, so its cert becomes the one the client
expects; the next service presenting a different cert fails its handshake. That
is why the redirect itself always succeeded and the failure surfaced later, on
the roster fetch -- "An error occurred downloading the FUT Squad Update".

It stayed invisible because Python's socketserver swallows it: a handshake
failure at accept() raises ssl.SSLError, which subclasses OSError and is
discarded by _handle_request_noblock. No request log, no stderr. Every server
looked healthy while the client could not talk to any of them.

Confirmed on the wire: tls-observe in front of the roster server captured four
ClientHellos from the client, correct SNI and the same 8 static-RSA suites it
offers the redirector, none of which produced a request.

The check is mutation-tested against the real bug: with a redirector started on
the stale repo cert it exits 1 and names the mismatch.
2026-08-11 04:38:23 +00:00
funman300 5bc39e902d tooling: observe client connection ATTEMPTS; make the build guard reject bad args
openfut-observe.sh answers the one question no server log can: when a gate
fails and a service logged nothing, did the client try and fail, or never try?
Both look like silence. Two redirector gates were lost to that ambiguity --
"roster server logged nothing" was equally consistent with a broken roster
service, a wrong roster URL, and a client that never asked.

Built on iptables packet counters because this box has no tcpdump, no
conntrack, and no readable kernel log. That last one is verified rather than
assumed: an initial LOG-based version installed correctly and its rules matched
(counters proved it), but the output went nowhere -- journalctl -k has no
entries and dmesg is empty. Counters are also lower volume and record only SYNs,
so no payload can be captured even in principle.

Validated against the live client, not a loopback stand-in: an initial
self-test using this host's own address counted almost nothing, because
locally-generated packets never traverse PREROUTING. Against the real remote
client it counts 8081 at ~4/min, matching the roster server's own log.

Known gap, recorded rather than hidden: the catch-all TOTAL runs well above the
sum of the named ports, so the client makes steady background attempts to ports
not tracked here. It is present during a working session, so it is not the
failure signature, and it is not chased further here.

verify-build-identity.sh now rejects an argument that is not a commit hash.
Passing the binary path instead of its stamp previously produced a plausible
"REFUSING: binary was built from ./target/release/... but HEAD is <sha>", which
reads as a real stale-build finding rather than a caller mistake -- and a
safeguard that cries wolf is one people learn to route around. Usage error is
now exit 2, distinct from a genuine stale build (1) and success (0).
2026-08-11 04:22:40 +00:00
funman300 e2c4ca6d56 redirector-host: reproduce the oracle's connection lifecycle, not just its bytes
Two live gate attempts failed with "An error occurred downloading the FUT
Squad Update" while the redirect response was verified byte-identical to the
Python oracle. Rolling back to the Python redirector fixed it, so the response
bytes were never the whole contract.

Log archaeology found the discriminator: the client polls
/fifa17/fut/rosterupdate.xml ~4x/min in every successful FUT session, and the
only gap in 300 recorded fetches is 03:47-03:58 -- exactly the two
Rust-redirector sessions. The Blaze RPC sequence over those sessions is
identical (msgNum 0-53), so the divergence is entirely outside Blaze.

A differential lifecycle probe against both redirectors found the two
behaviours this host never reproduced:

  * the oracle drains the request body per Content-Length; this host stopped
    at the header terminator, leaving unread data in the receive queue, which
    makes Linux close with RST rather than FIN
  * the oracle holds the connection open ~300ms before closing
    (time.sleep(0.3)); this host closed at 0ms

Both are now reproduced. The dwell is a named constant, ORACLE_CLOSE_DWELL,
overridable only so the causal experiment -- set it to 0, confirm the failure
returns -- can be run without a rebuild.

The suite could not have caught either: it sent Content-Length: 0, so there
was never a body to drain. It now POSTs a body, and asserts a split-write body
is fully consumed.

Testing the drain via client-visible symptoms does NOT work -- verified by
mutation: with the drain removed the client still reads the buffered response
and sees close_notify before any reset. So the host records a per-connection
ConnOutcome and the test asserts on that. Both mutations (no-dwell, no-drain)
are now each caught by exactly one test.

This does not yet prove causation for the FUT Squad Update failure; it removes
the only two measured divergences. Gate 6 is the test.
2026-08-11 04:12:32 +00:00
funman300 288d990821 empty commit to advance HEAD for the stale-binary mutation test 2026-08-11 03:46:08 +00:00
funman300 c03702707b redirector: commit stamp + shared build-identity verifier that REFUSES
The binary records only the commit it was built from -- no dirty-tree flag.
Cargo will not re-run a build script because another crate's source changed, so
a compiled-in 'clean' claim can be stale and is not a safeguard; that was
verified on the Blaze host.

scripts/verify-build-identity.sh establishes both facts at LAUNCH, where they
cannot go stale: the stamped commit equals HEAD, and the migration crates are
clean. It REFUSES rather than warns, because for a migration gate a warning on
stderr is something to scroll past.

--identity prints the stamp without valid configuration. The launcher must be
able to establish which commit a binary came from BEFORE deciding whether to
run it; requiring a correct environment first would invert the check.

redirector.sh mirrors sidecar.sh: refuses to start with an orphan present or
the port busy, matches the resolved executable rather than the command line
(pgrep -f matches any shell mentioning the name), and stop PROVES the process
is gone and the port free.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 03:46:07 +00:00
funman300 89f77470f3 redirector: Rust host on vendored OpenSSL; shared typed config extracted
TLS DEPENDENCY, as directed: the openssl crate directly with the `vendored`
feature. NOT native-tls. native-tls abstracts over whatever the platform
provides; here the requirement is the opposite -- precise, evidenced behaviour
for one legacy client -- which needs explicit control of the cipher list,
protocol floor/ceiling and security level. Vendored so a distro libssl update
cannot silently change whether FIFA 17 can connect.

Scoped to this crate alone. Neither OpenFUT Core nor the generic protocol
crates gain an OpenSSL dependency.

CIPHERS driven by the captured retail ClientHello, not by generic legacy
assumptions. The six RSA+AES suites it offers are enabled; RC4 and MD5 are
deliberately NOT, even though the client offers them -- it already negotiates
AES256-GCM-SHA384, so resurrecting RC4 for completeness would weaken the
service for nothing. TLS 1.2 floor and ceiling, matching the observed client;
the floor is not dropped to 1.0 pre-emptively because "the oracle permits it"
is not "the client requires it".

SECURITY LEVEL IS NOT LOWERED. Tried the default policy first, as directed,
and OpenSSL 3.6.3 accepts static-RSA/AES without weakening. No SECLEVEL change
was needed and none is applied; it remains overridable per-listener with
evidence.

CERTIFICATE: the proven Python redirector's material is reused, so the TLS
implementation stays the only variable in an A/B. Verified RSA-2048, CN
winter15.gosredirector.ea.com, cert/key modulus match; the key stays
gitignored.

SHARED CONFIG. New openfut-host-config is now the only crate that reads the
environment, and both hosts resolve endpoints through it. Two hosts each
parsing OPENFUT_ADVERTISE would be exactly the "separate helpers constructing
endpoints from different sources of truth" the address audit forbids.

VERIFICATION BY REAL HANDSHAKE, not by enumeration. The crate exposes no
accessor for a context's configured suites at this version, which turned out
better: the host now rehearses the retail handshake at startup with a client
restricted to exactly FIFA's eight suites and REFUSES TO SERVE if it fails, so
a cipher/version misconfiguration surfaces at boot rather than as an
unexplained failure during a live gate.

Gates 1-5 pass: TLS config unit tests; a FIFA-suite-only client negotiates
TLSv1.2/AES256-GCM-SHA384; each enabled RSA+AES suite negotiable alone; an
RC4-only client is refused; an ECDHE-only client is refused (proving no modern
policy was silently inherited); a full HTTPS round-trip returns bytes
IDENTICAL to the Python oracle's recorded response.

Cargo.lock committed for reproducibility: openssl 0.10.81, openssl-sys 0.9.117,
openssl-src 300.6.1+3.6.3 (OpenSSL 3.6.3). Updating openssl-src is NOT a
routine bump -- it requires re-running the FIFA compatibility gates.

Gates 6-14 need the retail client and are next.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 03:28:04 +00:00
funman300 0d576a14b7 switch: one generic NAT implementation; blaze-switch becomes a wrapper
The Blaze switch was hardwired to 42130 and could not intercept the redirector.
Rather than clone it, the iptables logic now lives in one place:

  openfut-switch.sh   generic: --server-ip --intercept-port --target-port
                      --name [--client-ip] [--legacy-tag]
  blaze-switch.sh     thin wrapper, CLI and output UNCHANGED so the validated
                      gate runbook and sidecar.sh's cross-check keep working

No deployment IP or port literal in the generic tool; 42130 is supplied by the
wrapper, 42127 by the redirector experiment.

VERIFICATION IS INDEPENDENT OF REMOVAL. Rules are created and deleted by their
comment tag; they are verified by parsing the kernel's own FIELDS (chain,
destination, dport, to-ports) with no reference to the comment. Status detects
duplicates, incomplete pairs, conflicting targets under one name, and foreign
redirects on the same port -- which it reports but never deletes. `off` removes
only rules bearing this switch's exact tag, then re-reads the table to confirm.

THREE BUGS FOUND WHILE BUILDING IT, all in the same family as the original
lying rollback:

1. Renaming the tag ORPHANED live rules. Gate 10 deliberately ended with the
   switch on, so rules carrying the old tag were still installed and the
   renamed tool could not see them -- `off` would have reported success while
   traffic stayed redirected. Hence --legacy-tag: a rename must not strand
   rules it owns.
2. Deleting by re-feeding the raw `iptables-save` line through the shell fails
   on this iptables, which prints `--comment "tag"` WITH quotes; word-splitting
   leaves the quotes inside the value so nothing matches. Bare-comment rules
   deleted fine, which is exactly what made it look like it worked. Deletes are
   now rebuilt from parsed fields and passed as argv elements.
3. `IFS=$'\t' read` collapsed consecutive tabs because tab is IFS *whitespace*,
   so an absent `-s` shifted every later field left and produced
   `-s <dport> --dport <to_ports> --to-ports ''`. Harmless here, but a shifted
   spec that matched a real rule would delete the wrong one. Now uses \x1f.

Mutation-tested against all seven required cases: wrong intercept port, wrong
target port, missing rule, duplicate rule, changed comment representation
(bare vs quoted), and a rollback that leaves a foreign redirect installed --
which exits non-zero rather than claiming success.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 03:12:09 +00:00
funman300 c5807c07a9 blaze-host: passive ClientHello observer for the redirector TLS decision
The redirector TLS question cannot be answered from the cipher OpenSSL
selected: its server follows client preference by default, so FIFA preferring
static RSA does not prove ECDHE was unavailable. Choosing a TLS stack on that
inference would be a guess. This reads the actual ClientHello.

PASSIVE BY CONSTRUCTION. Bytes relay verbatim both ways, nothing is injected
or rewritten, and the handshake is still terminated by the untouched Python
redirector. A parse failure logs and relays anyway -- observation must never be
able to break the path it observes.

Reports record/client version, supported_versions, SNI, every offered suite by
name, extensions, and a verdict on whether ANY forward-secret suite is offered,
which is exactly the rustls question. Unknown suites print as hex rather than
being dropped.

Verified end to end against the live Python redirector with openssl s_client:
31 offered suites parsed, 18 classified forward-secret, and Python logged the
relayed request and served its 406B serverinstanceinfo -- proving observation
AND pass-through in one run.

Unit-tested on truncated and non-TLS input; the verdict is asserted in both
directions so a static-RSA-only hello reports RULED OUT rather than defaulting
to the permissive answer.

NOTE: that 18-suite result is from openssl s_client, NOT from FIFA. It proves
the instrument works. The actual question is still open until a retail FIFA
ClientHello is captured.

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 02:55:50 +00:00
funman300 8aab2c0d41 adapter: FIFA 17 redirector response; Nucleus deliberately not ported
REDIRECTOR. The first hop's <serverinstanceinfo> XML, byte-for-byte against
the oracle across three advertised addresses. Owns the response only; TLS and
HTTP transport belong to a host, exactly as the Blaze adapter owns dispatch
while the sidecar owns the socket.

The <secure>0</secure> field is the client being told the second hop is
plaintext -- independent corroboration of the plaintext Blaze finding, now
expressed in code.

NUCLEUS IS NOT PORTED, and that is a finding rather than an omission.
Instrumented across every live session:

  listener bound            YES  0.0.0.0:42131 since 00:21:32
  handler logs on connect   YES  unconditional, before any parsing
  client received the URL   YES  OSDK_NUCLEUS fetched 10+ times
  client connected          NO   zero requests, including 4 full FUT flows

So the long-standing nucleusConnect=0.0.0.0 anomaly is explained: FIFA never
follows that URL on this path. The invalid address has never mattered because
nothing dials it. Porting the stub would add an untested component for no
parity gain.

TLS CONSTRAINT RECORDED, NOT RESOLVED. All 9 observed handshakes negotiated
AES256-GCM-SHA384 = TLS 1.2 with STATIC RSA key exchange. rustls supports only
forward-secret (EC)DHE suites and cannot serve that. Whether the client also
OFFERS ECDHE is unknown -- OpenSSL follows client preference by default, so
preferring static RSA does not prove it is the only option. This must be
instrumented from a real ClientHello before a TLS stack is chosen; the module
docs say so rather than guessing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 02:48:12 +00:00
funman300 ed0ccb8c2b blaze-host: check client sessions in BOTH network namespaces
Host-side ss cannot see the Python backend's connections: the responders run in
a container, so a client session terminates at 172.20.0.2:42130 inside its
namespace and the host only sees the NAT'd flow. 'ss | grep <client>' on the
host therefore reports nothing while a session is very much alive.

That produced a wrong precondition: 'no .105 Blaze session -- closed' was
reported while FIFA was mid-session on Python, and gate 9 was armed against a
client that had never exited. Python's own log had the answer -- it logs closes
reliably and there was no close for that session.

client-state.sh looks in both namespaces, reports Rust and Python separately,
and exits non-zero while any session is live. An unreachable container counts
as 'cannot confirm', not as 'clear'.

Fourth measurement bug in this tooling, and the most consequential: the other
three mis-COUNTED, this one mis-STATED a precondition and caused an action.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 02:34:46 +00:00
funman300 b40adac3fc gate-evidence: window FUT-action counts to the gate, not the whole log
'pack opens recorded: 45' appeared in the gate 8 report. The UTAS log is
cumulative across the entire deployment, so a bare count reads as if 45 packs
were opened during that gate; the real number was 1.

Now reports both, labelled, windowed from the sidecar's start time (it is
restarted per gate, so that is the gate boundary). A bare count in a gate
report will be read as belonging to that gate, so it has to be the one that
does.

Third counting bug in this tooling: the trace frame counter matched OPEN/CLOSE
markers, the capture and trace were read seconds apart during a live session,
and now this. Evidence tooling gets the same scrutiny as the code under test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 02:31:55 +00:00
funman300 bfb7876ed4 gate-evidence: count trace frames correctly, archive the raw capture, observe FUT actions
Three fixes, all found while closing out gate 7.

1. Frame count was wrong. It counted lines matching '^conn-', which also
   matches the OPEN/CLOSE lifecycle markers, inflating the figure by one or
   two. Compared against the capture's record count that looked like a
   capture/trace divergence (89 vs 88) when there was none: read at the same
   instant, both report 99. Evidence tooling that miscounts is exactly what
   this project cannot afford.

2. The raw capture is now copied into the evidence bundle (0600), so a gate's
   forensic bytes travel with its report.

3. FUT actions are now observed on the UTAS side. 'Known FUT action succeeded'
   is a client-side fact, but FUT actions go over UTAS -- which is never
   switched -- so the UTAS log confirms them independently of anyone's
   recollection. Gate 7's pack open shows up as:
     STORE: opened pack Special Players Pack -> 11 items

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 02:28:27 +00:00
funman300 55b4e54d5f gate-evidence: add the Python-side positive/negative observation
'Rust did not receive it' is weaker than 'Python did'. The redirector always
runs on Python and is never switched, so it advertises the Blaze endpoint on
every run; whether Python then receives the Blaze CONNECT it just advertised
says where the hop actually went.

This is already visible in the existing logs and settles gate 5-6 more firmly
than the sidecar record alone:

  02:02:13  Python REDIR SENT -> 10.10.0.120:42130  (to .105)
  02:02:13  Rust  conn-0005 CONNECT from 10.10.0.105
            Python received NO Blaze CONNECT

Same second, both sides: Python advertised the endpoint and did not get the
connection; Rust did. It is also the mechanism gate 8 needs in reverse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 02:21:21 +00:00
funman300 fafa2f1858 blaze-host: document capture, sanitization, and what the tests do not cover 2026-08-11 02:16:11 +00:00
funman300 c84fd14cac blaze-host: make the build stamp trustworthy for evidence attribution
Committing updates refs/heads/<branch>, not the HEAD file, so watching HEAD
alone left the stamp one commit behind -- observed live, the banner read
a84a72e immediately after 2337431 was committed. build.rs now also watches the
resolved branch ref.

Belt and braces, since cargo still cannot see every source change: sidecar.sh
compares the binary's stamped commit against the tree's real HEAD at launch and
says so loudly on a mismatch. An evidence artefact that names the WRONG commit
is worse than one that names none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 02:15:11 +00:00
funman300 23374312bc blaze-host: opt-in raw frame capture + auditable sanitizer
Evidence infrastructure, not protocol functionality. Built before gates 7-10
because those sessions cannot be reproduced -- a later run is a different
session, and the migration-validation runs happen once. Gates 5-6 already went
past without their bytes being recorded.

TWO LAYERS

  live FIFA traffic
    ├── raw capture      exact RX/TX bytes, mode 0600, gitignored
    └── blaze-sanitize   → repository-safe, replayable fixtures

CAPTURE. Off unless OPENFUT_BLAZE_CAPTURE names a file. Deterministic
big-endian container: 20-byte file header, then per-frame records carrying
connection id, a global monotonic sequence, timestamp, direction and the EXACT
frame bytes. RX is recorded as received; TX only AFTER a successful write, so a
record means the bytes were sent rather than intended.

Component/command/msgNum/msgType/payload length are deliberately NOT stored
beside the frame: they are already in its 16-byte header, and a redundant copy
can disagree with the bytes, leaving a reader unable to tell which is true.
Record::header() derives them, so every field the requirements name is
available without duplicating it.

SANITIZER. Redacts only the named tags in SENSITIVE_TAGS (KEY, AUTH, SESS,
MAIL, PML) and reports every substitution with path, kind and length.
Replacement is LENGTH-PRESERVING, so the TDF varint, payload length and Fire2
header are unchanged and the sanitized frame is exactly the size of the
captured one -- asserted per frame, failing rather than emitting a subtly
different conversation. Frames with nothing sensitive keep their exact wire
bytes. Payloads that will not decode are passed through and REPORTED, so a
reader knows they were never inspected rather than assuming they were checked.

TESTS. 39 in this crate. All nine required cases: capture disabled produces no
artefact; RX and TX captured exactly; ordering preserved; fragmented input
(one byte at a time) reconstructs the same frames as a single write; coalesced
input is captured as separate frames, not per-read; capture does not alter wire
output; sanitization removes a real session key from a real captured login;
malformed/truncated/wrong-version captures fail clearly; every listed sensitive
tag is provably reachable.

MUTATION TESTED. Dropping TX capture, truncating captured frames to their
header, and removing KEY from the sensitive list were each verified to turn the
suite red. One mutation was NOT caught: moving the TX capture above the write.
It is indistinguishable while writes succeed and only diverges when one fails.
That invariant is held by code placement and a comment saying so, not by a
test, and the code says as much rather than implying coverage it does not have.

Python oracle unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 02:14:25 +00:00
funman300 a84a72e0c0 blaze-host: A/B the RPC routes a real FIFA session actually used
The gate 5-6 live run exercised 14 RPCs the recorded fixtures never covered --
Stats, Clubs, OSDKSettings, SponsoredEvents, Messaging::fetchMessages,
Util::getTelemetryServer, Util::userSettingsLoadAll, UserSessions cmd 0x0008,
and the transport PING -- every one taking the empty-reply fallback.

That Python does the same was an inference from reading its dispatch table.
This sends those exact routes to both backends and diffs the replies:
14/14 byte-identical.

Worth keeping: the fixtures were built from what the responder implements, so
they could never have covered what the client asks for and the responder does
not. Only a live session reveals that surface, and this makes it checkable
afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 02:05:18 +00:00
funman300 6c102f00c0 gitignore: gate-evidence bundles are run artefacts, not source
They contain live traces and logs from a specific run; they belong with the
run, not in the tree.
2026-08-11 02:01:18 +00:00
funman300 48aa955212 blaze-host: per-gate evidence capture, separating asserted from observed
For the live FIFA gates. Records switch rules, sidecar status, log, trace and
the Python contract result into a timestamped bundle, and reports CONFIGURED
and OBSERVED state as two distinct sections.

The separation is the whole point. 'blaze-switch.sh status = ON' is an
assertion produced by the same tooling that performs the switch, and that
tooling reported a successful rollback once when none had happened. The
observed half comes from an unrelated source: the sidecar's own record of
which peers connected to it. A non-loopback peer in that log proves the
client's Blaze traffic landed on Rust without depending on reading an iptables
rule correctly.

Verified both ways: loopback-only traffic reports 'a FIFA session did NOT land
here'; a non-loopback peer reports that it observably did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 02:00:31 +00:00
funman300 cf3ddde3a6 blaze-host: move the dirty-tree safeguard to launch and evidence time
The compiled-in dirty flag cannot be trusted for this job. Cargo does not
re-run a build script when another crate's source changes, so editing the
adapter and rebuilding the host leaves it reading 'clean' -- verified by
appending a line to the adapter and watching the flag not move.

So the stamp now only names the commit, and the real safeguards run at the
moment they matter and cannot go stale:

  * sidecar.sh checks the working tree at LAUNCH and warns.
  * check-live-parity.sh REFUSES on a dirty tree, since it produces the
    artefact a migration decision is made from. ALLOW_DIRTY=1 overrides for a
    throwaway check.

Both scope to the three migration crates, so unrelated submodule dirt does not
trigger them -- a warning that is always on is a warning nobody reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 01:56:43 +00:00
funman300 468b006008 blaze-host: scope the dirty-tree check to the crates the binary is built from
A whole-repo check read DIRTY permanently, because unrelated submodules carry
pre-existing modifications. A warning that is always on is a warning nobody
reads, which defeats the point: the flag exists so a mutated build announces
itself before it can be mistaken for parity evidence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 01:55:20 +00:00
funman300 e091921b18 blaze-host: safe sidecar lifecycle, Blaze switch, build identity
Prerequisites for the live FIFA A/B. Two safeguards here exist because the
corresponding failure actually happened, not because it was imagined.

BUILD IDENTITY. build.rs stamps commit + working-tree cleanliness; the host
prints commit, tree state, profile and a fingerprint of the bundled config
table at startup, into both the log and the trace. A dirty tree prints an
explicit "do NOT treat results from this binary as parity evidence" warning.
The previous step left four sidecars running, two serving mutated builds, and
nothing in their output said so.

SIDECAR LIFECYCLE (sidecar.sh). start/stop/status/check-orphans/with. Start
refuses when any sidecar is already running or the port is busy. Stop kills,
waits, then PROVES it: PID gone AND port free AND no stray processes, failing
if any check does not hold. `with -- CMD` traps EXIT/INT/TERM so cleanup runs
however the command exits.

  Bug found and fixed while testing it: orphan detection used `pgrep -f`,
  which matched any process whose command line merely mentioned the name --
  including the shell running the test script. It now matches the resolved
  executable via /proc/PID/exe. `pgrep -x` is unusable because Linux truncates
  the process name to "openfut-blaze-h".

BLAZE SWITCH (blaze-switch.sh). Redirects Blaze to the sidecar with a scoped
NAT rule instead of editing the frozen Python oracle, whose redirector
advertises a hardcoded BLAZE_PORT = 42130. Rules match only <LAN_IP>:42130;
127.0.0.1:42130 is deliberately left alone so Python stays reachable on
loopback and the A/B compares real Python against real Rust. Verified both
directions live: LAN->Rust with the switch on, LAN->Python with it off.

  Bug found and fixed: `off` reported success while two rules remained active
  and rollback had NOT happened. It matched `--comment "tag"` with quotes this
  iptables does not emit -- and the verification used the SAME broken matcher,
  so it confirmed its own failure. A rollback that lies is worse than one that
  fails. Now matched on the bare tag, verified with iptables-save plus a
  tag-independent check that nothing still redirects the port.

  Second flaw fixed: `sidecar.sh stop` originally warned about a live switch
  and then stopped anyway, creating the exact broken state it warned about. It
  now REFUSES, with --force as the deliberate override.

The general rule this all converges on, now stated in the README: a
verification must not share the failure mode of the thing it verifies.

116 tests still passing; clippy clean; Python backend untouched and contract
suite 446/446. NAT table left clean, no orphan processes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 01:54:40 +00:00
funman300 a9eb54ae9c openfut-blaze-host: thin Blaze sidecar, live-parity with Python
Third migration step, and the one that turns fixture parity into transport
parity. A TCP host that frames a Fire2 stream, keeps one Session per
connection, calls openfut-adapter-fifa17::dispatch(), and writes the returned
frames in order. It owns a socket, a buffer, a session and diagnostics --
that is the complete list. No coins, club, packs, profiles or UTAS logic:
those belong to Core, reached through the adapter later.

NO TLS, and that is evidence-based rather than an omission. The Blaze main
port is plaintext: sending a raw Fire2 Util::ping to the running backend
returns a plaintext PingResponse, blaze_handle uses the raw socket, and only
redir_handle wraps ssl. TLS belongs to the redirector phase.

LIVE A/B AGAINST THE RUNNING PYTHON BACKEND: 101 frames across three
conversations, identical normalized traces. This is the first result in the
migration that is not purely offline. check-live-parity.sh replays the
recorded conversations against both endpoints over real sockets and diffs
volatile-masked traces; session keys and clocks are masked, so anything that
differs is behavioural.

Transport tests cover what fixtures cannot: byte-for-byte replay over a
socket, requests dribbled one byte at a time, several requests in one write,
the four-frame login burst ordered on the wire, session state persisting
across frames and NOT leaking between connections, an absurd payload length
closing the connection instead of allocating, and an undecodable body still
getting a reply. 18 tests here, 116 across the three migration crates.

MUTATION TESTED, including the comparison itself. Dropping a post-login
notification is caught by the probe (frame count) AND the diff; a same-length
content change deep inside a notification body (CTY "US"->"GB", payload 116
both sides) is caught ONLY by the trace digest. So the probe's exit code is
not the test -- the diff is, and the README says so. check-live-parity.sh was
itself verified to exit 1 under mutation.

The listen port is required configuration with no default, so the sidecar
cannot silently collide with the working container. OPENFUT_BIND stays the
advertised-config bind (the adapter derives nucleusConnect from it,
reproducing the oracle) and the listener gets its own setting, so the two are
not conflated.

Gates 1-4 pass and are re-runnable. Gates 5-10 need a FIFA client and are
listed in the README, including the Python -> Rust -> Python -> Rust
back-and-forth that proves the rollback path rather than asserting it.

Python backend untouched and still the live runtime; contract suite 446/446
after this work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 01:42:23 +00:00
funman300 cf961603fe openfut-adapter-fifa17: FIFA 17 Blaze adapter, oracle-tested
The second migration step: the layer above the codec, deciding WHAT to say
rather than how to encode it. Sits on openfut-protocol-blaze and supplies
what that crate deliberately refuses to know.

  blaze/ids.rs           component/command/notification tables
  blaze/config.rs        injectable identity + endpoints, nothing hardcoded
  blaze/session.rs       per-connection state
  blaze/client_config.rs the fetchClientConfig tables
  blaze/responses.rs     16 Blaze::* response bodies
  blaze/dispatch.rs      (component, command) -> Vec<Frame>

Parity is tested, not asserted. fixtures/generate.py drives the real
blaze_responder_v3b.dispatch() and records 49 request->response(s)
transactions, replayed in order against a shared session per connection so
ordering-dependent behaviour is exercised: preAuth captures the locale later
ALOC fields echo, login sets the auth code getAuthToken returns. Comparison
is byte-for-byte including frame count and order.

98 tests green across both crates; clippy clean.

MUTATION TESTED, and it found a real defect in this commit's own design.
Swapping two post-login notifications and flipping one enum inside
AccountInfo both turned the suite red as intended. Hardcoding an address in
utas_base() did NOT -- the config templating substituted raw hosts directly,
making those helpers dead code that merely looked load-bearing. The table now
templates on URL-level tokens ({utas_base}, {nucleus_base},
{pow_content_url}) so they are the single place a URL shape is defined, and
the mutation is caught.

The client config table (227-243 rows per CFID) is generated from the oracle
rather than transcribed: it is reverse-engineered data, not logic, and 400
hand-copied string literals would add a typo class no reviewer can catch. The
generator substitutes real addresses back in and diffs against the oracle for
every section before writing, so the templating is verified rather than
assumed.

Reproduces one known defect deliberately: nucleusConnect is built from BIND,
not advertise, so the live split deployment tells a client on another machine
to reach Nucleus at http://0.0.0.0:42131. Confirmed against the running
container. Reproduced because it is what the only proven-working config does;
fixing it needs live validation and is a separate change. It also implies the
Nucleus stub is not reached in the current remote flow.

Blaze carries no FUT domain state -- no coins, packs, clubs or squads on this
wire -- so Session stays a session key, locale, service name, auth code and a
flag. That boundary will need defending when UTAS is migrated.

Not wired into anything. The crate answers frames; it opens no socket and
owns no runtime. The Python backend remains the live service and the oracle,
and is unmodified (contract suite still green).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 01:18:29 +00:00
funman300 cc3ecddc06 openfut-protocol-blaze: pin advertise/bind so fixtures do not depend on the shell
The responder reads OPENFUT_ADVERTISE/OPENFUT_BIND at import time and several
live payloads embed the advertised address (Blaze redirect target, RS4/POW/
roster URLs). Without pinning, regenerating on a machine that exports
OPENFUT_ADVERTISE produces different bytes and --check goes red for a reason
that has nothing to do with the codec.

Pinned to 127.0.0.1 as a placeholder so no real LAN address is baked into a
committed fixture. Not a claim about deployment: remote mode still requires an
explicit advertised address and has no loopback fallback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 00:59:36 +00:00
funman300 a9a816e0ed openfut-protocol-blaze: generic Blaze protocol layer, oracle-tested
First Rust component of the Python -> Rust migration. Chosen first because
it is the lowest genuinely game-independent layer, it has an executable
oracle, and both existing Rust implementations of it are wrong.

Contents:
  * fire2   -- the proven 16-byte frame header, frame/stream splitting
  * heat2   -- tag packing, varints, all 11 TDF value types
  * message -- frame + decoded body, routed by NUMERIC component/command
  * diagnostics -- dumps for capture review

No FIFA 17 command tables, response schemas or notification IDs: this layer
knows 0x0009/0x0007 is component 9, command 7, not that it means
Util::preAuth. That mapping belongs to a game adapter, which is what lets a
future FIFA 18/23 adapter reuse this.

Parity is tested, not asserted. fixtures/generate.py drives the proven
Python responders (heat2.py, blaze_responder_v3b.py) and freezes 56 vectors
-- 31 of them real payloads from the responder's own builders, including
the 11.8 KB preAuth reply. tests/oracle_parity.rs replays every one
byte-for-byte. 54 tests green; clippy clean.

Supersedes two wrong framings, neither of which is removed yet:
  * fifa-blaze/crates/blaze-proto/frame.rs -- a 12-byte header with a u16
    length, nibble-packed type/options, an error field and a JUMBO flag.
    A documented guess at FIFA 23 predating the FIFA 17 recon.
  * heat2.py::build_fire2_frame -- packs >IHHHHB3s, msgId at [10:12] and
    msgType at [12]. Dead code, but its docstring still states that layout.

Confidence is carried in the types: TypeId::is_verified() reports which
layouts are capture-backed (int/string/blob/struct) and which the oracle
marks UNVERIFIED (list/map/union/varlist/objtype/objid/float), with a test
asserting the unverified ones stay flagged.

Cargo.lock is deliberately NOT included: it re-resolves ~240 lines against
the current registry even without this crate, so that churn is pre-existing
and does not belong in a foundation commit.

The Python backend remains the live runtime and is untouched. Nothing
consumes this crate yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 00:53:59 +00:00
182 changed files with 125772 additions and 259 deletions
+5 -5
View File
@@ -30,9 +30,9 @@ Thumbs.db
# Frozen baseline archives / inspects / manifests
/docker-backups/
gate-evidence/
# local dev screenshots (not versioned)
fifa17-recon/.screens/
# local hook backup
*.pre-storeguard.bak
# Raw Fire2 frame captures — forensic evidence, may contain session material.
# Sanitize with `blaze-sanitize` before anything leaves this machine.
*.ofcap
captures/
Generated
+242 -124
View File
@@ -114,17 +114,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -457,6 +446,29 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "aws-lc-rs"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
"pkg-config",
]
[[package]]
name = "axum"
version = "0.7.9"
@@ -613,19 +625,6 @@ dependencies = [
"tokio-util",
]
[[package]]
name = "blaze-ssl-async"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fec08f35919613bda0b3eb3bc772c2f793b3634133923b931874b18e1ac55de"
dependencies = [
"bytes",
"num_enum",
"rsa",
"tokio",
"x509-cert",
]
[[package]]
name = "block"
version = "0.1.6"
@@ -811,16 +810,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]]
name = "clipboard-win"
version = "5.4.1"
@@ -830,6 +819,15 @@ dependencies = [
"error-code",
]
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "codespan-reporting"
version = "0.11.1"
@@ -1062,23 +1060,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"der_derive",
"flagset",
"pem-rfc7468",
"zeroize",
]
[[package]]
name = "der_derive"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "deranged"
version = "0.5.8"
@@ -1187,6 +1172,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "ecolor"
version = "0.29.1"
@@ -1457,12 +1448,6 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flagset"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe"
[[package]]
name = "flate2"
version = "1.1.9"
@@ -1547,6 +1532,12 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures-channel"
version = "0.3.33"
@@ -2113,9 +2104,9 @@ dependencies = [
"futures-util",
"http 0.2.12",
"hyper 0.14.32",
"rustls",
"rustls 0.21.12",
"tokio",
"tokio-rustls",
"tokio-rustls 0.24.1",
]
[[package]]
@@ -2316,15 +2307,6 @@ dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]]
name = "ipnet"
version = "2.12.1"
@@ -3126,11 +3108,30 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openfut-adapter-fifa17"
version = "0.1.0"
dependencies = [
"openfut-protocol-blaze",
"rand",
"serde",
"serde_json",
]
[[package]]
name = "openfut-blaze-host"
version = "0.1.0"
dependencies = [
"openfut-adapter-fifa17",
"openfut-protocol-blaze",
"rand",
"serde_json",
]
[[package]]
name = "openfut-bridge"
version = "0.1.0"
dependencies = [
"aes",
"anyhow",
"axum",
"bytes",
@@ -3141,13 +3142,13 @@ dependencies = [
"hyper-util",
"rcgen",
"reqwest",
"rustls",
"rustls-pemfile",
"rustls 0.21.12",
"rustls-pemfile 1.0.4",
"serde",
"serde_json",
"thiserror 1.0.69",
"tokio",
"tokio-rustls",
"tokio-rustls 0.24.1",
"tokio-stream",
"tower 0.4.13",
"tower-http",
@@ -3188,10 +3189,46 @@ dependencies = [
name = "openfut-hook"
version = "0.1.0"
dependencies = [
"openfut-common",
"windows-sys 0.59.0",
]
[[package]]
name = "openfut-host-config"
version = "0.1.0"
dependencies = [
"openfut-adapter-fifa17",
]
[[package]]
name = "openfut-http"
version = "0.1.0"
[[package]]
name = "openfut-identity"
version = "0.1.0"
dependencies = [
"parking_lot",
"serde",
"serde_json",
"tempfile",
]
[[package]]
name = "openfut-import-fifa17"
version = "0.1.0"
dependencies = [
"anyhow",
"openfut-adapter-fifa17",
"openfut-core",
"openfut-identity",
"serde",
"serde_json",
"sqlx",
"tempfile",
"tokio",
"uuid",
]
[[package]]
name = "openfut-launcher"
version = "0.1.0"
@@ -3201,11 +3238,64 @@ dependencies = [
"dirs",
"eframe",
"egui",
"openfut-common",
"serde",
"serde_json",
"tokio",
]
[[package]]
name = "openfut-protocol-blaze"
version = "0.1.0"
dependencies = [
"serde_json",
]
[[package]]
name = "openfut-redirector-host"
version = "0.1.0"
dependencies = [
"openfut-adapter-fifa17",
"openfut-host-config",
"openfut-http",
"openfut-tls",
"openssl",
]
[[package]]
name = "openfut-roster-host"
version = "0.1.0"
dependencies = [
"openfut-adapter-fifa17",
"openfut-host-config",
"openfut-http",
"openfut-tls",
]
[[package]]
name = "openfut-tls"
version = "0.1.0"
dependencies = [
"openssl",
]
[[package]]
name = "openfut-utas-host"
version = "0.1.0"
dependencies = [
"axum",
"openfut-adapter-fifa17",
"openfut-core",
"openfut-http",
"openfut-identity",
"parking_lot",
"rand",
"reqwest",
"serde_json",
"sqlx",
"tokio",
]
[[package]]
name = "openssl"
version = "0.10.81"
@@ -3237,6 +3327,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[package]]
name = "openssl-src"
version = "300.6.1+3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846"
dependencies = [
"cc",
]
[[package]]
name = "openssl-sys"
version = "0.9.117"
@@ -3245,6 +3344,7 @@ checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
dependencies = [
"cc",
"libc",
"openssl-src",
"pkg-config",
"vcpkg",
]
@@ -3685,8 +3785,8 @@ dependencies = [
"once_cell",
"percent-encoding",
"pin-project-lite",
"rustls",
"rustls-pemfile",
"rustls 0.21.12",
"rustls-pemfile 1.0.4",
"serde",
"serde_json",
"serde_urlencoded",
@@ -3694,7 +3794,7 @@ dependencies = [
"system-configuration",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-rustls 0.24.1",
"tower-service",
"url",
"wasm-bindgen",
@@ -3833,10 +3933,26 @@ checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
dependencies = [
"log",
"ring 0.17.14",
"rustls-webpki",
"rustls-webpki 0.101.7",
"sct",
]
[[package]]
name = "rustls"
version = "0.23.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring 0.17.14",
"rustls-pki-types",
"rustls-webpki 0.103.13",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pemfile"
version = "1.0.4"
@@ -3846,6 +3962,24 @@ dependencies = [
"base64 0.21.7",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.101.7"
@@ -3856,6 +3990,18 @@ dependencies = [
"untrusted 0.9.0",
]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"aws-lc-rs",
"ring 0.17.14",
"rustls-pki-types",
"untrusted 0.9.0",
]
[[package]]
name = "rustversion"
version = "1.0.23"
@@ -4042,15 +4188,17 @@ version = "0.1.0"
dependencies = [
"anyhow",
"blaze-proto",
"blaze-ssl-async",
"bytes",
"chrono",
"futures-util",
"hex",
"rustls 0.23.43",
"rustls-pemfile 2.2.0",
"serde",
"serde_json",
"tdf",
"tokio",
"tokio-rustls 0.26.4",
"tokio-util",
"toml",
"tracing",
@@ -4068,6 +4216,12 @@ dependencies = [
"digest",
]
[[package]]
name = "sha1_smol"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
[[package]]
name = "sha2"
version = "0.10.9"
@@ -4334,8 +4488,8 @@ dependencies = [
"once_cell",
"paste",
"percent-encoding",
"rustls",
"rustls-pemfile",
"rustls 0.21.12",
"rustls-pemfile 1.0.4",
"serde",
"serde_json",
"sha2",
@@ -4789,27 +4943,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tls_codec"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b"
dependencies = [
"tls_codec_derive",
"zeroize",
]
[[package]]
name = "tls_codec_derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "tokio"
version = "1.53.1"
@@ -4854,7 +4987,17 @@ version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
dependencies = [
"rustls",
"rustls 0.21.12",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls 0.23.43",
"tokio",
]
@@ -5219,6 +5362,7 @@ dependencies = [
"getrandom 0.4.3",
"js-sys",
"serde_core",
"sha1_smol",
"wasm-bindgen",
]
@@ -6157,18 +6301,6 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "x509-cert"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94"
dependencies = [
"const-oid",
"der",
"spki",
"tls_codec",
]
[[package]]
name = "xcursor"
version = "0.3.11"
@@ -6393,20 +6525,6 @@ name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zerotrie"
+11
View File
@@ -2,6 +2,17 @@
resolver = "2"
members = [
"openfut-core",
"openfut-protocol-blaze",
"openfut-adapter-fifa17",
"openfut-blaze-host",
"openfut-host-config",
"openfut-http",
"openfut-tls",
"openfut-redirector-host",
"openfut-roster-host",
"openfut-utas-host",
"openfut-identity",
"openfut-import-fifa17",
"openfut-bridge",
"openfut-launcher",
"openfut-launcher/openfut-hook",
-45
View File
@@ -263,48 +263,3 @@ Both matter beyond themselves, because they are the only two routes into a match
Useful framing: this project's failures have almost always come from proposing a fix
before testing the assumption under it. Hypotheses that come with a cheap way to
disconfirm them are worth far more than plausible ones.
## FIFA 17 network-redirect milestone (2026-08-09)
Hook now installs a GENERIC network redirect on the fifa17 feature path (fifa17.rs
install_network_redirect): getaddrinfo IAT patch + inline connect detour + WSAConnect
IAT, with a configurable destination (connect_hook::set_target_ipv4) read from
openfut.cfg (single-line IP). Deployed DLL md5 bc9e0bc6, cfg=10.10.0.120.
RESULT of live launch (client 105 -> server 120):
- Error changed: "servers shut down" -> "Unable to connect to EA servers / check
network". Redirect IS firing (progress).
- BLOCKER A: getaddrinfo IAT patched 0+0 -> FIFA 17 does NOT resolve via IAT
getaddrinfo in the main exe or EAWebKit.dll. Names resolved via another path
(gethostbyname or internal DirtySDK resolver). So no hostname reached 120.
- BLOCKER B (architectural): FIFA 17 online = Blaze binary TCP on high ports. Log
shows connect 20.51.153.159:42230 sock_type=1 -> wsa_err=10035 (WOULDBLOCK->dead).
Port 42230 is NOT in the remap set (443,10041,42127,3216) so it was not redirected.
Even if redirected, the Docker bridge only speaks HTTPS on 8443 -- no Blaze
listener exists for FIFA 17. This is a server-side build, not a hook tweak.
NEXT (evidence-first): add gethostbyname (and possibly a DirtySDK resolver) capture
to learn the hostname behind 20.51.153.159; widen Blaze port remap; then scope a
Blaze-speaking bridge listener before expecting the error to clear.
## DNS/getaddrinfo fix — RESOLVED (2026-08-09, hook md5 67e3639b)
Added src/resolver_hook.rs: INLINE detours at ws2_32 export addresses for
getaddrinfo + GetAddrInfoW + gethostbyname (same unhook/rehook pattern as
connect_hook). Replaces the IAT approach that patched 0 slots on FIFA 17.
Wired into fifa17.rs install_network_redirect; hooks.rs gained redirect_ip_cstr()
and redirect_ip_str() helpers.
LIVE RESULT (client 105 -> server 120):
- resolver detours 3/3 installed.
- getaddrinfo(winter15.gosredirector.ea.com) -> redirect. Game now dials
10.10.0.120 (was 20.51.153.159 before). DNS BLOCKER A = SOLVED.
REMAINING BLOCKER B (architectural, NOT DNS): FIFA 17 online = EA Blaze binary
TCP. Game connects 10.10.0.120:42230 (gosredirector/Blaze redirector) ->
wsa_err=10035 (nothing listening). Two gaps: (1) connect_hook remap set lacks
42230; (2) even remapped, the Docker bridge only serves HTTPS on 8443 — no Blaze
listener exists. Clearing Unable to connect requires a Blaze redirector+main
server on the bridge side (real server build), not a hook change.
NOTE: the 3s TLS-handshake-EOF spam in bridge logs on :8443 is the LAUNCHER health
poller, not the game.
+142
View File
@@ -0,0 +1,142 @@
# 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 | 5001001–5001013 | {201, 202} | {7, 8} |
| Fitness + Healing | `fcc_healingcards.json` | 27 | 5002001–5002030 | {211,212,213,215,216,217,218,219,220} | {9, 10} |
| Training (6 sub-families) | `fcc_trainingcards.json` | 143 | 5003001–5003159 | 51-57, 61-67, 91-110, 121-136, 250-273, 300-340 | {1,3,32,34,35,50,51} |
| Misc | `fcc_misccards.json` | 42 | 5004001–5004042 | {231, 232, 233, 236} | {43, 44, 45, 46} |
| Badges | `fcc_badgecards.json` | 656 | 6000000–6000656 | (none in row) | {39} |
| Stadiums | `fcc_stadium.json` | 78 | 6200000–6200077 | (none in row) | {36} |
| Kits | `fcc_kitcards.json` | 1482 | 6300000–6400654 | (none in row) | {35} |
| League logos | `fcc_leaguelogos.json` | 44 | 8010000–8010044 | (none in row) | {40} |
| League logo stickers | `fcc_leaguelogostickers.json` | 39 | 8010000–8010039 | (none in row) | {40} |
| Balls | `fcc_balls.json` | 42 | 8120194–8120236 | (none in row) | {37} |
| Managers | `managercards.json` | 417 | 1000001–1001552 | (staff; by cardsubtypeid 4) | (assetid col) |
| Head coaches | `headcoachcards.json` | 124 | 2000004–2000328 | (staff; subtype 5) | — |
| GK coaches | `gkcoachcards.json` | 121 | 9000001–9000324 | (staff; subtype 6) | — |
| Physios | `physiocards.json` | 51 | 4000002–4000259 | (staff; subtype 7) | — |
| Fitness coaches | `fitnesscoachcards.json` | 115 | 3000019–3000328 | (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 6300000–6400654 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 | 1000001–1001552 |
| 5 | 3 | Head coach | `headcoachcards.json` | 124 | 2000004–2000328 |
| 6 | 10 | GK coach | `gkcoachcards.json` | 121 | 9000001–9000324 |
| 7 | 5 | Physio | `physiocards.json` | 51 | 4000002–4000259 |
| 8 | 4 | Fitness coach | `fitnesscoachcards.json` | 115 | 3000019–3000328 |
`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.
+192
View File
@@ -0,0 +1,192 @@
# 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 0–10); 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.
@@ -0,0 +1,443 @@
# 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
@@ -0,0 +1,924 @@
# 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.
@@ -0,0 +1,44 @@
# 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
@@ -0,0 +1,199 @@
{
"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
}
@@ -0,0 +1,199 @@
{
"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
}
@@ -0,0 +1 @@
{"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}
@@ -0,0 +1,173 @@
{
"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
}
@@ -0,0 +1 @@
{"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}
@@ -0,0 +1,199 @@
{
"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
}
@@ -0,0 +1,523 @@
# 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.
@@ -0,0 +1,358 @@
# 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 A–J) 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.
@@ -3,7 +3,7 @@
# OPENFUT_ADVERTISE — the address of THIS host as seen from the game machine
# (105). The responders advertise it to the client for every next hop (Blaze,
# roster, UTAS, POW). Compose refuses to start without it.
OPENFUT_ADVERTISE=10.10.0.120
OPENFUT_ADVERTISE=203.0.113.10 # <- REPLACE with this host's LAN IP
# OPENFUT_BIND — address the listeners bind inside the container.
# Defaults to 0.0.0.0 (container-facing); the original all-on-localhost flow
@@ -24,7 +24,7 @@ services:
OPENFUT_BIND: "${OPENFUT_BIND:-0.0.0.0}"
# Address advertised to the client for the next hop. MUST be this host's
# LAN IP as seen from the game machine (105). Required (see .env.example).
OPENFUT_ADVERTISE: "${OPENFUT_ADVERTISE:?set OPENFUT_ADVERTISE in .env to this host's LAN IP, e.g. 10.10.0.120}"
OPENFUT_ADVERTISE: "${OPENFUT_ADVERTISE:?set OPENFUT_ADVERTISE in .env to this host's LAN IP, e.g. 203.0.113.10}"
# POW content advertises port 8080 by default, which collides with the
# openfut-core publish on this host. Remap it to 8085 on the host and
# advertise the remapped endpoint.
@@ -11,13 +11,13 @@
# Address behaviour is driven by two env vars (see each responder):
# OPENFUT_BIND bind address for every listener (container: 0.0.0.0)
# OPENFUT_ADVERTISE address handed to the client for the next hop
# (the server's LAN IP, e.g. 10.10.0.120)
# (the server's LAN IP, e.g. 203.0.113.10)
# ============================================================================
set -uo pipefail
cd "$(dirname "$(readlink -f "$0")")/tools"
BIND="${OPENFUT_BIND:-0.0.0.0}"
ADV="${OPENFUT_ADVERTISE:?OPENFUT_ADVERTISE must be set to the server LAN IP (e.g. 10.10.0.120)}"
ADV="${OPENFUT_ADVERTISE:?OPENFUT_ADVERTISE must be set to the server LAN IP (e.g. 203.0.113.10)}"
export OPENFUT_BIND="$BIND"
export OPENFUT_ADVERTISE="$ADV"
# POW keys advertised by blaze must also point at the server, not loopback.
+17 -44
View File
@@ -55,34 +55,6 @@ verify_exports() {
done
}
# Refuse any DLL that is not a FIFA-17-profile build.
#
# openfut-hook builds TWO mutually exclusive injection paths from one crate: the
# default (FIFA 23) path installs getaddrinfo/connect/ProtoSSL/origin hooks, while
# `--features fifa17` installs ONLY the FIFA-17-safe logic (module map, FIFA 17
# cert-verify, SBC dispatch, store tab bind). Deploying a default-feature build
# into FIFA 17 hijacks the login transport and the client reports "Unable to
# connect to the EA servers", with none of the FIFA 17 repairs present.
#
# That exact mistake happened on 2026-08-19 (artifact 1c71a17a, hand-built without
# the feature): two failed launches, diagnosed only by comparing embedded strings.
# `build` below passes the feature, but a hand-built DLL can reach `stage`/`deploy`
# via OPENFUT_FIFA17_HOOK_DLL, so assert the profile on the bytes themselves.
verify_fifa17_profile() {
local dll=$1 marker
# Markers that MUST be present: the FIFA 17 target module and its repairs.
for marker in 'CardsDLL_Win64_retail.dll' 'SBC_DISPATCH'; do
grep -qaF -- "$marker" "$dll" ||
die "$dll is not a --features fifa17 build (missing $marker); refusing to stage/deploy"
done
# Markers that MUST be absent: the FIFA-23-only transport hooking.
for marker in 'getaddrinfo IAT patched' 'connect: inline-hooked' 'origin_spy'; do
if grep -qaF -- "$marker" "$dll"; then
die "$dll contains FIFA-23-only hook '$marker'; build with --features fifa17"
fi
done
}
verify_inputs() {
command -v sha256sum >/dev/null || die "sha256sum is required"
command -v x86_64-w64-mingw32-objdump >/dev/null ||
@@ -90,7 +62,6 @@ verify_inputs() {
need_file "$hook_dll"
need_file "$system_version"
verify_pe64 "$hook_dll"
verify_fifa17_profile "$hook_dll"
}
inspect() {
@@ -158,7 +129,6 @@ deploy() {
need_file "$manifest"
verify_pe64 "$staged"
verify_exports "$staged"
verify_fifa17_profile "$staged"
local recorded actual
recorded="$(awk -F= '$1=="artifact_sha256"{print $2}' "$manifest")"
actual="$(sha256 "$staged")"
@@ -188,7 +158,7 @@ launch() {
local trace_enabled=0
local request_trace_enabled=0
local notifier_trace_enabled=0
local dispatch_enabled=0
local commit_enabled=0
case "$mode" in
baseline)
[[ "${OPENFUT_FIFA17_LAUNCH:-}" == "I_ACCEPT_M1_BASELINE_LAUNCH" ]] ||
@@ -207,11 +177,14 @@ launch() {
request_trace_enabled=1
notifier_trace_enabled=1
;;
dispatch)
[[ "${OPENFUT_FIFA17_DISPATCH:-}" == "I_ACCEPT_GUARDED_NATIVE_DISPATCH" ]] ||
die "launch-dispatch requires OPENFUT_FIFA17_DISPATCH=I_ACCEPT_GUARDED_NATIVE_DISPATCH"
commit)
[[ "${OPENFUT_FIFA17_COMMIT:-}" == "I_ACCEPT_POST_PARSE_READY_BYTE" ]] ||
die "launch-commit requires OPENFUT_FIFA17_COMMIT=I_ACCEPT_POST_PARSE_READY_BYTE"
hook_enabled=1
trace_enabled=1
request_trace_enabled=1
dispatch_enabled=1
notifier_trace_enabled=1
commit_enabled=1
;;
*) die "unknown launch mode: $mode" ;;
esac
@@ -234,7 +207,7 @@ launch() {
done
mkdir -p "${wine_prefix}/dosdevices"
ln -sfn /mnt "${wine_prefix}/dosdevices/w:"
note "Launching $mode mode (SBC_HOOK=$hook_enabled; SBC_TRACE=$trace_enabled; SBC_REQUEST_TRACE=$request_trace_enabled; SBC_NOTIFIER_TRACE=$notifier_trace_enabled; SBC_DISPATCH=$dispatch_enabled); log=/tmp/fifa17-hook-m1-launch.log"
note "Launching $mode mode (SBC_HOOK=$hook_enabled; SBC_TRACE=$trace_enabled; SBC_REQUEST_TRACE=$request_trace_enabled; SBC_NOTIFIER_TRACE=$notifier_trace_enabled; SBC_COMMIT=$commit_enabled); log=/tmp/fifa17-hook-m1-launch.log"
cd "$game_dir"
env \
GAMEID=fifa17 \
@@ -245,8 +218,8 @@ launch() {
OPENFUT_SBC_TRACE="$trace_enabled" \
OPENFUT_SBC_REQUEST_TRACE="$request_trace_enabled" \
OPENFUT_SBC_NOTIFIER_TRACE="$notifier_trace_enabled" \
OPENFUT_SBC_DISPATCH="$dispatch_enabled" \
OPENFUT_SBC_DISPATCH_TRACE=0 \
OPENFUT_SBC_DISPATCH=0 \
OPENFUT_SBC_COMMIT="$commit_enabled" \
OPENFUT_SBC_ARM_ONLY=0 \
OPENFUT_SBC_POPULATE=0 \
umu-run _fifa17.exe 2>&1 | tee /tmp/fifa17-hook-m1-launch.log
@@ -254,7 +227,7 @@ launch() {
usage() {
cat <<'EOF'
Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launch-trace|launch-dispatch]
Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launch-trace|launch-commit]
inspect Read-only PE/hash/export preflight (default).
build Cross-build the inert FIFA17 hook, then run inspect.
@@ -267,11 +240,11 @@ Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launc
Start M2 resolve-only mode (guarded reads/logging, no detours/writes); requires:
OPENFUT_FIFA17_RESOLVE=I_ACCEPT_M2_RESOLVE_LAUNCH
launch-trace
Start the M3-M6 passive parser/request/notifier trace; requires:
Start the single M3 passive factory/deserializer trace; requires:
OPENFUT_FIFA17_TRACE=I_ACCEPT_M3_PASSIVE_TRACE
launch-dispatch
Trace and repair only a fully validated native status-999 completion; requires:
OPENFUT_FIFA17_DISPATCH=I_ACCEPT_GUARDED_NATIVE_DISPATCH
launch-commit
Trace and arm the SBC cache only after a validated native parse; requires:
OPENFUT_FIFA17_COMMIT=I_ACCEPT_POST_PARSE_READY_BYTE
Optional path overrides:
OPENFUT_FIFA17_HOOK_DLL, OPENFUT_FIFA17_GAME_DIR,
@@ -287,7 +260,7 @@ case "${1:-inspect}" in
launch) launch baseline ;;
launch-resolve) launch resolve ;;
launch-trace) launch trace ;;
launch-dispatch) launch dispatch ;;
launch-commit) launch commit ;;
-h|--help|help) usage ;;
*) usage >&2; die "unknown command: $1" ;;
esac
-14
View File
@@ -162,19 +162,6 @@ def log(*a):
print("[lsx]", *a, flush=True)
def spawn_parent_watchdog():
parent = os.getppid()
def _watch():
while True:
time.sleep(1)
if os.getppid() != parent:
log(f"launcher pid {parent} exited; stopping lsx")
os._exit(0)
threading.Thread(target=_watch, daemon=True).start()
_SECRET_ATTR_RE = re.compile(
r'(?i)\b(AuthCode|AuthToken|SessionKey|Token|Sid)="[^"]*"')
_AUTH_CODE_ATTR_RE = re.compile(r'(?i)\b(value|Code|Return)="[^"]*"')
@@ -585,7 +572,6 @@ def serve(sock, addr):
def main():
spawn_parent_watchdog()
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((os.environ.get("OPENFUT_BIND", "127.0.0.1"), 4216))
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Pure unit test for the empty-My-Packs store resolver guard in autopatch.py.
Covers the fail-closed guard decision (original -> PATCH, already-patched -> NOOP,
unknown -> SKIP) and pins the guarded patch table to the exact RVA/bytes proven on
the tested FIFA 17 build (JNZ 0x14869 -> JG 0x14869 at CardsDLL RVA 0x14858).
Run: python3 test_autopatch_guard.py
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import autopatch # importable: runtime loop is guarded by `if __name__ == "__main__"`
GUARD_VA = 0x180014858
ORIG = bytes.fromhex("750f") # JNZ 0x14869
PATCH = bytes.fromhex("7f0f") # JG 0x14869
def test_table_exact():
assert autopatch.STORE_PATCHES_GUARDED == {GUARD_VA: (ORIG, PATCH)}, \
autopatch.STORE_PATCHES_GUARDED
# Byte-level pin so a bad hex literal cannot slip through.
assert ORIG == b"\x75\x0f" and PATCH == b"\x7f\x0f"
def test_decision():
assert autopatch.guarded_action(ORIG, ORIG, PATCH) == "patch" # apply
assert autopatch.guarded_action(PATCH, ORIG, PATCH) == "noop" # already patched
assert autopatch.guarded_action(b"\x00\x00", ORIG, PATCH) == "skip" # build mismatch
assert autopatch.guarded_action(b"\x90", ORIG, PATCH) == "skip" # wrong length
def test_guard_state_after():
# already patched (7f0f) -> VERIFIED (guarded_action "noop"); write args irrelevant.
assert autopatch.guard_state_after(PATCH, ORIG, PATCH, True, PATCH) == autopatch.GUARD_VERIFIED
# original (750f) + write ok + reread 7f0f -> VERIFIED (guarded_action "patch").
assert autopatch.guard_state_after(ORIG, ORIG, PATCH, True, PATCH) == autopatch.GUARD_VERIFIED
# original + write FAILS -> WRITE_FAILED.
assert autopatch.guard_state_after(ORIG, ORIG, PATCH, False, ORIG) == autopatch.GUARD_WRITE_FAILED
# original + write ok but reread != 7f0f -> VERIFY_FAILED.
assert autopatch.guard_state_after(ORIG, ORIG, PATCH, True, ORIG) == autopatch.GUARD_VERIFY_FAILED
assert autopatch.guard_state_after(ORIG, ORIG, PATCH, True, b"") == autopatch.GUARD_VERIFY_FAILED
# unknown bytes -> UNSUPPORTED_BUILD (guarded_action "skip"); write args irrelevant.
assert autopatch.guard_state_after(b"\x00\x00", ORIG, PATCH, True, PATCH) == autopatch.GUARD_UNSUPPORTED_BUILD
def test_capability_constants():
assert autopatch.EMPTY_MYPACKS_RESOLVER_VERSION == 1
assert autopatch.EMPTY_MYPACKS_RESOLVER_CAPABILITY == "fifa17.empty_mypacks_resolver"
# State constant values are the exact tokens carried in the emitted status line.
assert autopatch.GUARD_VERIFIED == "VERIFIED"
assert autopatch.GUARD_UNSUPPORTED_BUILD == "UNSUPPORTED_BUILD"
assert autopatch.GUARD_WRITE_FAILED == "WRITE_FAILED"
assert autopatch.GUARD_VERIFY_FAILED == "VERIFY_FAILED"
assert autopatch.GUARD_NOT_ATTEMPTED == "NOT_ATTEMPTED"
if __name__ == "__main__":
test_table_exact()
test_decision()
test_guard_state_after()
test_capability_constants()
print("OK: autopatch guard table + fail-closed decision + guard-state function + capability constants")
+301
View File
@@ -0,0 +1,301 @@
#!/usr/bin/env python3
"""Tests for the FIFA 17 verified-patched-client capability negotiation.
The additive empty-My-Packs switch on top of the P2 65534 sentinel: the sentinel is
suppressed for ONE FIFA session only when the launcher has registered a verified
resolver capability (v1) that binds to THAT process's UTAS session (keyed by the
per-login-unique X-UT-SID; source IP + persona are auxiliary). Every failure /
unknown / late / cross-process / cross-session case is fail-closed to the sentinel.
The initial prototype keyed by source IP alone; this suite proves the hardened
per-session binding, including two sessions that SHARE a source IP.
Matrix (docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md):
A no-capability, zero packs -> sentinel
B verified v1, zero packs -> clean (no 65534)
C real unopened pack + no capability -> genuine pack, no sentinel
D real unopened pack + capability -> genuine pack, no sentinel
E unsupported version / capability -> endpoint 400 AND mode sentinel
F late capability after sentinel freeze -> stays sentinel
G capability disappears after clean freeze -> stays clean (immutable)
H two IPs (A verified, B none) -> A clean, B sentinel (no global leak)
I new session after reset -> fresh unpatched -> sentinel
J autopatch mismatch => never registers -> sentinel
K SAME IP, two sessions (A patched, B not) -> A clean, B sentinel
L SAME IP+persona relaunch (old ok, new not) -> new session sentinel
M SAME IP, failed-patch second session -> first clean, second sentinel
N late registration when sessions are frozen -> does not modify active sessions
O session cleanup / TTL expiry -> capability gone, sentinel
P duplicate registration for a session -> idempotent; no post-freeze change
Q register-before-login (pending consumed) -> clean
R topology freeze immutable per SID -> no flip either way; new SID fresh
Standalone unit test in the project style: `python3 test_capability_negotiation.py`.
"""
import importlib
import json
import os
import sys
import tempfile
TOOLS = os.path.dirname(os.path.abspath(__file__))
if TOOLS not in sys.path:
sys.path.insert(0, TOOLS)
SENTINEL_ID = 65534
REAL_PACK_ID = 1
PERSONA = 111001
class _H:
"""Minimal request-handler stand-in: peer IP, optional X-UT-SID, optional body."""
def __init__(self, ip, body=None, sid=None):
self.client_address = (ip, 54321)
self.headers = {"X-UT-SID": sid} if sid is not None else {}
self._body = json.dumps(body).encode("utf-8") if body is not None else b""
def _ids(catalog):
return [p["id"] for p in catalog["purchase"]]
def main():
with tempfile.TemporaryDirectory() as state:
os.environ["FUT_ACCOUNT_PATH"] = os.path.join(state, "active_account.json")
os.environ["FUT_PROFILE_ROOT"] = os.path.join(state, "accounts")
os.environ.pop("FUT_PROFILE", None)
import fut_account
import fut_store
import fut_accounts
import utas_server
importlib.reload(fut_account)
importlib.reload(fut_store)
importlib.reload(fut_accounts)
importlib.reload(utas_server)
us = utas_server
CLEAN, SENT = us.FIFA17_MODE_CLEAN, us.FIFA17_MODE_SENTINEL
_orig_visible = us.visible_unopened_packs
def set_zero_packs():
us.visible_unopened_packs = lambda: []
def set_real_pack():
us.visible_unopened_packs = lambda: [REAL_PACK_ID]
def reset_state():
us._FIFA17_SESSIONS.clear()
us._FIFA17_PENDING.clear()
def auth(sid, ip, persona=PERSONA):
"""Simulate /ut/auth opening a per-login session with a chosen sid."""
us.fifa17_open_session(sid, ip, persona)
def register(ip, version, persona=PERSONA, pid=4242):
return us.fifa17_capability_route(_H(ip, {
"capability": "empty_mypacks_resolver", "version": version,
"personaId": persona, "fifaPid": pid,
}))
def store(sid, ip):
status, cat = us.store_catalog(_H(ip, sid=sid))
assert status == 200, status
return _ids(cat)
def mode_of(sid):
return us._FIFA17_SESSIONS[sid]["mode"]
try:
# ---- A. no capability, zero packs -> sentinel ----------------------
reset_state(); set_zero_packs()
auth("sidA", "10.0.0.1")
assert SENTINEL_ID in store("sidA", "10.0.0.1")
assert mode_of("sidA") == SENT
print("A no-capability zero-packs -> sentinel: OK")
# ---- B. verified v1, zero packs -> clean ---------------------------
reset_state(); set_zero_packs()
auth("sidB", "10.0.0.2")
assert register("10.0.0.2", 1)[0] == 200
ids = store("sidB", "10.0.0.2")
assert SENTINEL_ID not in ids, ids
assert mode_of("sidB") == CLEAN
print("B verified-v1 zero-packs -> clean: OK")
# ---- C. real pack + no capability -> genuine, no sentinel ----------
reset_state(); set_real_pack()
auth("sidC", "10.0.0.3")
ids = store("sidC", "10.0.0.3")
assert REAL_PACK_ID in ids and SENTINEL_ID not in ids, ids
print("C real-pack no-capability -> genuine, no sentinel: OK")
# ---- D. real pack + capability -> genuine, no sentinel -------------
reset_state(); set_real_pack()
auth("sidD", "10.0.0.4"); register("10.0.0.4", 1)
ids = store("sidD", "10.0.0.4")
assert REAL_PACK_ID in ids and SENTINEL_ID not in ids, ids
print("D real-pack capability -> genuine, no sentinel: OK")
# ---- E. unsupported version / capability -> 400 + sentinel ---------
reset_state(); set_zero_packs()
auth("sidE", "10.0.0.5")
assert register("10.0.0.5", 2)[0] == 400
assert register("10.0.0.5", 99)[0] == 400
assert us.fifa17_capability_route(
_H("10.0.0.5", {"capability": "bogus", "version": 1}))[0] == 400
assert SENTINEL_ID in store("sidE", "10.0.0.5")
assert mode_of("sidE") == SENT
print("E unsupported version/capability -> 400 + sentinel: OK")
# ---- F. late capability after sentinel freeze -> sentinel ----------
reset_state(); set_zero_packs()
auth("sidF", "10.0.0.6")
assert SENTINEL_ID in store("sidF", "10.0.0.6") # freezes sentinel
assert register("10.0.0.6", 1)[0] == 200 # session frozen -> ignored-late
assert SENTINEL_ID in store("sidF", "10.0.0.6")
assert mode_of("sidF") == SENT
print("F late capability after sentinel freeze -> sentinel: OK")
# ---- G. capability disappears after clean freeze -> clean ----------
reset_state(); set_zero_packs()
auth("sidG", "10.0.0.7"); register("10.0.0.7", 1)
assert SENTINEL_ID not in store("sidG", "10.0.0.7") # freezes clean
us._FIFA17_SESSIONS["sidG"]["resolver"] = None # capability vanishes
assert SENTINEL_ID not in store("sidG", "10.0.0.7")
assert mode_of("sidG") == CLEAN
print("G capability disappears after clean freeze -> clean: OK")
# ---- H. two IPs (A verified, B none) -> no global leak -------------
reset_state(); set_zero_packs()
auth("sidH1", "10.0.1.1"); register("10.0.1.1", 1)
auth("sidH2", "10.0.1.2")
assert SENTINEL_ID not in store("sidH1", "10.0.1.1")
assert SENTINEL_ID in store("sidH2", "10.0.1.2")
print("H two IPs (A clean, B sentinel) -> no global leak: OK")
# ---- I. new session after reset -> fresh unpatched -> sentinel -----
reset_state(); set_zero_packs()
auth("sidI1", "10.0.1.3"); register("10.0.1.3", 1)
assert SENTINEL_ID not in store("sidI1", "10.0.1.3") # A clean
us.fifa17_clear_pending("10.0.1.3") # relaunch boundary
auth("sidI2", "10.0.1.3") # new SID, autopatch failed
assert SENTINEL_ID in store("sidI2", "10.0.1.3")
print("I new session after reset -> sentinel (no cross-process leak): OK")
# ---- J. autopatch mismatch => never registers -> sentinel ----------
reset_state(); set_zero_packs()
auth("sidJ", "10.0.1.4")
assert SENTINEL_ID in store("sidJ", "10.0.1.4")
print("J autopatch mismatch (never registers) -> sentinel: OK")
# ---- K. SAME IP, two sessions: patched A clean, unpatched B sent ---
reset_state(); set_zero_packs()
IP = "10.0.2.1"
auth("sidK_A", IP)
assert register(IP, 1)[0] == 200 # A sole candidate -> bound
auth("sidK_B", IP) # B joins, never registers
assert SENTINEL_ID not in store("sidK_A", IP)
assert SENTINEL_ID in store("sidK_B", IP)
print("K same-IP two sessions -> A clean, B sentinel: OK")
# ---- L. SAME IP+persona relaunch: old ok, new not -> new sentinel --
reset_state(); set_zero_packs()
IP = "10.0.2.2"
auth("sidL_old", IP, PERSONA); register(IP, 1, PERSONA)
assert SENTINEL_ID not in store("sidL_old", IP)
us.fifa17_clear_pending(IP)
auth("sidL_new", IP, PERSONA) # same persona, unverified
assert SENTINEL_ID in store("sidL_new", IP)
print("L same-IP+persona relaunch -> new session sentinel: OK")
# ---- M. SAME IP, failed-patch second session -----------------------
reset_state(); set_zero_packs()
IP = "10.0.2.3"
auth("sidM1", IP); register(IP, 1)
assert SENTINEL_ID not in store("sidM1", IP)
auth("sidM2", IP) # autopatch failed
assert SENTINEL_ID in store("sidM2", IP)
print("M same-IP failed-patch second session -> sentinel: OK")
# ---- N. late reg when sessions frozen -> no active session change --
reset_state(); set_zero_packs()
IP = "10.0.2.4"
auth("sidN1", IP); register(IP, 1)
assert SENTINEL_ID not in store("sidN1", IP) # N1 frozen clean
auth("sidN2", IP)
assert SENTINEL_ID in store("sidN2", IP) # N2 frozen sentinel
assert register(IP, 1)[0] == 200 # late: both frozen -> ignored
assert SENTINEL_ID not in store("sidN1", IP) # unchanged
assert SENTINEL_ID in store("sidN2", IP) # unchanged
print("N late registration does not modify active sessions: OK")
# ---- O. session cleanup / TTL expiry -> capability gone ------------
reset_state(); set_zero_packs()
IP = "10.0.2.5"
auth("sidO", IP); register(IP, 1)
assert SENTINEL_ID not in store("sidO", IP) # clean while live
us._FIFA17_SESSIONS["sidO"]["last_seen"] = (
us._fifa17_now() - us.FIFA17_SESSION_TTL - 10.0)
store("sidUNKNOWN", IP) # any op triggers reap
assert "sidO" not in us._FIFA17_SESSIONS, "expired session not reaped"
assert SENTINEL_ID in store("sidO", IP) # gone -> sentinel
print("O session cleanup / TTL expiry -> sentinel: OK")
# ---- P. duplicate registration -> idempotent, no post-freeze change
reset_state(); set_zero_packs()
IP = "10.0.2.6"
auth("sidP", IP)
assert register(IP, 1)[0] == 200 # bound
assert register(IP, 1)[0] == 200 # duplicate -> ignored-late
assert SENTINEL_ID not in store("sidP", IP) # still clean
assert register(IP, 1)[0] == 200 # after freeze
assert SENTINEL_ID not in store("sidP", IP) # unchanged
assert mode_of("sidP") == CLEAN
print("P duplicate registration -> idempotent: OK")
# ---- Q. register-before-login: pending consumed at auth -> clean ---
reset_state(); set_zero_packs()
IP = "10.0.2.7"
assert register(IP, 1)[0] == 200 # no session yet -> pending
assert (IP, PERSONA) in us._FIFA17_PENDING
auth("sidQ", IP, PERSONA) # consumes pending
assert (IP, PERSONA) not in us._FIFA17_PENDING # single-use
assert SENTINEL_ID not in store("sidQ", IP)
assert mode_of("sidQ") == CLEAN
print("Q register-before-login pending consumed -> clean: OK")
# ---- R. topology freeze immutable per SID; new SID decides fresh ----
# F3 invariant: once a SID's store topology is decided it NEVER flips,
# in either direction, and a different SID may decide differently.
reset_state(); set_zero_packs()
IP = "10.0.2.8"
# frozen Sentinel never becomes Clean, even if a capability appears later
auth("sidR_s", IP)
assert SENTINEL_ID in store("sidR_s", IP) # freeze Sentinel
register(IP, 1)
us._FIFA17_SESSIONS["sidR_s"]["resolver"] = 1 # force-present capability
assert SENTINEL_ID in store("sidR_s", IP) # STILL Sentinel
assert mode_of("sidR_s") == SENT
# frozen Clean never becomes Sentinel, even if the capability is wiped
auth("sidR_c", IP); register(IP, 1)
assert SENTINEL_ID not in store("sidR_c", IP) # freeze Clean
us._FIFA17_SESSIONS["sidR_c"]["resolver"] = None # capability vanishes
assert SENTINEL_ID not in store("sidR_c", IP) # STILL Clean
assert mode_of("sidR_c") == CLEAN
# a fresh SID (same IP) decides independently
auth("sidR_new", IP)
assert SENTINEL_ID in store("sidR_new", IP)
print("R topology freeze immutable per SID; new SID fresh: OK")
finally:
us.visible_unopened_packs = _orig_visible
print("capability negotiation matrix A-R: OK")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""Regression tests for the empty-My-Packs FIFA 17 compatibility workaround (bug 6c).
Pins the behavior store_catalog() now depends on:
- unopenedPackIds == [] -> exactly one synthetic active `mypacks` placeholder id 65534
- unopenedPackIds == [70] -> no synthetic placeholder; the genuine owned pack is shown
- synthetic id 65534 stays economy-safe (non-resolvable, non-openable, non-granting)
- normal store packs (1/5/6/7) are untouched by the empty-state behavior
See docs/evidence/STORE_TILE_6C.md and FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md.
Standalone unit test in the project style: `python3 test_empty_mypacks.py`.
"""
import importlib
import os
import sys
import tempfile
TOOLS = os.path.dirname(os.path.abspath(__file__))
if TOOLS not in sys.path:
sys.path.insert(0, TOOLS)
SENTINEL_ID = 65534
def _set_unopened(fut_store, ids):
"""Deterministically set the active profile's owned unopened packs."""
p = fut_store.STORE.load()
p["unopenedPackIds"] = list(ids)
fut_store.STORE._save()
def _mypacks(catalog):
return [p for p in catalog["purchase"]
if (p.get("displayGroup") or {}).get("value") == "mypacks"]
def _fake_request(command, body):
class _H:
pass
h = _H()
h.command = command
h._body = body
return h
def main():
with tempfile.TemporaryDirectory() as state:
os.environ["FUT_ACCOUNT_PATH"] = os.path.join(state, "active_account.json")
os.environ["FUT_PROFILE_ROOT"] = os.path.join(state, "accounts")
os.environ.pop("FUT_PROFILE", None)
import fut_account
import fut_store
import fut_accounts
import utas_server
importlib.reload(fut_account)
importlib.reload(fut_store)
importlib.reload(fut_accounts)
importlib.reload(utas_server)
fut_accounts.activate({"personaId": 111001, "personaName": "TEST_A"})
catalog_ids = [p["id"] for p in fut_store.PACK_CATALOG]
# ---- A. Empty unopened packs -> one active synthetic 65534 placeholder ----
_set_unopened(fut_store, [])
utas_server._OPENED_PACK_GRACE.clear()
status, cat = utas_server.store_catalog(None)
assert status == 200
myp = _mypacks(cat)
assert len(myp) == 1, "expected exactly one mypacks entry, got %r" % myp
s = myp[0]
assert s["id"] == SENTINEL_ID, s
assert s["state"] == "active", s # the P2 fix: active, not inactive
assert (s.get("displayGroup") or {}).get("value") == "mypacks", s
assert SENTINEL_ID not in catalog_ids, "65534 must not be in PACK_CATALOG"
assert fut_store.pack_by_id(SENTINEL_ID) is None
print("A empty-state active placeholder: PASS")
# ---- D (empty half). Normal packs untouched in empty state ----
norm = {p["id"]: p for p in cat["purchase"] if p["id"] in (1, 5, 6, 7)}
assert set(norm) == {1, 5, 6, 7}, sorted(norm)
assert all(norm[i]["state"] == "active" for i in norm), norm
assert norm[1]["packType"] == "BRONZE" and norm[1]["description"] == "Bronze Pack"
# ---- B. Non-empty unopened packs -> NO synthetic; genuine owned pack shown ----
_set_unopened(fut_store, [70])
utas_server._OPENED_PACK_GRACE.clear()
status, cat = utas_server.store_catalog(None)
assert status == 200
ids = [p["id"] for p in cat["purchase"]]
assert SENTINEL_ID not in ids, "synthetic placeholder must be suppressed when a pack exists"
myp = _mypacks(cat)
assert len(myp) == 1 and myp[0]["id"] == 70, myp
assert myp[0]["state"] == "active" and myp[0]["unopened"] is True, myp[0]
# normal packs still intact alongside the owned pack
assert {1, 5, 6, 7}.issubset(set(ids)), sorted(ids)
print("B non-empty-state genuine pack: PASS")
# ---- C. Economy safety of the synthetic placeholder ----
_set_unopened(fut_store, [])
utas_server._OPENED_PACK_GRACE.clear()
coins0 = fut_store.STORE.coins()
items0 = len(fut_store.STORE.items())
next0 = fut_store.STORE.load()["nextItemId"]
assert fut_store.pack_by_id(SENTINEL_ID) is None
# store_buy: a confirmed-buy transaction for 65534 must be a no-op {}
status, body = utas_server.store_buy(
_fake_request("PUT", b'{"packId":65534,"state":"TRANSACTIONCREATED"}'))
assert status == 200 and body == {}, (status, body)
# purchased_items: POST buy for 65534 must not open/grant anything
status, body = utas_server.purchased_items(
_fake_request("POST", b'{"packId":65534,"useCredits":1,"usePreOrder":0,"currency":"COINS"}'))
assert status == 200, (status, body)
assert "createPackResponse" not in body, body
# 65534 cannot enter the owned-pack pile (not a catalog pack)
assert fut_store.STORE.grant_unopened_pack(SENTINEL_ID) is False
assert SENTINEL_ID not in fut_store.STORE.unopened_packs()
# nothing mutated
assert fut_store.STORE.coins() == coins0, (fut_store.STORE.coins(), coins0)
assert len(fut_store.STORE.items()) == items0
assert fut_store.STORE.load()["nextItemId"] == next0
assert not any(i.get("id") == SENTINEL_ID or i.get("resourceId") == SENTINEL_ID
for i in fut_store.STORE.items())
print("C economy safety (65534 non-openable / non-granting): PASS")
print("empty My Packs compatibility: PASS")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+257 -21
View File
@@ -11,7 +11,7 @@ Rules (from CardsDLL 0x18016D230 / 0x1801a33a0):
* body must parse as JSON (else err 0x3E6); 204 + empty body is accepted.
* [resp+0x1c] == 0 is the success test; 404 is OK only on the first user GET.
"""
import copy, datetime, json, os, random, re, sys, http.server
import copy, datetime, json, os, random, re, sys, threading, time, http.server
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@@ -52,6 +52,173 @@ def visible_unopened_packs():
return STORE.unopened_packs() + list(_OPENED_PACK_GRACE)
# ---- FIFA17 empty-My-Packs capability negotiation (PER-SESSION, hardened) ----
# The synthetic 65534 sentinel (store_catalog) is the universal P2 fallback. It is
# suppressed for ONE FIFA session only when the launcher has registered that THAT
# process positively verified the CardsDLL resolver guard (RVA 0x14858 == JG).
#
# BINDING: the authoritative key is the per-login-unique UTAS session id (X-UT-SID),
# minted fresh at every /ut/auth and echoed by the client on every later call incl.
# /store/purchasegroup (live-confirmed present on real store requests). The initial
# prototype keyed on source IP ALONE; that was rejected because two FIFA processes
# (concurrent or relaunched) share an IP, so an unverified process could inherit a
# verified one's clean topology and crash. IP + persona are retained only as
# auxiliary data: a fail-closed sid/ip sanity check and the (ip,persona) key for the
# short-lived launcher->session hand-off.
#
# The launcher verifies out-of-band (autopatch) and cannot know the SID, so its
# registration is staged as a SINGLE-USE, short-TTL PENDING keyed by (ip,persona)
# and bound to exactly one FIFA session (directly if that session already exists,
# else consumed at the session's login or its first store request). Fail-closed
# everywhere: unknown / expired / absent / ambiguous / late => sentinel.
# See docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md (§Session binding).
FIFA17_EMPTY_MYPACKS_RESOLVER_VERSION = 1
FIFA17_MODE_SENTINEL = "sentinel"
FIFA17_MODE_CLEAN = "clean-v1"
FIFA17_SESSION_TTL = 3600.0 # reap a FIFA session after this many idle seconds
FIFA17_PENDING_TTL = 120.0 # a launcher capability may await its session this long
# sid -> {"ip","persona","resolver": Optional[int],"mode": Optional[str],"created","last_seen"}
_FIFA17_SESSIONS = {}
# (ip, persona) -> {"resolver": int, "ts"}: single-use launcher->session hand-off.
_FIFA17_PENDING = {}
_FIFA17_LOCK = threading.Lock()
def _fifa17_now():
return time.monotonic()
def _fifa17_client_ip(h):
"""Peer IP for the handler, or None when unavailable (e.g. h is None)."""
try:
return h.client_address[0]
except Exception:
return None
def _fifa17_sid(h):
"""The client's UTAS session id (X-UT-SID) for this request, or None."""
try:
return h.headers.get("X-UT-SID")
except Exception:
return None
def _fifa17_sidlog(sid):
"""A short, non-secret tag for correlating a session in logs."""
return ("\u2026" + sid[-6:]) if sid else "-"
def _fifa17_mint_sid():
"""A fresh, per-login-unique UTAS session id (same shape/length as the legacy
constant). Uniqueness -- not unpredictability -- is what the binding needs."""
return "OPENFUT-SID-%016X" % random.getrandbits(64)
def _fifa17_reap_locked(now):
for sid in [s for s, r in _FIFA17_SESSIONS.items()
if now - r["last_seen"] > FIFA17_SESSION_TTL]:
del _FIFA17_SESSIONS[sid]
for key in [k for k, p in _FIFA17_PENDING.items()
if now - p["ts"] > FIFA17_PENDING_TTL]:
del _FIFA17_PENDING[key]
def _fifa17_take_pending_locked(ip, persona, now):
"""Single-use: remove and return a fresh pending resolver for (ip,persona)."""
p = _FIFA17_PENDING.get((ip, persona))
if p is not None and now - p["ts"] <= FIFA17_PENDING_TTL:
del _FIFA17_PENDING[(ip, persona)]
return p["resolver"]
return None
def fifa17_session_known(sid):
"""True if sid is a live session (or the legacy constant, accepted by the
retired security-question gate ONLY -- never used to grant clean store mode)."""
if sid == SID:
return True
with _FIFA17_LOCK:
return sid in _FIFA17_SESSIONS
def fifa17_open_session(sid, ip, persona):
"""/ut/auth: open a per-login session and bind any pending launcher capability
for (ip,persona) that arrived before login."""
if not sid:
return
now = _fifa17_now()
with _FIFA17_LOCK:
_fifa17_reap_locked(now)
resolver = _fifa17_take_pending_locked(ip, persona, now)
_FIFA17_SESSIONS[sid] = {"ip": ip, "persona": persona, "resolver": resolver,
"mode": None, "created": now, "last_seen": now}
log("[fifa17-store] session opened %s (ip=%s persona=%s resolver=%s)"
% (_fifa17_sidlog(sid), ip, persona, resolver))
def fifa17_clear_pending(ip):
"""/openfut/account/sync hygiene: drop any stale pending for this machine so a
new launch's unverified session cannot inherit a leftover capability."""
now = _fifa17_now()
with _FIFA17_LOCK:
_fifa17_reap_locked(now)
for key in [k for k in _FIFA17_PENDING if k[0] == ip]:
del _FIFA17_PENDING[key]
def fifa17_register_capability(ip, persona, version):
"""Launcher registration. Returns one of:
"bound" exactly one live, unfrozen, unbound session for (ip,persona)
existed (registration after login -- the common case): bound now.
"pending" no session for (ip,persona) yet (before login): staged single-use.
"ignored-late" a session for (ip,persona) exists but is frozen or ambiguous
(>1 unbound): NOT staged, so no later/unverified process can
inherit it. Fail-closed.
Never authorizes more than one session."""
now = _fifa17_now()
with _FIFA17_LOCK:
_fifa17_reap_locked(now)
sessions = [r for r in _FIFA17_SESSIONS.values()
if r["ip"] == ip and r["persona"] == persona]
candidates = [r for r in sessions if r["mode"] is None and r["resolver"] is None]
if len(candidates) == 1:
candidates[0]["resolver"] = version
return "bound"
if sessions:
return "ignored-late"
_FIFA17_PENDING[(ip, persona)] = {"resolver": version, "ts": now}
return "pending"
def fifa17_empty_mypacks_mode(sid, ip):
"""Freeze (once) and return the empty-My-Packs mode for FIFA session `sid`.
Freeze point = the first /store/purchasegroup of the session. Fail-closed: an
unknown session, or a sid presented from a different IP than it was opened on,
resolves to the sentinel."""
now = _fifa17_now()
with _FIFA17_LOCK:
_fifa17_reap_locked(now)
rec = _FIFA17_SESSIONS.get(sid)
if rec is None:
return FIFA17_MODE_SENTINEL
rec["last_seen"] = now
if rec["ip"] is not None and ip is not None and rec["ip"] != ip:
log("[fifa17-store] sid %s ip mismatch (session %s != request %s) -> sentinel"
% (_fifa17_sidlog(sid), rec["ip"], ip))
return FIFA17_MODE_SENTINEL
if rec["mode"] is None:
if rec["resolver"] is None:
rec["resolver"] = _fifa17_take_pending_locked(rec["ip"], rec["persona"], now)
rec["mode"] = (FIFA17_MODE_CLEAN
if rec["resolver"] == FIFA17_EMPTY_MYPACKS_RESOLVER_VERSION
else FIFA17_MODE_SENTINEL)
log("[fifa17-store] session %s empty-mypacks mode frozen: %s"
% (_fifa17_sidlog(sid), rec["mode"]))
return rec["mode"]
def now():
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
@@ -102,7 +269,7 @@ def security_question_route(h):
well-formed value without retaining or comparing it. Account selection has
already initialized the server-owned verified compatibility state.
"""
if h.headers.get("X-UT-SID") != SID:
if not fifa17_session_known(h.headers.get("X-UT-SID")):
log("[FUT] security-question request has no matching OpenFUT session")
return 400, {"reason": "invalid_session"}
@@ -193,11 +360,19 @@ def auth_body(h=None):
except Exception as e: # adoption must never break auth
log(" AUTH: adopt failed (%s: %s) -- keeping %s/%r"
% (type(e).__name__, e, before[0], before[1]))
return {"protocol": 1, "sid": SID, "serverTime": now(), "lastOnlineTime": now()}
sid = _fifa17_mint_sid()
fifa17_open_session(sid, _fifa17_client_ip(h), ACCOUNT.persona_id)
return {"protocol": 1, "sid": sid, "serverTime": now(), "lastOnlineTime": now()}
def account_sync_route(h):
"""Launcher-only active-profile selection, before LSX/Blaze login starts."""
# Pre-launch hygiene: drop any stale launcher capability still pending for this
# machine so a new launch's unverified FIFA session cannot inherit it. The real
# per-process session is opened later, at /ut/auth (keyed by the minted X-UT-SID).
ip = _fifa17_client_ip(h)
fifa17_clear_pending(ip)
log(" ACCOUNT: cleared stale FIFA17 pending capability for ip %s" % ip)
try:
body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
account = activate_account(body)
@@ -209,6 +384,33 @@ def account_sync_route(h):
return 200, {"account": account, "status": "OK"}
def fifa17_capability_route(h):
"""POST /openfut/fifa17/capability -- launcher registers a verified resolver
capability for the current FIFA process (bound to the peer IP). Fail-closed:
anything but capability==empty_mypacks_resolver && version==current is a 400
that records NOTHING (the session stays on the sentinel fallback)."""
try:
body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
except Exception:
return 400, {"error": "unsupported capability"}
if not isinstance(body, dict):
return 400, {"error": "unsupported capability"}
try:
version = int(body.get("version"))
except (TypeError, ValueError):
return 400, {"error": "unsupported capability"}
if (body.get("capability") != "empty_mypacks_resolver"
or version != FIFA17_EMPTY_MYPACKS_RESOLVER_VERSION):
return 400, {"error": "unsupported capability"}
ip = _fifa17_client_ip(h)
persona = body.get("personaId")
fifa_pid = body.get("fifaPid", "?")
status = fifa17_register_capability(ip, persona, version)
log("[fifa17-store] capability empty_mypacks_resolver=%s ip=%s persona=%s "
"fifa_pid=%s -> %s" % (version, ip, persona, fifa_pid, status))
return 200, {"status": "OK"}
def current_squad():
"""The squad the client should see: the persisted one (item refs re-embedded
from the club) or the seed ladder squad on first run.
@@ -1203,6 +1405,10 @@ ROUTES = [
# Launcher control-plane endpoint. It is intentionally outside /ut so FIFA
# never calls it; launch is blocked unless this succeeds first.
(re.compile(r"^/openfut/account/sync$"), lambda m, h: account_sync_route(h)),
# Launcher registers a verified per-FIFA-process resolver capability (bound to
# peer IP). Adjacent to account/sync, above the generic /ut routes; FIFA never
# calls it. Fail-closed: absent/late/wrong-version => sentinel (store_catalog).
(re.compile(r"^/openfut/fifa17/capability$"), lambda m, h: fifa17_capability_route(h)),
# ---- FUT item-definition endpoints (must precede generic /item, /user) ----
(re.compile(G + r"/item/resource"), lambda m, h: defs_route(h)),
(re.compile(G + r"/defid"), lambda m, h: defs_route(h)),
@@ -3426,24 +3632,54 @@ def store_catalog(h):
if owned:
packs.append(_pack_body(owned, idx, owned=True))
if not owned_ids:
# GOTO_STORE_MYPACK resolves the hard-coded `mypacks` group before it
# renders rows. If the group is absent FIFA falls back to Bronze and
# shows the empty-category dialog over the wrong tab. Retain an inactive
# zero-item sentinel so the destination resolves, while state != active
# keeps it out of the visible row list. Its id is deliberately absent
# from PACK_CATALOG, so both purchase/open handlers reject it as well.
sentinel = {
"id": 65534,
"name": "",
"price": 0,
"count": 0,
"gold": True,
"specialChance": 0.0,
}
empty = _pack_body(sentinel, 1, owned=True)
empty["state"] = "inactive"
empty["unopened"] = False
packs.append(empty)
# ADDITIVE capability switch (see docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md
# §7/§9). This is the session-freeze point: the empty-mypacks decision for
# this FIFA session (keyed by its X-UT-SID) is committed here at the first
# /store/purchasegroup and is immutable for the session thereafter.
mode = fifa17_empty_mypacks_mode(_fifa17_sid(h), _fifa17_client_ip(h))
if mode == FIFA17_MODE_CLEAN:
# Verified patched client: emit NO mypacks group; the CardsDLL resolver
# guard (RVA 0x14858 JG) routes the -1 ordinal to Browse instead of
# dereferencing a null group. (append nothing)
pass
else:
# EMPTY MY PACKS -- FIFA 17 client-compatibility workaround (bug 6c, P2).
#
# The 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).
# CardsDLL FUN_1800147f0 then dereferences the resolved group with NO null
# guard, so if no `mypacks` group exists the client CRASHES
# (CardsDLL_Win64_retail.dll+0x14882, read of [NULL+0x48] -- confirmed by
# minidump). We therefore MUST emit a `mypacks` group when empty.
#
# state="inactive" avoids the crash but makes the client report the pack
# unavailable immediately on Store entry and bounce to the Hub. state="active"
# keeps the group structurally valid AND lets the Store open normally; the
# empty tile renders as "0 items" and an explicit open is rejected
# CLIENT-SIDE ("This pack is no longer available") -- it sends NO backend
# request and mutates nothing.
#
# id 65534 is deliberately ABSENT from PACK_CATALOG, so pack_by_id() returns
# None and store_buy()/purchased_items() cannot open it, grant items/coins,
# or add it to unopenedPackIds. This is a compatibility shim for FIFA 17
# client behavior, NOT an EA-authentic empty-My-Packs representation, and it
# is FIFA17-specific (do not lift into game-independent Core). A fully clean
# zero-pack UX requires a client-side fix -- see
# docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md and the evidence in
# docs/evidence/STORE_TILE_6C.md / FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md.
sentinel = {
"id": 65534,
"name": "",
"price": 0,
"count": 0,
"gold": True,
"specialChance": 0.0,
}
empty = _pack_body(sentinel, 1, owned=True)
empty["state"] = "active"
empty["unopened"] = False
packs.append(empty)
return 200, {"purchase": packs, "timestamp": 1596326400}
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "openfut-adapter-fifa17"
version = "0.1.0"
edition = "2021"
license = "MIT"
description = "FIFA 17 game adapter: Blaze command tables, response bodies and dispatch"
publish = false
[dependencies]
openfut-protocol-blaze = { path = "../openfut-protocol-blaze" }
# Reads the bundled fetchClientConfig table (227-243 rows per CFID), which is
# generated from the Python oracle rather than transcribed by hand. Unlike the
# protocol crate below it, this crate is ordinary server-side code, so a real
# JSON parser is the right call — hand-rolling one to preserve a zero-dependency
# streak would be reinventing a solved problem in the riskiest possible place.
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Seeded RNG for the Store pack-content generator (`fut::pack_content`). The
# generator is pure over an injected `rand::Rng`, so packs are deterministic
# under a seeded `StdRng` in tests and reproducible in production.
rand = "0.8"
[dev-dependencies]
# Differential fixtures are JSONL; the runtime dependency already covers it.
+115
View File
@@ -0,0 +1,115 @@
# openfut-adapter-fifa17
The FIFA 17 game adapter. Everything true of *FIFA 17 specifically* lives here,
so that neither OpenFUT Core nor the generic protocol crates have to know about
it.
```
openfut-protocol-blaze generic Blaze: Fire2 framing, Heat2/TDF codec
▲
openfut-adapter-fifa17 THIS: command tables, response bodies, dispatch order
▲
OpenFUT Core game-independent FUT domain (not yet wired)
```
## Status
| Surface | Port | State |
|---|---|---|
| **Blaze / Fire2 RPC** | 42130 | **Implemented**, byte-for-byte parity-tested |
| Redirector (HTTPS + XML) | 42127 | Python only |
| Nucleus OAuth stub | 42131 | Python only |
| LSX / Origin | 4216 | Python only |
| Roster XML | 8081 | Python only |
| UTAS / RS4 | 8099 | Python only |
| POW / EASFC | 8094 / 8080 | Python only |
**Nothing here is wired into the running backend.** The crate answers frames; it
opens no socket, terminates no TLS and owns no runtime. The Python backend
remains the live service and the behavioural oracle.
## What the adapter owns, and what it must not
Owns: component/command/notification IDs, response body shapes, dispatch
ordering, session identity, the `fetchClientConfig` tables.
Must not own: FUT domain state. Blaze is an auth/session/config protocol — no
coins, packs, clubs or squads appear on this wire — so `Session` holds a session
key, a locale, a service name, an auth code and a flag, and that is all. When
UTAS is migrated that boundary will need active defending; here it comes free.
## Parity
```bash
./check-parity.sh # oracle freshness + byte-for-byte replay
./check-parity.sh --regen # after an intentional oracle change
```
`fixtures/blaze_transactions.jsonl` holds 49 request→response(s) transactions
produced by calling the real `blaze_responder_v3b.dispatch()`. They replay in
order against a shared session per connection, so ordering-dependent behaviour
is exercised rather than assumed: preAuth captures the locale that later `ALOC`
fields echo, and login sets the auth code `getAuthToken` returns afterwards.
Comparison is byte-for-byte including frame count and order — a missing
post-login notification or a reply where the oracle stays silent fails here.
The suite was **mutation-tested**: swapping two post-login notifications,
flipping one enum deep inside `AccountInfo`, and hardcoding an address in
`utas_base()`/`nucleus_base()` were each verified to turn it red. The third
initially did *not*, because the config templating had made those helpers dead
code; the table now templates on URL-level tokens so they are the single place a
URL shape is defined.
## Three behaviours that are easy to get wrong
* **Login answers with four frames, in order**: reply, then `UserAuthenticated`,
`UserSessionExtendedDataUpdate`, `UserAdded`.
* **An unimplemented RPC still gets an empty reply.** Silence makes the client
wait for a timeout; an empty reply lets every field fall back to a client-side
default and the boot continues.
* **Non-request message types get nothing at all.**
No error replies are emitted. `msgType` 3 exists, but the error-code placement
is UNRESOLVED — three clean-room sources disagree between `header[14:16]`, a
metadata `ERRC`, and a payload `CNTX`/`ERRC` — so emitting one would be a guess
on the wire.
## The client config table
`fixtures/client_config.json` carries 227–243 rows per CFID, generated from the
Python oracle and templated on `{utas_base}`, `{nucleus_base}`,
`{pow_content_url}`, `{advertise}`, `{bind}`, `{pow_host}`. It is
reverse-engineered *data*, not logic, and deriving it mechanically removes a
class of transcription typo no reviewer could catch. The generator does not take
its own templating on trust: it substitutes real addresses back in and diffs
against the oracle for every section before writing the file.
The table must be *complete*, not representative. The client resolves a per-call
key (`FUT_RS4_URL_<CALL>`) before a per-module one, and any unresolved call falls
back to a real, dead EA host — that is what produced "there has been an error
connecting to FIFA 17 Ultimate Team" mid-session when only the boot subset was
served.
## Known defect reproduced deliberately
`nucleusConnect` and `nucleusConnectTrusted` are built from the **bind** address,
not the advertised one. On the live split deployment that means the backend
tells a client on another machine to reach Nucleus at `http://0.0.0.0:42131`,
which it cannot. Verified against the running container, not inferred.
This is reproduced exactly, because it is what the only proven-working
configuration does and changing it would break parity. It also implies the
Nucleus stub is not actually reached in the current remote flow. Fixing it is a
separate change that needs live validation — see the vault.
## Configuration
Nothing is hardcoded. `AdapterConfig` carries `Identity` (persona, ids, email,
namespace, entitlement group, …) and `Endpoints` (advertise, bind, POW hosts,
telemetry/ticker/QoS ports). `Default` gives the project's synthetic offline
identity on loopback; a remote deployment must override `advertise`.
Bind and advertise are deliberately distinct: an advertised URL must carry the
address the *client* can reach, which on a two-machine deployment is not the
address the server binds.
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Differential check: the Rust FIFA 17 Blaze adapter vs the Python responder.
#
# 1. assert the committed fixtures still match what the Python oracle emits
# 2. replay every recorded transaction through the Rust adapter, byte-for-byte
#
# Read-only with respect to the running backend: the oracle is imported as a
# library, no responder is started, no port is bound, no live service is
# touched. Safe to run while the Python backend is serving a live FIFA client.
#
# Use --regen to rewrite the fixtures after an intentional oracle change.
set -euo pipefail
cd "$(dirname "$(readlink -f "$0")")"
if [[ "${1:-}" == "--regen" ]]; then
echo "==> regenerating fixtures from the Python oracle"
python3 fixtures/generate.py
else
echo "==> checking committed fixtures against the Python oracle"
python3 fixtures/generate.py --check
fi
echo "==> replaying transactions through the Rust adapter"
cargo test -p openfut-adapter-fifa17
echo
echo "PARITY OK — the adapter reproduces the Python dispatcher byte-for-byte."
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+460
View File
@@ -0,0 +1,460 @@
#!/usr/bin/env python3
"""Freeze the Python Blaze responder's DISPATCH contract as replayable fixtures.
The crate-level fixtures in `openfut-protocol-blaze` pin the *codec*: given a
field tree, what bytes come out. This file pins the layer above: given an
inbound Fire2 frame and a session, **which frames go back, in what order**.
That is the whole contract of a Blaze adapter, and it is the thing a rewrite can
silently get wrong in ways a codec test cannot see — a missing post-login
notification, a reply where the oracle stays silent, notifications in the wrong
order, session state not carried between RPCs.
Every transaction is produced by calling the real
`blaze_responder_v3b.dispatch()`. Session state is threaded across a scripted
connection exactly as it would be on a live socket, so ordering-dependent
behaviour (preAuth captures the locale; login sets the auth code that
getAuthToken later returns) is captured rather than assumed.
Determinism: the oracle's clock is pinned and its PRNG seeded, and the
deployment-dependent addresses are set before import (the responder reads them
at import time). See the sibling generator in openfut-protocol-blaze.
NO SECRETS. The identity here (persona 33068179 / "CAGE") is the project's fixed
synthetic offline identity. Session keys are minted from a seeded PRNG.
Usage: python3 fixtures/generate.py (write)
python3 fixtures/generate.py --check (verify committed files are current)
"""
from __future__ import annotations
import json
import os
import random
import sys
from collections import OrderedDict
HERE = os.path.dirname(os.path.abspath(__file__))
TOOLS = os.path.normpath(os.path.join(HERE, "..", "..", "fifa17-recon", "tools"))
if not os.path.isdir(TOOLS):
sys.exit("cannot find the Python oracle at %s" % TOOLS)
sys.path.insert(0, TOOLS)
CHECK_ONLY = "--check" in sys.argv[1:]
# Internal mode: re-exec of this script with sentinel addresses, used to derive
# the templated client-config table (see emit_config_table).
CONFIG_TABLE_MODE = "--_config_table" in sys.argv[1:]
sys.argv = [sys.argv[0]]
# Sentinels substituted back into template tokens. Deliberately not IP-shaped so
# a stray literal cannot be mistaken for a real address.
SENTINELS = [
("ADVERTISE-SENTINEL", "{advertise}"),
("BIND-SENTINEL", "{bind}"),
("POWCONTENT-SENTINEL", "{pow_content_host}"),
("POWHOST-SENTINEL", "{pow_host}"),
]
if CONFIG_TABLE_MODE:
os.environ["OPENFUT_ADVERTISE"] = "ADVERTISE-SENTINEL"
os.environ["OPENFUT_BIND"] = "BIND-SENTINEL"
os.environ["POW_CONTENT_HOST"] = "POWCONTENT-SENTINEL"
os.environ["POW_HOST"] = "POWHOST-SENTINEL"
import blaze_responder_v3b as _B # noqa: E402
out = {cfid: _B.client_config_for(cfid) for cfid in sorted(_B.CLIENT_CONFIGS)}
out["__default__"] = _B.client_config_for("__no_such_section__")
print(json.dumps(out))
raise SystemExit(0)
# Pin deployment config BEFORE import — the responder snapshots these at import
# time into module globals used by the response builders.
#
# Distinct, obviously-fake values on purpose: if the Rust adapter hardcoded an
# address instead of reading its config, these make the failure loud rather than
# accidentally matching a loopback default.
ADVERTISE = "198.51.100.7"
BIND = "0.0.0.0"
POW_CONTENT_HOST = "198.51.100.7:8085"
POW_HOST = "198.51.100.7:8094"
os.environ["OPENFUT_ADVERTISE"] = ADVERTISE
os.environ["OPENFUT_BIND"] = BIND
os.environ["POW_CONTENT_HOST"] = POW_CONTENT_HOST
os.environ["POW_HOST"] = POW_HOST
import heat2 # noqa: E402
import blaze_responder_v3b as B # noqa: E402
from fut_account import ACCOUNT # noqa: E402
FIXED_NOW = 1754870400
INT, STRING, STRUCT, LIST, MAP, BLOB = (
heat2.INT, heat2.STRING, heat2.STRUCT, heat2.LIST, heat2.MAP, heat2.BLOB)
RECORDS = []
# ------------------------------------------------------------------ helpers
def req_frame(component, command, fields=None, msg_num=1, msg_type=None,
user_index=0):
"""Build an inbound request frame the way the client would."""
msg_type = B.MESSAGE if msg_type is None else msg_type
payload = heat2.encode_tdf(fields) if fields else b""
return B.fire2(component, command, msg_num, msg_type, payload,
user_index=user_index)
def tx(session, name, frame, note=""):
"""Run one frame through the real dispatcher and record what came back."""
hdr = B.parse_fire2_header(frame)
body = frame[16 + hdr["metadata_len"]:]
fields = heat2.decode_tdf(body) if body else OrderedDict()
out = B.dispatch(hdr, fields, body, session["sess"])
RECORDS.append(OrderedDict((
("kind", "tx"),
("session", session["id"]),
("name", name),
("note", note),
("request_hex", frame.hex()),
("responses", [f.hex() for f in out]),
)))
return out
def new_session(sid):
s = {"id": sid, "sess": B.Session()}
RECORDS.append(OrderedDict((
("kind", "session"),
("id", sid),
# Minted per connection by the oracle; the Rust side must be able to
# inject it, because it appears in LoginResponse.SESS.KEY, the
# UserAuthenticated push and PostAuthResponse.TELE.SESS and all three
# must be the same string.
("session_key", s["sess"].session_key),
("account_locale", s["sess"].account_locale),
("service_name", s["sess"].service_name),
)))
return s
# ------------------------------------------------------------------ script
def build():
RECORDS.append(OrderedDict((
("kind", "config"),
("advertise", ADVERTISE),
("bind", BIND),
("pow_content_host", POW_CONTENT_HOST),
("pow_host", POW_HOST),
("now", FIXED_NOW),
("identity", OrderedDict((
("persona_id", ACCOUNT.persona_id),
("persona_name", ACCOUNT.persona_name),
("user_id", ACCOUNT.user_id),
("ext_id", ACCOUNT.ext_id),
("email", ACCOUNT.email),
("namespace", ACCOUNT.NAMESPACE),
("client_platform", ACCOUNT.CLIENT_PLATFORM),
("persona_status", ACCOUNT.PERSONA_STATUS),
("user_session_type", ACCOUNT.USER_SESSION_TYPE),
("account_locale_int", ACCOUNT.account_locale_int),
("locale", ACCOUNT.locale),
("content_id", ACCOUNT.CONTENT_ID),
("entitlement_tag", ACCOUNT.ENTITLEMENT_TAG),
("entitlement_group", ACCOUNT.ENTITLEMENT_GROUP),
("title_id", ACCOUNT.TITLE_ID),
("client_id", ACCOUNT.CLIENT_ID),
("platform", ACCOUNT.PLATFORM),
("server_version", B.SERVER_VERSION),
))),
)))
# ================= main connection: the real boot order =================
#
# Mirrors what FIFA 17 actually does, because ordering is load-bearing:
# preAuth captures the locale that later ALOC fields echo, and login sets
# the auth code that getAuthToken returns afterwards.
m = new_session("main")
tx(m, "preauth", req_frame(B.COMP_UTIL, B.CMD_PREAUTH, OrderedDict([
("CDAT", (STRUCT, OrderedDict([
("IITO", (INT, 0)),
("LANG", (INT, 0x656E5553)), # 'enUS'
("SVCN", (STRING, "fifa-2017-pc")), # echoed back as INST
("TYPE", (INT, 0)),
]))),
("CINF", (STRUCT, OrderedDict([
("BSDK", (STRING, "15.1.1.3.0")),
("CLNT", (STRING, "FIFA17")),
("ENV", (STRING, "prod")),
("LOC", (INT, 0x656E5553)),
]))),
("FCCR", (STRUCT, OrderedDict([("CFID", (STRING, "BlazeSDK"))]))),
])), "first RPC; echoes SVCN as INST and captures LANG for ALOC")
tx(m, "ping", req_frame(B.COMP_UTIL, B.CMD_PING, msg_num=2),
"Util::ping -> STIM only")
# Every section the responder knows, plus unknown ones. The known sections
# each add their own rows on top of the shared FUT/RS4 base — OSDK_ROSTER in
# particular carries the roster URL, itself a documented loading gate — so
# covering only "BlazeSDK" would leave most of the table unverified.
for cfid in ("BlazeSDK", "netres", "IdentityParams", "OSDK_CORE",
"OSDK_CLIENT", "OSDK_NUCLEUS", "OSDK_ROSTER", "OSDK_TICKER",
"OSDK_WEBOFFER", "OSDK_POW", "OSDK_ABUSE_REPORTING",
"OSDK_XMS_ABUSE_REPORTING", "UTAS", "FUT", "",
"TOTALLY_UNKNOWN"):
tx(m, "fetch_config_%s" % (cfid or "empty"),
req_frame(B.COMP_UTIL, B.CMD_FETCHCLIENTCONFIG,
OrderedDict([("CFID", (STRING, cfid))]), msg_num=3),
"unknown CFIDs still get the shared FUT/POW rows")
tx(m, "get_auth_token_before_login",
req_frame(B.COMP_AUTH, B.CMD_GETAUTHTOKEN, msg_num=4),
"no auth code yet -> synthesised OPENFUT-<key[:16]> token")
tx(m, "logout_before_login", req_frame(B.COMP_AUTH, B.CMD_LOGOUT, msg_num=5),
"routine LoginStateLogout (state 500), NOT a failure; empty reply")
tx(m, "login", req_frame(B.COMP_AUTH, B.CMD_LOGIN, OrderedDict([
("AUTH", (STRING, "OPENFUT-TEST-AUTHCODE")),
("EXTB", (BLOB, b"")),
("PNAM", (STRING, "")),
]), msg_num=6),
"reply THEN three UserSessions pushes, in that order")
tx(m, "get_auth_token_after_login",
req_frame(B.COMP_AUTH, B.CMD_GETAUTHTOKEN, msg_num=7),
"now echoes the login's AUTH verbatim")
tx(m, "get_account", req_frame(B.COMP_AUTH, B.CMD_GETACCOUNT, msg_num=8),
"the RPC behind 'Unable to retrieve account information'")
tx(m, "get_persona", req_frame(B.COMP_AUTH, B.CMD_GETPERSONA, msg_num=9))
tx(m, "list_personas", req_frame(B.COMP_AUTH, B.CMD_LISTPERSONAS, msg_num=10))
for cmd, label in ((B.CMD_LISTUSERENTITLEMENTS2, "listUserEntitlements2"),
(0x20, "listEntitlements"),
(0x30, "listPersonaEntitlements2"),
(0x27, "grantEntitlement2")):
tx(m, "entitlements_%s" % label,
req_frame(B.COMP_AUTH, cmd, msg_num=11),
"all four aliases return the same two ONLINE_ACCESS records")
tx(m, "post_auth", req_frame(B.COMP_UTIL, B.CMD_POSTAUTH, msg_num=12),
"TELE/TICK/UROP; TELE.SESS must equal the login session key")
tx(m, "fetch_qos_config", req_frame(B.COMP_UTIL, 0x15, msg_num=13))
tx(m, "user_settings_load",
req_frame(B.COMP_UTIL, B.CMD_USERSETTINGSLOAD, msg_num=14))
tx(m, "user_settings_save",
req_frame(B.COMP_UTIL, B.CMD_USERSETTINGSSAVE, msg_num=15),
"accepted and discarded; empty reply")
tx(m, "set_client_state",
req_frame(B.COMP_UTIL, B.CMD_SETCLIENTSTATE, msg_num=16))
tx(m, "set_client_metrics",
req_frame(B.COMP_UTIL, B.CMD_SETCLIENTMETRICS, msg_num=17))
tx(m, "update_network_info",
req_frame(B.COMP_USERSESSIONS, B.CMD_UPDATENETWORKINFO, msg_num=18),
"empty reply PLUS an unsolicited ExtendedDataUpdate push")
tx(m, "get_lists", req_frame(B.COMP_ASSOCLISTS, B.CMD_GETLISTS, msg_num=19))
tx(m, "census_subscribe",
req_frame(B.COMP_CENSUSDATA, B.CMD_SUBSCRIBETOCENSUSDATAUPDATES,
OrderedDict([("RSUB", (INT, 1))]), msg_num=20),
"non-zero TimeValues or the client storms at ~30/s and hangs the FUT load")
tx(m, "logout_after_login",
req_frame(B.COMP_AUTH, B.CMD_LOGOUT, msg_num=21),
"session teardown after a login; still an empty reply")
# ============================ fallback behaviour ========================
f = new_session("fallbacks")
tx(f, "transport_ping",
req_frame(B.COMP_UTIL, B.CMD_PING, msg_num=30, msg_type=B.PING),
"msgType PING -> PING_REPLY with an empty body, whatever the command")
for mt, label in ((B.REPLY, "reply"), (B.NOTIFICATION, "notification"),
(B.ERROR_REPLY, "error_reply"),
(B.PING_REPLY, "ping_reply")):
tx(f, "ignores_%s" % label,
req_frame(B.COMP_UTIL, B.CMD_PING, msg_num=31, msg_type=mt),
"not a request -> NO frames at all")
tx(f, "unknown_command",
req_frame(B.COMP_UTIL, 0x0FFF, msg_num=32),
"unimplemented RPC still gets an EMPTY reply so the client cannot hang")
tx(f, "unknown_component",
req_frame(0x1234, 0x0001, msg_num=33),
"same fallback for an entirely unknown component")
tx(f, "user_index_is_echoed",
req_frame(B.COMP_UTIL, B.CMD_PING, msg_num=34, user_index=7),
"a reply echoes component/command/msgNum/userIndex verbatim")
# ================= locale echo on a non-default client ==================
loc = new_session("locale")
tx(loc, "preauth_de_locale",
req_frame(B.COMP_UTIL, B.CMD_PREAUTH, OrderedDict([
("CDAT", (STRUCT, OrderedDict([
("LANG", (INT, 0x64654445)), # 'deDE'
("SVCN", (STRING, "fifa-2017-pc-de")),
]))),
])),
"a non-enUS client: SVCN echo AND the captured locale must both change")
tx(loc, "login_with_de_locale",
req_frame(B.COMP_AUTH, B.CMD_LOGIN, msg_num=41),
"UserAuthenticated.ALOC must carry the captured deDE locale")
# ------------------------------------------------------------------- output
def emit_config_table():
"""Derive the fetchClientConfig tables as address-TEMPLATED data.
These are 227-243 key/value rows per CFID, almost all of them the same URL.
Hand-transcribing them into Rust would be 400 lines of string literals that
nobody can review and one typo can break; deriving them mechanically from
the oracle removes that whole class of error and keeps them regenerable.
They are reverse-engineered *data*, not logic — the same reason
`openfut-core` loads its content from `data/` rather than from source.
The values are templated on {advertise}/{bind}/{pow_content_host}/{pow_host}
so the adapter stays configurable; baking an address in here would recreate
exactly the hardcoding the client/server split removed.
Correctness is not assumed: the caller substitutes real addresses back in
and diffs against the oracle. See verify_config_table.
"""
import subprocess
raw = subprocess.run(
[sys.executable, os.path.abspath(__file__), "--_config_table"],
capture_output=True, text=True, check=True,
# Inherit nothing address-shaped; the child sets its own sentinels.
env={k: v for k, v in os.environ.items()
if not k.startswith(("OPENFUT_", "POW_", "FUT_"))},
).stdout
table = json.loads(raw)
def templatise(value):
for sentinel, token in SENTINELS:
value = value.replace(sentinel, token)
# Collapse whole URLs to URL-level tokens where one exists, so the Rust
# side builds them in exactly one place (AdapterConfig::utas_base and
# friends) instead of re-deriving the shape here. Without this the
# helpers become dead code and a hardcoded address in them goes
# undetected — verified by mutation testing. Longest first.
for whole, token in (
("http://{advertise}:8099/", "{utas_base}"),
("http://{bind}:42131", "{nucleus_base}"),
("http://{pow_content_host}", "{pow_content_url}"),
):
if value == whole:
return token
return value
return {cfid: [[k, templatise(v)] for k, v in rows]
for cfid, rows in table.items()}
def verify_config_table(table):
"""Substitute the real addresses back and require the oracle's exact rows.
This is what makes the templated table trustworthy rather than plausible.
"""
subst = {
"{utas_base}": "http://%s:8099/" % ADVERTISE,
"{nucleus_base}": "http://%s:42131" % BIND,
"{pow_content_url}": "http://%s" % POW_CONTENT_HOST,
"{advertise}": ADVERTISE,
"{bind}": BIND,
"{pow_content_host}": POW_CONTENT_HOST,
"{pow_host}": POW_HOST,
}
def render(v):
for token, real in subst.items():
v = v.replace(token, real)
return v
for cfid, rows in table.items():
expected = B.client_config_for(
"__no_such_section__" if cfid == "__default__" else cfid)
got = [(k, render(v)) for k, v in rows]
if got != [(k, v) for k, v in expected]:
for (gk, gv), (ek, ev) in zip(got, expected):
if (gk, gv) != (ek, ev):
sys.exit("config template mismatch in %s: %r -> %r, oracle "
"has %r -> %r" % (cfid, gk, gv, ek, ev))
sys.exit("config template row-count mismatch in %s: %d vs %d"
% (cfid, len(got), len(expected)))
print("config table verified against the oracle for %d sections"
% len(table))
def frozen_clock():
import time as _time
original = _time.time
_time.time = lambda: float(FIXED_NOW)
return original, _time
def write(path, records):
body = "".join(json.dumps(r, separators=(",", ":")) + "\n" for r in records)
if CHECK_ONLY:
if not os.path.exists(path):
sys.exit("MISSING: %s has never been generated" % path)
with open(path, "r", encoding="utf-8") as fh:
if fh.read() != body:
sys.exit("STALE: %s does not match the oracle; re-run without "
"--check" % path)
print("current: %s (%d records)" % (os.path.basename(path), len(records)))
return
with open(path, "w", encoding="utf-8") as fh:
fh.write(body)
print("wrote %s (%d records)" % (os.path.basename(path), len(records)))
def write_json(path, obj):
body = json.dumps(obj, indent=1, sort_keys=True) + "\n"
if CHECK_ONLY:
if not os.path.exists(path):
sys.exit("MISSING: %s has never been generated" % path)
with open(path, "r", encoding="utf-8") as fh:
if fh.read() != body:
sys.exit("STALE: %s does not match the oracle" % path)
print("current: %s" % os.path.basename(path))
return
with open(path, "w", encoding="utf-8") as fh:
fh.write(body)
print("wrote %s (%d sections)" % (os.path.basename(path), len(obj)))
def main():
# The oracle logs every dispatch to stdout; useful live, pure noise here.
B.log = lambda *_a, **_k: None
table = emit_config_table()
verify_config_table(table)
write_json(os.path.join(HERE, "client_config.json"), table)
random.seed(0xB1A2E)
original_time, time_mod = frozen_clock()
try:
build()
finally:
time_mod.time = original_time
write(os.path.join(HERE, "blaze_transactions.jsonl"), RECORDS)
txs = [r for r in RECORDS if r["kind"] == "tx"]
frames = sum(len(r["responses"]) for r in txs)
print("%d transactions, %d response frames, %d sessions"
% (len(txs), frames,
len([r for r in RECORDS if r["kind"] == "session"])))
if __name__ == "__main__":
main()
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""Capture the roster oracle's responses byte-for-byte.
generate_roster.py [--check] [host:port]
Unlike `generate.py`, which imports the Blaze responder and calls its pure
functions, this captures over the wire. The roster response is shaped as much by
`http.server.BaseHTTPRequestHandler` as by the handler code -- HTTP/1.0 status
line, `Server:`/`Date:` injected ahead of the handler's own headers, POST
answered without a body -- and only the real socket shows all of that.
Two fields are volatile and are MASKED rather than recorded:
Date: changes every second
Server: carries the container's Python version
They are masked, not dropped, so their presence and position are still asserted.
The Server string is additionally recorded verbatim under `observed_server`, so
a drift between the container's Python and the adapter's `ORACLE_SERVER`
constant is visible rather than silent.
`--check` re-captures and compares. If the oracle is unreachable it FAILS rather
than passing: a check that cannot check must not report success.
"""
import json
import os
import re
import socket
import ssl
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "roster.json")
PATH = "/fifa17/fut/rosterupdate.xml"
DATE_RE = re.compile(rb"^Date: .+?\r\n", re.M)
SERVER_RE = re.compile(rb"^Server: (.+?)\r\n", re.M)
def fetch(host, port, method, body=None):
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
ctx.set_ciphers("ALL:@SECLEVEL=0")
s = ctx.wrap_socket(socket.create_connection((host, port), timeout=8),
server_hostname="fixture")
req = "%s %s HTTP/1.1\r\nHost: %s:%d\r\nAccept: */*\r\n" % (method, PATH, host, port)
if body is not None:
req += "Content-Length: %d\r\n" % len(body)
req += "\r\n"
s.sendall(req.encode() + (body or b""))
out = b""
while True:
chunk = s.recv(4096)
if not chunk:
break
out += chunk
s.close()
return out
def capture(host, port):
result = {"path": PATH, "responses": {}}
servers = set()
for method, body in (("GET", None), ("HEAD", None), ("POST", b"probe=1")):
raw = fetch(host, port, method, body)
m = SERVER_RE.search(raw)
if m:
servers.add(m.group(1).decode())
masked = DATE_RE.sub(b"Date: <MASKED>\r\n", raw)
masked = SERVER_RE.sub(b"Server: <MASKED>\r\n", masked)
result["responses"][method] = masked.hex()
if len(servers) != 1:
raise SystemExit("oracle returned inconsistent Server headers: %r" % servers)
result["observed_server"] = servers.pop()
return result
def main():
check = "--check" in sys.argv
args = [a for a in sys.argv[1:] if not a.startswith("--")]
host, port = (args[0].split(":") if args else ("127.0.0.1", "8081"))[0], \
int((args[0].split(":")[1] if args and ":" in args[0] else "8081"))
try:
fresh = capture(host, port)
except Exception as e:
# Explicitly a failure. A --check that silently passes when it could not
# reach the oracle is exactly the class of self-confirming tooling this
# project has been bitten by repeatedly.
raise SystemExit("cannot reach the roster oracle at %s:%d (%s). "
"Refusing to report success." % (host, port, e))
if check:
if not os.path.exists(OUT):
raise SystemExit("no fixture at %s -- run without --check first" % OUT)
with open(OUT) as f:
stored = json.load(f)
if stored.get("responses") != fresh["responses"]:
for m in sorted(set(stored.get("responses", {})) | set(fresh["responses"])):
a = stored.get("responses", {}).get(m)
b = fresh["responses"].get(m)
if a != b:
print("MISMATCH %s\n stored: %s\n live : %s" % (m, a, b))
raise SystemExit("roster fixtures differ from the live oracle")
if stored.get("observed_server") != fresh["observed_server"]:
raise SystemExit(
"the oracle's Server header changed: %r -> %r.\n"
"Update roster::ORACLE_SERVER and regenerate."
% (stored.get("observed_server"), fresh["observed_server"]))
print("roster fixtures match the live oracle (%d responses, server=%r)"
% (len(fresh["responses"]), fresh["observed_server"]))
return
with open(OUT, "w") as f:
json.dump(fresh, f, indent=2, sort_keys=True)
f.write("\n")
print("wrote %s (%d responses, server=%r)"
% (OUT, len(fresh["responses"]), fresh["observed_server"]))
if __name__ == "__main__":
main()
@@ -0,0 +1,6 @@
{
"127.0.0.1": "485454502f312e3120323030204f4b0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a203331350d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a3c3f786d6c2076657273696f6e3d22312e302220656e636f64696e673d225554462d38223f3e0a3c736572766572696e7374616e6365696e666f3e0a093c61646472657373206d656d6265723d2230223e0a09093c76616c753e0a0909093c686f73746e616d653e3132372e302e302e313c2f686f73746e616d653e0a0909093c69703e323133303730363433333c2f69703e0a0909093c706f72743e34323133303c2f706f72743e0a09093c2f76616c753e0a093c2f616464726573733e0a093c7365637572653e303c2f7365637572653e0a093c747269616c736572766963656e616d653e3c2f747269616c736572766963656e616d653e0a093c64656661756c74646e73616464726573733e303c2f64656661756c74646e73616464726573733e0a3c2f736572766572696e7374616e6365696e666f3e0a",
"192.0.2.1": "485454502f312e3120323030204f4b0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a203331350d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a3c3f786d6c2076657273696f6e3d22312e302220656e636f64696e673d225554462d38223f3e0a3c736572766572696e7374616e6365696e666f3e0a093c61646472657373206d656d6265723d2230223e0a09093c76616c753e0a0909093c686f73746e616d653e3139322e302e322e313c2f686f73746e616d653e0a0909093c69703e333232313232353938353c2f69703e0a0909093c706f72743e34323133303c2f706f72743e0a09093c2f76616c753e0a093c2f616464726573733e0a093c7365637572653e303c2f7365637572653e0a093c747269616c736572766963656e616d653e3c2f747269616c736572766963656e616d653e0a093c64656661756c74646e73616464726573733e303c2f64656661756c74646e73616464726573733e0a3c2f736572766572696e7374616e6365696e666f3e0a",
"198.51.100.7": "485454502f312e3120323030204f4b0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a203331380d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a3c3f786d6c2076657273696f6e3d22312e302220656e636f64696e673d225554462d38223f3e0a3c736572766572696e7374616e6365696e666f3e0a093c61646472657373206d656d6265723d2230223e0a09093c76616c753e0a0909093c686f73746e616d653e3139382e35312e3130302e373c2f686f73746e616d653e0a0909093c69703e333332353235363731313c2f69703e0a0909093c706f72743e34323133303c2f706f72743e0a09093c2f76616c753e0a093c2f616464726573733e0a093c7365637572653e303c2f7365637572653e0a093c747269616c736572766963656e616d653e3c2f747269616c736572766963656e616d653e0a093c64656661756c74646e73616464726573733e303c2f64656661756c74646e73616464726573733e0a3c2f736572766572696e7374616e6365696e666f3e0a",
"203.0.113.42": "485454502f312e3120323030204f4b0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a203331380d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a3c3f786d6c2076657273696f6e3d22312e302220656e636f64696e673d225554462d38223f3e0a3c736572766572696e7374616e6365696e666f3e0a093c61646472657373206d656d6265723d2230223e0a09093c76616c753e0a0909093c686f73746e616d653e3230332e302e3131332e34323c2f686f73746e616d653e0a0909093c69703e333430353830333831383c2f69703e0a0909093c706f72743e34323133303c2f706f72743e0a09093c2f76616c753e0a093c2f616464726573733e0a093c7365637572653e303c2f7365637572653e0a093c747269616c736572766963656e616d653e3c2f747269616c736572766963656e616d653e0a093c64656661756c74646e73616464726573733e303c2f64656661756c74646e73616464726573733e0a3c2f736572766572696e7374616e6365696e666f3e0a"
}
@@ -0,0 +1,9 @@
{
"observed_server": "BaseHTTP/0.6 Python/3.12.13",
"path": "/fifa17/fut/rosterupdate.xml",
"responses": {
"GET": "485454502f312e3020323030204f4b0d0a5365727665723a203c4d41534b45443e0d0a446174653a203c4d41534b45443e0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a2036370d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a3c3f786d6c2076657273696f6e3d22312e302220656e636f64696e673d227574662d38223f3e0a3c726f737465727570646174652076657273696f6e3d2230222f3e0a",
"HEAD": "485454502f312e3020323030204f4b0d0a5365727665723a203c4d41534b45443e0d0a446174653a203c4d41534b45443e0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a2036370d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a",
"POST": "485454502f312e3020323030204f4b0d0a5365727665723a203c4d41534b45443e0d0a446174653a203c4d41534b45443e0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a2036370d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a"
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
MARKER at-player-search 19:03:01 UTC
watermark=13
MARKER NO_FILTER 19:04:35 UTC
MARKER GOLD 19:16:44 UTC
NOTE: the FIFA 17 My Squad UI labels this filter SPECIAL, not Rare. Captured under label SPECIAL.
MARKER SPECIAL 19:21:05 UTC
MARKER POSITION_ST 19:22:00 UTC
MARKER NATION_ARGENTINA 19:22:53 UTC
MARKER LEAGUE_PREMIER 19:23:34 UTC
MARKER CLUB_CHELSEA 19:24:34 UTC
MARKER GOLD_PLUS_ST 19:25:26 UTC
MARKER LEAGUE_PLUS_ST 19:27:27 UTC
MARKER PAGINATION 19:28:49 UTC
NOTE: no sort control exists in the My Squad UI; sort=desc is a client constant.
@@ -0,0 +1,84 @@
{
"routes": {
"accountInfo": {
"body_len": 2,
"fields": {
"keys": []
},
"sha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
"status": 200
},
"activeSquad": {
"body_len": 8034,
"fields": {
"formation": "f442",
"id": 0,
"players.count": 23,
"slots": {
"0": 100000003,
"1": 100000006,
"10": 100000010,
"11": 0,
"12": 0,
"13": 0,
"14": 0,
"15": 0,
"16": 0,
"17": 0,
"18": 0,
"19": 0,
"2": 100000005,
"20": 0,
"21": 0,
"22": 0,
"3": 100000008,
"4": 100000007,
"5": 100000002,
"6": 100000004,
"7": 100000009,
"8": 100000001,
"9": 100000025
}
},
"sha256": "07e330ed358fbefe31379cd2462aaac4bdc9c85ee28b7500dd2d963e5ea565bd",
"status": 200
},
"credits": {
"body_len": 148,
"fields": {
"credits": 28112944
},
"sha256": "0a5f3bac80c3a8ee6f088ccf180f5fdcbcbe8a2ef19c7026e4f21876016d7786",
"status": 200
},
"tradePile": {
"body_len": 862,
"fields": {
"auctionInfo.count": 1
},
"sha256": "b42bab98202209bd8309beb0eca73dba471688e69fef3e014b59901fbc21fe04",
"status": 200
},
"unassigned": {
"body_len": 16,
"fields": {
"itemData.count": 0
},
"sha256": "873f8bba8baf9c573fc51b54d100c357b3bf0caeb2ccd104245c295e073cf342",
"status": 200
},
"userMassInfo": {
"body_len": 8929,
"fields": {
"clubAbbr": "OFC",
"clubName": "OpenFUT",
"personaId": 33068179,
"trophies": 0
},
"sha256": "a616aca1742263c47ade9409693e66ec13b50114e608d88bb94141ce80237b66",
"status": 200
}
},
"unix": 1786476617.899911,
"upstream": "127.0.0.1:8099"
}
@@ -0,0 +1,84 @@
{
"routes": {
"accountInfo": {
"body_len": 2,
"fields": {
"keys": []
},
"sha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
"status": 200
},
"activeSquad": {
"body_len": 8034,
"fields": {
"formation": "f442",
"id": 0,
"players.count": 23,
"slots": {
"0": 100000003,
"1": 100000006,
"10": 100000010,
"11": 0,
"12": 0,
"13": 0,
"14": 0,
"15": 0,
"16": 0,
"17": 0,
"18": 0,
"19": 0,
"2": 100000005,
"20": 0,
"21": 0,
"22": 0,
"3": 100000008,
"4": 100000007,
"5": 100000002,
"6": 100000004,
"7": 100000009,
"8": 100000001,
"9": 100000025
}
},
"sha256": "07e330ed358fbefe31379cd2462aaac4bdc9c85ee28b7500dd2d963e5ea565bd",
"status": 200
},
"credits": {
"body_len": 148,
"fields": {
"credits": 28112944
},
"sha256": "0a5f3bac80c3a8ee6f088ccf180f5fdcbcbe8a2ef19c7026e4f21876016d7786",
"status": 200
},
"tradePile": {
"body_len": 862,
"fields": {
"auctionInfo.count": 1
},
"sha256": "b42bab98202209bd8309beb0eca73dba471688e69fef3e014b59901fbc21fe04",
"status": 200
},
"unassigned": {
"body_len": 16,
"fields": {
"itemData.count": 0
},
"sha256": "873f8bba8baf9c573fc51b54d100c357b3bf0caeb2ccd104245c295e073cf342",
"status": 200
},
"userMassInfo": {
"body_len": 8929,
"fields": {
"clubAbbr": "OFC",
"clubName": "OpenFUT",
"personaId": 33068179,
"trophies": 0
},
"sha256": "a616aca1742263c47ade9409693e66ec13b50114e608d88bb94141ce80237b66",
"status": 200
}
},
"unix": 1786474768.2925124,
"upstream": "127.0.0.1:8099"
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"squad": [{"rating": 89, "chemistry": 59, "formation": "f442", "id": 0, "squadName": "OpenFUT", "squadType": "REGULAR_SQUAD"}]}
@@ -0,0 +1 @@
{"id": 0, "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]", "squadName": "OpenFUT", "chemistry": 49, "starRating": 90, "rating": 90, "formation": "f433", "squadType": "REGULAR_SQUAD", "manager": [{"id": 100000427, "dream": false}], "players": [{"index": 0, "itemData": {"id": 100000003, "dream": false}, "kitNumber": 1}, {"index": 1, "itemData": {"id": 100000006, "dream": false}, "kitNumber": 4}, {"index": 2, "itemData": {"id": 100000005, "dream": false}, "kitNumber": 3}, {"index": 3, "itemData": {"id": 100000008, "dream": false}, "kitNumber": 6}, {"index": 4, "itemData": {"id": 100000007, "dream": false}, "kitNumber": 5}, {"index": 5, "itemData": {"id": 100000002, "dream": false}, "kitNumber": 9}, {"index": 6, "itemData": {"id": 100000004, "dream": false}, "kitNumber": 2}, {"index": 7, "itemData": {"id": 100000009, "dream": false}, "kitNumber": 7}, {"index": 8, "itemData": {"id": 100000010, "dream": false}, "kitNumber": 10}, {"index": 9, "itemData": {"id": 100000025, "dream": false}, "kitNumber": 11}, {"index": 10, "itemData": {"id": 100000001, "dream": false}, "kitNumber": 8}, {"index": 11, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 12, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 13, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 14, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 15, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 16, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 17, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 18, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 19, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 20, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 21, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 22, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}], "captain": 100000001, "kicktakers": [{"index": 0, "id": 100000001, "dream": false}, {"index": 1, "id": 100000001, "dream": false}, {"index": 2, "id": 100000001, "dream": false}, {"index": 3, "id": 100000001, "dream": false}, {"index": 4, "id": 100000001, "dream": false}]}
@@ -0,0 +1 @@
{"id":0,"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]","squadName":"OpenFUT","chemistry":52,"starRating":90,"rating":90,"formation":"f442","squadType":"REGULAR_SQUAD","manager":[{"id":100000427,"dream":false}],"players":[{"index":0,"itemData":{"id":100000003,"dream":false},"kitNumber":1},{"index":1,"itemData":{"id":100000010,"dream":false},"kitNumber":10},{"index":2,"itemData":{"id":100000005,"dream":false},"kitNumber":3},{"index":3,"itemData":{"id":100000008,"dream":false},"kitNumber":6},{"index":4,"itemData":{"id":100000007,"dream":false},"kitNumber":5},{"index":5,"itemData":{"id":100000006,"dream":false},"kitNumber":4},{"index":6,"itemData":{"id":100000004,"dream":false},"kitNumber":2},{"index":7,"itemData":{"id":100000009,"dream":false},"kitNumber":7},{"index":8,"itemData":{"id":100000001,"dream":false},"kitNumber":8},{"index":9,"itemData":{"id":100000002,"dream":false},"kitNumber":9},{"index":10,"itemData":{"id":100000025,"dream":false},"kitNumber":11},{"index":11,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":12,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":13,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":14,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":15,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":16,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":17,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":18,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":19,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":20,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":21,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":22,"itemData":{"id":0,"dream":false},"kitNumber":0}],"captain":100000001,"kicktakers":[{"index":0,"id":100000001,"dream":false},{"index":1,"id":100000001,"dream":false},{"index":2,"id":100000001,"dream":false},{"index":3,"id":100000001,"dream":false},{"index":4,"id":100000001,"dream":false}]}
@@ -0,0 +1 @@
{"id": 0, "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]", "squadName": "OpenFUT", "chemistry": 58, "starRating": 90, "rating": 90, "formation": "f442", "squadType": "REGULAR_SQUAD", "manager": [{"id": 100000427, "dream": false}], "players": [{"index": 0, "itemData": {"id": 100000003, "dream": false}, "kitNumber": 1}, {"index": 1, "itemData": {"id": 100000006, "dream": false}, "kitNumber": 4}, {"index": 2, "itemData": {"id": 100000005, "dream": false}, "kitNumber": 3}, {"index": 3, "itemData": {"id": 100000008, "dream": false}, "kitNumber": 6}, {"index": 4, "itemData": {"id": 100000007, "dream": false}, "kitNumber": 5}, {"index": 5, "itemData": {"id": 100000002, "dream": false}, "kitNumber": 9}, {"index": 6, "itemData": {"id": 100000004, "dream": false}, "kitNumber": 2}, {"index": 7, "itemData": {"id": 100000009, "dream": false}, "kitNumber": 7}, {"index": 8, "itemData": {"id": 100000001, "dream": false}, "kitNumber": 8}, {"index": 9, "itemData": {"id": 100000010, "dream": false}, "kitNumber": 10}, {"index": 10, "itemData": {"id": 100000025, "dream": false}, "kitNumber": 11}, {"index": 11, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 12, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 13, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 14, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 15, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 16, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 17, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 18, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 19, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 20, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 21, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 22, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}], "captain": 100000001, "kicktakers": [{"index": 0, "id": 100000001, "dream": false}, {"index": 1, "id": 100000001, "dream": false}, {"index": 2, "id": 100000001, "dream": false}, {"index": 3, "id": 100000001, "dream": false}, {"index": 4, "id": 100000001, "dream": false}]}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,84 @@
{
"routes": {
"accountInfo": {
"body_len": 2,
"fields": {
"keys": []
},
"sha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
"status": 200
},
"activeSquad": {
"body_len": 8034,
"fields": {
"formation": "f442",
"id": 0,
"players.count": 23,
"slots": {
"0": 100000003,
"1": 100000006,
"10": 100000010,
"11": 0,
"12": 0,
"13": 0,
"14": 0,
"15": 0,
"16": 0,
"17": 0,
"18": 0,
"19": 0,
"2": 100000005,
"20": 0,
"21": 0,
"22": 0,
"3": 100000008,
"4": 100000007,
"5": 100000002,
"6": 100000004,
"7": 100000009,
"8": 100000001,
"9": 100000025
}
},
"sha256": "07e330ed358fbefe31379cd2462aaac4bdc9c85ee28b7500dd2d963e5ea565bd",
"status": 200
},
"credits": {
"body_len": 148,
"fields": {
"credits": 28112944
},
"sha256": "0a5f3bac80c3a8ee6f088ccf180f5fdcbcbe8a2ef19c7026e4f21876016d7786",
"status": 200
},
"tradePile": {
"body_len": 862,
"fields": {
"auctionInfo.count": 1
},
"sha256": "b42bab98202209bd8309beb0eca73dba471688e69fef3e014b59901fbc21fe04",
"status": 200
},
"unassigned": {
"body_len": 16,
"fields": {
"itemData.count": 0
},
"sha256": "873f8bba8baf9c573fc51b54d100c357b3bf0caeb2ccd104245c295e073cf342",
"status": 200
},
"userMassInfo": {
"body_len": 8929,
"fields": {
"clubAbbr": "OFC",
"clubName": "OpenFUT",
"personaId": 33068179,
"trophies": 0
},
"sha256": "a616aca1742263c47ade9409693e66ec13b50114e608d88bb94141ce80237b66",
"status": 200
}
},
"unix": 1786472465.339646,
"upstream": "127.0.0.1:8099"
}
@@ -0,0 +1,84 @@
{
"routes": {
"accountInfo": {
"body_len": 2,
"fields": {
"keys": []
},
"sha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
"status": 200
},
"activeSquad": {
"body_len": 8034,
"fields": {
"formation": "f442",
"id": 0,
"players.count": 23,
"slots": {
"0": 100000003,
"1": 100000010,
"10": 100000001,
"11": 100000002,
"12": 0,
"13": 0,
"14": 0,
"15": 0,
"16": 0,
"17": 0,
"18": 0,
"19": 0,
"2": 100000009,
"20": 0,
"21": 0,
"22": 0,
"3": 100000008,
"4": 100000007,
"5": 100000006,
"6": 100000005,
"7": 100000004,
"8": 100000025,
"9": 0
}
},
"sha256": "768bb0584ad953ac5f088ad029f161d302ee1be3a63826eb186405acaf1b6232",
"status": 200
},
"credits": {
"body_len": 148,
"fields": {
"credits": 28020656
},
"sha256": "8d39fee51c24ddee9f12d98d7833af6cd3d3399a0e7567ab7062945a1180e30f",
"status": 200
},
"tradePile": {
"body_len": 862,
"fields": {
"auctionInfo.count": 1
},
"sha256": "1575046afb8ca60c76de64427ee0c70e1d4ca2da032819a60db95d72d272d11d",
"status": 200
},
"unassigned": {
"body_len": 16,
"fields": {
"itemData.count": 0
},
"sha256": "873f8bba8baf9c573fc51b54d100c357b3bf0caeb2ccd104245c295e073cf342",
"status": 200
},
"userMassInfo": {
"body_len": 8929,
"fields": {
"clubAbbr": "OFC",
"clubName": "OpenFUT",
"personaId": 33068179,
"trophies": 0
},
"sha256": "3d89d0497661fc62f107081208a14c4fa5753ee4e6482eeda825fe4b622f871f",
"status": 200
}
},
"unix": 1786472102.908128,
"upstream": "127.0.0.1:8099"
}
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# FIFA 17 squad-adapter mutation battery.
#
# Each mutation injects a specific WRONG behaviour into the committed source,
# runs the one test that defends the invariant, and requires that test to FAIL
# (non-zero exit) — i.e. the mutant is killed. The source is reverted via
# `git checkout` after every mutation, so the tree is left untouched.
#
# A mutant that SURVIVES (its guard test still passes) means the invariant is
# not actually defended; the battery then exits non-zero.
#
# Run from the adapter crate root: bash mutation-battery.sh
set -u
cd "$(dirname "$0")"
FUT=src/fut
PASS=0
FAIL=0
declare -a SURVIVORS=()
# mutate <file> <literal-old> <literal-new> (literal, multiline-safe)
mutate() {
OLD="$2" NEW="$3" perl -0777 -pi -e \
's/\Q$ENV{OLD}\E/$ENV{NEW}/g or die "MUTATION PATTERN NOT FOUND in '"$1"'\n"' "$1"
}
# kill <n> <label> <test-filter> <file> <old> <new>
kill_test() {
local n="$1" label="$2" filter="$3" file="$4" old="$5" new="$6"
git checkout -- "$file"
mutate "$file" "$old" "$new" || { echo " [#$n] SETUP ERROR"; FAIL=$((FAIL+1)); SURVIVORS+=("#$n $label (setup)"); return; }
if cargo test --quiet "$filter" >/dev/null 2>&1; then
echo " [#$n] SURVIVED — $label (test '$filter' still passed)"
FAIL=$((FAIL+1)); SURVIVORS+=("#$n $label")
else
echo " [#$n] killed — $label"
PASS=$((PASS+1))
fi
git checkout -- "$file"
}
echo "== FIFA17 squad adapter mutation battery =="
kill_test 1 "kitNumber keyed by slot index, not owned item" \
kit_number_is_keyed_by_owned_item_not_slot "$FUT/squad_ext.rs" \
'(s.owned_card_id.clone(), s.kit_number)' '(s.index.to_string(), s.kit_number)'
kill_test 2 "captain emitted using resourceId (asset), not wire id" \
fresh_projects_full_23_slot_array_with_captain_wire_id "$FUT/squad_projection.rs" \
'captain_wire = id.item_id as i64;' 'captain_wire = id.asset_id as i64;'
kill_test 3 "client chemistry silently reconciled (shadow lost)" \
swap_moves_two_players_with_their_kits_and_round_trips "$FUT/squad_projection.rs" \
'"chemistry": ext.client_reported.chemistry,' '"chemistry": 0,'
kill_test 4 "custom[] regenerated instead of round-tripped verbatim" \
custom_is_preserved_byte_for_byte "$FUT/squad_ext.rs" \
'custom: put.custom.clone(),' 'custom: Some("[]".to_string()),'
kill_test 5 "index derived (zeroed) instead of round-tripped" \
baseline_projects_the_known_squad_round_trip "$FUT/squad.rs" \
'index: p.index,' 'index: 0,'
kill_test 6 "stale extension accepted and projected" \
stale_is_never_applied "$FUT/squad_projection.rs" \
'SquadExtInput::Stale(_) => return Ok(SquadProjection::Stale),' 'SquadExtInput::Stale(ext) => ext,'
kill_test 7 "missing extension fabricates default fields" \
missing_is_explicit_never_fabricated "$FUT/squad_projection.rs" \
'SquadExtInput::Missing => return Ok(SquadProjection::Missing),' \
'SquadExtInput::Missing => return Ok(SquadProjection::Projected(json!({"custom":"[]","manager":[]}))),'
kill_test 8 "shaper fabricates asset id, bypassing real FIFA identity" \
shapes_real_identity_and_reverse_entity_ids "$FUT/item.rs" \
'let asset = id.asset_id;' 'let asset = 0;'
kill_test 9 "duplicate definition collapses owned instances (id=asset)" \
two_owned_copies_of_one_definition_stay_distinct_on_the_wire "$FUT/item.rs" \
'"id": id.item_id,' '"id": id.asset_id,'
kill_test 10 "PUT treated as a partial slot diff (drops slots)" \
full_replacement_carries_every_occupied_slot_no_diff "$FUT/squad.rs" \
'if p.item_data.id == 0 {' 'if p.item_data.id == 0 || p.index > 1 {'
kill_test 11 "FIFA wire item id stored in canonical replacement" \
full_replacement_carries_every_occupied_slot_no_diff "$FUT/squad.rs" \
'ProposedSlot {
owned_card_id,' 'ProposedSlot {
owned_card_id: p.item_data.id.to_string(),'
kill_test 12 "projector rebuilds items independently of shared shaper" \
persisted_read_round_trips_via_reconstructed_canonical_and_extension "$FUT/squad_projection.rs" \
'"itemData": shape_item(item, id, ent),' '"itemData": json!({"id": id.item_id}),'
kill_test 13 "extension schema version ignored on read" \
unknown_schema_version_is_rejected_not_coerced "$FUT/squad_ext.rs" \
'if schema_version != EXT_SCHEMA_VERSION {' 'if false {'
kill_test 14 "player state keyed by CardDefinitionId not OwnedItemId" \
two_owned_copies_of_one_definition_project_as_distinct_players "$FUT/squad_projection.rs" \
'.get(&slot.owned_card_id)' '.get(&item.card_id)'
echo "== mutants killed: $PASS / $((PASS+FAIL)) =="
if [ "$FAIL" -ne 0 ]; then
printf 'SURVIVORS:\n'; printf ' - %s\n' "${SURVIVORS[@]}"
exit 1
fi
echo "all mutants killed"
@@ -0,0 +1,177 @@
//! `Util::fetchClientConfig` tables.
//!
//! Between 227 and 243 key/value rows per CFID, overwhelmingly the same RS4
//! base URL repeated across 212 endpoint keys. The client resolves a per-call
//! key (`FUT_RS4_URL_<CALL>`) before a per-module one
//! (`FUT_RS4_APIURL_<MODULE>`), and any call left unresolved falls back to a
//! real (dead) EA host — which is what produced "there has been an error
//! connecting to FIFA 17 Ultimate Team" mid-session when only the boot subset
//! was served. The table has to be complete, not representative.
//!
//! # Why this is data and not code
//!
//! The rows are reverse-engineered *configuration*, not logic. They live in
//! `fixtures/client_config.json`, derived mechanically from the Python oracle
//! and templated on `{advertise}`, `{bind}`, `{pow_content_host}` and
//! `{pow_host}` so the adapter stays deployable anywhere. Hand-transcribing 400
//! string literals would add a class of silent typo no reviewer can catch, and
//! `openfut-core` already loads its content from `data/` for the same reason.
//!
//! The generator does not take its own templating on trust: it substitutes real
//! addresses back in and diffs against the oracle for every section before
//! writing the file.
use std::collections::BTreeMap;
use std::sync::OnceLock;
use super::config::AdapterConfig;
/// Rows for every known CFID, plus `__default__` for unknown ones.
const TABLE_JSON: &str = include_str!("../../fixtures/client_config.json");
type Table = BTreeMap<String, Vec<(String, String)>>;
fn table() -> &'static Table {
static TABLE: OnceLock<Table> = OnceLock::new();
TABLE.get_or_init(|| {
serde_json::from_str(TABLE_JSON).expect("bundled client_config.json is valid")
})
}
/// Resolve the rows for a CFID, with addresses substituted in.
///
/// Unknown CFIDs deliberately still receive the shared FUT/RS4/POW rows: those
/// consumers read a merged `_all` store and which section contributes is
/// unproven, so a present-but-shared table is safer than an empty one.
pub fn rows_for(cfid: &str, cfg: &AdapterConfig) -> Vec<(String, String)> {
let t = table();
let rows = t
.get(cfid)
.or_else(|| t.get("__default__"))
.expect("client_config.json always carries a __default__ section");
// URL-level tokens resolve through AdapterConfig so those helpers are the
// single place a URL shape is defined. Host-level tokens cover the values
// that are not one of the three standard URLs (roster, POW API).
let utas_base = cfg.utas_base();
let nucleus_base = cfg.nucleus_base();
let pow_content_url = cfg.pow_content_url();
rows.iter()
.map(|(k, v)| {
let v = if v.contains('{') {
v.replace("{utas_base}", &utas_base)
.replace("{nucleus_base}", &nucleus_base)
.replace("{pow_content_url}", &pow_content_url)
.replace("{advertise}", &cfg.endpoints.advertise)
.replace("{bind}", &cfg.endpoints.bind)
.replace("{pow_content_host}", &cfg.endpoints.pow_content_host)
.replace("{pow_host}", &cfg.endpoints.pow_host)
} else {
v.clone()
};
debug_assert!(!v.contains('{'), "unsubstituted token left in {k}: {v}");
(k.clone(), v)
})
.collect()
}
/// Fingerprint of the bundled config table.
///
/// The table is generated data, so "which binary is this?" is only half the
/// question — "which data does it carry?" is the other half. A running host
/// logs this at startup so a live FIFA trace can be tied to an exact table, and
/// a rebuild that silently picked up regenerated fixtures is visible.
///
/// FNV-1a, not a security hash and never used as one.
pub fn table_fingerprint() -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in TABLE_JSON.as_bytes() {
hash ^= *byte as u64;
hash = hash.wrapping_mul(0x1000_0000_01b3);
}
hash
}
/// Every CFID with its own section. Unknown CFIDs are still valid requests.
pub fn known_sections() -> Vec<&'static str> {
table()
.keys()
.filter(|k| k.as_str() != "__default__")
.map(String::as_str)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg() -> AdapterConfig {
let mut c = AdapterConfig::loopback();
c.endpoints.advertise = "198.51.100.7".into();
c.endpoints.bind = "0.0.0.0".into();
c.endpoints.pow_content_host = "198.51.100.7:8085".into();
c.endpoints.pow_host = "198.51.100.7:8094".into();
c
}
#[test]
fn bundled_table_parses() {
assert!(table().contains_key("__default__"));
assert!(table().contains_key("BlazeSDK"));
assert!(known_sections().len() >= 10);
}
#[test]
fn default_section_is_the_shared_fut_base() {
let rows = rows_for("literally-anything", &cfg());
assert_eq!(rows.len(), 227);
assert!(rows.iter().any(|(k, _)| k == "FUT_RS4_BASE_URL"));
}
#[test]
fn addresses_are_substituted_not_baked() {
let rows = rows_for("BlazeSDK", &cfg());
let base = rows
.iter()
.find(|(k, _)| k == "FUT_RS4_BASE_URL")
.expect("base url present");
assert_eq!(base.1, "http://198.51.100.7:8099/");
assert!(
!rows.iter().any(|(_, v)| v.contains('{')),
"a template token survived substitution"
);
}
#[test]
fn nucleus_follows_bind_reproducing_the_oracle() {
let rows = rows_for("BlazeSDK", &cfg());
let n = rows.iter().find(|(k, _)| k == "nucleusConnect").unwrap();
assert_eq!(n.1, "http://0.0.0.0:42131");
}
#[test]
fn roster_section_carries_the_roster_urls() {
let rows = rows_for("OSDK_ROSTER", &cfg());
let r = rows.iter().find(|(k, _)| k == "ROSTER_URL").unwrap();
assert_eq!(r.1, "https://198.51.100.7:8081/fifa17/roster/");
}
#[test]
fn rows_are_sorted_as_the_wire_requires() {
// The oracle sorts; the TDF map encoder does not, so order is ours to keep.
let rows = rows_for("BlazeSDK", &cfg());
let mut sorted = rows.clone();
sorted.sort();
assert_eq!(rows, sorted);
}
#[test]
fn every_known_section_substitutes_cleanly() {
for cfid in known_sections() {
for (k, v) in rows_for(cfid, &cfg()) {
assert!(!v.contains('{'), "{cfid}/{k} kept a token: {v}");
}
}
}
}
+230
View File
@@ -0,0 +1,230 @@
//! Adapter configuration: identity and endpoints.
//!
//! Everything deployment-dependent lives here, injected by the caller. No
//! address, port or persona is baked into the response builders — the
//! client/server split exists precisely because the Python responders used to
//! assume loopback, and rebuilding that assumption in Rust would undo it.
//!
//! Note the deliberate asymmetry between *bind* and *advertise*: an advertised
//! URL must carry the address the CLIENT can reach, which on a two-machine
//! deployment is not the address the server binds.
/// The forged account the whole stack agrees on.
///
/// Identity has to be byte-identical across LSX, Blaze, POW and UTAS or the
/// client rejects the session, so this is one struct passed everywhere rather
/// than constants per responder.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Identity {
pub persona_id: i64,
pub persona_name: String,
/// blazeId / userId. Must be non-zero or login is refused.
pub user_id: i64,
/// XREF externalId.
pub ext_id: i64,
pub email: String,
/// Must equal `PreAuthResponse.NASP`.
pub namespace: String,
/// `Blaze::ClientPlatformType`; 4 = pc.
pub client_platform: i64,
/// `PersonaStatus::Code`; 2 = ACTIVE.
pub persona_status: i64,
/// `Blaze::UserSessionType`; 0 = normal user.
pub user_session_type: i64,
/// Fallback locale as a packed four-char int (`'enUS'`). Overwritten per
/// session by the client's own preAuth `LANG`/`LOC`.
pub account_locale: i64,
/// `AccountInfo.LN`, e.g. `"en_US"`.
pub locale: String,
/// EA offer id.
pub content_id: String,
pub entitlement_tag: String,
/// Must contain `"FIFA17PCBoxContent"` or `"FIFA16PC"` or FUT drops the
/// entitlement and the store comes up empty.
pub entitlement_group: String,
pub title_id: String,
pub client_id: String,
pub platform: String,
}
impl Default for Identity {
/// The project's fixed synthetic offline identity.
///
/// A default, not a constant: the launcher can select a different persona,
/// and FUT saves are isolated per persona id.
fn default() -> Identity {
Identity {
persona_id: 33_068_179,
persona_name: "CAGE".into(),
user_id: 33_068_179,
ext_id: 33_068_179,
email: "cage@openfut.local".into(),
namespace: "cem_ea_id".into(),
client_platform: 4,
persona_status: 2,
user_session_type: 0,
account_locale: 0x656E_5553, // 'enUS'
locale: "en_US".into(),
content_id: "1027460".into(),
entitlement_tag: "ONLINE_ACCESS".into(),
entitlement_group: "FIFA17PCBoxContent".into(),
title_id: "309111".into(),
client_id: "FIFA17-PC-SERVER-BLAZE".into(),
platform: "pc".into(),
}
}
}
/// Where the client should be told to go next.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Endpoints {
/// Address handed to the CLIENT for every next hop. On a split deployment
/// this is the backend's LAN address as the game machine sees it.
pub advertise: String,
/// Address the server binds. Not interchangeable with `advertise`.
pub bind: String,
/// `host:port` for POW content.
pub pow_content_host: String,
/// `host:port` for the POW/EASFC API.
pub pow_host: String,
/// Blaze port ADVERTISED to the client by the redirector.
///
/// Our choice, not a protocol constant — the client goes wherever
/// `<serverinstanceinfo>` sends it. Configurable so a sidecar can be
/// advertised on a different port without a rebuild.
pub blaze_port: u16,
/// UTAS/RS4 port in generated `FUT_RS4_*` URLs.
///
/// 8099 is the client's own built-in default (`http://easw.easports.com:8099/`
/// in CardsDLL), so it is the sane value — but it is still deployment
/// configuration, not a constant we are entitled to bake in.
pub utas_port: u16,
pub telemetry_port: i64,
pub ticker_port: i64,
pub qos_port: i64,
}
// NOTE: there is deliberately NO `impl Default for Endpoints`.
//
// A default would silently supply loopback, and a remote deployment that forgot
// to set an address would then advertise `127.0.0.1` to a client on another
// machine — failing far from the cause. Choosing loopback has to be an explicit
// act, so it is a named constructor.
impl Endpoints {
/// Endpoints for a backend the client reaches at `advertise`.
///
/// POW hosts DERIVE from the advertised host, matching what the deployed
/// Python entrypoint does (`POW_HOST="${POW_HOST:-$ADV:8094}"`). They must
/// not fall back to loopback independently: that would leave a remote
/// deployment emitting loopback POW URLs while every other URL was correct.
pub fn advertising(advertise: impl Into<String>) -> Endpoints {
let advertise = advertise.into();
Endpoints {
pow_content_host: format!("{advertise}:8080"),
pow_host: format!("{advertise}:8094"),
bind: advertise.clone(),
advertise,
blaze_port: 42130,
utas_port: 8099,
telemetry_port: 9988,
ticker_port: 8999,
qos_port: 17502,
}
}
/// Explicit local-only / oracle mode: game and backend on one host.
///
/// Named rather than defaulted so that "everything is loopback" is always a
/// decision someone made, and greppable.
pub fn loopback() -> Endpoints {
Endpoints::advertising("127.0.0.1")
}
}
/// Full adapter configuration.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdapterConfig {
pub identity: Identity,
pub endpoints: Endpoints,
/// `PreAuthResponse.SVER`. Carries a trailing newline in the oracle; kept
/// because it is on the wire, not because it is meaningful.
pub server_version: String,
}
/// `PreAuthResponse.SVER`. On the wire, so it is config rather than a literal.
pub const DEFAULT_SERVER_VERSION: &str = "Blaze 15.1.1.3.0 (OpenFUT)\n";
// No `Default` here either, for the same reason as `Endpoints`.
impl AdapterConfig {
/// Adapter serving a client that reaches this backend at `advertise`.
pub fn advertising(advertise: impl Into<String>) -> AdapterConfig {
AdapterConfig {
identity: Identity::default(),
endpoints: Endpoints::advertising(advertise),
server_version: DEFAULT_SERVER_VERSION.into(),
}
}
/// Explicit local-only / oracle mode.
pub fn loopback() -> AdapterConfig {
AdapterConfig::advertising("127.0.0.1")
}
/// `http://<advertise>:8099/` — the RS4/UTAS base.
///
/// The trailing slash and the scheme are both mandatory: CardsDLL's
/// `ServerSettings::resolve` uses the value verbatim once it contains
/// `"://"`, and the auth path breaks without the slash.
pub fn utas_base(&self) -> String {
format!(
"http://{}:{}/",
self.endpoints.advertise, self.endpoints.utas_port
)
}
/// `http://<bind>:42131` — the Nucleus OAuth stub.
///
/// This derives from **bind**, not advertise, faithfully reproducing the
/// Python oracle. On the live split deployment that makes it
/// `http://0.0.0.0:42131`, which the client cannot dial — see the crate
/// README and the vault. Reproduced deliberately: changing it would break
/// byte parity with the only configuration ever proven to work, and the
/// fix belongs in a separate, live-validated change.
pub fn nucleus_base(&self) -> String {
format!("http://{}:42131", self.endpoints.bind)
}
pub fn pow_content_url(&self) -> String {
format!("http://{}", self.endpoints.pow_content_host)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn utas_base_keeps_scheme_and_trailing_slash() {
let mut cfg = AdapterConfig::loopback();
cfg.endpoints.advertise = "10.0.0.5".into();
assert_eq!(cfg.utas_base(), "http://10.0.0.5:8099/");
}
#[test]
fn nucleus_follows_bind_not_advertise() {
// Documents the oracle's behaviour, including its consequence.
let mut cfg = AdapterConfig::loopback();
cfg.endpoints.advertise = "10.0.0.5".into();
cfg.endpoints.bind = "0.0.0.0".into();
assert_eq!(cfg.nucleus_base(), "http://0.0.0.0:42131");
}
#[test]
fn pow_content_url_has_no_trailing_slash() {
let mut cfg = AdapterConfig::loopback();
cfg.endpoints.pow_content_host = "10.0.0.5:8085".into();
assert_eq!(cfg.pow_content_url(), "http://10.0.0.5:8085");
}
}
@@ -0,0 +1,464 @@
//! Blaze RPC dispatch: inbound frame → outbound frames.
//!
//! Three behaviours here are load-bearing and none of them are obvious from the
//! individual response shapes:
//!
//! * **Login answers with four frames, in order**: the reply first, then
//! `UserAuthenticated`, `UserSessionExtendedDataUpdate`, `UserAdded`.
//! * **An unimplemented RPC still gets an empty reply.** Silence makes the
//! client wait for a timeout; an empty reply lets every field fall back to a
//! client-side default and the boot continues.
//! * **Non-request message types get nothing at all** — answering a reply or a
//! notification would desynchronise the client's own correlation.
//!
//! No error replies are emitted. `msgType` 3 exists, but the error-code
//! placement is UNRESOLVED — three clean-room sources disagree between
//! `header[14:16]`, a metadata `ERRC`, and a payload `CNTX`/`ERRC` — so
//! emitting one would be a guess on the wire. Do not add one without a capture.
use openfut_protocol_blaze::fire2::{Frame, Header, MsgType};
use openfut_protocol_blaze::heat2::{self, Struct, Value};
use super::config::AdapterConfig;
use super::ids::{association_lists, auth, census_data, component, user_sessions, util};
use super::responses as r;
use super::session::Session;
/// Everything needed to answer one RPC.
pub struct Adapter {
pub config: AdapterConfig,
}
impl Adapter {
pub fn new(config: AdapterConfig) -> Adapter {
Adapter { config }
}
/// Answer one inbound frame.
///
/// `now` is passed in rather than read from the clock so responses are
/// reproducible: several bodies stamp a timestamp, and a hidden clock read
/// would make every fixture unrepeatable.
pub fn dispatch(
&self,
header: &Header,
body: &Struct,
session: &mut Session,
now: i64,
) -> Vec<Frame> {
// Transport-level ping, whatever the component/command.
if header.msg_type == MsgType::Ping {
return vec![reply(header, Vec::new(), MsgType::PingReply)];
}
// Only requests are answered.
if header.msg_type != MsgType::Message {
return Vec::new();
}
let cfg = &self.config;
match (header.component, header.command) {
// ------------------------------------------------------- Util
(component::UTIL, util::PRE_AUTH) => {
// preAuth is where the session learns who it is talking to:
// the service name is echoed back, and the locale is captured
// for every later ALOC field.
session.service_name = service_name_of(body);
if let Some(loc) =
find_nested_int(body, "LANG").or_else(|| find_nested_int(body, "LOC"))
{
session.account_locale = loc;
}
let svc = session.service_name.clone();
reply_tdf(header, &r::preauth_response(&svc, cfg))
}
(component::UTIL, util::PING) => reply_tdf(header, &r::ping_response(now)),
(component::UTIL, util::FETCH_CLIENT_CONFIG) => {
let cfid = get_str(body, "CFID");
reply_tdf(header, &r::fetch_config_response(&cfid, cfg))
}
(component::UTIL, util::POST_AUTH) => {
reply_tdf(header, &r::post_auth_response(session, cfg))
}
(component::UTIL, util::FETCH_QOS_CONFIG) => reply_tdf(header, &r::qos_config(cfg)),
(component::UTIL, util::USER_SETTINGS_LOAD) => {
reply_tdf(header, &r::user_settings_response())
}
// Accepted and discarded; the client only needs the ack.
(component::UTIL, util::USER_SETTINGS_SAVE)
| (component::UTIL, util::SET_CLIENT_STATE)
| (component::UTIL, util::SET_CLIENT_METRICS) => empty_reply(header),
// --------------------------------------------- Authentication
(component::AUTHENTICATION, auth::LOGIN) => {
session.auth_code = get_str(body, "AUTH");
session.logged_in = true;
session.login_time = now;
self.login_burst(header, session, now)
}
// Same forged session; the request fields differ and are ignored.
(component::AUTHENTICATION, auth::TRUSTED_LOGIN)
| (component::AUTHENTICATION, auth::EXPRESS_LOGIN) => {
session.logged_in = true;
session.login_time = now;
self.login_burst(header, session, now)
}
// Receiving logout is NORMAL, not a failure: the OSDK state table
// orders Connect -> Logout -> VersionCheck -> PCLogin, so this is
// the routine "drop any stale session" step before login. It is
// only a symptom if login never follows.
(component::AUTHENTICATION, auth::LOGOUT) => empty_reply(header),
(component::AUTHENTICATION, auth::LIST_USER_ENTITLEMENTS2)
| (component::AUTHENTICATION, auth::LIST_ENTITLEMENTS)
| (component::AUTHENTICATION, auth::LIST_PERSONA_ENTITLEMENTS2)
| (component::AUTHENTICATION, auth::GRANT_ENTITLEMENT2) => {
reply_tdf(header, &r::entitlements_response(cfg))
}
(component::AUTHENTICATION, auth::GET_AUTH_TOKEN) => {
reply_tdf(header, &r::get_auth_token_response(session))
}
(component::AUTHENTICATION, auth::GET_ACCOUNT) => {
reply_tdf(header, &r::account_info(now, cfg))
}
(component::AUTHENTICATION, auth::GET_PERSONA) => {
reply_tdf(header, &r::get_persona_response(now, cfg))
}
(component::AUTHENTICATION, auth::LIST_PERSONAS) => {
reply_tdf(header, &r::list_personas_response(now, cfg))
}
// ---------------------------------------------- UserSessions
(component::USER_SESSIONS, user_sessions::UPDATE_NETWORK_INFO) => {
// Ack, then re-push the extended data so the client's cached
// copy reflects the network info it just reported.
vec![
reply(header, Vec::new(), MsgType::Reply),
notify(
component::USER_SESSIONS,
user_sessions::notify::EXTENDED_DATA_UPDATE,
&r::user_session_extended_data_update(cfg),
),
]
}
// ------------------------------------------ AssociationLists
(component::ASSOCIATION_LISTS, association_lists::GET_LISTS) => {
reply_tdf(header, &r::get_lists_response())
}
// ----------------------------------------------- CensusData
(component::CENSUS_DATA, census_data::SUBSCRIBE_TO_CENSUS_DATA_UPDATES) => {
reply_tdf(header, &r::census_subscribe_response())
}
// An empty reply, never silence: see the module docs.
_ => empty_reply(header),
}
}
/// Login reply followed by the three UserSessions pushes, in order.
///
/// The order is the oracle's ("pamplona" order: reply first). The
/// alternative ("grid-blaze": notifications first) is also reported to
/// work, but only this one is proven against our client, so it is the one
/// reproduced.
fn login_burst(&self, header: &Header, session: &Session, now: i64) -> Vec<Frame> {
let cfg = &self.config;
vec![
reply(
header,
heat2::encode(&r::login_response(session, now, cfg)),
MsgType::Reply,
),
notify(
component::USER_SESSIONS,
user_sessions::notify::USER_AUTHENTICATED,
&r::user_session_login_info(session, now, cfg),
),
notify(
component::USER_SESSIONS,
user_sessions::notify::EXTENDED_DATA_UPDATE,
&r::user_session_extended_data_update(cfg),
),
notify(
component::USER_SESSIONS,
user_sessions::notify::USER_ADDED,
&r::user_data(session, cfg),
),
]
}
}
// ------------------------------------------------------------------ helpers
fn reply(request: &Header, payload: Vec<u8>, msg_type: MsgType) -> Frame {
let mut frame = Frame::new(
request.component,
request.command,
request.msg_num,
msg_type,
payload,
);
// A reply echoes routing verbatim and changes only the msgType bits.
frame.header.user_index = request.user_index;
frame
}
fn reply_tdf(request: &Header, body: &Struct) -> Vec<Frame> {
vec![reply(request, heat2::encode(body), MsgType::Reply)]
}
fn empty_reply(request: &Header) -> Vec<Frame> {
vec![reply(request, Vec::new(), MsgType::Reply)]
}
fn notify(component: u16, notify_id: u16, body: &Struct) -> Frame {
Frame::notification(component, notify_id, heat2::encode(body))
}
/// `PreAuthRequest.CDAT.SVCN`, echoed back as `INST`.
fn service_name_of(body: &Struct) -> String {
body.get("CDAT")
.and_then(Value::as_struct)
.and_then(|c| c.get("SVCN"))
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.unwrap_or(super::session::DEFAULT_SERVICE_NAME)
.to_string()
}
/// Depth-first search for an INT member anywhere in a decoded body.
///
/// The client has moved which struct carries `LANG`/`LOC` between builds, so
/// the oracle searches rather than addressing a fixed path.
fn find_nested_int(body: &Struct, tag: &str) -> Option<i64> {
for (t, v) in body.iter() {
if t.to_label() == tag {
if let Value::Int(n) = v {
return Some(*n);
}
}
if let Value::Struct(inner) = v {
if let Some(found) = find_nested_int(inner, tag) {
return Some(found);
}
}
}
None
}
fn get_str(body: &Struct, tag: &str) -> String {
body.get(tag)
.and_then(Value::as_str)
.unwrap_or("")
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use openfut_protocol_blaze::heat2::Struct as S;
fn adapter() -> Adapter {
Adapter::new(AdapterConfig::loopback())
}
fn req(component: u16, command: u16) -> Header {
Header::new(component, command, 7, MsgType::Message)
}
#[test]
fn login_answers_with_reply_then_three_pushes_in_order() {
let a = adapter();
let mut sess = Session::new("k", 0);
let out = a.dispatch(
&req(component::AUTHENTICATION, auth::LOGIN),
&S::new(),
&mut sess,
1,
);
assert_eq!(out.len(), 4);
assert_eq!(out[0].header.msg_type, MsgType::Reply);
let ids: Vec<u16> = out[1..].iter().map(|f| f.header.command).collect();
assert_eq!(
ids,
vec![
user_sessions::notify::USER_AUTHENTICATED,
user_sessions::notify::EXTENDED_DATA_UPDATE,
user_sessions::notify::USER_ADDED,
]
);
for f in &out[1..] {
assert_eq!(f.header.msg_type, MsgType::Notification);
assert_eq!(f.header.msg_num, 0, "notifications are uncorrelated");
}
}
#[test]
fn unimplemented_rpcs_get_an_empty_reply_not_silence() {
let a = adapter();
let mut sess = Session::new("k", 0);
let out = a.dispatch(&req(0x1234, 0x0001), &S::new(), &mut sess, 1);
assert_eq!(out.len(), 1);
assert_eq!(out[0].header.msg_type, MsgType::Reply);
assert!(out[0].payload.is_empty());
}
#[test]
fn non_requests_are_ignored_entirely() {
let a = adapter();
let mut sess = Session::new("k", 0);
for mt in [
MsgType::Reply,
MsgType::Notification,
MsgType::ErrorReply,
MsgType::PingReply,
] {
let h = Header::new(component::UTIL, util::PING, 1, mt);
assert!(a.dispatch(&h, &S::new(), &mut sess, 1).is_empty(), "{mt:?}");
}
}
#[test]
fn transport_ping_gets_an_empty_ping_reply() {
let a = adapter();
let mut sess = Session::new("k", 0);
let h = Header::new(component::UTIL, util::PING, 1, MsgType::Ping);
let out = a.dispatch(&h, &S::new(), &mut sess, 1);
assert_eq!(out.len(), 1);
assert_eq!(out[0].header.msg_type, MsgType::PingReply);
assert!(out[0].payload.is_empty());
}
#[test]
fn replies_echo_routing_including_user_index() {
let a = adapter();
let mut sess = Session::new("k", 0);
let mut h = req(component::UTIL, util::PING);
h.user_index = 7;
h.msg_num = 0x4242;
let out = a.dispatch(&h, &S::new(), &mut sess, 1);
assert_eq!(out[0].header.user_index, 7);
assert_eq!(out[0].header.msg_num, 0x4242);
assert_eq!(out[0].header.component, component::UTIL);
assert_eq!(out[0].header.command, util::PING);
}
#[test]
fn preauth_captures_locale_and_service_name() {
let a = adapter();
let mut sess = Session::new("k", 0x656E5553);
let body = S::new().with(
"CDAT",
Value::Struct(
S::new()
.with("LANG", Value::Int(0x64654445))
.with("SVCN", Value::String("fifa-2017-pc-de".into())),
),
);
a.dispatch(&req(component::UTIL, util::PRE_AUTH), &body, &mut sess, 1);
assert_eq!(sess.account_locale, 0x64654445);
assert_eq!(sess.service_name, "fifa-2017-pc-de");
}
#[test]
fn preauth_without_svcn_falls_back_to_the_default() {
let a = adapter();
let mut sess = Session::new("k", 0);
a.dispatch(
&req(component::UTIL, util::PRE_AUTH),
&S::new(),
&mut sess,
1,
);
assert_eq!(
sess.service_name,
super::super::session::DEFAULT_SERVICE_NAME
);
}
#[test]
fn login_records_the_auth_code_for_later_get_auth_token() {
let a = adapter();
let mut sess = Session::new("k", 0);
let body = S::new().with("AUTH", Value::String("CODE-123".into()));
a.dispatch(
&req(component::AUTHENTICATION, auth::LOGIN),
&body,
&mut sess,
1,
);
let out = a.dispatch(
&req(component::AUTHENTICATION, auth::GET_AUTH_TOKEN),
&S::new(),
&mut sess,
1,
);
let decoded = heat2::decode(&out[0].payload).unwrap();
assert_eq!(
decoded.get("AUTH").and_then(Value::as_str),
Some("CODE-123")
);
}
#[test]
fn update_network_info_acks_then_pushes() {
let a = adapter();
let mut sess = Session::new("k", 0);
let out = a.dispatch(
&req(component::USER_SESSIONS, user_sessions::UPDATE_NETWORK_INFO),
&S::new(),
&mut sess,
1,
);
assert_eq!(out.len(), 2);
assert!(out[0].payload.is_empty());
assert_eq!(out[1].header.msg_type, MsgType::Notification);
}
#[test]
fn all_four_entitlement_aliases_agree() {
let a = adapter();
let mut sess = Session::new("k", 0);
let bodies: Vec<Vec<u8>> = [
auth::LIST_USER_ENTITLEMENTS2,
auth::LIST_ENTITLEMENTS,
auth::LIST_PERSONA_ENTITLEMENTS2,
auth::GRANT_ENTITLEMENT2,
]
.iter()
.map(|&cmd| {
a.dispatch(
&req(component::AUTHENTICATION, cmd),
&S::new(),
&mut sess,
1,
)[0]
.payload
.clone()
})
.collect();
assert!(bodies.windows(2).all(|w| w[0] == w[1]));
}
#[test]
fn finds_a_nested_int_at_any_depth() {
let body = S::new().with(
"A",
Value::Struct(S::new().with("B", Value::Struct(S::new().with("LANG", Value::Int(42))))),
);
assert_eq!(find_nested_int(&body, "LANG"), Some(42));
assert_eq!(find_nested_int(&body, "NOPE"), None);
}
}
+269
View File
@@ -0,0 +1,269 @@
//! FIFA 17 Blaze component, command and notification IDs.
//!
//! This is exactly the knowledge that must NOT live in
//! `openfut-protocol-blaze`: the generic layer routes on numbers, and what
//! those numbers mean is per-title.
//!
//! # Provenance
//!
//! The Util table was recovered from FIFA17.exe's own `getCommandName` switch
//! (jump table `0x141b17af4`). The Authentication table could not be recovered
//! statically — the name pool is Denuvo-mutated — so it was obtained by CALLING
//! the client's own `getCommandName` (`0x146e0d2a0`) in-process over ids 1..320,
//! validated by reproducing the known Util names, and cross-checked against a
//! static REST-binding struct (`0x143896a80` → `trustedLogin = 0x0B`).
//! UserSessions notification ids come from the clean, unmutated
//! `getNotificationName` jump table at `0x141b03f70`.
//!
//! Names are for diagnostics only. Dispatch matches on the numeric constants.
pub mod component {
pub const AUTHENTICATION: u16 = 0x0001;
pub const GAME_MANAGER: u16 = 0x0004;
pub const REDIRECTOR: u16 = 0x0005;
pub const STATS: u16 = 0x0007;
pub const UTIL: u16 = 0x0009;
pub const CENSUS_DATA: u16 = 0x000A;
pub const CLUBS: u16 = 0x000B;
pub const MESSAGING: u16 = 0x000F;
pub const ASSOCIATION_LISTS: u16 = 0x0019;
pub const GAME_REPORTING: u16 = 0x001C;
pub const SPONSORED_EVENTS: u16 = 0x081C;
pub const OSDK_SETTINGS: u16 = 0x08C9;
pub const USER_SESSIONS: u16 = 0x7802;
}
pub mod util {
pub const FETCH_CLIENT_CONFIG: u16 = 0x0001;
pub const PING: u16 = 0x0002;
pub const PRE_AUTH: u16 = 0x0007;
pub const POST_AUTH: u16 = 0x0008;
pub const USER_SETTINGS_LOAD: u16 = 0x000A;
pub const USER_SETTINGS_SAVE: u16 = 0x000B;
pub const FETCH_QOS_CONFIG: u16 = 0x0015;
pub const SET_CLIENT_METRICS: u16 = 0x0016;
pub const SET_CLIENT_STATE: u16 = 0x001C;
}
pub mod auth {
pub const LOGIN: u16 = 0x000A;
pub const TRUSTED_LOGIN: u16 = 0x000B;
pub const LIST_USER_ENTITLEMENTS2: u16 = 0x001D;
pub const GET_ACCOUNT: u16 = 0x001E;
pub const LIST_ENTITLEMENTS: u16 = 0x0020;
pub const GET_AUTH_TOKEN: u16 = 0x0024;
pub const GRANT_ENTITLEMENT2: u16 = 0x0027;
pub const LIST_PERSONA_ENTITLEMENTS2: u16 = 0x0030;
pub const EXPRESS_LOGIN: u16 = 0x003C;
/// Routine "drop any stale session" step before PCLogin, NOT a failure.
pub const LOGOUT: u16 = 0x0046;
pub const GET_PERSONA: u16 = 0x005A;
pub const LIST_PERSONAS: u16 = 0x0064;
}
pub mod user_sessions {
pub const UPDATE_NETWORK_INFO: u16 = 0x0014;
/// Notification ids live in a separate number space from commands.
pub mod notify {
pub const EXTENDED_DATA_UPDATE: u16 = 0x0001;
pub const USER_ADDED: u16 = 0x0002;
pub const USER_REMOVED: u16 = 0x0003;
pub const USER_UPDATED: u16 = 0x0005;
pub const USER_AUTHENTICATED: u16 = 0x0008;
pub const USER_UNAUTHENTICATED: u16 = 0x0009;
pub const SERVER_DRAINING: u16 = 0x000C;
}
}
pub mod association_lists {
pub const GET_LISTS: u16 = 0x0006;
}
pub mod census_data {
pub const SUBSCRIBE_TO_CENSUS_DATA_UPDATES: u16 = 0x0005;
}
/// Components advertised in `PreAuthResponse.CIDS`.
///
/// Order is the oracle's and is preserved: `CIDS` is a TDF list, and list
/// elements are NOT reordered by the encoder the way struct members are.
pub const ADVERTISED_COMPONENT_IDS: [i64; 9] = [
component::AUTHENTICATION as i64,
component::GAME_MANAGER as i64,
component::REDIRECTOR as i64,
component::STATS as i64,
component::UTIL as i64,
component::MESSAGING as i64,
component::ASSOCIATION_LISTS as i64,
component::GAME_REPORTING as i64,
component::USER_SESSIONS as i64,
];
pub fn component_name(component: u16) -> Option<&'static str> {
Some(match component {
component::AUTHENTICATION => "Authentication",
component::GAME_MANAGER => "GameManager",
component::REDIRECTOR => "Redirector",
component::STATS => "Stats",
component::UTIL => "Util",
component::CENSUS_DATA => "CensusData",
component::CLUBS => "Clubs",
component::MESSAGING => "Messaging",
component::ASSOCIATION_LISTS => "AssociationLists",
component::GAME_REPORTING => "GameReporting",
component::SPONSORED_EVENTS => "SponsoredEvents",
component::OSDK_SETTINGS => "OSDKSettings",
component::USER_SESSIONS => "UserSessions",
_ => return None,
})
}
pub fn command_name(component: u16, command: u16) -> Option<&'static str> {
Some(match (component, command) {
(component::UTIL, 0x01) => "fetchClientConfig",
(component::UTIL, 0x02) => "ping",
(component::UTIL, 0x03) => "setClientData",
(component::UTIL, 0x04) => "localizeStrings",
(component::UTIL, 0x05) => "getTelemetryServer",
(component::UTIL, 0x06) => "getTickerServer",
(component::UTIL, 0x07) => "preAuth",
(component::UTIL, 0x08) => "postAuth",
(component::UTIL, 0x0A) => "userSettingsLoad",
(component::UTIL, 0x0B) => "userSettingsSave",
(component::UTIL, 0x0C) => "userSettingsLoadAll",
(component::UTIL, 0x0E) => "userSettingsDelete",
(component::UTIL, 0x0F) => "userSettingsLoadAllForUser",
(component::UTIL, 0x14) => "filterForProfanity",
(component::UTIL, 0x15) => "fetchQosConfig",
(component::UTIL, 0x16) => "setClientMetrics",
(component::UTIL, 0x17) => "setConnectionState",
(component::UTIL, 0x19) => "getUserOptions",
(component::UTIL, 0x1A) => "setUserOptions",
(component::UTIL, 0x1B) => "suspendUserPing",
(component::UTIL, 0x1C) => "setClientState",
(component::AUTHENTICATION, 0x0A) => "login",
(component::AUTHENTICATION, 0x0B) => "trustedLogin",
(component::AUTHENTICATION, 0x14) => "updateAccount",
(component::AUTHENTICATION, 0x15) => "upgradeAccount",
(component::AUTHENTICATION, 0x1D) => "listUserEntitlements2",
(component::AUTHENTICATION, 0x1E) => "getAccount",
(component::AUTHENTICATION, 0x1F) => "grantEntitlement",
(component::AUTHENTICATION, 0x20) => "listEntitlements",
(component::AUTHENTICATION, 0x22) => "getUseCount",
(component::AUTHENTICATION, 0x23) => "decrementUseCount",
(component::AUTHENTICATION, 0x24) => "getAuthToken",
(component::AUTHENTICATION, 0x26) => "getPasswordRules",
(component::AUTHENTICATION, 0x27) => "grantEntitlement2",
(component::AUTHENTICATION, 0x2B) => "modifyEntitlement2",
(component::AUTHENTICATION, 0x2C) => "consumecode",
(component::AUTHENTICATION, 0x2D) => "passwordForgot",
(component::AUTHENTICATION, 0x2F) => "getPrivacyPolicyContent",
(component::AUTHENTICATION, 0x30) => "listPersonaEntitlements2",
(component::AUTHENTICATION, 0x33) => "checkAgeReq",
(component::AUTHENTICATION, 0x34) => "getOptIn",
(component::AUTHENTICATION, 0x35) => "enableOptIn",
(component::AUTHENTICATION, 0x36) => "disableOptIn",
(component::AUTHENTICATION, 0x3C) => "expressLogin",
(component::AUTHENTICATION, 0x46) => "logout",
(component::AUTHENTICATION, 0x5A) => "getPersona",
(component::AUTHENTICATION, 0x64) => "listPersonas",
(component::AUTHENTICATION, 0x65) => "expressCreateAccount",
(component::AUTHENTICATION, 0xE6) => "createWalUserSession",
(component::AUTHENTICATION, 0xF1) => "acceptLegalDocs",
(component::AUTHENTICATION, 0xF2) => "getEmailOptInSettings",
(component::AUTHENTICATION, 0xF6) => "getTermsOfServiceContent",
(component::AUTHENTICATION, 0x104) => "getOriginPersona",
(component::AUTHENTICATION, 0x10E) => "checkEmail",
(component::AUTHENTICATION, 0x118) => "getPersonaNameSuggestions",
(component::AUTHENTICATION, 0x122) => "guestLogin",
(component::CENSUS_DATA, 0x01) => "subscribeToCensusData",
(component::CENSUS_DATA, 0x02) => "unsubscribeFromCensusData",
(component::CENSUS_DATA, 0x03) => "getRegionCounts",
(component::CENSUS_DATA, 0x04) => "getLatestCensusData",
(component::CENSUS_DATA, 0x05) => "subscribeToCensusDataUpdates",
(component::USER_SESSIONS, 0x14) => "updateNetworkInfo",
(component::ASSOCIATION_LISTS, 0x06) => "getLists",
_ => return None,
})
}
pub fn notification_name(component: u16, notify_id: u16) -> Option<&'static str> {
if component != component::USER_SESSIONS {
return None;
}
Some(match notify_id {
0x01 => "UserSessionExtendedDataUpdate",
0x02 => "UserAdded",
0x03 => "UserRemoved",
0x05 => "UserUpdated",
0x08 => "UserAuthenticated",
0x09 => "UserUnauthenticated",
0x0C => "ServerDraining",
_ => return None,
})
}
/// Human-readable label for a route, for logs and captures.
pub fn describe(component: u16, command: u16, is_notification: bool) -> String {
let comp = component_name(component)
.map(str::to_string)
.unwrap_or_else(|| format!("Component:0x{component:04x}"));
if is_notification {
if let Some(n) = notification_name(component, command) {
return format!("{comp}::<{n}>");
}
return format!("{comp}::<notify:0x{command:04x}>");
}
match command_name(component, command) {
Some(name) => format!("{comp}::{name}"),
None => format!("{comp}::cmd:0x{command:04x}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn describes_the_first_rpc_fifa_sends() {
assert_eq!(
describe(component::UTIL, util::PRE_AUTH, false),
"Util::preAuth"
);
}
#[test]
fn commands_and_notifications_are_separate_number_spaces() {
// 0x0002 is UserSessions "UserAdded" as a notification, but is not a
// known UserSessions *command*.
assert_eq!(
describe(component::USER_SESSIONS, 0x0002, true),
"UserSessions::<UserAdded>"
);
assert_eq!(
describe(component::USER_SESSIONS, 0x0002, false),
"UserSessions::cmd:0x0002"
);
}
#[test]
fn unknown_routes_degrade_to_numbers() {
assert_eq!(
describe(0x1234, 0x0001, false),
"Component:0x1234::cmd:0x0001"
);
}
#[test]
fn advertised_components_are_in_oracle_order() {
// A list, not a struct: the encoder will NOT sort these, so the order
// here is the order on the wire.
assert_eq!(ADVERTISED_COMPONENT_IDS[0], 0x0001);
assert_eq!(ADVERTISED_COMPONENT_IDS[8], 0x7802);
assert_eq!(ADVERTISED_COMPONENT_IDS.len(), 9);
}
}
+31
View File
@@ -0,0 +1,31 @@
//! FIFA 17 Blaze adapter.
//!
//! Sits on `openfut-protocol-blaze` (Fire2 framing + Heat2/TDF) and supplies
//! everything the generic layer deliberately refuses to know: which component
//! and command numbers mean what, what each response body must contain, and in
//! what order frames leave the server.
//!
//! ```text
//! FIFA 17 client
//! │ Fire2 frames
//! openfut-protocol-blaze generic: framing + codec
//! │ Header + Struct
//! blaze::Adapter THIS: FIFA 17 ids, bodies, ordering
//! │ (future) semantic calls
//! OpenFUT Core game-independent FUT domain
//! ```
//!
//! Blaze is an auth/session/config protocol: no coins, packs, clubs or squads
//! appear on this wire, so the adapter carries no FUT domain state and has no
//! reason to grow into a second backend.
pub mod client_config;
pub mod config;
pub mod dispatch;
pub mod ids;
pub mod responses;
pub mod session;
pub use config::{AdapterConfig, Endpoints, Identity};
pub use dispatch::Adapter;
pub use session::Session;
@@ -0,0 +1,623 @@
//! FIFA 17 Blaze response bodies.
//!
//! Every builder here mirrors a `Blaze::*` TDF class reversed from FIFA17.exe's
//! own reflection metadata. Member counts and tags are not guesses, and the
//! comments carry the class addresses so a future reader can re-derive them.
//!
//! Two recurring rules, both learned the hard way:
//!
//! * **An absent member is safe; a wrongly-typed one is fatal.** A member the
//! client does not receive keeps its client-side default. A member encoded
//! with the wrong wire type desynchronises the whole TDF parse. That is why
//! `CGID`, `ADDR`, `CVAR` and `ULST` are omitted rather than guessed — their
//! union/objid encodings are UNVERIFIED.
//! * **Identity must be byte-identical across responses.** `MAIL`/`UID`/`ASRC`
//! in `AccountInfo` must match `LoginResponse.SESS` and `PreAuthResponse.NASP`,
//! and the session key must be the same string in three places.
//!
//! Member order in the source below is the oracle's for readability; the
//! encoder sorts by packed tag, so source order never reaches the wire.
use openfut_protocol_blaze::heat2::{Struct, TypeId, Value};
use super::client_config;
use super::config::AdapterConfig;
use super::ids::ADVERTISED_COMPONENT_IDS;
use super::session::Session;
fn s(v: impl Into<String>) -> Value {
Value::String(v.into())
}
fn i(v: i64) -> Value {
Value::Int(v)
}
/// `Blaze::Util::FetchConfigResponse` @0x1448752e0 — a single `CONF`
/// map<string,string>.
///
/// NOT double-nested. The extra nesting exists only inside `PreAuthResponse`,
/// where `CONF` is itself a `FetchConfigResponse` whose own single member is
/// also called `CONF`. Easy to get wrong.
pub fn fetch_config_response(cfid: &str, cfg: &AdapterConfig) -> Struct {
let entries = client_config::rows_for(cfid, cfg)
.into_iter()
.map(|(k, v)| (Value::String(k), Value::String(v)))
.collect();
Struct::new().with(
"CONF",
Value::Map {
key: TypeId::String,
val: TypeId::String,
entries,
},
)
}
/// `Blaze::QosConfigInfo` — 4 members.
///
/// FIFA 17's descriptor has no `SVID`, unlike Mirror's Edge Catalyst; do not
/// add one back from another title's emulator.
pub fn qos_config(cfg: &AdapterConfig) -> Struct {
Struct::new()
.with(
"BWPS",
Value::Struct(
Struct::new()
.with("PSA", s(&cfg.endpoints.advertise))
.with("PSP", i(cfg.endpoints.qos_port)),
),
)
.with("LNP", i(10))
.with(
"LTPS",
Value::Map {
key: TypeId::String,
val: TypeId::Struct,
entries: vec![],
},
)
.with("TIME", i(5_000_000))
}
/// `Blaze::Util::PreAuthResponse`.
pub fn preauth_response(service_name: &str, cfg: &AdapterConfig) -> Struct {
let id = &cfg.identity;
Struct::new()
.with("ASRC", s(&id.title_id))
.with(
"CIDS",
Value::List {
elem: TypeId::Int,
items: ADVERTISED_COMPONENT_IDS.iter().copied().map(i).collect(),
},
)
.with("CLID", s(&id.client_id))
.with(
"CONF",
Value::Struct(fetch_config_response("BlazeSDK", cfg)),
)
.with("ESRC", s(&id.title_id))
.with("INST", s(service_name)) // echo of CDAT.SVCN
.with("MAID", i(0))
.with("MINR", i(0))
.with("NASP", s(&id.namespace))
.with("PILD", s(""))
.with("PLAT", s(&id.platform))
.with("QOSS", Value::Struct(qos_config(cfg)))
.with("RSRC", s(&id.title_id))
.with("SVER", s(&cfg.server_version))
}
/// `Blaze::Util::PingResponse` @0x144875560 — exactly one member.
///
/// v2 also sent `TIME`; that is MEC's field, not FIFA 17's.
pub fn ping_response(now: i64) -> Struct {
Struct::new().with("STIM", i(now))
}
/// `Blaze::CensusData::SubscribeToCensusDataUpdatesResponse` — 3 TimeValues,
/// encoded as INT microseconds.
///
/// These must be non-zero. The client computes `delay_ms = (CNP + NTMT) / 1000`
/// and re-arms a resend timer; an empty reply gives delay 0, which lands the job
/// on the scheduler's ready list and produces a ~30/s re-subscribe storm that
/// hangs the FUT loading screen.
pub fn census_subscribe_response() -> Struct {
Struct::new()
.with("CNP", i(30 * 1_000_000))
.with("NTMT", i(90 * 1_000_000))
.with("RTMT", i(300 * 1_000_000))
}
/// `Blaze::Authentication::PersonaDetails` @0x14487cab0 — 6 members.
pub fn persona_details(now: i64, cfg: &AdapterConfig) -> Struct {
let id = &cfg.identity;
Struct::new()
.with("DSNM", s(&id.persona_name))
.with("LAST", i(now))
.with("PID", i(id.persona_id))
.with("PLAT", i(id.client_platform))
.with("STAS", i(id.persona_status))
.with("XREF", i(id.ext_id))
}
/// `Blaze::Authentication::UserLoginInfo` @0x14487cb00 — 8 members.
///
/// `'1CON'` packs to 0x11, which sorts below `'A'` = 0x21, so it leads.
pub fn user_login_info(sess: &Session, now: i64, cfg: &AdapterConfig) -> Struct {
let id = &cfg.identity;
Struct::new()
.with("1CON", i(0))
.with("BUID", i(id.user_id)) // must be non-zero
.with("FRST", i(0))
.with("KEY", s(&sess.session_key)) // must be non-empty
.with("LLOG", i(now))
.with("MAIL", s(&id.email))
.with("PDTL", Value::Struct(persona_details(now, cfg)))
.with("UID", i(id.user_id)) // must be non-zero
}
/// `Blaze::Authentication::LoginResponse` @0x14487d170 — exactly 5 members.
///
/// Diverges from both public MEC emulators, which emit `CNTX`, `ERRC` and a
/// top-level `SKEY`. FIFA 17 has none of those: `CNTX`/`ERRC` are the Blaze
/// error-metadata block, and the session key lives at `SESS.KEY`.
pub fn login_response(sess: &Session, now: i64, cfg: &AdapterConfig) -> Struct {
Struct::new()
.with("ANON", i(0))
.with("NTOS", i(0)) // 1 would divert to the legal-doc flow
.with("SESS", Value::Struct(user_login_info(sess, now, cfg)))
.with("SPAM", i(1))
.with("UNDR", i(0))
}
/// ISO-8601 UTC, matching the oracle's `%Y-%m-%dT%H:%M:%SZ`.
///
/// Hand-rolled from a Unix timestamp to keep this crate free of a date
/// dependency for one format string. Proleptic Gregorian, no leap seconds —
/// the same calendar `time.gmtime` uses.
fn iso8601_utc(unix: i64) -> String {
let days = unix.div_euclid(86_400);
let secs = unix.rem_euclid(86_400);
let (h, mi, sec) = (secs / 3600, (secs % 3600) / 60, secs % 60);
// Civil-from-days (Howard Hinnant's algorithm), shifted to a March-based year.
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{sec:02}Z")
}
/// `Blaze::Authentication::AccountInfo` @0x14487c810 — exactly 16 members.
///
/// The RPC behind the "Unable to retrieve account information" popup: before it
/// was implemented, the empty-reply fallback produced an AccountInfo with
/// `UID=0`/`CO=""` and the popup appeared one layer later.
///
/// Member tags come from the reflection tag table @0x1448775a0; wire types from
/// each member's subtype descriptor (string subtype 0x144867628 covers ASRC CO
/// DOB DTCR LATH LN MAIL PML; the other eight are int/enum). Enum values:
/// `STAS` = AccountStatus ACTIVE = 1, `STAT` = EmailStatus VERIFIED = 2,
/// `RC` = StatusReason none = 0.
pub fn account_info(now: i64, cfg: &AdapterConfig) -> Struct {
let id = &cfg.identity;
Struct::new()
.with("AMU", i(0))
.with("ASRC", s(&id.namespace)) // == PreAuthResponse.NASP
.with("CO", s("US"))
.with("DOB", s("1990-01-01T00:00:00Z"))
.with("DTCR", s("2016-09-01T00:00:00Z"))
.with("GOPT", i(0))
.with("LATH", s(iso8601_utc(now)))
.with("LN", s(&id.locale))
.with("MAIL", s(&id.email)) // == LoginResponse.SESS.MAIL
.with("PML", s(""))
.with("RC", i(0))
.with("STAS", i(1))
.with("STAT", i(2))
.with("TPOT", i(0))
.with("UDU", i(0))
.with("UID", i(id.user_id)) // == LoginResponse.SESS.UID
}
/// `Blaze::Authentication::PersonaInfo` @0x14487c7c0 — 7 members.
///
/// `STAS` here is PersonaStatus ACTIVE = 2 (table 0x14487ad20) — a different
/// enum from AccountInfo's `STAS`, which is AccountStatus ACTIVE = 1. `LADT`'s
/// wire type is a best guess (INT timestamp); it is only reachable via
/// getPersona/listPersonas, off the critical getAccount path.
pub fn persona_info(now: i64, cfg: &AdapterConfig) -> Struct {
let id = &cfg.identity;
Struct::new()
.with("DSNM", s(&id.persona_name))
.with("DTCR", s("2016-09-01T00:00:00Z"))
.with("LADT", i(now))
.with("NSNM", s(&id.namespace))
.with("PID", i(id.persona_id))
.with("STAS", i(2))
.with("STRC", i(0))
}
/// `Blaze::Authentication::GetPersonaResponse` @0x14487d1c0 — PINF + UID.
pub fn get_persona_response(now: i64, cfg: &AdapterConfig) -> Struct {
Struct::new()
.with("PINF", Value::Struct(persona_info(now, cfg)))
.with("UID", i(cfg.identity.user_id))
}
/// `Blaze::Authentication::ListPersonasResponse` @0x14487d210 — one member.
pub fn list_personas_response(now: i64, cfg: &AdapterConfig) -> Struct {
Struct::new().with(
"PINF",
Value::List {
elem: TypeId::Struct,
items: vec![Value::Struct(persona_info(now, cfg))],
},
)
}
/// `Blaze::UserSessionLoginInfo` @0x14486f920 — 16 members.
///
/// A superset of `UserLoginInfo` with the persona fields flattened in rather
/// than nested. `KEY` must be byte-identical to `LoginResponse.SESS.KEY`.
///
/// `CGID` (a connectionGroup ObjectId) is omitted: the OBJID encoding is
/// UNVERIFIED and a wrong one desynchronises the parse, while an absent member
/// simply keeps its default.
pub fn user_session_login_info(sess: &Session, now: i64, cfg: &AdapterConfig) -> Struct {
let id = &cfg.identity;
Struct::new()
.with("1CON", i(0))
.with("ALOC", i(sess.account_locale)) // echo the client's own locale
.with("BUID", i(id.user_id))
.with("DSNM", s(&id.persona_name))
.with("FRST", i(0))
.with("KEY", s(&sess.session_key)) // same string as LoginResponse
.with("LAST", i(now))
.with("LLOG", i(now))
.with("MAIL", s(&id.email))
.with("NASP", s(&id.namespace))
.with("PID", i(id.persona_id))
.with("PLAT", i(id.client_platform))
.with("UID", i(id.user_id))
.with("USTP", i(id.user_session_type))
.with("XREF", i(id.ext_id))
}
/// `Blaze::Util::NetworkQosData` @0x14486e680 — 5 members. `NATT` 0 = OPEN.
pub fn network_qos_data() -> Struct {
Struct::new()
.with("BWHR", i(0))
.with("DBPS", i(100_000))
.with("NAHR", i(0))
.with("NATT", i(0))
.with("UBPS", i(100_000))
}
/// `Blaze::UserSessionExtendedData` @0x144870390.
///
/// Two FIFA-17-specific deltas from the MEC emulators: FIFA HAS `PSLM`
/// (latencyList), which they lack, and FIFA carries `BPS` as a top-level string
/// whereas they bury it inside the `ADDR` union. Follow FIFA's layout.
///
/// `ADDR`, `CVAR` and `ULST` are omitted — unverified union/objid encodings.
pub fn user_session_extended_data() -> Struct {
Struct::new()
.with("BPS", s("openfut"))
.with("CTY", s("US"))
.with(
"DMAP",
Value::Map {
key: TypeId::Int,
val: TypeId::Int,
entries: vec![],
},
)
.with("HWFG", i(0))
.with("ISP", s("OpenFUT"))
.with(
"PSLM",
Value::List {
elem: TypeId::Int,
items: vec![i(0)],
},
)
.with("QDAT", Value::Struct(network_qos_data()))
.with("TZ", s(""))
.with("UATT", i(0))
}
/// `Blaze::UserSessionExtendedDataUpdate` @0x1448703e0 — 3 members.
pub fn user_session_extended_data_update(cfg: &AdapterConfig) -> Struct {
Struct::new()
.with("DATA", Value::Struct(user_session_extended_data()))
.with("SUBS", i(1))
.with("USID", i(cfg.identity.user_id))
}
/// `Blaze::UserIdentification` @0x14486ebc0 — 9 members.
pub fn user_identification(sess: &Session, cfg: &AdapterConfig) -> Struct {
let id = &cfg.identity;
Struct::new()
.with("AID", i(id.user_id))
.with("ALOC", i(sess.account_locale))
.with("EXBB", Value::Blob(vec![]))
.with("EXID", i(id.ext_id))
.with("ID", i(id.user_id))
.with("NAME", s(&id.persona_name))
.with("NASP", s(&id.namespace))
.with("ORIG", i(id.persona_id))
.with("PIDI", i(id.persona_id))
}
/// `Blaze::UserData` @0x1448706b0 — payload of the `UserAdded` push.
/// `FLGS` is a UserDataFlags bitfield; bit 0 = online/authenticated.
pub fn user_data(sess: &Session, cfg: &AdapterConfig) -> Struct {
Struct::new()
.with("EDAT", Value::Struct(user_session_extended_data()))
.with("FLGS", i(3))
.with("USER", Value::Struct(user_identification(sess, cfg)))
}
/// `Blaze::Util::PostAuthResponse` @0x144875810 — TELE, TICK, UROP.
///
/// Telemetry and ticker point at dead local ports on purpose: the client gets a
/// well-formed config and then fails to connect quietly, rather than resolving
/// a real EA hostname.
pub fn post_auth_response(sess: &Session, cfg: &AdapterConfig) -> Struct {
let tele = Struct::new()
.with("ADRS", s(&cfg.endpoints.advertise))
.with("ANON", i(0))
.with("DISA", s(""))
.with("EDCT", i(0))
.with("FILT", s(""))
.with("LOC", i(sess.account_locale))
.with("MINR", i(0))
.with("NOOK", s(""))
.with("PORT", i(cfg.endpoints.telemetry_port))
.with("SDLY", i(15_000))
.with("SESS", s(&sess.session_key)) // same key as login
.with("SKEY", s(""))
.with("SPCT", i(75))
.with("STIM", s(""))
.with("SVNM", s("telemetry-openfut"));
let tick = Struct::new()
.with("ADRS", s(&cfg.endpoints.advertise))
.with("PORT", i(cfg.endpoints.ticker_port))
.with("SKEY", s(""));
let urop = Struct::new()
.with("TMOP", i(0))
.with("UID", i(cfg.identity.user_id));
Struct::new()
.with("TELE", Value::Struct(tele))
.with("TICK", Value::Struct(tick))
.with("UROP", Value::Struct(urop))
}
/// `Blaze::Authentication::Entitlement` @0x14487d490 — 16 members.
///
/// FUT's client-side filter (`onListEntitlements` @0x146f27440) keeps a record
/// only if `GNAM` contains `"FIFA17PCBoxContent"` or `"FIFA16PC"`, `TAG` is
/// non-empty, and `STAT == 1`. A plain `"FIFA17PC"` group matched neither
/// needle and produced an empty store.
///
/// `PRID`/`GNAM`/`TAG` must contain no `'|'` and no `'/'`: the client
/// re-serialises them as `PRID|GNAM|TAG|UCNT/`.
pub fn entitlement(group: &str, tag: &str, eid: i64, cfg: &AdapterConfig) -> Struct {
let id = &cfg.identity;
Struct::new()
.with("DEVI", s(""))
.with("GDAY", s("2016-09-01T00:00:00Z"))
.with("GNAM", s(group))
.with("ID", i(eid))
.with("ISCO", i(0))
.with("PID", i(id.persona_id))
.with("PJID", s(&id.content_id))
.with("PRCA", i(2))
.with("PRID", s(&id.content_id))
.with("STAT", i(1)) // must be 1 or FUT drops it
.with("STRC", i(0))
.with("TAG", s(tag)) // must be non-empty
.with("TDAY", s(""))
.with("TYPE", i(1))
.with("UCNT", i(0))
.with("VER", i(1))
}
/// `Blaze::Authentication::Entitlements` @0x14487d4e0 — single member `NLST`.
///
/// Emits BOTH accepted groups so the entitlement manager's "loaded" flag
/// (`byte[entMgr+0x88]`) flips however the client asks.
pub fn entitlements_response(cfg: &AdapterConfig) -> Struct {
let tag = &cfg.identity.entitlement_tag;
Struct::new().with(
"NLST",
Value::List {
elem: TypeId::Struct,
items: vec![
Value::Struct(entitlement("FIFA17PCBoxContent", tag, 1, cfg)),
Value::Struct(entitlement("FIFA16PC", tag, 2, cfg)),
],
},
)
}
/// `Blaze::Authentication::GetAuthTokenResponse` @0x14487d080 — one member.
pub fn get_auth_token_response(sess: &Session) -> Struct {
Struct::new().with("AUTH", s(sess.auth_token()))
}
/// `Util::userSettingsLoad` response.
///
/// TODO(verify): the response descriptor was never reflected. Both independent
/// clean-room emulators use a single `DATA` string, and an unknown-tag payload
/// is ignored rather than fatal, so an empty `DATA` is the safe minimum — the
/// client falls back to its defaults.
pub fn user_settings_response() -> Struct {
Struct::new().with("DATA", s(""))
}
/// `AssociationLists::getLists` response.
///
/// TODO(verify): FIFA's association-list names are NOT known — do not invent
/// them. An empty list is well-formed and means "this user has no association
/// lists", which is true offline.
pub fn get_lists_response() -> Struct {
Struct::new().with(
"LMAP",
Value::List {
elem: TypeId::Struct,
items: vec![],
},
)
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg() -> AdapterConfig {
AdapterConfig::loopback()
}
#[test]
fn iso8601_matches_known_instants() {
assert_eq!(iso8601_utc(0), "1970-01-01T00:00:00Z");
assert_eq!(iso8601_utc(1_754_870_400), "2025-08-11T00:00:00Z");
// A leap day, to exercise the civil-from-days branch.
assert_eq!(iso8601_utc(1_709_164_800), "2024-02-29T00:00:00Z");
assert_eq!(iso8601_utc(951_782_400), "2000-02-29T00:00:00Z");
}
#[test]
fn login_response_has_exactly_five_members() {
let sess = Session::new("k", 0);
assert_eq!(login_response(&sess, 0, &cfg()).len(), 5);
}
#[test]
fn account_info_has_exactly_sixteen_members() {
assert_eq!(account_info(0, &cfg()).len(), 16);
}
#[test]
fn session_key_appears_identically_in_all_three_places() {
let sess = Session::new("THE-KEY", 0);
let c = cfg();
let login = login_response(&sess, 1, &c);
let in_login = login
.get("SESS")
.and_then(Value::as_struct)
.and_then(|s| s.get("KEY"))
.and_then(Value::as_str)
.unwrap();
let notify = user_session_login_info(&sess, 1, &c);
let in_notify = notify.get("KEY").and_then(Value::as_str).unwrap();
let post = post_auth_response(&sess, &c);
let in_post = post
.get("TELE")
.and_then(Value::as_struct)
.and_then(|s| s.get("SESS"))
.and_then(Value::as_str)
.unwrap();
assert_eq!(in_login, "THE-KEY");
assert_eq!(in_notify, "THE-KEY");
assert_eq!(in_post, "THE-KEY");
}
#[test]
fn identity_is_consistent_between_login_and_account_info() {
let sess = Session::new("k", 0);
let c = cfg();
let acct = account_info(0, &c);
let sess_info = user_login_info(&sess, 0, &c);
assert_eq!(
acct.get("MAIL").and_then(Value::as_str),
sess_info.get("MAIL").and_then(Value::as_str)
);
assert_eq!(
acct.get("UID").and_then(Value::as_int),
sess_info.get("UID").and_then(Value::as_int)
);
assert_eq!(
acct.get("ASRC").and_then(Value::as_str),
Some(c.identity.namespace.as_str())
);
}
#[test]
fn entitlement_groups_match_the_clients_needles() {
let c = cfg();
let list = entitlements_response(&c);
let items = match list.get("NLST") {
Some(Value::List { items, .. }) => items,
_ => panic!("NLST is a list"),
};
assert_eq!(items.len(), 2);
for item in items {
let e = item.as_struct().unwrap();
let gnam = e.get("GNAM").and_then(Value::as_str).unwrap();
assert!(
gnam.contains("FIFA17PCBoxContent") || gnam.contains("FIFA16PC"),
"group {gnam} matches neither client needle"
);
assert_eq!(e.get("STAT").and_then(Value::as_int), Some(1));
assert!(!e.get("TAG").and_then(Value::as_str).unwrap().is_empty());
// The client re-serialises these delimited; a separator would corrupt it.
for tag in ["PRID", "GNAM", "TAG"] {
let v = e.get(tag).and_then(Value::as_str).unwrap();
assert!(
!v.contains('|') && !v.contains('/'),
"{tag} has a separator"
);
}
}
}
#[test]
fn census_periods_are_non_zero() {
// Zero here is the ~30/s storm that hangs the FUT loading screen.
let r = census_subscribe_response();
assert!(r.get("CNP").and_then(Value::as_int).unwrap() > 0);
assert!(r.get("NTMT").and_then(Value::as_int).unwrap() > 0);
}
#[test]
fn preauth_echoes_the_requested_service_name() {
let p = preauth_response("fifa-2017-pc-de", &cfg());
assert_eq!(
p.get("INST").and_then(Value::as_str),
Some("fifa-2017-pc-de")
);
}
#[test]
fn extended_data_omits_the_unverified_members() {
// Absent is safe; a wrong union/objid encoding breaks the whole parse.
let d = user_session_extended_data();
for absent in ["ADDR", "CVAR", "ULST"] {
assert!(d.get(absent).is_none(), "{absent} must stay omitted");
}
assert!(
d.get("PSLM").is_some(),
"PSLM is FIFA-specific and required"
);
}
}
+110
View File
@@ -0,0 +1,110 @@
//! Per-connection Blaze session state.
//!
//! Note how little there is: a session key, the client's locale, the echoed
//! service name, an auth code and a logged-in flag. That is the whole of it.
//!
//! This is the point of the adapter boundary. Blaze is an auth/session/config
//! protocol — coins, packs, clubs, squads and the rest of the FUT domain never
//! appear on this wire, so there is nothing here tempting the adapter into
//! becoming a second backend. When UTAS is migrated that discipline will need
//! actively defending; here it comes for free.
/// State carried across RPCs on one Blaze connection.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Session {
/// Minted once per connection. Must appear byte-identically in
/// `LoginResponse.SESS.KEY`, the `UserAuthenticated` push, and
/// `PostAuthResponse.TELE.SESS`.
pub session_key: String,
/// Whatever `LoginRequest.AUTH` carried; echoed back by `getAuthToken`.
pub auth_code: String,
/// Packed four-char locale, seeded from config and overwritten by the
/// client's own preAuth `LANG`/`LOC`.
pub account_locale: i64,
/// Echoed back as `PreAuthResponse.INST`.
pub service_name: String,
pub logged_in: bool,
pub login_time: i64,
}
/// The oracle's default service name when preAuth carries no `CDAT.SVCN`.
pub const DEFAULT_SERVICE_NAME: &str = "fifa-2017-pc";
impl Session {
/// Start a session with an explicit key.
///
/// The key is injected rather than generated internally so it can be made
/// deterministic for differential tests — it appears verbatim in three
/// different responses, so a self-generated one would make every login
/// fixture unreproducible.
pub fn new(session_key: impl Into<String>, account_locale: i64) -> Session {
Session {
session_key: session_key.into(),
auth_code: String::new(),
account_locale,
service_name: DEFAULT_SERVICE_NAME.into(),
logged_in: false,
login_time: 0,
}
}
/// The token `getAuthToken` returns.
///
/// Before login there is no auth code, so the oracle synthesises one from
/// the session key. Reproduced exactly, including the 16-character slice.
pub fn auth_token(&self) -> String {
if !self.auth_code.is_empty() {
return self.auth_code.clone();
}
// Byte slicing is safe here in practice (session keys are ASCII), but
// char_indices keeps it correct for any injected key.
let cut = self
.session_key
.char_indices()
.nth(16)
.map(|(i, _)| i)
.unwrap_or(self.session_key.len());
format!("OPENFUT-{}", &self.session_key[..cut])
}
}
/// Mint a Blaze-shaped session key: 16 hex, an underscore, then 44 alphanumerics.
///
/// The client never validates the format — one public emulator ships the
/// literal `"0"` — so this only has to be stable within a connection. Callers
/// supply the randomness so this crate needs no RNG dependency and stays
/// deterministic under test.
pub fn format_session_key(high_bits: u64, tail: &str) -> String {
format!("{high_bits:016x}_{tail}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn synthesises_a_token_before_login() {
let s = Session::new("0123456789abcdef_TAIL", 0);
assert_eq!(s.auth_token(), "OPENFUT-0123456789abcdef");
}
#[test]
fn echoes_the_login_auth_code_afterwards() {
let mut s = Session::new("0123456789abcdef_TAIL", 0);
s.auth_code = "REAL-CODE".into();
assert_eq!(s.auth_token(), "REAL-CODE");
}
#[test]
fn short_session_keys_do_not_panic() {
assert_eq!(Session::new("abc", 0).auth_token(), "OPENFUT-abc");
assert_eq!(Session::new("", 0).auth_token(), "OPENFUT-");
}
#[test]
fn session_key_has_the_blaze_shape() {
let k = format_session_key(0x0123456789abcdef, &"x".repeat(44));
assert_eq!(k.len(), 16 + 1 + 44);
assert!(k.starts_with("0123456789abcdef_"));
}
}
+338
View File
@@ -0,0 +1,338 @@
//! FIFA 17 **card-definition identity catalog** and owned-item **wire-id policy**.
//!
//! Two distinct identities (never conflate them):
//!
//! * **Card definition** — *what card is this?* A semantic OpenFUT
//! `CardDefinitionId` maps to a FIFA 17 render identity here:
//! `resource_id = (version << 24) | asset_id`. The client resolves
//! `resource_id & 0xFFFFFF` (= `asset_id`) against its own local player DB;
//! an invented id renders a blank card, so this catalog is authored from
//! verified FIFA 17 data (`pool.json` player asset ids), never guessed.
//! * **Owned-item instance** — *which exact copy?* A monotonic integer wire id,
//! allocated per account by the generic external-identity store; this module
//! only holds the FIFA 17 numeric **policy** ([`Fifa17WireItemIdPolicy`]).
//!
//! The catalog is game DATA (a versioned JSON file), not deployment config, and
//! not a generic-Core concern. Unknown definitions resolve to `None` — callers
//! drop them, never fabricate an asset id.
use std::collections::HashMap;
use serde::Deserialize;
/// The FIFA 17 render identity of a card definition. `version` is the high byte
/// of `resource_id`; `asset_id` (the low 24 bits) is the real FIFA player id.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Fifa17CardIdentity {
pub asset_id: u32,
pub version: u8,
pub resource_id: u32,
/// FIFA wire `rareflag` (rare/special card TYPE). Carried so specials render
/// as specials; observed metadata, not a guessed label.
pub rareflag: i64,
}
/// The FIFA 17 numeric namespace policy for owned-item wire ids.
///
/// Owned-item ids are monotonic from `OWNED_ITEM_BASE + 1` (= 100_000_001,
/// matching the oracle's `ITEM_ID_BASE = 100_000_000` and its first minted id),
/// staying below the synthetic-overlay ranges the responder uses (≥ 9e8). The
/// generic store enforces monotonicity/uniqueness; this type supplies the game,
/// entity-kind and base floor.
pub struct Fifa17WireItemIdPolicy;
impl Fifa17WireItemIdPolicy {
pub const GAME: &'static str = "fifa17";
pub const OWNED_ITEM_KIND: &'static str = "owned-item";
pub const OWNED_ITEM_BASE: i64 = 100_000_000;
/// First owned-item wire id (`100_000_001`).
pub fn owned_item_base_floor() -> i64 {
Self::OWNED_ITEM_BASE + 1
}
}
/// Highest representable asset id (24 bits); above this `version` would be
/// clobbered in `resource_id`.
const MAX_ASSET_ID: u32 = 0x00FF_FFFF;
const SCHEMA_VERSION: u32 = 1;
/// Catalog load/validation errors — all explicit, no silent fallback.
#[derive(Debug, PartialEq, Eq)]
pub enum CatalogError {
BadSchemaVersion(u32),
WrongGame(String),
AssetTooLarge {
card_id: String,
asset_id: u32,
},
DuplicateResource {
resource_id: u32,
first: String,
second: String,
},
Parse(String),
}
impl std::fmt::Display for CatalogError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CatalogError::BadSchemaVersion(v) => {
write!(f, "unsupported catalog schema_version {v}")
}
CatalogError::WrongGame(g) => write!(f, "catalog game is '{g}', expected 'fifa17'"),
CatalogError::AssetTooLarge { card_id, asset_id } => {
write!(f, "card '{card_id}' asset_id {asset_id} exceeds 24 bits")
}
CatalogError::DuplicateResource {
resource_id,
first,
second,
} => write!(
f,
"resource_id {resource_id} claimed by both '{first}' and '{second}'"
),
CatalogError::Parse(e) => write!(f, "catalog parse error: {e}"),
}
}
}
impl std::error::Error for CatalogError {}
#[derive(Deserialize)]
struct RawCatalog {
schema_version: u32,
game: String,
#[serde(default)]
cards: std::collections::BTreeMap<String, RawCard>,
}
#[derive(Deserialize)]
struct RawCard {
asset_id: u32,
#[serde(default)]
version: u8,
/// Absent in a base-only catalog → default 1 (rare), preserving prior wire
/// behaviour; the production catalog carries the observed value.
#[serde(default = "default_rareflag")]
rareflag: i64,
}
fn default_rareflag() -> i64 {
1
}
/// A loaded, validated FIFA 17 card-definition identity catalog.
#[derive(Debug, Default, Clone)]
pub struct Fifa17CardCatalog {
by_card: HashMap<String, Fifa17CardIdentity>,
/// Reverse index: full versioned `resource_id` → the card definition id. The
/// wire carries a `resourceId`; the synthetic market must mint the
/// authoritative Core `card_id`, never the raw FIFA number.
by_resource: HashMap<u32, String>,
}
impl Fifa17CardCatalog {
/// Parse + validate a catalog document. Rejects wrong schema/game, an
/// asset id that would overflow into the version byte, and two card ids
/// claiming the same `resource_id` (a semantic-vs-FIFA identity conflict).
pub fn from_json_str(s: &str) -> Result<Self, CatalogError> {
let raw: RawCatalog =
serde_json::from_str(s).map_err(|e| CatalogError::Parse(e.to_string()))?;
if raw.schema_version != SCHEMA_VERSION {
return Err(CatalogError::BadSchemaVersion(raw.schema_version));
}
if raw.game != Fifa17WireItemIdPolicy::GAME {
return Err(CatalogError::WrongGame(raw.game));
}
let mut by_card = HashMap::new();
let mut by_resource: HashMap<u32, String> = HashMap::new();
for (card_id, rc) in raw.cards {
if rc.asset_id > MAX_ASSET_ID {
return Err(CatalogError::AssetTooLarge {
card_id,
asset_id: rc.asset_id,
});
}
let resource_id = ((rc.version as u32) << 24) | rc.asset_id;
if let Some(first) = by_resource.get(&resource_id) {
return Err(CatalogError::DuplicateResource {
resource_id,
first: first.clone(),
second: card_id,
});
}
by_resource.insert(resource_id, card_id.clone());
by_card.insert(
card_id,
Fifa17CardIdentity {
asset_id: rc.asset_id,
version: rc.version,
resource_id,
rareflag: rc.rareflag,
},
);
}
Ok(Fifa17CardCatalog {
by_card,
by_resource,
})
}
/// Load a catalog from a JSON file.
pub fn from_file(path: &std::path::Path) -> Result<Self, CatalogError> {
let raw = std::fs::read_to_string(path)
.map_err(|e| CatalogError::Parse(format!("reading {}: {e}", path.display())))?;
Self::from_json_str(&raw)
}
/// The FIFA 17 identity for a definition, or `None` (never a fabricated id).
pub fn lookup(&self, card_id: &str) -> Option<Fifa17CardIdentity> {
self.by_card.get(card_id).copied()
}
/// Reverse a FIFA wire `resource_id` (full versioned id) to its authoritative
/// Core `card_id`, or `None` (never a fabricated/heuristic id). Used by the
/// synthetic transfer market so a purchase mints real Core content.
pub fn card_id_for_resource(&self, resource_id: u32) -> Option<&str> {
self.by_resource.get(&resource_id).map(String::as_str)
}
pub fn len(&self) -> usize {
self.by_card.len()
}
pub fn is_empty(&self) -> bool {
self.by_card.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resource_id_composition_base_and_special() {
let cat = Fifa17CardCatalog::from_json_str(
r#"{"schema_version":1,"game":"fifa17","cards":{
"card_base":{"asset_id":20801},
"card_totw":{"asset_id":20801,"version":3}
}}"#,
)
.unwrap();
// version 0 -> resource_id == asset_id
let base = cat.lookup("card_base").unwrap();
assert_eq!(base.version, 0);
assert_eq!(base.resource_id, 20801);
assert_eq!(base.resource_id, base.asset_id);
// version 3 -> high byte set; same base player, different FIFA card
let totw = cat.lookup("card_totw").unwrap();
assert_eq!(totw.asset_id, 20801, "asset_id (base player) unchanged");
assert_eq!(totw.resource_id, (3u32 << 24) | 20801);
assert_ne!(base.resource_id, totw.resource_id);
}
#[test]
fn unknown_card_is_none() {
let cat = Fifa17CardCatalog::from_json_str(
r#"{"schema_version":1,"game":"fifa17","cards":{"card_base":{"asset_id":1}}}"#,
)
.unwrap();
assert_eq!(cat.lookup("card_missing"), None);
}
#[test]
fn two_cards_same_resource_is_a_conflict() {
let err = Fifa17CardCatalog::from_json_str(
r#"{"schema_version":1,"game":"fifa17","cards":{
"card_a":{"asset_id":20801},
"card_b":{"asset_id":20801}
}}"#,
)
.unwrap_err();
assert!(matches!(
err,
CatalogError::DuplicateResource {
resource_id: 20801,
..
}
));
}
#[test]
fn schema_and_game_are_validated() {
assert_eq!(
Fifa17CardCatalog::from_json_str(r#"{"schema_version":2,"game":"fifa17","cards":{}}"#)
.unwrap_err(),
CatalogError::BadSchemaVersion(2)
);
assert_eq!(
Fifa17CardCatalog::from_json_str(r#"{"schema_version":1,"game":"fifa23","cards":{}}"#)
.unwrap_err(),
CatalogError::WrongGame("fifa23".into())
);
}
#[test]
fn asset_exceeding_24_bits_is_rejected() {
let err = Fifa17CardCatalog::from_json_str(
r#"{"schema_version":1,"game":"fifa17","cards":{"c":{"asset_id":16777216}}}"#,
)
.unwrap_err();
assert!(matches!(
err,
CatalogError::AssetTooLarge {
asset_id: 16_777_216,
..
}
));
}
#[test]
fn malformed_json_is_a_parse_error() {
assert!(matches!(
Fifa17CardCatalog::from_json_str("{not json").unwrap_err(),
CatalogError::Parse(_)
));
}
#[test]
fn deterministic_reload() {
let doc = r#"{"schema_version":1,"game":"fifa17","cards":{"a":{"asset_id":10},"b":{"asset_id":20,"version":1}}}"#;
let c1 = Fifa17CardCatalog::from_json_str(doc).unwrap();
let c2 = Fifa17CardCatalog::from_json_str(doc).unwrap();
assert_eq!(c1.lookup("a"), c2.lookup("a"));
assert_eq!(c1.lookup("b"), c2.lookup("b"));
assert_eq!(c1.len(), 2);
}
#[test]
fn wire_id_policy_constants() {
assert_eq!(Fifa17WireItemIdPolicy::GAME, "fifa17");
assert_eq!(Fifa17WireItemIdPolicy::OWNED_ITEM_KIND, "owned-item");
assert_eq!(Fifa17WireItemIdPolicy::owned_item_base_floor(), 100_000_001);
}
#[test]
fn loads_the_committed_generated_catalog() {
// The generated base-card catalog (scripts/seed_fifa17_cards.py) must be
// loadable by this adapter and carry real asset identities.
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("data/fifa17-card-identities.json");
if !path.exists() {
eprintln!("skip: {} not generated", path.display());
return;
}
let cat = Fifa17CardCatalog::from_file(&path).expect("load committed catalog");
assert!(
cat.len() > 17_000,
"full FIFA17 base pool, got {}",
cat.len()
);
// Ronaldo (asset 20801), version 0 => resource_id == asset_id.
let ron = cat
.lookup("fifa17_20801")
.expect("known base asset present");
assert_eq!(ron.asset_id, 20801);
assert_eq!(ron.version, 0);
assert_eq!(ron.resource_id, 20801);
}
}
@@ -0,0 +1,196 @@
//! Shape OpenFUT Core's semantic owned inventory into the FIFA 17 `/club`
//! response envelope `{"itemData":[ <player item>, … ]}`.
//!
//! This module owns only the **`/club` envelope**; the per-item shape lives in
//! the shared [`crate::fut::item`] primitive so `/club` and squad projection
//! emit byte-identical items. Items whose real FIFA asset id is unknown are
//! **dropped and counted** here (a collection may omit an unrenderable card);
//! squad projection, which cannot omit a starter, refuses instead.
use serde_json::{json, Value};
use crate::fut::entities::ReverseEntityResolver;
use crate::fut::item::shape_item;
// Re-exported so existing `club_response::{…}` callers keep working; the types
// are now defined once in `fut::item`.
pub use crate::fut::item::{CoreOwnedItem, Fifa17Identity, ItemIdentityResolver, ShapeStats};
/// Shape the whole `/club` response. Items without a resolvable real asset id
/// are dropped (counted in `ShapeStats`), never emitted with a fabricated id.
pub fn shape_club_response<I: ItemIdentityResolver + ?Sized>(
items: &[CoreOwnedItem],
ent: &impl ReverseEntityResolver,
ident: &I,
) -> (Value, ShapeStats) {
let mut out = Vec::with_capacity(items.len());
let mut stats = ShapeStats::default();
for item in items {
match ident.resolve(item) {
Some(id) => {
out.push(shape_item(item, id, ent));
stats.emitted += 1;
}
None => stats.dropped_no_asset += 1,
}
}
(json!({ "itemData": out }), stats)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fut::entities::Fifa17Entities;
use std::collections::HashMap;
fn entities() -> Fifa17Entities {
Fifa17Entities::from_maps(
HashMap::from([(13, "Premier League".to_string())]),
HashMap::from([(52, "Argentina".to_string())]),
HashMap::from([(5, "Chelsea".to_string())]),
)
}
fn item(
owned: &str,
card: &str,
rating: u8,
pos: &str,
nation: &str,
league: &str,
club: &str,
) -> CoreOwnedItem {
CoreOwnedItem {
owned_card_id: owned.into(),
card_id: card.into(),
rating,
position: pos.into(),
nation: nation.into(),
league: league.into(),
club: club.into(),
attributes: [90, 88, 70, 85, 40, 78],
}
}
/// Test resolver: card_id -> real asset id, item_id from a table. Stands in
/// for the (unresolved-in-production) Core-card→asset mapping.
struct MapIdentity(HashMap<String, Fifa17Identity>);
impl ItemIdentityResolver for MapIdentity {
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
self.0.get(&it.card_id).copied()
}
}
#[test]
fn shapes_item_with_full_field_set_and_reverse_ids() {
let ent = entities();
let ident = MapIdentity(HashMap::from([(
"card_ch_1".to_string(),
Fifa17Identity {
item_id: 100000001,
asset_id: 20801,
resource_id: 20801,
rareflag: 1,
},
)]));
let items = vec![item(
"oc1",
"card_ch_1",
86,
"CDM",
"Argentina",
"Premier League",
"Chelsea",
)];
let (body, stats) = shape_club_response(&items, &ent, &ident);
assert_eq!(stats.emitted, 1);
assert_eq!(stats.dropped_no_asset, 0);
let it = &body["itemData"][0];
assert_eq!(it["id"], 100000001);
assert_eq!(it["resourceId"], 20801);
assert_eq!(it["assetId"], 20801);
assert_eq!(
it["definitionId"], 20801,
"version byte 0 => resourceId==assetId==definitionId"
);
assert_eq!(it["rating"], 86);
assert_eq!(it["preferredPosition"], "CDM");
assert_eq!(it["leagueId"], 13);
assert_eq!(it["teamid"], 5);
assert_eq!(it["nation"], 52);
assert_eq!(it["itemType"], "player");
assert_eq!(it["rareflag"], 1);
assert_eq!(it["contract"], 7);
assert_eq!(it["fitness"], 99);
assert_eq!(it["attributeList"].as_array().unwrap().len(), 6);
assert_eq!(it["attributeList"][0], json!({"index":0,"value":90}));
}
#[test]
fn drops_items_without_a_real_asset_id_never_faking() {
let ent = entities();
// Empty identity map == the current synthetic-catalogue reality.
let ident = MapIdentity(HashMap::new());
let items = vec![item(
"oc1",
"card_pl_001",
84,
"ST",
"England",
"Premier League",
"Northgate United",
)];
let (body, stats) = shape_club_response(&items, &ent, &ident);
assert_eq!(stats.emitted, 0);
assert_eq!(stats.dropped_no_asset, 1);
assert_eq!(
body["itemData"].as_array().unwrap().len(),
0,
"no fabricated ids emitted"
);
}
#[test]
fn unresolved_entity_names_become_neutral_zero_not_dropped() {
let ent = entities();
let ident = MapIdentity(HashMap::from([(
"card_x".to_string(),
Fifa17Identity {
item_id: 100000002,
asset_id: 158023,
resource_id: 158023,
rareflag: 1,
},
)]));
// Synthetic club "Northgate United" has no FIFA team id.
let items = vec![item(
"oc2",
"card_x",
84,
"ST",
"England",
"Premier League",
"Northgate United",
)];
let (body, _) = shape_club_response(&items, &ent, &ident);
let it = &body["itemData"][0];
assert_eq!(
it["teamid"], 0,
"unknown club -> neutral 0, item still emitted"
);
assert_eq!(it["leagueId"], 13);
assert_eq!(it["nation"], 0, "England not in the test nation map -> 0");
}
#[test]
fn envelope_is_itemdata_object() {
let ent = entities();
let ident = MapIdentity(HashMap::new());
let (body, _) = shape_club_response(&[], &ent, &ident);
assert!(body.get("itemData").unwrap().is_array());
assert_eq!(
body.as_object().unwrap().len(),
1,
"only itemData at top level"
);
}
}
+442
View File
@@ -0,0 +1,442 @@
//! FIFA 17 authoritative economy engine — coins + unopened-pack entitlements +
//! owned inventory + stable item ids — with all-or-nothing transactional mutations.
//!
//! A faithful port of the Python oracle's `fut_store.Store` mutation primitives
//! (`spend`/`grant_coins`/`quick_sell`/`record_match`/`grant_unopened_pack`/
//! `consume_unopened_pack`/`open_pack`/`add_items`) — the single-writer engine the
//! eventual economy cutover needs.
//!
//! ## Status / why not wired (R3)
//!
//! This engine is deliberately **not wired** into the live host. The live FIFA 17
//! coin balance is one indivisible writer set — Store BUY (`spend`), pack-open,
//! quick-sell, match rewards (`record_match`) AND the transfer-market buy-now
//! (`spend`) all mutate the same `coins` + inventory in Python's `fut_profile.json`.
//! No single route can become Rust-authoritative without migrating the whole
//! cluster at once. The intended generic home (OpenFUT Core) is a preserved-dirty,
//! frozen submodule, so the generic currency/inventory/transaction primitives can't
//! land there yet. This engine + importer is therefore the coherent prerequisite:
//! wiring it (and migrating every coin/inventory writer in one cut) is the R1 task.
//!
//! Generic concepts (balance/inventory/transaction) belong in Core once unfrozen;
//! they are kept in the adapter meanwhile without distorting Core.
//!
//! ## Economy-parameter provenance
//!
//! Prices/odds/quick-sell values are current OpenFUT **PLACEHOLDER** economy, not
//! EA-authentic. The mutation *invariants* (atomic debit, consume-once, no partial
//! state, sentinel non-grantable) are the load-bearing contract this engine enforces.
use serde_json::{json, Value};
use crate::fut::store_catalog::pack_by_id;
use crate::fut::store_session::SENTINEL_PACK_ID;
/// A transactional failure. On any `Err`, the engine is left UNCHANGED (no partial
/// mutation) — the caller may retry or surface a wire error.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EconomyError {
/// Debit rejected: balance would go negative.
InsufficientFunds { balance: i64, needed: i64 },
/// Pack id is not in the catalogue (includes the 65534 sentinel).
UnknownPack(u64),
/// No owned instance of this pack to consume.
NotOwned(u64),
/// The 65534 sentinel can never be bought/granted/opened.
SentinelRejected,
}
/// The authoritative per-profile economy state. Mirrors the load-bearing
/// `fut_profile.json` fields. Wrap in a profile-scoped `Mutex`/DB transaction at the
/// host boundary (as the eventual Core repository will); the methods here are the
/// atomic units.
#[derive(Debug, Clone, PartialEq)]
pub struct ProfileEconomy {
coins: i64,
unopened_pack_ids: Vec<u64>,
items: Vec<Value>,
next_item_id: i64,
}
impl ProfileEconomy {
/// A fresh, empty economy (coins 0, no packs/items, ids from 1).
pub fn new() -> Self {
ProfileEconomy {
coins: 0,
unopened_pack_ids: Vec::new(),
items: Vec::new(),
next_item_id: 1,
}
}
/// Import from a `fut_profile.json` object (the current authoritative store).
/// Deterministic and idempotent on a fixture: reads coins, `unopenedPackIds`,
/// `items`, `nextItemId`; missing fields take safe defaults. Never mutates the
/// source. Production migration is NOT executed here.
pub fn from_fut_profile(profile: &Value) -> Self {
let coins = profile.get("coins").and_then(Value::as_i64).unwrap_or(0);
let unopened_pack_ids = profile
.get("unopenedPackIds")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_u64).collect())
.unwrap_or_default();
let items = profile
.get("items")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
// Continue item-id allocation past the highest existing id so re-import can
// never mint a duplicate (Python persists nextItemId; we also floor by it).
let max_item_id = items
.iter()
.filter_map(|it| it.get("id").and_then(Value::as_i64))
.max()
.unwrap_or(0);
let next_item_id = profile
.get("nextItemId")
.and_then(Value::as_i64)
.unwrap_or(1)
.max(max_item_id + 1);
ProfileEconomy {
coins,
unopened_pack_ids,
items,
next_item_id,
}
}
/// Write this economy back onto a `fut_profile.json` object (round-trip / export).
/// Only the economy fields are touched; every other key is preserved.
pub fn apply_to_fut_profile(&self, profile: &mut Value) {
let obj = profile
.as_object_mut()
.expect("fut_profile is a JSON object");
obj.insert("coins".into(), json!(self.coins));
obj.insert("unopenedPackIds".into(), json!(self.unopened_pack_ids));
obj.insert("items".into(), Value::Array(self.items.clone()));
obj.insert("nextItemId".into(), json!(self.next_item_id));
}
// ── reads ────────────────────────────────────────────────────────────────
pub fn coins(&self) -> i64 {
self.coins
}
pub fn unopened_pack_ids(&self) -> &[u64] {
&self.unopened_pack_ids
}
pub fn next_item_id(&self) -> i64 {
self.next_item_id
}
pub fn item_ids(&self) -> Vec<i64> {
self.items
.iter()
.filter_map(|it| it.get("id").and_then(Value::as_i64))
.collect()
}
// ── generic primitives (atomic building blocks) ───────────────────────────
/// Credit coins (reward/quick-sell proceeds). Non-negative by type.
pub fn credit(&mut self, amount: u64) {
self.coins += amount as i64;
}
/// Debit coins. Fail-closed: insufficient balance leaves coins UNCHANGED.
pub fn debit(&mut self, amount: u64) -> Result<(), EconomyError> {
let needed = amount as i64;
if self.coins < needed {
return Err(EconomyError::InsufficientFunds {
balance: self.coins,
needed,
});
}
self.coins -= needed;
Ok(())
}
/// Grant one owned instance of a catalogue pack (reward/purchase entitlement).
/// Rejects the 65534 sentinel and any non-catalogue id (mirrors
/// `grant_unopened_pack`, which returns False for `pack_by_id() is None`).
pub fn grant_pack(&mut self, pack_id: u64) -> Result<(), EconomyError> {
if pack_id == SENTINEL_PACK_ID {
return Err(EconomyError::SentinelRejected);
}
if pack_by_id(pack_id).is_none() {
return Err(EconomyError::UnknownPack(pack_id));
}
self.unopened_pack_ids.push(pack_id);
Ok(())
}
/// Consume exactly one owned instance of a pack. Fail-closed: not owned ⇒ Err,
/// no mutation (consume-once; mirrors `consume_unopened_pack`).
pub fn consume_pack(&mut self, pack_id: u64) -> Result<(), EconomyError> {
match self.unopened_pack_ids.iter().position(|&p| p == pack_id) {
Some(idx) => {
self.unopened_pack_ids.remove(idx);
Ok(())
}
None => Err(EconomyError::NotOwned(pack_id)),
}
}
/// Allocate the next stable, monotonic, unique item id.
pub fn allocate_item_id(&mut self) -> i64 {
let id = self.next_item_id;
self.next_item_id += 1;
id
}
/// Add an owned item, stamping a fresh unique id (overwriting any incoming id),
/// and return the assigned id.
pub fn add_item(&mut self, mut item: Value) -> i64 {
let id = self.allocate_item_id();
if let Some(obj) = item.as_object_mut() {
obj.insert("id".into(), json!(id));
}
self.items.push(item);
id
}
// ── composed atomic transactions (validate-then-mutate) ────────────────────
/// Store BUY as an entitlement: validate the pack + funds FIRST, then debit and
/// grant the unopened pack — all-or-nothing. Rejects the sentinel. (The Python
/// oracle opens on buy; the authoritative model separates buy→entitlement→open,
/// which is why `open_pack` exists — a DIFFERENT-BY-DESIGN improvement over the
/// reference, preserving the coin/entitlement invariants.)
pub fn buy_pack(&mut self, pack_id: u64, price: u64) -> Result<(), EconomyError> {
if pack_id == SENTINEL_PACK_ID {
return Err(EconomyError::SentinelRejected);
}
if pack_by_id(pack_id).is_none() {
return Err(EconomyError::UnknownPack(pack_id));
}
if self.coins < price as i64 {
return Err(EconomyError::InsufficientFunds {
balance: self.coins,
needed: price as i64,
});
}
// Both preconditions hold: commit.
self.coins -= price as i64;
self.unopened_pack_ids.push(pack_id);
Ok(())
}
/// Open an owned pack: consume exactly one entitlement FIRST (so a failed/absent
/// entitlement grants nothing), then add the generated items with fresh ids.
/// Returns the ids granted. Content generation/odds are the caller's concern and
/// are current OpenFUT PLACEHOLDER.
pub fn open_pack(&mut self, pack_id: u64, items: Vec<Value>) -> Result<Vec<i64>, EconomyError> {
self.consume_pack(pack_id)?; // fail-closed: no items on a missing entitlement
Ok(items.into_iter().map(|it| self.add_item(it)).collect())
}
/// Quick-sell owned items by id: remove them and credit the caller-computed value
/// total (values are FIFA17 policy, placeholder). Only ids actually owned are
/// sold/credited (mirrors `quick_sell`). Returns `(sold_count, coins_credited)`.
pub fn quick_sell(&mut self, ids: &[i64], value_of: impl Fn(&Value) -> u64) -> (u64, u64) {
let want: std::collections::HashSet<i64> = ids.iter().copied().collect();
let mut credited = 0u64;
let mut sold = 0u64;
let mut kept = Vec::with_capacity(self.items.len());
for it in std::mem::take(&mut self.items) {
let owned_id = it.get("id").and_then(Value::as_i64);
if owned_id.is_some_and(|id| want.contains(&id)) {
credited += value_of(&it);
sold += 1;
} else {
kept.push(it);
}
}
self.items = kept;
if sold > 0 {
self.credit(credited);
}
(sold, credited)
}
/// Transfer-market buy-now: debit the price FIRST, then acquire the item — the
/// market shares the SAME authoritative coin balance (that is why it is inside
/// this engine's boundary). All-or-nothing.
pub fn market_buy_now(&mut self, price: u64, item: Value) -> Result<i64, EconomyError> {
self.debit(price)?;
Ok(self.add_item(item))
}
/// Match/reward coin credit (`record_match` coin part; SBC/objective grants).
pub fn grant_reward(&mut self, coins: u64) {
self.credit(coins);
}
}
impl Default for ProfileEconomy {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn eco(coins: i64, unopened: &[u64]) -> ProfileEconomy {
ProfileEconomy {
coins,
unopened_pack_ids: unopened.to_vec(),
items: Vec::new(),
next_item_id: 1,
}
}
#[test]
fn debit_insufficient_is_fail_closed() {
let mut e = eco(100, &[]);
assert_eq!(
e.debit(101),
Err(EconomyError::InsufficientFunds {
balance: 100,
needed: 101
})
);
assert_eq!(e.coins(), 100, "no mutation on failure");
assert_eq!(e.debit(100), Ok(()));
assert_eq!(e.coins(), 0);
}
#[test]
fn buy_pack_is_atomic() {
// Insufficient funds: neither coins nor entitlements change.
let mut poor = eco(399, &[]);
assert!(matches!(
poor.buy_pack(1, 400),
Err(EconomyError::InsufficientFunds { .. })
));
assert_eq!(poor.coins(), 399);
assert!(poor.unopened_pack_ids().is_empty());
// Enough funds: debit + grant together.
let mut ok = eco(1000, &[]);
assert_eq!(ok.buy_pack(1, 400), Ok(()));
assert_eq!(ok.coins(), 600);
assert_eq!(ok.unopened_pack_ids(), &[1]);
}
#[test]
fn sentinel_can_never_be_bought_or_granted() {
let mut e = eco(1_000_000, &[]);
assert_eq!(
e.buy_pack(SENTINEL_PACK_ID, 0),
Err(EconomyError::SentinelRejected)
);
assert_eq!(
e.grant_pack(SENTINEL_PACK_ID),
Err(EconomyError::SentinelRejected)
);
// Never openable either (no entitlement can exist for it).
assert_eq!(
e.open_pack(SENTINEL_PACK_ID, vec![json!({})]),
Err(EconomyError::NotOwned(SENTINEL_PACK_ID))
);
assert_eq!(e.coins(), 1_000_000, "sentinel ops never mutate economy");
assert!(e.item_ids().is_empty());
}
#[test]
fn unknown_pack_rejected() {
let mut e = eco(1_000_000, &[]);
assert_eq!(e.buy_pack(999, 0), Err(EconomyError::UnknownPack(999)));
assert_eq!(e.grant_pack(999), Err(EconomyError::UnknownPack(999)));
}
#[test]
fn open_pack_consumes_exactly_once() {
let mut e = eco(0, &[70]);
let granted = e.open_pack(70, vec![json!({"rating": 84}), json!({"rating": 90})]);
assert_eq!(granted, Ok(vec![1, 2]));
assert!(e.unopened_pack_ids().is_empty(), "entitlement consumed");
assert_eq!(e.item_ids(), vec![1, 2], "unique ids assigned");
// Second open of the same (now-absent) entitlement grants nothing.
assert_eq!(
e.open_pack(70, vec![json!({})]),
Err(EconomyError::NotOwned(70))
);
assert_eq!(e.item_ids(), vec![1, 2], "no items added on failed open");
}
#[test]
fn quick_sell_removes_owned_and_credits() {
let mut e = eco(100, &[]);
let a = e.add_item(json!({"rating": 84}));
let b = e.add_item(json!({"rating": 90}));
// sell only `a`; an unknown id is ignored (not owned).
let (sold, credited) = e.quick_sell(&[a, 99999], |_| 300);
assert_eq!((sold, credited), (1, 300));
assert_eq!(e.coins(), 400);
assert_eq!(e.item_ids(), vec![b], "only the sold item removed");
}
#[test]
fn market_buy_now_is_atomic() {
let mut poor = eco(50, &[]);
assert!(matches!(
poor.market_buy_now(100, json!({"rating": 84})),
Err(EconomyError::InsufficientFunds { .. })
));
assert_eq!(poor.coins(), 50);
assert!(poor.item_ids().is_empty(), "no item acquired on failed buy");
let mut ok = eco(500, &[]);
let id = ok.market_buy_now(100, json!({"rating": 84})).unwrap();
assert_eq!(ok.coins(), 400);
assert_eq!(ok.item_ids(), vec![id]);
}
#[test]
fn item_ids_are_unique_and_monotonic() {
let mut e = ProfileEconomy::new();
let ids: Vec<i64> = (0..5).map(|_| e.allocate_item_id()).collect();
assert_eq!(ids, vec![1, 2, 3, 4, 5]);
assert_eq!(e.next_item_id(), 6);
}
#[test]
fn fut_profile_import_export_round_trip() {
let mut profile = json!({
"personaId": 33068179,
"coins": 29876776,
"unopenedPackIds": [70],
"items": [{"id": 41, "rating": 84}, {"id": 42, "rating": 90}],
"nextItemId": 43,
"clubName": "OpenFUT"
});
let e = ProfileEconomy::from_fut_profile(&profile);
assert_eq!(e.coins(), 29876776);
assert_eq!(e.unopened_pack_ids(), &[70]);
assert_eq!(e.next_item_id(), 43, "past the highest existing id");
// Export preserves unrelated keys and reflects the economy exactly.
e.apply_to_fut_profile(&mut profile);
assert_eq!(
profile["clubName"],
json!("OpenFUT"),
"unrelated key preserved"
);
assert_eq!(profile["coins"], json!(29876776));
assert_eq!(profile["nextItemId"], json!(43));
// Re-import is stable.
let e2 = ProfileEconomy::from_fut_profile(&profile);
assert_eq!(e, e2);
}
#[test]
fn import_floors_next_item_id_past_existing_ids() {
// A stale/low nextItemId must never mint a duplicate id.
let profile = json!({
"coins": 0,
"items": [{"id": 500}],
"nextItemId": 10
});
let mut e = ProfileEconomy::from_fut_profile(&profile);
assert_eq!(e.next_item_id(), 501);
assert_eq!(e.allocate_item_id(), 501);
}
}
@@ -0,0 +1,87 @@
//! FIFA 17 economy POLICY mappers (pure, game-specific).
//!
//! These translate FIFA 17 wire semantics into the generic amounts the host
//! feeds to Core economy authority. They own NO state — Core owns balances and
//! inventory; these are the FIFA-specific numbers/derivations. Values are the
//! current OpenFUT economy (match rewards are the Python oracle's
//! `MATCH_COINS`/`MATCH_PARTICIPATION` at production defaults); pack prices come
//! from the Store catalogue.
use crate::fut::store_catalog::pack_by_id;
/// Normalized match outcome for reward purposes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatchResult {
Win,
Draw,
Loss,
}
/// Participation award added to every match reward (oracle `MATCH_PARTICIPATION`
/// default = 0).
pub const MATCH_PARTICIPATION: i64 = 0;
/// Per-result match coins (oracle `MATCH_COINS`: won 400 / draw 200 / loss 100).
pub fn match_result_coins(result: MatchResult) -> i64 {
match result {
MatchResult::Win => 400,
MatchResult::Draw => 200,
MatchResult::Loss => 100,
}
}
/// Total match reward = per-result coins + participation.
pub fn match_reward_total(result: MatchResult) -> i64 {
match_result_coins(result) + MATCH_PARTICIPATION
}
/// Derive the outcome from the match `endReason` enum (the oracle's primary
/// signal, `_END_REASON`). Unknown/absent reasons default to `Draw`, matching
/// the oracle's conservative default. Score-based derivation is a fallback the
/// oracle also supports; the enum is authoritative when present.
pub fn result_from_end_reason(end_reason: Option<&str>) -> MatchResult {
match end_reason.unwrap_or("").to_ascii_uppercase().as_str() {
"WIN" | "DNF_WIN" => MatchResult::Win,
"LOSS" | "QUIT" | "DNF" | "DNF_LOSS" => MatchResult::Loss,
// "DRAW", "DNF_DRAW", "NO_CONTEST", unknown -> draw.
_ => MatchResult::Draw,
}
}
/// The Store buy-now price for a pack id (`None` for unknown/owned-only packs,
/// which are never purchasable).
pub fn pack_price(pack_id: u64) -> Option<u64> {
pack_by_id(pack_id)
.filter(|p| !p.owned_only)
.map(|p| p.price)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn match_rewards_match_oracle() {
assert_eq!(match_reward_total(MatchResult::Win), 400);
assert_eq!(match_reward_total(MatchResult::Draw), 200);
assert_eq!(match_reward_total(MatchResult::Loss), 100);
}
#[test]
fn end_reason_maps_to_outcome() {
assert_eq!(result_from_end_reason(Some("WIN")), MatchResult::Win);
assert_eq!(result_from_end_reason(Some("dnf_win")), MatchResult::Win);
assert_eq!(result_from_end_reason(Some("LOSS")), MatchResult::Loss);
assert_eq!(result_from_end_reason(Some("QUIT")), MatchResult::Loss);
assert_eq!(result_from_end_reason(Some("DRAW")), MatchResult::Draw);
assert_eq!(result_from_end_reason(None), MatchResult::Draw);
assert_eq!(result_from_end_reason(Some("weird")), MatchResult::Draw);
}
#[test]
fn pack_price_rejects_unknown_and_owned_only() {
assert!(pack_price(1).is_some());
assert_eq!(pack_price(65534), None); // sentinel absent from catalogue
assert_eq!(pack_price(70), None); // owned-only reward pack, not purchasable
}
}
+239
View File
@@ -0,0 +1,239 @@
//! FIFA 17 entity id ⇄ name resolution, loaded from the committed game-DB
//! tables (`leagues.json`, `nations.json`, `teams.json`, dumped from
//! `FIFA17.exe`'s resident DB).
//!
//! Two directions, both FIFA-17-specific and therefore adapter-owned:
//! * **forward** id → name — resolves a wire filter (`league=13`) to the
//! semantic name Core filters on ("Premier League"). See [`EntityResolver`].
//! * **reverse** name → id — shapes a Core item's names back into the numeric
//! ids the FIFA `/club` response carries (`leagueId`/`teamid`/`nation`).
//!
//! Grounding (worker-verified against the tables): forward is 1:1 for all three
//! (unique ids, no gaps). Reverse is clean for leagues (50 distinct names) and
//! nations (221 distinct); **team names collide** (e.g. `Arsenal` ×3, plus the
//! FUT "CHAMPIONS *" placeholder teams), so reverse team lookup is first-id-wins
//! and a collision count is exposed for diagnostics. `teamid == assetid` on
//! every row.
use std::collections::HashMap;
use crate::fut::owned_query::EntityResolver;
/// Reverse (name → FIFA id) resolution, used when shaping a Core item back onto
/// the FIFA wire. Unknown names return `None`; the shaper substitutes a neutral
/// `0` (a valid, non-desyncing int) rather than dropping the item.
pub trait ReverseEntityResolver {
fn league_id(&self, name: &str) -> Option<u32>;
fn nation_id(&self, name: &str) -> Option<u32>;
fn team_id(&self, name: &str) -> Option<u32>;
}
/// Forward + reverse FIFA 17 entity maps.
#[derive(Debug, Default, Clone)]
pub struct Fifa17Entities {
league_by_id: HashMap<u32, String>,
league_by_name: HashMap<String, u32>,
nation_by_id: HashMap<u32, String>,
nation_by_name: HashMap<String, u32>,
team_by_id: HashMap<u32, String>,
team_by_name: HashMap<String, u32>,
/// name-collisions dropped from the reverse maps (diagnostics only).
pub reverse_collisions: usize,
}
/// Errors loading the entity tables.
#[derive(Debug)]
pub enum LoadError {
Io(std::io::Error),
Parse { file: String, detail: String },
}
impl std::fmt::Display for LoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LoadError::Io(e) => write!(f, "reading entity tables: {e}"),
LoadError::Parse { file, detail } => write!(f, "parsing {file}: {detail}"),
}
}
}
impl std::error::Error for LoadError {}
impl Fifa17Entities {
/// Load from a directory holding `leagues.json`, `nations.json`,
/// `teams.json` in the FIFA17 `{schema, rows:[...]}` dump format.
pub fn from_tables_dir(dir: &std::path::Path) -> Result<Self, LoadError> {
let mut e = Fifa17Entities::default();
e.load_table(
&dir.join("leagues.json"),
"leagueid",
"leaguename",
Entity::League,
)?;
e.load_table(
&dir.join("nations.json"),
"nationid",
"nationname",
Entity::Nation,
)?;
e.load_table(&dir.join("teams.json"), "teamid", "teamname", Entity::Team)?;
Ok(e)
}
fn load_table(
&mut self,
path: &std::path::Path,
id_key: &str,
name_key: &str,
which: Entity,
) -> Result<(), LoadError> {
let raw = std::fs::read_to_string(path).map_err(LoadError::Io)?;
let file = path.display().to_string();
let doc: serde_json::Value = serde_json::from_str(&raw).map_err(|e| LoadError::Parse {
file: file.clone(),
detail: e.to_string(),
})?;
let rows = doc
.get("rows")
.and_then(|r| r.as_array())
.ok_or_else(|| LoadError::Parse {
file: file.clone(),
detail: "missing `rows` array".into(),
})?;
for row in rows {
let (Some(id), Some(name)) = (
row.get(id_key).and_then(|v| v.as_u64()),
row.get(name_key).and_then(|v| v.as_str()),
) else {
continue;
};
let id = id as u32;
let (by_id, by_name) = match which {
Entity::League => (&mut self.league_by_id, &mut self.league_by_name),
Entity::Nation => (&mut self.nation_by_id, &mut self.nation_by_name),
Entity::Team => (&mut self.team_by_id, &mut self.team_by_name),
};
by_id.insert(id, name.to_string());
// Reverse: first id wins on a name collision; count the rest.
if by_name.contains_key(name) {
self.reverse_collisions += 1;
} else {
by_name.insert(name.to_string(), id);
}
}
Ok(())
}
/// Build directly from maps (tests / small deployments).
pub fn from_maps(
leagues: HashMap<u32, String>,
nations: HashMap<u32, String>,
teams: HashMap<u32, String>,
) -> Self {
let invert = |m: &HashMap<u32, String>| {
let mut out = HashMap::new();
for (&id, name) in m {
out.entry(name.clone()).or_insert(id);
}
out
};
Fifa17Entities {
league_by_name: invert(&leagues),
nation_by_name: invert(&nations),
team_by_name: invert(&teams),
league_by_id: leagues,
nation_by_id: nations,
team_by_id: teams,
reverse_collisions: 0,
}
}
pub fn league_count(&self) -> usize {
self.league_by_id.len()
}
pub fn nation_count(&self) -> usize {
self.nation_by_id.len()
}
pub fn team_count(&self) -> usize {
self.team_by_id.len()
}
}
#[derive(Clone, Copy)]
enum Entity {
League,
Nation,
Team,
}
impl EntityResolver for Fifa17Entities {
fn league_name(&self, id: u32) -> Option<String> {
self.league_by_id.get(&id).cloned()
}
fn nation_name(&self, id: u32) -> Option<String> {
self.nation_by_id.get(&id).cloned()
}
fn team_name(&self, id: u32) -> Option<String> {
self.team_by_id.get(&id).cloned()
}
}
impl ReverseEntityResolver for Fifa17Entities {
fn league_id(&self, name: &str) -> Option<u32> {
self.league_by_name.get(name).copied()
}
fn nation_id(&self, name: &str) -> Option<u32> {
self.nation_by_name.get(name).copied()
}
fn team_id(&self, name: &str) -> Option<u32> {
self.team_by_name.get(name).copied()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Path to the committed game-DB tables, relative to this crate.
fn tables_dir() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../fifa17-recon/data/tables")
}
#[test]
fn loads_committed_tables_and_resolves_both_directions() {
let dir = tables_dir();
if !dir.join("leagues.json").exists() {
eprintln!(
"skipping: committed tables not present at {}",
dir.display()
);
return;
}
let e = Fifa17Entities::from_tables_dir(&dir).expect("load tables");
assert_eq!(e.league_count(), 50);
assert_eq!(e.nation_count(), 221);
assert_eq!(e.team_count(), 750);
// forward id -> name (grounding pinned in owned_query too)
assert_eq!(e.league_name(13).as_deref(), Some("Premier League"));
assert_eq!(e.nation_name(52).as_deref(), Some("Argentina"));
assert_eq!(e.team_name(5).as_deref(), Some("Chelsea"));
// reverse name -> id (clean for leagues/nations)
assert_eq!(e.league_id("Premier League"), Some(13));
assert_eq!(e.nation_id("Argentina"), Some(52));
assert_eq!(e.team_id("Chelsea"), Some(5));
// unknown -> None (never a raw-id fallback)
assert_eq!(e.league_name(999_999), None);
assert_eq!(e.team_id("Northgate United"), None);
}
#[test]
fn from_maps_inverts() {
let e = Fifa17Entities::from_maps(
HashMap::from([(13, "Premier League".to_string())]),
HashMap::from([(52, "Argentina".to_string())]),
HashMap::from([(5, "Chelsea".to_string())]),
);
assert_eq!(e.league_id("Premier League"), Some(13));
assert_eq!(e.team_name(5).as_deref(), Some("Chelsea"));
}
}
+266
View File
@@ -0,0 +1,266 @@
//! The shared FIFA 17 FUT **item-shaping primitive**.
//!
//! One function shapes one owned FUT item into the numeric card object the FIFA
//! 17 client renders, and **every** route that emits a player item goes through
//! it — `/club` (via [`crate::fut::club_response`]) and squad projection (via
//! [`crate::fut::squad_projection`]) alike. There is deliberately no second copy
//! of the field set: an item is shaped in exactly one place so the two routes
//! can never drift.
//!
//! ## The asset-id boundary (load-bearing, evidence-grounded)
//!
//! FIFA renders a card by resolving `resourceId & 0xffffff` against the client's
//! OWN local players table (proven live): a real id renders a real footballer,
//! an **invented id renders a blank generic card**. OpenFUT Core's catalogue is
//! synthetic string ids (`card_pl_001`) with no FIFA asset id. So an
//! [`ItemIdentityResolver`] is injected; when it cannot supply a **real** FIFA
//! asset id for an item, the item carries no fabricated identity — the caller
//! decides what that means (`/club` drops and counts it; a squad refuses to
//! project a starter it cannot render, never faking one).
//!
//! Entity ids (`leagueId`/`teamid`/`nation`) come from a reverse resolver; an
//! unresolved name yields a neutral `0` (a valid int — non-fatal; it only means
//! "no badge/flag"), because those are not the identity the renderer keys on.
use serde_json::{json, Value};
use crate::fut::entities::ReverseEntityResolver;
/// One owned item in game-independent terms, as read from Core's inventory.
#[derive(Debug, Clone)]
pub struct CoreOwnedItem {
/// Core owned-instance id (string). The stable per-copy identity — two
/// copies of the same card definition have distinct `owned_card_id`s.
pub owned_card_id: String,
/// Core card-definition id (string), used for asset resolution.
pub card_id: String,
/// Effective overall rating.
pub rating: u8,
/// Effective position, e.g. "ST".
pub position: String,
pub nation: String,
pub league: String,
pub club: String,
/// [pace, shooting, passing, dribbling, defending, physical].
pub attributes: [u8; 6],
}
/// The FIFA-side numeric identity of an owned item. `asset_id` MUST be a real
/// FIFA player asset (low 24 bits the client resolves); `item_id` is the wire
/// instance id used for later item operations. Two owned copies of the same
/// definition share an `asset_id` but MUST have distinct `item_id`s.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Fifa17Identity {
pub item_id: u32,
/// Base FIFA player asset (low 24 bits the client resolves art/name from).
pub asset_id: u32,
/// Full versioned resource id = `(version << 24) | asset_id`. Equals
/// `asset_id` for a version-0 base card. This is the wire
/// `resourceId`/`definitionId`, kept DISTINCT from `asset_id` so a versioned
/// (special) card never collapses onto its base on the wire.
pub resource_id: u32,
/// FIFA wire `rareflag` — the card's rare/special TYPE (e.g. 3=inform,
/// 21..=24 = special programmes). Drives the client's special-card art;
/// carried from the catalog, never hardcoded, so specials render as specials.
pub rareflag: i64,
}
/// Supplies the FIFA numeric identity for a Core item. Returning `None` means
/// "no real FIFA asset id known" → the caller must not fabricate one.
pub trait ItemIdentityResolver {
fn resolve(&self, item: &CoreOwnedItem) -> Option<Fifa17Identity>;
}
/// Diagnostics from shaping (safe to log — counts only).
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct ShapeStats {
pub emitted: usize,
pub dropped_no_asset: usize,
}
/// Quick-sell / discard value by rating tier (mirrors Core's quick-sell table;
/// non-fatal display field).
fn discard_value(rating: u8) -> i64 {
match rating {
r if r >= 85 => 1500,
r if r >= 80 => 900,
r if r >= 75 => 600,
r if r >= 65 => 300,
_ => 150,
}
}
/// Build one FIFA `_item` object. `resourceId`/`definitionId` carry the full
/// versioned resource id; `assetId`/`cardassetid` carry the base asset. For a
/// version-0 base card these coincide; for a special they differ and MUST NOT
/// be collapsed.
///
/// This is the single source of truth for a player item's on-wire shape; the
/// `/club` envelope and squad projection both call it, so their items are
/// identical by construction. `id` is the owned instance's resolved FIFA
/// identity — pass the resolver's answer for *this* owned copy so two copies of
/// one definition stay distinct on the wire.
pub fn shape_item(
item: &CoreOwnedItem,
id: Fifa17Identity,
ent: &impl ReverseEntityResolver,
) -> Value {
let asset = id.asset_id;
let league_id = ent.league_id(&item.league).unwrap_or(0);
let team_id = ent.team_id(&item.club).unwrap_or(0);
let nation_id = ent.nation_id(&item.nation).unwrap_or(0);
let attribute_list: Vec<Value> = item
.attributes
.iter()
.enumerate()
.map(|(i, v)| json!({ "index": i, "value": v }))
.collect();
json!({
"id": id.item_id,
"resourceId": id.resource_id,
"assetId": asset,
"cardassetid": asset,
"definitionId": id.resource_id,
"cardsubtypeid": 0,
"itemType": "player",
"rareflag": id.rareflag,
"rating": item.rating,
"preferredPosition": item.position,
"nation": nation_id,
"teamid": team_id,
"leagueId": league_id,
"playStyle": 250,
"attributeList": attribute_list,
"itemState": "free",
"owners": 1,
"untradeable": true,
"contract": 7,
"fitness": 99,
"discardValue": discard_value(item.rating),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fut::entities::Fifa17Entities;
use std::collections::HashMap;
fn entities() -> Fifa17Entities {
Fifa17Entities::from_maps(
HashMap::from([(13, "Premier League".to_string())]),
HashMap::from([(52, "Argentina".to_string())]),
HashMap::from([(5, "Chelsea".to_string())]),
)
}
fn item(owned: &str, card: &str, rating: u8, pos: &str) -> CoreOwnedItem {
CoreOwnedItem {
owned_card_id: owned.into(),
card_id: card.into(),
rating,
position: pos.into(),
nation: "Argentina".into(),
league: "Premier League".into(),
club: "Chelsea".into(),
attributes: [90, 88, 70, 85, 40, 78],
}
}
#[test]
fn shapes_real_identity_and_reverse_entity_ids() {
let ent = entities();
let it = shape_item(
&item("oc1", "card_ch_1", 86, "CDM"),
Fifa17Identity {
item_id: 100000001,
asset_id: 20801,
resource_id: 20801,
rareflag: 1,
},
&ent,
);
assert_eq!(it["id"], 100000001, "wire instance id");
assert_eq!(it["resourceId"], 20801);
assert_eq!(it["assetId"], 20801);
assert_eq!(
it["definitionId"], 20801,
"version byte 0 => resourceId==assetId==definitionId"
);
assert_eq!(it["rating"], 86);
assert_eq!(it["preferredPosition"], "CDM");
assert_eq!(it["leagueId"], 13);
assert_eq!(it["teamid"], 5);
assert_eq!(it["nation"], 52);
assert_eq!(it["itemType"], "player");
assert_eq!(it["attributeList"].as_array().unwrap().len(), 6);
assert_eq!(it["attributeList"][0], json!({"index":0,"value":90}));
}
#[test]
fn two_owned_copies_of_one_definition_stay_distinct_on_the_wire() {
// fifa17_101490 has two owned instances: same definition/asset, two
// distinct owned ids and two distinct wire ids. Shaping each from its
// own identity must NEVER collapse them.
let ent = entities();
let a = shape_item(
&item("oc-a", "fifa17_101490", 84, "ST"),
Fifa17Identity {
item_id: 100000030,
asset_id: 101490,
resource_id: 101490,
rareflag: 1,
},
&ent,
);
let b = shape_item(
&item("oc-b", "fifa17_101490", 84, "ST"),
Fifa17Identity {
item_id: 100000031,
asset_id: 101490,
resource_id: 101490,
rareflag: 1,
},
&ent,
);
assert_eq!(
a["resourceId"], b["resourceId"],
"same definition => same asset"
);
assert_ne!(
a["id"], b["id"],
"distinct owned copies keep distinct wire ids"
);
assert_eq!(a["id"], 100000030);
assert_eq!(b["id"], 100000031);
}
#[test]
fn versioned_special_keeps_resourceid_distinct_from_assetid() {
// A versioned (special) card: resourceId/definitionId carry the full
// versioned id; assetId/cardassetid stay the base asset. They MUST NOT
// collapse. (resource 117617092 = version 7 of asset 176580.)
let ent = entities();
let it = shape_item(
&item("oc-v", "fifa17_117617092", 92, "ST"),
Fifa17Identity {
item_id: 100000384,
asset_id: 176580,
resource_id: 117617092,
rareflag: 3,
},
&ent,
);
assert_eq!(
it["resourceId"], 117617092,
"versioned resource id on the wire"
);
assert_eq!(it["definitionId"], 117617092);
assert_eq!(it["assetId"], 176580, "base asset id preserved");
assert_eq!(it["cardassetid"], 176580);
assert_eq!(
it["rareflag"], 3,
"special rareflag carried, not hardcoded 1"
);
}
}
+19
View File
@@ -0,0 +1,19 @@
//! FIFA 17 FUT (UTAS/RS4) wire → OpenFUT Core semantic mappings.
//!
//! Unlike [`crate::blaze`] (binary Blaze RPC), this is the JSON/HTTP FUT surface.
//! It currently holds the owned-player ("My Squad") search mapping; more UTAS
//! routes join it as the UTAS→Core migration proceeds. Nothing here opens a
//! socket — a Rust UTAS host wires it to Core later.
pub mod catalog;
pub mod club_response;
pub mod economy;
pub mod economy_policy;
pub mod entities;
pub mod item;
pub mod owned_query;
pub mod pack_content;
pub mod squad;
pub mod squad_ext;
pub mod squad_projection;
pub mod store_catalog;
pub mod store_session;
@@ -0,0 +1,525 @@
//! FIFA 17 "My Squad" owned-player search: parse the RS4 `club` query and map
//! FIFA 17 wire encodings to the **game-independent** semantic values OpenFUT
//! Core understands.
//!
//! ## The boundary this enforces
//!
//! The FIFA 17 client sends its owned-player search as query params on
//! `GET /ut/game/fifa17/club`, e.g.
//! `?year=2017&type=player&count=11&level=gold&position=ST&nation=52&league=13&team=5&sort=desc&start=10`.
//! Two encoding families appear: **string enums** (`level`, `rare`, `position`)
//! and **numeric FIFA entity ids** (`nation`, `league`, `team`).
//!
//! **Numeric FIFA ids must never reach Core.** Core filters on semantic names
//! ("Premier League", "Chelsea", "Argentina"), so this adapter resolves each id
//! to a name via an injected [`EntityResolver`]. An id the resolver cannot map is
//! a hard [`MapError`] — never a silent passthrough of the raw number, which is
//! exactly how a game-specific id would leak into the generic layer.
//!
//! ## Evidence-grounded semantics (see vault Protocol Findings / Endpoint Map)
//!
//! * `level=gold` → semantic quality tier. Grounded in FIFA 17's own convention
//! (`fut_cards.py::tier`, gold ≥ 75). `level=any` (the always-present default)
//! → no quality constraint.
//! * `position` / `nation` / `league` / `team` → applied. The Python oracle
//! applied only `league`+`team`; applying the rest is a deliberate correction
//! of a proven bug, not a guess (each maps to a card attribute Core already
//! stores). FIFA `team` is Core `club`.
//! * `start` / `count` → semantic `offset` / `limit`. The oracle ignored both and
//! re-served page one forever; Core paginates for real. The client's 11-count /
//! 10-step windowing is a UI convention and stays out of Core.
//! * `sort=desc` → **dropped**. No sort key was ever proven (the oracle does not
//! sort); Core imposes its own deterministic order. We do not invent a named
//! FIFA sort mode.
//! * `rare=SP` ("Special") → **UNKNOWN and unsupported.** The oracle never reads
//! it and no committed metadata grounds "SP" to a card set. It is recorded in
//! [`CoreOwnedQuery::unsupported`] and deliberately produces **no** Core filter.
//!
//! ## Decoupling
//!
//! This module does not depend on `openfut-core`. The contract between the two is
//! the set of Core `/collection` query-parameter *names* emitted by
//! [`CoreOwnedQuery::to_query_pairs`]; they mirror Core's `OwnedItemQuery` fields
//! and are pinned by a test so drift is caught.
use std::collections::HashMap;
/// The FIFA 17 club-search query exactly as it arrives on the wire. Numeric
/// fields are FIFA entity ids that MUST be resolved before reaching Core.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Fifa17ClubQuery {
/// Quality filter: `any` (default, always present) or `gold`.
pub level: Option<String>,
/// "Special" filter (`SP`). Semantics UNKNOWN — never applied.
pub rare: Option<String>,
/// Playing position, e.g. `ST`.
pub position: Option<String>,
/// FIFA nation id (e.g. 52 = Argentina).
pub nation: Option<u32>,
/// FIFA league id (e.g. 13 = Premier League).
pub league: Option<u32>,
/// FIFA team id (e.g. 5 = Chelsea). Core calls this "club".
pub team: Option<u32>,
/// Client sort token (`desc`). No proven key; dropped.
pub sort: Option<String>,
/// Pagination offset.
pub start: Option<u32>,
/// Pagination page size.
pub count: Option<u32>,
}
/// Minimal percent/`+` decoding, dependency-free. FIFA sends bare tokens and
/// numeric ids, but names in general may be percent-encoded.
fn percent_decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let hex = |c: u8| (c as char).to_digit(16);
let mut i = 0;
while i < b.len() {
match b[i] {
b'+' => {
out.push(b' ');
i += 1;
}
b'%' if i + 2 < b.len() => match (hex(b[i + 1]), hex(b[i + 2])) {
(Some(hi), Some(lo)) => {
out.push((hi * 16 + lo) as u8);
i += 3;
}
_ => {
out.push(b'%');
i += 1;
}
},
c => {
out.push(c);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
/// Parse the raw query string into a [`Fifa17ClubQuery`].
///
/// Order-independent by construction (each key sets its own field), so HTTP
/// parameter order can never change the result. Unknown keys (`year`, `type`, …)
/// are ignored. A present-but-unparseable numeric id is treated as absent (the
/// retail client never sends one; absent is the safe, non-amplifying choice).
pub fn parse_club_query(query: &str) -> Fifa17ClubQuery {
let q = query.strip_prefix('?').unwrap_or(query);
let mut out = Fifa17ClubQuery::default();
for pair in q.split('&').filter(|p| !p.is_empty()) {
let (k, v) = match pair.split_once('=') {
Some((k, v)) => (k, percent_decode(v)),
None => (pair, String::new()),
};
match k {
"level" => out.level = Some(v),
"rare" => out.rare = Some(v),
"position" => out.position = Some(v),
"nation" => out.nation = v.parse().ok(),
"league" => out.league = v.parse().ok(),
"team" => out.team = v.parse().ok(),
"sort" => out.sort = Some(v),
"start" => out.start = v.parse().ok(),
"count" => out.count = v.parse().ok(),
_ => {}
}
}
out
}
/// Resolves FIFA 17 numeric entity ids to their semantic names. A real
/// implementation reads the game's `leagues`/`teams`/`nations` tables; tests use
/// [`StaticResolver`]. Returning `None` means "unknown id" and is fatal, by
/// design — the raw id must not flow onward.
pub trait EntityResolver {
fn league_name(&self, id: u32) -> Option<String>;
fn nation_name(&self, id: u32) -> Option<String>;
fn team_name(&self, id: u32) -> Option<String>;
}
/// A map-backed [`EntityResolver`] for tests and small deployments.
#[derive(Debug, Default, Clone)]
pub struct StaticResolver {
pub leagues: HashMap<u32, String>,
pub nations: HashMap<u32, String>,
pub teams: HashMap<u32, String>,
}
impl EntityResolver for StaticResolver {
fn league_name(&self, id: u32) -> Option<String> {
self.leagues.get(&id).cloned()
}
fn nation_name(&self, id: u32) -> Option<String> {
self.nations.get(&id).cloned()
}
fn team_name(&self, id: u32) -> Option<String> {
self.teams.get(&id).cloned()
}
}
/// A FIFA id that no resolver could map. Fatal on purpose: never fall back to
/// the raw id.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MapError {
UnknownLeague(u32),
UnknownNation(u32),
UnknownTeam(u32),
}
impl std::fmt::Display for MapError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MapError::UnknownLeague(id) => write!(f, "unknown FIFA league id {id}"),
MapError::UnknownNation(id) => write!(f, "unknown FIFA nation id {id}"),
MapError::UnknownTeam(id) => write!(f, "unknown FIFA team id {id}"),
}
}
}
impl std::error::Error for MapError {}
/// The semantic query handed to OpenFUT Core. Contains only game-independent
/// values: a quality tier string, entity **names**, and semantic offset/limit.
/// No FIFA ids.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct CoreOwnedQuery {
pub quality: Option<String>,
pub position: Option<String>,
pub nation: Option<String>,
pub league: Option<String>,
pub club: Option<String>,
pub offset: Option<i64>,
pub limit: Option<i64>,
/// "Special" filter (`rare=SP`): keep only special cards. Applied by the
/// HOST via the FIFA `rareflag` (which lives in the catalog, not Core) — so
/// it is NEVER a Core `/collection` param. See [`is_special_rareflag`].
pub special: bool,
/// Wire filters that were parsed but deliberately NOT applied because their
/// semantics are unproven (currently: `rare`/Special). Recorded, never guessed.
pub unsupported: Vec<&'static str>,
}
impl CoreOwnedQuery {
/// Core `/collection` query parameters. Param **names mirror**
/// `openfut_core::services::inventory::OwnedItemQuery` and are the wire
/// contract between this adapter and Core (pinned by test). `unsupported`
/// filters are intentionally absent.
pub fn to_query_pairs(&self) -> Vec<(&'static str, String)> {
let mut p = Vec::new();
if let Some(q) = &self.quality {
p.push(("quality", q.clone()));
}
if let Some(x) = &self.position {
p.push(("position", x.clone()));
}
if let Some(x) = &self.nation {
p.push(("nation", x.clone()));
}
if let Some(x) = &self.league {
p.push(("league", x.clone()));
}
if let Some(x) = &self.club {
p.push(("club", x.clone()));
}
if let Some(x) = self.offset {
p.push(("offset", x.to_string()));
}
if let Some(x) = self.limit {
p.push(("limit", x.to_string()));
}
p
}
}
/// Map a parsed FIFA 17 query to the semantic Core query, resolving every
/// numeric id to a name. Any unknown id is a hard error — the raw id never flows
/// through.
pub fn map_to_core(
q: &Fifa17ClubQuery,
resolver: &impl EntityResolver,
) -> Result<CoreOwnedQuery, MapError> {
// level: only the proven quality tiers map; "any"/absent → no constraint.
let quality = match q.level.as_deref() {
Some("gold") => Some("gold".to_string()),
Some("silver") => Some("silver".to_string()),
Some("bronze") => Some("bronze".to_string()),
_ => None,
};
// rare=SP → "Special" quality. Now GROUNDED via the observed FIFA rareflag
// (carried in the catalog): a special is rareflag > 1 (base rare = 1). The
// host applies it post-shape; Core never sees it. Any OTHER `rare` value
// stays genuinely unsupported (recorded, never guessed).
let special = matches!(q.rare.as_deref(), Some(s) if s.eq_ignore_ascii_case("SP"));
let mut unsupported = Vec::new();
if q.rare.is_some() && !special {
unsupported.push("rare");
}
let position = q.position.as_ref().map(|p| p.to_uppercase());
let nation = match q.nation {
Some(id) => Some(
resolver
.nation_name(id)
.ok_or(MapError::UnknownNation(id))?,
),
None => None,
};
let league = match q.league {
Some(id) => Some(
resolver
.league_name(id)
.ok_or(MapError::UnknownLeague(id))?,
),
None => None,
};
// FIFA "team" is Core "club".
let club = match q.team {
Some(id) => Some(resolver.team_name(id).ok_or(MapError::UnknownTeam(id))?),
None => None,
};
Ok(CoreOwnedQuery {
quality,
position,
nation,
league,
club,
offset: q.start.map(|s| s as i64),
limit: q.count.map(|c| c as i64),
special,
unsupported,
})
}
/// Whether a FIFA `rareflag` denotes a SPECIAL card (in-form/programme), as
/// opposed to a base card. Grounded in the observed profile + the FIFA 17
/// taxonomy: 0 = common, 1 = rare (both BASE gold/silver/bronze); every value
/// above 1 is a special programme (3 = TOTW, 21..=24 = programmes, etc.).
pub fn is_special_rareflag(rareflag: i64) -> bool {
rareflag > 1
}
#[cfg(test)]
mod tests {
use super::*;
fn resolver() -> StaticResolver {
// Confirmed against fifa17-recon/data/tables/{leagues,teams,nations}.json.
StaticResolver {
leagues: HashMap::from([(13, "Premier League".to_string())]),
nations: HashMap::from([(52, "Argentina".to_string())]),
teams: HashMap::from([(5, "Chelsea".to_string())]),
}
}
#[test]
fn parse_full_query() {
let q = parse_club_query(
"year=2017&type=player&count=11&level=gold&position=ST&nation=52&league=13&team=5&sort=desc&start=10",
);
assert_eq!(
q,
Fifa17ClubQuery {
level: Some("gold".into()),
rare: None,
position: Some("ST".into()),
nation: Some(52),
league: Some(13),
team: Some(5),
sort: Some("desc".into()),
start: Some(10),
count: Some(11),
}
);
}
#[test]
fn parse_is_parameter_order_independent() {
let a = parse_club_query("level=gold&league=13&position=ST&start=10&count=11");
let b = parse_club_query("count=11&start=10&position=ST&league=13&level=gold");
assert_eq!(a, b);
}
#[test]
fn parse_ignores_unknown_keys_and_omitted_optionals() {
let q = parse_club_query("year=2017&type=player&level=any&sort=desc");
assert_eq!(q.level.as_deref(), Some("any"));
assert!(q.rare.is_none() && q.position.is_none() && q.nation.is_none());
assert!(q.league.is_none() && q.team.is_none() && q.start.is_none());
}
#[test]
fn parse_percent_encoded_value() {
let q = parse_club_query("position=ST&rare=SP");
assert_eq!(q.position.as_deref(), Some("ST"));
assert_eq!(q.rare.as_deref(), Some("SP"));
}
#[test]
fn parse_malformed_numeric_is_absent() {
let q = parse_club_query("league=notanumber");
assert!(
q.league.is_none(),
"malformed id treated as absent, not applied"
);
}
#[test]
fn map_level_gold_to_quality() {
let core = map_to_core(&parse_club_query("level=gold"), &resolver()).unwrap();
assert_eq!(core.quality.as_deref(), Some("gold"));
}
#[test]
fn map_level_any_has_no_quality_filter() {
let core = map_to_core(&parse_club_query("level=any"), &resolver()).unwrap();
assert_eq!(core.quality, None, "'any' must NOT become a quality filter");
}
#[test]
fn map_rare_sp_sets_special_and_never_a_core_param() {
let core = map_to_core(&parse_club_query("level=any&rare=SP"), &resolver()).unwrap();
// rare=SP is now GROUNDED: a host-applied special flag, not "unsupported".
assert!(core.special, "rare=SP must set the special flag");
assert!(!core.unsupported.contains(&"rare"));
// still NEVER a Core predicate (Core has no rareflag) and no quality guess.
assert_eq!(core.quality, None);
let keys: Vec<&str> = core.to_query_pairs().into_iter().map(|(k, _)| k).collect();
assert!(!keys.contains(&"rare") && !keys.contains(&"special"));
}
#[test]
fn map_unknown_rare_value_stays_unsupported() {
let core = map_to_core(&parse_club_query("rare=WAT"), &resolver()).unwrap();
assert!(!core.special);
assert!(core.unsupported.contains(&"rare"));
}
#[test]
fn special_predicate_base_vs_special() {
assert!(!is_special_rareflag(0)); // common
assert!(!is_special_rareflag(1)); // rare gold (base)
assert!(is_special_rareflag(3)); // TOTW
assert!(is_special_rareflag(24)); // programme
}
#[test]
fn map_resolves_ids_to_semantic_names() {
let core =
map_to_core(&parse_club_query("nation=52&league=13&team=5"), &resolver()).unwrap();
assert_eq!(core.nation.as_deref(), Some("Argentina"));
assert_eq!(core.league.as_deref(), Some("Premier League"));
assert_eq!(
core.club.as_deref(),
Some("Chelsea"),
"FIFA team -> Core club"
);
}
#[test]
fn map_unknown_id_is_a_hard_error_not_passthrough() {
assert_eq!(
map_to_core(&parse_club_query("league=9999"), &resolver()),
Err(MapError::UnknownLeague(9999))
);
assert_eq!(
map_to_core(&parse_club_query("nation=9999"), &resolver()),
Err(MapError::UnknownNation(9999))
);
assert_eq!(
map_to_core(&parse_club_query("team=9999"), &resolver()),
Err(MapError::UnknownTeam(9999))
);
}
#[test]
fn no_raw_fifa_id_ever_reaches_core() {
// Every resolvable id becomes a name; a numeric string must never appear
// as a nation/league/club value in the Core-bound pairs.
let core =
map_to_core(&parse_club_query("nation=52&league=13&team=5"), &resolver()).unwrap();
for (k, v) in core.to_query_pairs() {
if matches!(k, "nation" | "league" | "club") {
assert!(
v.parse::<u32>().is_err(),
"{k}={v} looks like a raw FIFA id leaking into Core"
);
}
}
}
#[test]
fn map_start_count_to_offset_limit() {
let core = map_to_core(&parse_club_query("start=20&count=11"), &resolver()).unwrap();
assert_eq!(core.offset, Some(20));
assert_eq!(core.limit, Some(11));
}
#[test]
fn map_position_uppercased() {
let core = map_to_core(&parse_club_query("position=st"), &resolver()).unwrap();
assert_eq!(core.position.as_deref(), Some("ST"));
}
#[test]
fn sort_is_dropped_no_core_param() {
let core = map_to_core(&parse_club_query("sort=desc"), &resolver()).unwrap();
let keys: Vec<&str> = core.to_query_pairs().into_iter().map(|(k, _)| k).collect();
assert!(
!keys.contains(&"sort"),
"no proven FIFA sort key; must not emit one"
);
}
#[test]
fn core_query_param_names_mirror_core_contract() {
// Pins the wire contract with openfut-core's OwnedItemQuery field names.
let core = CoreOwnedQuery {
quality: Some("gold".into()),
position: Some("ST".into()),
nation: Some("Argentina".into()),
league: Some("Premier League".into()),
club: Some("Chelsea".into()),
offset: Some(10),
limit: Some(11),
special: false,
unsupported: vec![],
};
let keys: Vec<&str> = core.to_query_pairs().into_iter().map(|(k, _)| k).collect();
assert_eq!(
keys,
["quality", "position", "nation", "league", "club", "offset", "limit"]
);
}
#[test]
fn end_to_end_capture_shaped_query() {
// Mirrors the retail PAGINATION capture: PL + Chelsea, page 2.
let core = map_to_core(
&parse_club_query(
"year=2017&type=player&count=11&level=gold&nation=52&league=13&team=5&sort=desc&start=10",
),
&resolver(),
)
.unwrap();
assert_eq!(
core,
CoreOwnedQuery {
quality: Some("gold".into()),
position: None,
nation: Some("Argentina".into()),
league: Some("Premier League".into()),
club: Some("Chelsea".into()),
offset: Some(10),
limit: Some(11),
special: false,
unsupported: vec![],
}
);
}
}
@@ -0,0 +1,254 @@
//! FIFA 17 Store pack-content generator (pure, seeded).
//!
//! Draws the cards a Store pack awards. It is a **pure function** of
//! `(pack definition, RNG, candidate pool)` — no IO, no Core, no catalogue
//! lookup — so it is deterministic under a seeded [`rand::Rng`] and trivially
//! unit-tested. The host owns the impure parts: it builds the candidate pool
//! (only card ids that resolve in BOTH the FIFA catalogue and Core content),
//! mints the drawn cards into Core, and shapes them onto the wire.
//!
//! ## Parity note — Python `open_pack` / `_pack_body`
//! (`fifa17-recon/tools/fut_store.py:689`, `utas_server.py:3474`)
//! 1. `open_pack(price, count, gold, tiers, special_chance)` deducts coins then
//! draws `count` items (mostly players); the reveal body wraps them verbatim.
//! 2. Non-tiered draws split the pool at rating 75 by `gold` (`p[1] >= 75 == gold`)
//! and fall back to the whole pool when that tier is empty (`... or PACK_POOL`).
//! 3. Each drawn player becomes a special with probability `special_chance`
//! (`random.random() < special_chance`).
//! 4. `FUT_PACK_MIX` swaps ~`count // 4` players for consumables/staff extras;
//! we deliberately OMIT that mix (Core candidates are player defs — players-only).
//! 5. Prices/counts/odds are the OpenFUT **PLACEHOLDER** economy (the audit found
//! them invented); only the wire *shape* is EA-observed/oracle-verified.
//! 6. This port reproduces the count + gold-tier split + `special_chance` gate as
//! that same PLACEHOLDER policy, drawing with replacement from the pool.
use rand::Rng;
use crate::fut::store_catalog::PackDef;
/// A candidate the host has already verified resolves in BOTH the FIFA catalogue
/// and Core content. Carries the full Core definition the shaper needs plus the
/// two draw-policy annotations (`gold` tier, `special` version) the host derives
/// from the catalogue (keeping this generator pure — it never reads a catalogue).
#[derive(Debug, Clone)]
pub struct GeneratedCandidate {
/// Core card-definition id (resolves in the FIFA catalogue and Core content).
pub card_id: String,
pub rating: u8,
pub position: String,
pub nation: String,
pub league: String,
pub club: String,
/// [pace, shooting, passing, dribbling, defending, physical].
pub attributes: [u8; 6],
/// Gold tier (host derives this as `rating >= 75`, the oracle's split point).
pub gold: bool,
/// Special version available (host derives this from the catalogue rareflag
/// `> 1`); gated by [`PackDef::special_chance`].
pub special: bool,
}
impl GeneratedCandidate {
fn to_card(&self) -> GeneratedCard {
GeneratedCard {
card_id: self.card_id.clone(),
rating: self.rating,
position: self.position.clone(),
nation: self.nation.clone(),
league: self.league.clone(),
club: self.club.clone(),
attributes: self.attributes,
}
}
}
/// One card a pack awarded. Carries `card_id` (the Core definition the host mints
/// and shapes) plus the definition fields the shared item shaper needs. It is
/// NOT an owned instance yet — the host mints the Core instance id and allocates
/// the numeric wire id.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GeneratedCard {
pub card_id: String,
pub rating: u8,
pub position: String,
pub nation: String,
pub league: String,
pub club: String,
pub attributes: [u8; 6],
}
/// Draw `pack.count` cards from `pool` with the injected RNG. Pure and
/// deterministic under a seeded RNG. Returns an empty `Vec` (fail-closed) when
/// the pool is empty or the pack awards no cards.
///
/// Policy (PLACEHOLDER — see the module parity note): draw with replacement from
/// the pack's tier (`gold`), biasing each draw toward a special card with
/// probability `special_chance`. An empty tier or partition falls back to the
/// next-wider set so a draw is always possible when the pool is non-empty.
pub fn generate_pack_contents(
pack: &PackDef,
rng: &mut impl Rng,
pool: &[GeneratedCandidate],
) -> Vec<GeneratedCard> {
if pool.is_empty() || pack.count == 0 {
return Vec::new();
}
// Tier split: a gold pack draws gold-tier candidates, a non-gold pack draws
// non-gold; an empty tier falls back to the whole pool (oracle `... or POOL`).
let tier: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.gold == pack.gold).collect();
let tier: Vec<&GeneratedCandidate> = if tier.is_empty() {
pool.iter().collect()
} else {
tier
};
// Partition the tier by special so `special_chance` can bias a draw; either
// partition falls back to the whole tier when empty.
let special: Vec<&GeneratedCandidate> = tier.iter().copied().filter(|c| c.special).collect();
let normal: Vec<&GeneratedCandidate> = tier.iter().copied().filter(|c| !c.special).collect();
let chance = pack.special_chance.clamp(0.0, 1.0);
let mut out = Vec::with_capacity(pack.count as usize);
for _ in 0..pack.count {
let want_special = chance > 0.0 && rng.gen_bool(chance);
let sub: &[&GeneratedCandidate] = if want_special && !special.is_empty() {
&special
} else if !want_special && !normal.is_empty() {
&normal
} else {
&tier
};
let pick = sub[rng.gen_range(0..sub.len())];
out.push(pick.to_card());
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use rand::rngs::StdRng;
use rand::SeedableRng;
fn cand(card: &str, rating: u8, gold: bool, special: bool) -> GeneratedCandidate {
GeneratedCandidate {
card_id: card.into(),
rating,
position: "ST".into(),
nation: "Brazil".into(),
league: "Premier League".into(),
club: "Arsenal".into(),
attributes: [rating; 6],
gold,
special,
}
}
/// A mixed pool: gold specials, gold normals, and a bronze tier.
fn pool() -> Vec<GeneratedCandidate> {
vec![
cand("g-sp-1", 90, true, true),
cand("g-sp-2", 88, true, true),
cand("g-1", 84, true, false),
cand("g-2", 82, true, false),
cand("g-3", 79, true, false),
cand("b-1", 64, false, false),
cand("b-2", 62, false, false),
]
}
fn pack(id: u64, count: u64, gold: bool, special_chance: f64) -> PackDef {
PackDef {
id,
name: "Test Pack",
price: 1000,
count,
gold,
special_chance,
owned_only: false,
}
}
#[test]
fn same_seed_same_output() {
let pool = pool();
let p = pack(5, 7, true, 0.3);
let mut a = StdRng::seed_from_u64(42);
let mut b = StdRng::seed_from_u64(42);
assert_eq!(
generate_pack_contents(&p, &mut a, &pool),
generate_pack_contents(&p, &mut b, &pool)
);
}
#[test]
fn different_seeds_can_diverge() {
let pool = pool();
let p = pack(5, 7, true, 0.3);
let a = generate_pack_contents(&p, &mut StdRng::seed_from_u64(1), &pool);
let b = generate_pack_contents(&p, &mut StdRng::seed_from_u64(999), &pool);
// Not a hard guarantee, but with this pool/count the two seeds differ.
assert_ne!(a, b);
}
#[test]
fn count_is_exact_and_all_cards_from_pool() {
let pool = pool();
let ids: std::collections::HashSet<&str> =
pool.iter().map(|c| c.card_id.as_str()).collect();
for &n in &[1u64, 5, 7, 11] {
let p = pack(6, n, true, 0.08);
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(n), &pool);
assert_eq!(cards.len() as u64, n);
for c in &cards {
assert!(
ids.contains(c.card_id.as_str()),
"drew unknown card {}",
c.card_id
);
}
}
}
#[test]
fn gold_pack_draws_only_gold_tier() {
let pool = pool();
let p = pack(5, 20, true, 0.03);
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(7), &pool);
assert!(
cards.iter().all(|c| c.rating >= 75),
"gold pack drew a bronze card"
);
}
#[test]
fn bronze_pack_draws_only_bronze_tier() {
let pool = pool();
let p = pack(1, 20, false, 0.005);
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(7), &pool);
assert!(
cards.iter().all(|c| c.rating < 75),
"bronze pack drew a gold card"
);
}
#[test]
fn special_chance_one_draws_only_specials() {
let pool = pool();
let special_ids: std::collections::HashSet<&str> = pool
.iter()
.filter(|c| c.special)
.map(|c| c.card_id.as_str())
.collect();
let p = pack(7, 11, true, 1.0);
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(3), &pool);
assert!(cards
.iter()
.all(|c| special_ids.contains(c.card_id.as_str())));
}
#[test]
fn empty_pool_fails_closed() {
let p = pack(5, 7, true, 0.03);
assert!(generate_pack_contents(&p, &mut StdRng::seed_from_u64(1), &[]).is_empty());
}
}
+346
View File
@@ -0,0 +1,346 @@
//! FIFA 17 squad **full-replacement** wire (`PUT /ut/game/fifa17/squad/<id>`) →
//! a game-independent proposed replacement OpenFUT Core can apply.
//!
//! ## Scope (deliberately preparatory — nothing is routed yet)
//!
//! This module parses the captured squad-save wire and reverse-maps each slot's
//! FIFA wire item id to a Core owned-instance id, producing a [`ProposedSquad`].
//! It does **not** open a socket, call Core, or mutate state — squad is a
//! *stateful* slice and its GET (retrieval) and PUT (save) must migrate together
//! as one authority, which needs more captured evidence first. Until then this is
//! pure, unit-tested scaffolding.
//!
//! ## What the wire proves (2 captured retail saves, `fixtures/utas/`)
//!
//! * The client sends the **whole** squad on every save — a fixed 23-slot array
//! plus `formation`, `captain`, `kicktakers`, a 33-int `custom` string, and
//! client-reported `chemistry`/`rating`/`starRating`. A two-player edit changed
//! nine slots, so slot deltas never describe intent: the only honest operation
//! is *this is the squad now*.
//! * Each occupied slot carries its FIFA **wire item id** (`itemData.id`, the same
//! namespace as `/club` and the identity store); an **empty** slot is `id == 0`.
//! * `captain` and `kicktakers` reference the same wire item id space.
//!
//! ## What it does NOT prove (kept UNKNOWN, never invented)
//!
//! * The `index → (slot, bench)` layout for formations other than **f442**, and
//! the meaning/stability of the `custom` 33-int array. `custom` is preserved
//! **opaquely** so it can round-trip unchanged; its integers are not decoded.
//! * FIFA's chemistry/rating algorithm — client-reported values are carried in
//! [`ClientReportedSquadEval`] and never reconciled with Core's own evaluation.
use serde::{Deserialize, Serialize};
/// A `{ "id": <wire item id>, "dream": bool }` reference (player, manager, …).
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SquadEntityRef {
pub id: i64,
#[serde(default)]
pub dream: bool,
}
/// One entry of the fixed-length `players` array.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SquadSlotWire {
pub index: i64,
pub item_data: SquadEntityRef,
#[serde(default)]
pub kit_number: i64,
}
/// A `kicktakers` entry (penalty/corner/free-kick roles). Carries a wire item id;
/// role semantics are UNKNOWN and not modelled.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SquadKicktaker {
pub index: i64,
pub id: i64,
#[serde(default)]
pub dream: bool,
}
/// The squad-save body exactly as FIFA 17 sends it. FIFA-only fields
/// (`custom`, `kicktakers`, `kit_number`, `manager`, `squad_type`) are captured
/// verbatim; their persistence/reconstruction rules await more evidence.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Fifa17SquadPut {
/// Squad id (the path `…/squad/0` and the body agree; `0` = active).
#[serde(default)]
pub id: i64,
#[serde(default)]
pub squad_name: Option<String>,
#[serde(default)]
pub formation: Option<String>,
#[serde(default)]
pub squad_type: Option<String>,
#[serde(default)]
pub chemistry: Option<i64>,
#[serde(default)]
pub rating: Option<i64>,
#[serde(default)]
pub star_rating: Option<i64>,
/// Wire item id of the captain (must be one of the occupied slots).
#[serde(default)]
pub captain: Option<i64>,
/// Opaque 33-int array as a JSON-encoded string. Semantics UNKNOWN — carried
/// verbatim, never parsed or interpreted.
#[serde(default)]
pub custom: Option<String>,
#[serde(default)]
pub manager: Vec<SquadEntityRef>,
#[serde(default)]
pub players: Vec<SquadSlotWire>,
#[serde(default)]
pub kicktakers: Vec<SquadKicktaker>,
}
/// Reverse-maps a FIFA wire item id to a Core owned-instance id. Implemented by
/// the host over `Fifa17IdentityResolver`; `None` = unknown id (never guessed).
pub trait SquadWireResolver {
fn owned_id_for_wire(&self, wire: i64) -> Option<String>;
}
/// Client-reported squad evaluation. Kept DISTINCT from Core's authoritative
/// evaluation and never reconciled — FIFA's chemistry/rating algorithm is UNKNOWN.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClientReportedSquadEval {
pub chemistry: Option<i64>,
pub rating: Option<i64>,
pub star_rating: Option<i64>,
}
/// One resolved slot of a proposed replacement (game-independent).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProposedSlot {
pub owned_card_id: String,
/// FIFA player-array index (0-based). Core imposes its own slot numbering;
/// the adapter carries the index plus a bench flag from the f442 convention.
pub index: i64,
pub kit_number: i64,
pub is_captain: bool,
pub is_on_bench: bool,
}
/// A full-squad replacement in **canonical** (game-independent) terms, ready for
/// the host to map onto Core's `SquadReplacement`/`SaveSquadRequest`. Carries no
/// FIFA-only state (that is [`crate::fut::squad_ext::Fifa17SquadExtensionV1`])
/// and no `openfut-core` dependency. No FIFA wire integer survives into a slot —
/// every player is a Core `owned_card_id`.
#[derive(Debug, Clone)]
pub struct ProposedSquad {
/// The FIFA wire squad id the PUT targeted (`0` = the active squad). Routing
/// only — it selects which Core squad to replace; it is never a Core field.
pub squad_id: i64,
pub name: Option<String>,
/// The FIFA formation token exactly as sent (e.g. `"f442"`, `"f433"`). Core
/// stores it verbatim as its opaque formation token and never interprets it,
/// so it round-trips exactly — never mapped to a second representation and
/// never used to derive slot layout.
pub formation: Option<String>,
pub slots: Vec<ProposedSlot>,
/// Occupied wire item ids the resolver could not map. A caller MUST refuse the
/// replacement if this is non-empty — a save must never silently drop an
/// owned player it failed to identify.
pub unresolved_wire_ids: Vec<i64>,
}
/// Parse errors — explicit, never a silent empty squad.
#[derive(Debug, PartialEq, Eq)]
pub enum SquadError {
Parse(String),
}
impl std::fmt::Display for SquadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SquadError::Parse(e) => write!(f, "squad wire parse error: {e}"),
}
}
}
impl std::error::Error for SquadError {}
/// The FIFA 17 squad wire is a fixed 23-slot array: indices `0..=10` are the
/// pitch (the 11 starters), `11..=22` are bench/reserves. This layout is a
/// property of the array, not of the formation — the captured f442 and f433
/// saves both place their 11 starters at `0..=10`. Bench membership is therefore
/// derived from the index alone, NEVER from the formation token.
pub const FIFA17_STARTER_SLOTS: i64 = 11;
/// Length of the fixed FIFA 17 squad slot array (evidence: every captured save
/// and read carries exactly 23 slots).
pub const FIFA17_SQUAD_SLOTS: i64 = 23;
/// Parse a squad-save body into the typed wire form. Structural only.
pub fn parse_squad_put(body: &[u8]) -> Result<Fifa17SquadPut, SquadError> {
serde_json::from_slice(body).map_err(|e| SquadError::Parse(e.to_string()))
}
/// Resolve a parsed save into a **canonical** [`ProposedSquad`]: drop empty
/// (`id == 0`) slots, reverse-map each occupied slot's wire id to a Core
/// `owned_card_id`, flag the captain, and derive the bench split from the fixed
/// 23-slot array. FIFA-only state (`custom`, manager, kicktakers, kit numbers,
/// squadType) and client-reported evaluation are NOT canonical — they are built
/// separately into [`crate::fut::squad_ext::Fifa17SquadExtensionV1`]. The
/// formation token is carried verbatim (never mapped). Unresolvable occupied ids
/// are reported, never guessed or dropped.
pub fn to_proposed(put: &Fifa17SquadPut, resolver: &dyn SquadWireResolver) -> ProposedSquad {
let captain = put.captain.unwrap_or(0);
let mut slots = Vec::new();
let mut unresolved = Vec::new();
for p in &put.players {
if p.item_data.id == 0 {
continue; // empty slot — never a Core player
}
match resolver.owned_id_for_wire(p.item_data.id) {
Some(owned_card_id) => slots.push(ProposedSlot {
owned_card_id,
index: p.index,
kit_number: p.kit_number,
is_captain: captain != 0 && p.item_data.id == captain,
is_on_bench: p.index >= FIFA17_STARTER_SLOTS,
}),
None => unresolved.push(p.item_data.id),
}
}
ProposedSquad {
squad_id: put.id,
name: put.squad_name.clone(),
formation: put.formation.clone(),
slots,
unresolved_wire_ids: unresolved,
}
}
/// The save acknowledgement FIFA expects: just the squad id (matches the oracle's
/// `{"id": <n>}`, 9 bytes — it does NOT echo the squad).
pub fn save_ack(squad_id: i64) -> serde_json::Value {
serde_json::json!({ "id": squad_id })
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
/// The real captured f442 save body (decoded from `session-001.jsonl:21`).
const PUT_F442: &str = include_str!("../../fixtures/utas/squad_put_f442.json");
/// A resolver mapping every occupied wire id in the fixture to a Core id.
struct MapResolver(HashMap<i64, String>);
impl SquadWireResolver for MapResolver {
fn owned_id_for_wire(&self, wire: i64) -> Option<String> {
self.0.get(&wire).cloned()
}
}
fn full_resolver() -> MapResolver {
// indices 0..=10 (11 starters); the rest of the 23 slots are id==0 (empty).
let ids = [
100000003, 100000010, 100000005, 100000008, 100000007, 100000006, 100000004, 100000009,
100000001, 100000002, 100000025,
];
MapResolver(ids.iter().map(|&w| (w, format!("oc-{w}"))).collect())
}
#[test]
fn parses_the_captured_full_squad_wire() {
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
assert_eq!(put.id, 0, "path/body squad id 0 = active");
assert_eq!(put.formation.as_deref(), Some("f442"));
assert_eq!(put.squad_type.as_deref(), Some("REGULAR_SQUAD"));
assert_eq!(put.chemistry, Some(52));
assert_eq!(put.rating, Some(90));
assert_eq!(put.star_rating, Some(90));
assert_eq!(put.captain, Some(100000001));
assert_eq!(put.players.len(), 23, "fixed 23-slot array");
assert_eq!(put.kicktakers.len(), 5);
// custom is carried opaquely, never parsed.
assert!(put.custom.as_deref().unwrap().starts_with("[0,0,0"));
}
#[test]
fn resolves_occupied_slots_drops_empties_and_flags_captain() {
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
let sq = to_proposed(&put, &full_resolver());
assert_eq!(sq.slots.len(), 11, "11 occupied; 12 empty (id==0) dropped");
assert!(
sq.unresolved_wire_ids.is_empty(),
"all occupied ids resolved"
);
assert_eq!(
sq.formation.as_deref(),
Some("f442"),
"formation token carried verbatim, never mapped"
);
// Every occupied f442 slot is a starter (indices 0..=10).
assert!(sq.slots.iter().all(|s| !s.is_on_bench));
// The captain flag lands on exactly the captain's slot (id 100000001 @ index 8).
let caps: Vec<_> = sq.slots.iter().filter(|s| s.is_captain).collect();
assert_eq!(caps.len(), 1);
assert_eq!(caps[0].owned_card_id, "oc-100000001");
assert_eq!(caps[0].index, 8);
assert_eq!(caps[0].kit_number, 8);
}
#[test]
fn unresolved_occupied_id_is_reported_never_dropped_silently() {
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
// Resolver missing one occupied id (100000025).
let mut ids = full_resolver().0;
ids.remove(&100000025);
let sq = to_proposed(&put, &MapResolver(ids));
assert_eq!(
sq.slots.len(),
10,
"the unresolved slot is not emitted as a player"
);
assert_eq!(
sq.unresolved_wire_ids,
vec![100000025],
"reported for the caller to refuse"
);
}
#[test]
fn empty_slot_is_dropped_not_resolved() {
// A minimal body: one occupied + one empty slot.
let body = br#"{"id":0,"formation":"f442","captain":0,"players":[
{"index":0,"itemData":{"id":100000003},"kitNumber":1},
{"index":11,"itemData":{"id":0},"kitNumber":0}]}"#;
let put = parse_squad_put(body).unwrap();
let sq = to_proposed(&put, &full_resolver());
assert_eq!(sq.slots.len(), 1);
assert_eq!(sq.slots[0].index, 0);
assert!(sq.unresolved_wire_ids.is_empty());
}
#[test]
fn formation_token_round_trips_verbatim() {
// Canonical formation is the FIFA token as-sent; f433 is preserved as
// readily as f442 (the old lossy f442->"4-4-2" map is gone).
for tok in ["f442", "f433"] {
let body = format!(
r#"{{"id":0,"formation":"{tok}","captain":0,"players":[{{"index":0,"itemData":{{"id":100000003}},"kitNumber":1}}]}}"#
);
let sq = to_proposed(&parse_squad_put(body.as_bytes()).unwrap(), &full_resolver());
assert_eq!(sq.formation.as_deref(), Some(tok));
}
}
#[test]
fn save_ack_is_the_bare_id() {
assert_eq!(save_ack(0), serde_json::json!({ "id": 0 }));
assert_eq!(save_ack(7), serde_json::json!({ "id": 7 }));
}
#[test]
fn malformed_body_errors_never_empty_squad() {
assert!(matches!(
parse_squad_put(b"not json"),
Err(SquadError::Parse(_))
));
}
}
+386
View File
@@ -0,0 +1,386 @@
//! FIFA 17 **Squad Extension v1** — the adapter-owned, versioned payload that
//! carries the squad's FIFA-only wire state that OpenFUT Core does not model.
//!
//! Core stores this serialized payload **opaquely** (never interpreting it) next
//! to the canonical squad, anchored by a server fingerprint. This module owns its
//! schema and meaning; Core must never import this type.
//!
//! ## What lives here (and why it is not canonical)
//!
//! | field | ownership rationale |
//! |-------|---------------------|
//! | `custom` | opaque 33-int string; meaning UNKNOWN, round-tripped verbatim |
//! | `squad_type` | an observed FIFA wire token; no matching generic Core concept |
//! | `kit_numbers` | keyed by **`owned_card_id`** — evidence: kit follows the player |
//! | `manager` | a FIFA manager item ref; not a squad player, semantics opaque |
//! | `kicktakers` | role→item refs; relationship to captain UNKNOWN, kept opaque |
//! | `client_reported` | chemistry/rating/starRating — client shadow, NOT authority |
//!
//! ## Versioning
//!
//! Two independent version numbers must not be confused:
//! * [`EXT_SCHEMA_VERSION`] — the version of **this** payload schema. Stored in
//! Core's `game_entity_ext.schema_version` and re-checked on read
//! ([`Fifa17SquadExtensionV1::from_payload`] rejects any other version).
//! * Core's own DB storage schema version — a Core concern, unrelated to this.
//!
//! [`EXT_NAMESPACE`] is the opaque scope key Core files the row under.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::fut::squad::{ClientReportedSquadEval, Fifa17SquadPut, ProposedSquad, SquadEntityRef};
/// Opaque scope key Core files this extension under (`game_entity_ext.namespace`).
pub const EXT_NAMESPACE: &str = "fifa17.squad";
/// The version of THIS payload schema (goes into `OpaqueExtensionWrite.schema_version`).
/// Distinct from Core's DB storage schema version.
pub const EXT_SCHEMA_VERSION: i64 = 1;
/// A FIFA item reference `{ id, dream }` preserved verbatim from the wire. `id`
/// is the FIFA wire item id (opaque to this extension — used for manager and
/// kicktaker refs whose semantics are not modelled).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WireItemRef {
pub id: i64,
#[serde(default)]
pub dream: bool,
}
impl From<&SquadEntityRef> for WireItemRef {
fn from(r: &SquadEntityRef) -> Self {
WireItemRef {
id: r.id,
dream: r.dream,
}
}
}
/// A kicktaker slot preserved verbatim. `index` is the role slot (0..=4 observed);
/// `item` is the referenced FIFA wire item. The role→player meaning and any
/// relationship to the captain are UNKNOWN, so this is stored opaquely and never
/// normalized to the captain or to a Core `owned_card_id`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KicktakerRef {
pub index: i64,
#[serde(flatten)]
pub item: WireItemRef,
}
/// FIFA 17 Squad Extension, version 1. Serialized to the opaque payload Core stores.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Fifa17SquadExtensionV1 {
/// Opaque 33-int array as a JSON-encoded string, verbatim. Never decoded.
#[serde(default)]
pub custom: Option<String>,
/// FIFA squad-type wire token (e.g. `"REGULAR_SQUAD"`).
#[serde(default)]
pub squad_type: Option<String>,
/// kit number per player, keyed by Core `owned_card_id`. Keyed by the player
/// instance — NEVER by slot/index or by card definition — because the wire
/// proves the kit number follows the player across swaps and formation change.
#[serde(default)]
pub kit_numbers: BTreeMap<String, i64>,
/// Manager item ref(s), opaque. Not a squad player; not shaped as an item.
#[serde(default)]
pub manager: Vec<WireItemRef>,
/// Kicktaker role refs, opaque (see [`KicktakerRef`]).
#[serde(default)]
pub kicktakers: Vec<KicktakerRef>,
/// Client-reported evaluation (shadow state). NEVER Core's authoritative
/// evaluation and never reconciled with it.
#[serde(default)]
pub client_reported: ClientReportedSquadEval,
}
/// Errors reading a stored extension payload.
#[derive(Debug, PartialEq, Eq)]
pub enum ExtError {
/// The stored `schema_version` is not one this adapter understands. Never
/// silently coerced — the caller decides (e.g. treat as unreadable).
UnsupportedSchemaVersion(i64),
/// The payload bytes did not deserialize as this schema.
Parse(String),
}
impl std::fmt::Display for ExtError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ExtError::UnsupportedSchemaVersion(v) => {
write!(f, "unsupported fifa17 squad extension schema version {v} (want {EXT_SCHEMA_VERSION})")
}
ExtError::Parse(e) => write!(f, "fifa17 squad extension parse error: {e}"),
}
}
}
impl std::error::Error for ExtError {}
impl Fifa17SquadExtensionV1 {
/// Build the extension from the parsed PUT plus the resolved canonical squad.
/// `custom`/`squad_type`/manager/kicktakers/client eval come straight off the
/// wire; kit numbers are re-keyed from slot index onto the resolved
/// `owned_card_id` so they stay bound to the player, not the slot.
pub fn from_put(put: &Fifa17SquadPut, canonical: &ProposedSquad) -> Self {
let kit_numbers = canonical
.slots
.iter()
.map(|s| (s.owned_card_id.clone(), s.kit_number))
.collect();
Fifa17SquadExtensionV1 {
custom: put.custom.clone(),
squad_type: put.squad_type.clone(),
kit_numbers,
manager: put.manager.iter().map(WireItemRef::from).collect(),
kicktakers: put
.kicktakers
.iter()
.map(|k| KicktakerRef {
index: k.index,
item: WireItemRef {
id: k.id,
dream: k.dream,
},
})
.collect(),
client_reported: ClientReportedSquadEval {
chemistry: put.chemistry,
rating: put.rating,
star_rating: put.star_rating,
},
}
}
/// Serialize to the opaque payload string Core stores.
pub fn to_payload(&self) -> String {
// Infallible for this type (no maps with non-string keys, no floats).
serde_json::to_string(self).expect("fifa17 squad extension serializes")
}
/// Parse a stored payload, enforcing the schema version FIRST. A version this
/// adapter does not understand is rejected — never coerced or ignored.
pub fn from_payload(schema_version: i64, payload: &str) -> Result<Self, ExtError> {
if schema_version != EXT_SCHEMA_VERSION {
return Err(ExtError::UnsupportedSchemaVersion(schema_version));
}
serde_json::from_str(payload).map_err(|e| ExtError::Parse(e.to_string()))
}
}
/// Errors building a squad write from a PUT. A save is refused, never silently
/// degraded, when it cannot be expressed faithfully as a canonical replacement.
#[derive(Debug, PartialEq, Eq)]
pub enum SquadBuildError {
/// One or more occupied wire ids did not reverse-map to a Core owned item.
/// Saving would silently drop an owned player — refused.
UnresolvedWireIds(Vec<i64>),
/// The same Core owned item appears in two slots. A full replacement cannot
/// place one instance twice (two *copies* of a definition are distinct owned
/// items and are fine — this is the same `owned_card_id` twice).
DuplicateOwnedItem(String),
}
impl std::fmt::Display for SquadBuildError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SquadBuildError::UnresolvedWireIds(ids) => {
write!(f, "unresolved FIFA wire item ids (save refused): {ids:?}")
}
SquadBuildError::DuplicateOwnedItem(id) => {
write!(f, "owned item {id} placed in two slots (save refused)")
}
}
}
}
impl std::error::Error for SquadBuildError {}
/// The adapter's PUT-build output: a canonical replacement plus the FIFA-only
/// extension. The host maps `canonical` onto Core's `SquadReplacement` and
/// serializes `extension` into an `OpaqueExtensionWrite`
/// (`namespace = EXT_NAMESPACE`, `schema_version = EXT_SCHEMA_VERSION`,
/// `payload = extension.to_payload()`) so both commit in one Core transaction.
#[derive(Debug, Clone)]
pub struct SquadWriteBuild {
pub canonical: ProposedSquad,
pub extension: Fifa17SquadExtensionV1,
}
/// Build a full-replacement squad write from a parsed PUT and a host-supplied
/// wire→owned resolver. Refuses (never degrades) on any unresolved occupied id
/// or a duplicate owned item.
///
/// The resolver only maps identity; it is NOT authorization. The host still
/// verifies every resolved `owned_card_id` belongs to the active FIFA 17
/// profile/club before committing — a resolvable id is not proof of ownership.
pub fn build_squad_write(
put: &Fifa17SquadPut,
resolver: &dyn crate::fut::squad::SquadWireResolver,
) -> Result<SquadWriteBuild, SquadBuildError> {
let canonical = crate::fut::squad::to_proposed(put, resolver);
if !canonical.unresolved_wire_ids.is_empty() {
return Err(SquadBuildError::UnresolvedWireIds(
canonical.unresolved_wire_ids.clone(),
));
}
let mut seen = std::collections::HashSet::new();
for slot in &canonical.slots {
if !seen.insert(slot.owned_card_id.as_str()) {
return Err(SquadBuildError::DuplicateOwnedItem(
slot.owned_card_id.clone(),
));
}
}
let extension = Fifa17SquadExtensionV1::from_put(put, &canonical);
Ok(SquadWriteBuild {
canonical,
extension,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fut::squad::{parse_squad_put, SquadWireResolver};
use std::collections::HashMap;
const PUT_F442: &str = include_str!("../../fixtures/utas/squad_put_f442.json");
struct MapResolver(HashMap<i64, String>);
impl SquadWireResolver for MapResolver {
fn owned_id_for_wire(&self, wire: i64) -> Option<String> {
self.0.get(&wire).cloned()
}
}
fn full_resolver() -> MapResolver {
let ids = [
100000003, 100000010, 100000005, 100000008, 100000007, 100000006, 100000004, 100000009,
100000001, 100000002, 100000025,
];
MapResolver(ids.iter().map(|&w| (w, format!("oc-{w}"))).collect())
}
fn built() -> SquadWriteBuild {
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
build_squad_write(&put, &full_resolver()).unwrap()
}
#[test]
fn serde_round_trips_the_whole_extension() {
let ext = built().extension;
let payload = ext.to_payload();
let back = Fifa17SquadExtensionV1::from_payload(EXT_SCHEMA_VERSION, &payload).unwrap();
assert_eq!(ext, back);
}
#[test]
fn custom_is_preserved_byte_for_byte() {
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
let ext = built().extension;
assert_eq!(ext.custom, put.custom, "opaque custom carried verbatim");
// survives a serialize/parse cycle unchanged.
let back =
Fifa17SquadExtensionV1::from_payload(EXT_SCHEMA_VERSION, &ext.to_payload()).unwrap();
assert_eq!(back.custom, put.custom);
}
#[test]
fn kit_number_is_keyed_by_owned_item_not_slot() {
let ext = built().extension;
// In the f442 fixture, owned oc-100000001 (captain) wears kit 8 at index 8;
// oc-100000025 wears kit 11 at index 10. Keyed by owned id, not index.
assert_eq!(ext.kit_numbers.get("oc-100000001"), Some(&8));
assert_eq!(ext.kit_numbers.get("oc-100000025"), Some(&11));
assert_eq!(ext.kit_numbers.len(), 11, "one per occupied slot");
}
#[test]
fn client_reported_eval_is_carried_as_shadow() {
let ext = built().extension;
assert_eq!(
ext.client_reported,
ClientReportedSquadEval {
chemistry: Some(52),
rating: Some(90),
star_rating: Some(90)
}
);
}
#[test]
fn manager_and_kicktakers_preserved_opaquely() {
let ext = built().extension;
assert_eq!(
ext.manager,
vec![WireItemRef {
id: 100000427,
dream: false
}]
);
assert_eq!(ext.kicktakers.len(), 5);
// All five reference the same wire id in this capture; carried verbatim,
// NEVER normalized to the captain even though they coincide here.
assert!(ext.kicktakers.iter().all(|k| k.item.id == 100000001));
assert_eq!(ext.kicktakers[0].index, 0);
}
#[test]
fn unknown_schema_version_is_rejected_not_coerced() {
let payload = built().extension.to_payload();
assert_eq!(
Fifa17SquadExtensionV1::from_payload(2, &payload),
Err(ExtError::UnsupportedSchemaVersion(2))
);
assert_eq!(
Fifa17SquadExtensionV1::from_payload(0, &payload),
Err(ExtError::UnsupportedSchemaVersion(0))
);
}
#[test]
fn malformed_payload_errors() {
assert!(matches!(
Fifa17SquadExtensionV1::from_payload(EXT_SCHEMA_VERSION, "not json"),
Err(ExtError::Parse(_))
));
}
#[test]
fn build_refuses_unresolved_wire_ids() {
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
let mut ids = full_resolver().0;
ids.remove(&100000025);
let err = build_squad_write(&put, &MapResolver(ids)).unwrap_err();
assert_eq!(err, SquadBuildError::UnresolvedWireIds(vec![100000025]));
}
#[test]
fn build_refuses_duplicate_owned_item() {
// Two occupied slots resolving to the SAME owned id (one instance twice).
let body = br#"{"id":0,"formation":"f442","captain":100000003,"players":[
{"index":0,"itemData":{"id":100000003},"kitNumber":1},
{"index":1,"itemData":{"id":100000004},"kitNumber":2}]}"#;
let put = parse_squad_put(body).unwrap();
let mut m = HashMap::new();
m.insert(100000003, "oc-dup".to_string());
m.insert(100000004, "oc-dup".to_string()); // collide onto same owned id
let err = build_squad_write(&put, &MapResolver(m)).unwrap_err();
assert_eq!(
err,
SquadBuildError::DuplicateOwnedItem("oc-dup".to_string())
);
}
#[test]
fn full_replacement_carries_every_occupied_slot_no_diff() {
let build = built();
assert_eq!(build.canonical.slots.len(), 11, "whole squad, not a diff");
assert_eq!(build.canonical.formation.as_deref(), Some("f442"));
// No FIFA wire integer survives into the canonical slots.
assert!(build
.canonical
.slots
.iter()
.all(|s| s.owned_card_id.starts_with("oc-")));
}
}
@@ -0,0 +1,426 @@
//! The **single** FIFA 17 squad projector: canonical Core squad + Fresh FIFA
//! extension → the FIFA 17 squad wire object.
//!
//! One projector serves every squad read shape. `userMassInfo.squad` embeds the
//! full object; `GET /squad/list` is a summary *subset* of it; a future
//! `/squad/active` is the same object again. Endpoint wrappers ([`user_mass_info_squad`],
//! [`squad_list`]) only shape the outer envelope — there is deliberately no
//! second squad domain model per endpoint.
//!
//! ## Purity / no N+1
//!
//! The projector touches no database, socket, or Core API. It consumes a
//! [`SquadProjectionInput`] the host assembles from ONE bounded batch — Core's
//! `read_squad_with_ext` (canonical squad + players + extension freshness) plus a
//! single "all owned cards for this club" fetch joined against the in-memory card
//! definitions. There is no per-slot lookup here or above.
//!
//! ## Item identity is shared, never reconstructed
//!
//! Each occupied slot is shaped by the shared [`crate::fut::item::shape_item`],
//! the same primitive `/club` uses — the projector never rebuilds the card shape
//! itself, so squad items and `/club` items cannot drift, and two owned copies of
//! one definition stay distinct (each carries its own resolved wire id).
//!
//! ## Fresh / Stale / Missing
//!
//! Freshness comes from Core and is surfaced, never buried in a default:
//! * **Fresh** → project the full object.
//! * **Stale** → NEVER overlay the stale extension on the newer canonical squad;
//! return [`SquadProjection::Stale`] for the host to act on (e.g. fall back).
//! * **Missing** → return [`SquadProjection::Missing`]; the projector does NOT
//! fabricate a manager/custom/kicktakers/kit numbers just to emit a response.
use std::collections::HashMap;
use serde_json::{json, Value};
use crate::fut::entities::ReverseEntityResolver;
use crate::fut::item::{shape_item, CoreOwnedItem, ItemIdentityResolver};
use crate::fut::squad::FIFA17_SQUAD_SLOTS;
use crate::fut::squad_ext::Fifa17SquadExtensionV1;
/// Freshness of the FIFA 17 extension relative to the current canonical squad,
/// as reported by Core's `read_squad_with_ext`. This is a game-independent mirror
/// the host populates from Core's `SquadExtState`; the adapter never computes the
/// canonical fingerprint itself (that is Core's server-side job).
#[derive(Debug, Clone)]
pub enum SquadExtInput {
Fresh(Fifa17SquadExtensionV1),
/// The stored extension whose fingerprint no longer matches the canonical
/// squad. Carried so the host can log/inspect it, but the projector NEVER
/// applies it over the newer canonical squad (mirrors Core's
/// `SquadExtState::Stale { stored, .. }`).
Stale(Fifa17SquadExtensionV1),
Missing,
}
/// One canonical slot as read back from Core. `is_on_bench` is carried through
/// from Core, never re-derived from the formation.
#[derive(Debug, Clone)]
pub struct ProjectionSlot {
pub owned_card_id: String,
pub index: i64,
pub is_captain: bool,
pub is_on_bench: bool,
}
/// Everything the pure projector needs to render one full squad.
pub struct SquadProjectionInput<'a> {
/// FIFA wire squad id (`0` = active).
pub fifa_squad_id: i64,
pub name: String,
/// FIFA formation token, verbatim from the canonical squad (never mapped).
pub formation: String,
pub slots: Vec<ProjectionSlot>,
pub ext: SquadExtInput,
/// Every owned item a slot references, keyed by `owned_card_id`. Assembled by
/// the host in one batch — the projector only reads from it.
pub owned: &'a HashMap<String, CoreOwnedItem>,
}
/// Result of a projection, with the extension-freshness verdict surfaced.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SquadProjection {
/// A fully projected FIFA 17 squad object (the `userMassInfo.squad` shape,
/// minus session envelope fields the endpoint wrapper adds).
Projected(Value),
/// The stored extension is stale vs the canonical squad — not applied.
Stale,
/// No extension stored — nothing fabricated.
Missing,
}
/// Hard projection failures — a squad cannot be rendered faithfully. Never
/// silently degraded (a squad cannot drop a starter the way `/club` drops a card).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SquadProjectError {
/// A slot references an `owned_card_id` absent from the projection input.
MissingOwnedItem(String),
/// An occupied slot's owned item has no real FIFA asset identity — it cannot
/// be rendered and MUST NOT be faked.
NoFifaIdentity(String),
}
impl std::fmt::Display for SquadProjectError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SquadProjectError::MissingOwnedItem(id) => {
write!(f, "projection input missing owned item {id}")
}
SquadProjectError::NoFifaIdentity(id) => {
write!(
f,
"owned item {id} has no real FIFA asset identity (cannot render)"
)
}
}
}
}
impl std::error::Error for SquadProjectError {}
/// Project a squad. On `Fresh`, returns the full FIFA squad object; on
/// `Stale`/`Missing`, returns that verdict without fabricating anything.
pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
input: &SquadProjectionInput<'_>,
ident: &I,
ent: &impl ReverseEntityResolver,
) -> Result<SquadProjection, SquadProjectError> {
let ext = match &input.ext {
SquadExtInput::Stale(_) => return Ok(SquadProjection::Stale),
SquadExtInput::Missing => return Ok(SquadProjection::Missing),
SquadExtInput::Fresh(ext) => ext,
};
// Index occupied slots by their FIFA array index for O(1) fill.
let by_index: HashMap<i64, &ProjectionSlot> =
input.slots.iter().map(|s| (s.index, s)).collect();
let mut players = Vec::with_capacity(FIFA17_SQUAD_SLOTS as usize);
let mut captain_wire: i64 = 0;
for index in 0..FIFA17_SQUAD_SLOTS {
match by_index.get(&index) {
Some(slot) => {
let item = input.owned.get(&slot.owned_card_id).ok_or_else(|| {
SquadProjectError::MissingOwnedItem(slot.owned_card_id.clone())
})?;
let id = ident
.resolve(item)
.ok_or_else(|| SquadProjectError::NoFifaIdentity(slot.owned_card_id.clone()))?;
if slot.is_captain {
captain_wire = id.item_id as i64;
}
// kit follows the player: look it up by owned id, never by index.
let kit = ext
.kit_numbers
.get(&slot.owned_card_id)
.copied()
.unwrap_or(0);
players.push(json!({
"index": index,
"itemData": shape_item(item, id, ent),
"kitNumber": kit,
}));
}
None => players.push(json!({
"index": index,
"itemData": { "id": 0, "dream": false },
"kitNumber": 0,
})),
}
}
let squad = json!({
"id": input.fifa_squad_id,
"squadName": input.name,
"formation": input.formation,
"squadType": ext.squad_type,
"chemistry": ext.client_reported.chemistry,
"starRating": ext.client_reported.star_rating,
"rating": ext.client_reported.rating,
"captain": captain_wire,
"manager": ext.manager,
"custom": ext.custom,
"players": players,
"kicktakers": ext.kicktakers,
});
Ok(SquadProjection::Projected(squad))
}
/// Wrap a projected squad object into the `userMassInfo.squad` shape, injecting
/// the session-envelope fields the projector does not own (`personaId`, plus the
/// observed constants `changed: 0`, `actives: []`).
pub fn user_mass_info_squad(projected: Value, persona_id: i64) -> Value {
let mut obj = projected;
if let Value::Object(map) = &mut obj {
map.insert("personaId".into(), json!(persona_id));
map.insert("changed".into(), json!(0));
map.insert("actives".into(), json!([]));
}
obj
}
/// The `GET /squad/list` summary response — a subset of the SAME projected
/// object, wrapped in `{"squad":[ … ]}`. Not a separate domain projection.
pub fn squad_list(projected: &Value) -> Value {
let summary = json!({
"id": projected.get("id").cloned().unwrap_or(Value::Null),
"squadName": projected.get("squadName").cloned().unwrap_or(Value::Null),
"formation": projected.get("formation").cloned().unwrap_or(Value::Null),
"squadType": projected.get("squadType").cloned().unwrap_or(Value::Null),
"rating": projected.get("rating").cloned().unwrap_or(Value::Null),
"chemistry": projected.get("chemistry").cloned().unwrap_or(Value::Null),
});
json!({ "squad": [summary] })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fut::entities::Fifa17Entities;
use crate::fut::item::Fifa17Identity;
// A resolver that mints a distinct wire id per owned item and a fixed asset.
struct TableIdentity(HashMap<String, Fifa17Identity>);
impl ItemIdentityResolver for TableIdentity {
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
self.0.get(&it.owned_card_id).copied()
}
}
fn ent() -> Fifa17Entities {
Fifa17Entities::from_maps(HashMap::new(), HashMap::new(), HashMap::new())
}
fn owned_item(id: &str, card: &str) -> CoreOwnedItem {
CoreOwnedItem {
owned_card_id: id.into(),
card_id: card.into(),
rating: 84,
position: "ST".into(),
nation: "n".into(),
league: "l".into(),
club: "c".into(),
attributes: [80, 80, 80, 80, 40, 80],
}
}
fn one_slot_input<'a>(
owned: &'a HashMap<String, CoreOwnedItem>,
ext: SquadExtInput,
) -> SquadProjectionInput<'a> {
SquadProjectionInput {
fifa_squad_id: 0,
name: "OpenFUT".into(),
formation: "f442".into(),
slots: vec![ProjectionSlot {
owned_card_id: "oc1".into(),
index: 0,
is_captain: true,
is_on_bench: false,
}],
ext,
owned,
}
}
fn fresh_ext() -> Fifa17SquadExtensionV1 {
let mut kit = std::collections::BTreeMap::new();
kit.insert("oc1".to_string(), 9);
Fifa17SquadExtensionV1 {
custom: Some("[1,2,3]".into()),
squad_type: Some("REGULAR_SQUAD".into()),
kit_numbers: kit,
manager: vec![],
kicktakers: vec![],
client_reported: Default::default(),
}
}
#[test]
fn fresh_projects_full_23_slot_array_with_captain_wire_id() {
let mut owned = HashMap::new();
owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
let ident = TableIdentity(HashMap::from([(
"oc1".to_string(),
Fifa17Identity {
item_id: 100000042,
asset_id: 20801,
resource_id: 20801,
rareflag: 1,
},
)]));
let input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
panic!("expected Projected");
};
assert_eq!(
v["players"].as_array().unwrap().len(),
23,
"fixed 23-slot array"
);
assert_eq!(
v["players"][0]["itemData"]["id"], 100000042,
"wire id, not resourceId"
);
assert_eq!(v["players"][0]["itemData"]["resourceId"], 20801);
assert_eq!(v["players"][0]["kitNumber"], 9, "kit from ext by owned id");
assert_eq!(v["players"][1]["itemData"]["id"], 0, "empty slot");
assert_eq!(v["captain"], 100000042, "captain is the resolved WIRE id");
assert_ne!(v["captain"], 20801, "captain must NOT be the resourceId");
assert_eq!(v["custom"], "[1,2,3]");
assert_eq!(v["formation"], "f442");
}
#[test]
fn stale_is_never_applied() {
let owned = HashMap::new();
// A stale extension IS carried (host may log it) but must not be applied.
let input = one_slot_input(&owned, SquadExtInput::Stale(fresh_ext()));
let ident = TableIdentity(HashMap::new());
assert_eq!(
project_squad(&input, &ident, &ent()).unwrap(),
SquadProjection::Stale
);
}
#[test]
fn missing_is_explicit_never_fabricated() {
let owned = HashMap::new();
let input = one_slot_input(&owned, SquadExtInput::Missing);
let ident = TableIdentity(HashMap::new());
assert_eq!(
project_squad(&input, &ident, &ent()).unwrap(),
SquadProjection::Missing
);
}
#[test]
fn occupied_starter_without_asset_identity_is_refused_not_faked() {
let mut owned = HashMap::new();
owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
let ident = TableIdentity(HashMap::new()); // resolves nothing
let input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
assert_eq!(
project_squad(&input, &ident, &ent()),
Err(SquadProjectError::NoFifaIdentity("oc1".into()))
);
}
#[test]
fn two_owned_copies_of_one_definition_project_as_distinct_players() {
// Same card definition -> same resourceId; two distinct owned instances
// in two slots with distinct kits must stay distinct on the wire.
let mut owned = HashMap::new();
owned.insert("oc-a".to_string(), owned_item("oc-a", "fifa17_101490"));
owned.insert("oc-b".to_string(), owned_item("oc-b", "fifa17_101490"));
let ident = TableIdentity(HashMap::from([
(
"oc-a".to_string(),
Fifa17Identity {
item_id: 100000030,
asset_id: 101490,
resource_id: 101490,
rareflag: 1,
},
),
(
"oc-b".to_string(),
Fifa17Identity {
item_id: 100000031,
asset_id: 101490,
resource_id: 101490,
rareflag: 1,
},
),
]));
let mut kit = std::collections::BTreeMap::new();
kit.insert("oc-a".to_string(), 7);
kit.insert("oc-b".to_string(), 19);
let ext = Fifa17SquadExtensionV1 {
kit_numbers: kit,
..fresh_ext()
};
let owned_ref = &owned;
let input = SquadProjectionInput {
fifa_squad_id: 0,
name: "OpenFUT".into(),
formation: "f442".into(),
slots: vec![
ProjectionSlot {
owned_card_id: "oc-a".into(),
index: 0,
is_captain: false,
is_on_bench: false,
},
ProjectionSlot {
owned_card_id: "oc-b".into(),
index: 1,
is_captain: false,
is_on_bench: false,
},
],
ext: SquadExtInput::Fresh(ext),
owned: owned_ref,
};
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
panic!();
};
let a = &v["players"][0]["itemData"];
let b = &v["players"][1]["itemData"];
assert_eq!(
a["resourceId"], b["resourceId"],
"same definition => same asset"
);
assert_ne!(
a["id"], b["id"],
"distinct owned copies keep distinct wire ids"
);
assert_eq!(v["players"][0]["kitNumber"], 7);
assert_eq!(
v["players"][1]["kitNumber"], 19,
"kit stays with the instance"
);
}
}
@@ -0,0 +1,268 @@
//! FIFA 17 Store pack catalogue + `/store/purchasegroup` wire shaping.
//!
//! A faithful Rust port of the Python oracle's `PACK_CATALOG` + `_pack_body` +
//! `store_catalog` assembly (`fifa17-recon/tools/{fut_store,utas_server}.py`) at the
//! **production flag defaults** (`FUT_STORE_DISPLAYGROUP=1` on, `FUT_STORE_GROUPID=0`
//! off, `FUT_PRICE_PROBE=0` off). Parity is pinned by differential fixtures generated
//! from the Python oracle (`tests/fixtures/purchasegroup_*.json`).
//!
//! ## Scope / split-brain safety
//!
//! This is **pure wire shaping** — no economy state, no IO. [`build_purchasegroup`]
//! is a function of `(owned unopened pack ids, empty-My-Packs StoreMode)`. It is
//! deliberately **not yet wired** into the live host: serving purchasegroup from Rust
//! requires an authoritative Rust owner of `unopenedPackIds`, and today Python is the
//! single writer of coins + unopened packs (BUY, quick-sell, rewards). Wiring this
//! before that economy authority exists would create a dual-write/split-brain. See
//! the R3 economy-authority prerequisite in the vault (`Rust UTAS Migration`).
//!
//! ## Economy-parameter provenance
//!
//! Prices, counts and odds are the current OpenFUT **PLACEHOLDER** economy, NOT
//! EA-authentic (the overnight audit established the store economy is invented). The
//! wire *shape* is EA-observed/oracle-verified; the *numbers* are placeholders.
use serde_json::{json, Value};
use crate::fut::store_session::{StoreMode, SENTINEL_PACK_ID};
/// A FIFA 17 Store pack definition. Wire shape is oracle-verified; the economy
/// numbers (`price`/`count`/`special_chance`) are OpenFUT PLACEHOLDER, not EA-authentic.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PackDef {
pub id: u64,
pub name: &'static str,
pub price: u64,
pub count: u64,
pub gold: bool,
pub special_chance: f64,
/// Reward-only pack (no purchase path): excluded from the normal catalogue,
/// rendered only when owned (in `unopenedPackIds`).
pub owned_only: bool,
}
/// The current supported FIFA 17 pack catalogue (`fut_store.py:820`). Only observed/
/// currently-supported ids. The 65534 sentinel is deliberately ABSENT — it is a
/// compatibility shim, never a catalogue pack (never purchasable/openable).
pub const PACK_CATALOG: &[PackDef] = &[
PackDef {
id: 1,
name: "Bronze Pack",
price: 400,
count: 5,
gold: false,
special_chance: 0.005,
owned_only: false,
},
PackDef {
id: 5,
name: "Gold Pack",
price: 5000,
count: 7,
gold: true,
special_chance: 0.03,
owned_only: false,
},
PackDef {
id: 6,
name: "Premium Gold",
price: 15000,
count: 11,
gold: true,
special_chance: 0.08,
owned_only: false,
},
PackDef {
id: 7,
name: "Special Players Pack",
price: 25000,
count: 11,
gold: true,
special_chance: 1.0,
owned_only: false,
},
PackDef {
id: 70,
name: "Reward Special Players Pack",
price: 0,
count: 11,
gold: true,
special_chance: 1.0,
owned_only: true,
},
];
/// Look up a catalogue pack by id (the 65534 sentinel is never present).
pub fn pack_by_id(id: u64) -> Option<&'static PackDef> {
PACK_CATALOG.iter().find(|p| p.id == id)
}
/// The FIFA17 StoreFront category token for a NORMAL pack tile (`utas_server.py:3579`):
/// one of the six hard-coded tokens the client resolves.
fn category(p: &PackDef) -> &'static str {
if p.special_chance >= 1.0 {
"special"
} else if p.gold {
"gold"
} else {
"bronze"
}
}
/// One `purchase[]` entry — the faithful `_pack_body` port (`utas_server.py:3474`) at
/// production flag defaults. `owned` packs (My Packs / reward / sentinel) drop the
/// purchase fields and take the `mypacks` display group.
pub fn pack_body(p: &PackDef, idx: u64, owned: bool) -> Value {
let mtx = std::cmp::max(1, p.price / 100);
let mut body = json!({
"assetId": p.id,
"id": p.id,
"packType": if p.gold { "GOLD" } else { "BRONZE" },
"description": p.name,
"state": "active",
"saleType": "promo",
"limitType": "NONE",
"quantity": 0,
"purchaseLimit": 0,
"purchaseCount": 0,
"isPremium": false,
"sortPriority": idx,
"currencies": [{ "name": "coins", "funds": p.price, "finalFunds": p.price }],
"extPrice": {
"finalPrice": { "amount": mtx, "currency": "mtx" },
"originalPrice": { "amount": mtx, "currency": "mtx" },
},
"packContentInfo": {
"bronzeQuantity": if p.gold { 0 } else { p.count },
"silverQuantity": 0,
"goldQuantity": if p.gold { p.count } else { 0 },
"rareQuantity": if p.gold { p.count } else { 0 },
"itemQuantity": p.count,
},
"unopened": owned,
});
let obj = body.as_object_mut().expect("pack body is a JSON object");
if owned {
// Reward/My-Packs tiles have no purchase path; leaving zero-value coin/mtx
// objects makes the client render the price label as literal "undefined".
obj.remove("currencies");
obj.remove("extPrice");
obj.insert(
"displayGroup".into(),
json!({ "value": "mypacks", "priority": idx }),
);
} else {
obj.insert("displayGroup".into(), json!({ "value": category(p) }));
}
body
}
/// The synthetic empty-My-Packs sentinel `purchase[]` entry (id 65534): an owned-style
/// body forced to `state:"active"`, `unopened:false`. Compatibility shim ONLY — 65534
/// is absent from [`PACK_CATALOG`], so it can never be bought/opened/granted.
pub fn sentinel_body(idx: u64) -> Value {
let sentinel = PackDef {
id: SENTINEL_PACK_ID,
name: "",
price: 0,
count: 0,
gold: true,
special_chance: 0.0,
owned_only: true,
};
let mut body = pack_body(&sentinel, idx, true);
let obj = body
.as_object_mut()
.expect("sentinel body is a JSON object");
obj.insert("state".into(), json!("active"));
obj.insert("unopened".into(), json!(false));
body
}
/// Build the full `/store/purchasegroup` body from the authoritative unopened-pack ids
/// and the frozen empty-My-Packs mode. Pure — mirrors `store_catalog` (`3627`):
/// normal packs (1,5,6,7) first, then any owned packs, then the empty-My-Packs shim
/// (sentinel for [`StoreMode::Sentinel`], nothing for [`StoreMode::CleanV1`]).
pub fn build_purchasegroup(unopened_ids: &[u64], mode: StoreMode) -> Value {
let mut packs: Vec<Value> = PACK_CATALOG
.iter()
.filter(|p| !p.owned_only)
.enumerate()
.map(|(i, p)| pack_body(p, i as u64 + 1, false))
.collect();
for (i, &pid) in unopened_ids.iter().enumerate() {
if let Some(owned) = pack_by_id(pid) {
packs.push(pack_body(owned, i as u64 + 1, true));
}
}
if unopened_ids.is_empty() && mode == StoreMode::Sentinel {
packs.push(sentinel_body(1));
}
json!({ "purchase": packs, "timestamp": 1596326400i64 })
}
#[cfg(test)]
mod tests {
//! Differential parity against the Python oracle. The fixtures under
//! `tests/fixtures/purchasegroup_*.json` are generated by calling the oracle's
//! `_pack_body`/`store_catalog` at production flag defaults; Rust must match
//! them semantically (object key order is irrelevant to `serde_json::Value` eq).
use super::*;
fn parse(s: &str) -> Value {
serde_json::from_str(s).expect("fixture parses")
}
#[test]
fn purchasegroup_zero_sentinel_matches_oracle() {
let got = build_purchasegroup(&[], StoreMode::Sentinel);
let want = parse(include_str!(
"../../tests/fixtures/purchasegroup_zero_sentinel.json"
));
assert_eq!(got, want);
}
#[test]
fn purchasegroup_zero_clean_matches_oracle() {
let got = build_purchasegroup(&[], StoreMode::CleanV1);
let want = parse(include_str!(
"../../tests/fixtures/purchasegroup_zero_clean.json"
));
assert_eq!(got, want);
}
#[test]
fn purchasegroup_pack70_matches_oracle() {
// Owned pack present -> no sentinel regardless of mode.
let got = build_purchasegroup(&[70], StoreMode::Sentinel);
let want = parse(include_str!(
"../../tests/fixtures/purchasegroup_pack70.json"
));
assert_eq!(got, want);
}
#[test]
fn sentinel_absent_from_catalog() {
assert!(pack_by_id(SENTINEL_PACK_ID).is_none());
assert!(PACK_CATALOG.iter().all(|p| p.id != SENTINEL_PACK_ID));
}
#[test]
fn clean_v1_empty_emits_no_mypacks_group() {
let got = build_purchasegroup(&[], StoreMode::CleanV1);
let ids: Vec<u64> = got["purchase"]
.as_array()
.unwrap()
.iter()
.map(|e| e["id"].as_u64().unwrap())
.collect();
assert_eq!(ids, vec![1, 5, 6, 7]);
}
#[test]
fn category_tokens_are_canonical() {
assert_eq!(category(pack_by_id(1).unwrap()), "bronze");
assert_eq!(category(pack_by_id(5).unwrap()), "gold");
assert_eq!(category(pack_by_id(7).unwrap()), "special");
}
}
@@ -0,0 +1,726 @@
//! FIFA 17 empty-My-Packs capability negotiation — per-session state machine.
//!
//! This is the Rust production port of the *novel* session/capability logic that
//! was proven live on staging and currently lives in the Python oracle
//! (`fifa17-recon/tools/utas_server.py`, "FIFA17 empty-My-Packs capability
//! negotiation"). Python remains the behavioural **reference/oracle**; this module
//! is the intended production **authority** for the machinery so new FUT session
//! behaviour stops accumulating in Python.
//!
//! ## Scope (deliberately bounded)
//!
//! This module owns the pure state machine only:
//! * per-login session table keyed by the unique UTAS session id (`X-UT-SID`),
//! * the single-use `(ip, persona)` launcher→session capability hand-off (pending),
//! * capability binding (`bound` / `pending` / `ignored-late`),
//! * the once-per-session empty-My-Packs freeze (`clean-v1` vs `sentinel`),
//! * TTL reaping and fail-closed rules (unknown / expired / ambiguous / late /
//! cross-session / sid-ip-mismatch ⇒ sentinel).
//!
//! It has **no** HTTP, Core, or IO dependencies, and it does **not** reproduce the
//! delicate `_pack_body` UTAS wire shaping (utas_server.py:3474 — a type-sensitive,
//! reverse-engineered body where a wrong scalar type silently breaks pack buying).
//! That shaping, the `/ut/auth` persona-adoption, `/store/purchasegroup` catalogue
//! assembly, and the store BUY path remain Python-owned until a separate, carefully
//! differential-tested slice ports them. Wiring these three routes
//! (`/ut/auth` SID, `/openfut/fifa17/capability`, `/store/purchasegroup`) into
//! `openfut-utas-host` against this state machine — without dividing store authority
//! (i.e. porting BUY too, so purchasegroup display and buy validation don't split) —
//! is the remaining bounded gap toward full Rust authority.
//!
//! ## Purity / testability
//!
//! The monotonic clock is injected (`now: f64` seconds) rather than read from a
//! global, so every A–R matrix scenario is a deterministic unit test. SID entropy
//! is likewise injected ([`format_sid`]) — the host supplies a unique 64-bit value;
//! the Python oracle notes uniqueness (not unpredictability) is the requirement.
use std::collections::HashMap;
/// The single capability this negotiation understands.
pub const CAPABILITY_NAME: &str = "empty_mypacks_resolver";
/// The only accepted resolver version (FIFA17 CardsDLL guard at RVA `0x14858`).
pub const EMPTY_MYPACKS_RESOLVER_VERSION: u32 = 1;
/// Synthetic non-openable pack id — the universal P2 sentinel. Deliberately ABSENT
/// from the store `PACK_CATALOG`, so it can never be bought/opened/granted.
pub const SENTINEL_PACK_ID: u64 = 65534;
/// A FIFA session is reaped after this many idle seconds.
pub const SESSION_TTL_SECS: f64 = 3600.0;
/// A launcher capability may await its session for this long before expiring.
pub const PENDING_TTL_SECS: f64 = 120.0;
/// The empty-My-Packs store topology, frozen once per session at its first store
/// request and immutable thereafter.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StoreMode {
/// Universal fallback: emit the synthetic non-openable [`SENTINEL_PACK_ID`] pack
/// so the client's `mypacks` category resolves and does not crash.
Sentinel,
/// Verified patched client: emit NO `mypacks` group; the CardsDLL resolver guard
/// routes the absent category to Browse.
CleanV1,
}
impl StoreMode {
/// The wire token the Python oracle logs/uses (`"sentinel"` / `"clean-v1"`).
pub fn as_str(self) -> &'static str {
match self {
StoreMode::Sentinel => "sentinel",
StoreMode::CleanV1 => "clean-v1",
}
}
}
/// Outcome of a launcher capability registration.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RegisterOutcome {
/// Exactly one live, unfrozen, unbound session for `(ip, persona)` existed and
/// was bound now (the common post-login case).
Bound,
/// No session for `(ip, persona)` yet (registration before login): staged as a
/// single-use pending hand-off.
Pending,
/// A session for `(ip, persona)` exists but is frozen or ambiguous (>1 unbound):
/// NOT staged, so no later/unverified process can inherit it. Fail-closed.
IgnoredLate,
}
/// Rejection reason for a capability POST body (maps to HTTP 400 at the route).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CapabilityError {
/// Capability name or version is not the supported `empty_mypacks_resolver` v1.
Unsupported,
}
/// Validate a capability registration request body. Anything but the supported
/// `empty_mypacks_resolver` at the current version is rejected — the session then
/// simply stays on the sentinel fallback (the route records nothing).
pub fn validate_capability(name: &str, version: u32) -> Result<(), CapabilityError> {
if name == CAPABILITY_NAME && version == EMPTY_MYPACKS_RESOLVER_VERSION {
Ok(())
} else {
Err(CapabilityError::Unsupported)
}
}
/// Format a unique per-login UTAS session id from a caller-supplied 64-bit value.
/// Same shape/length as the legacy constant; uniqueness — not unpredictability — is
/// what the binding needs, so the host may use any unique source (random or counter).
pub fn format_sid(bits: u64) -> String {
format!("OPENFUT-SID-{bits:016X}")
}
/// The identity of the synthetic empty-My-Packs sentinel pack (utas_server.py:3671).
/// This is the *identity* only; the full UTAS `purchase[]` entry is produced by the
/// Python `_pack_body` shaping, which is intentionally not ported here.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SentinelPack {
pub id: u64,
pub price: u64,
pub count: u64,
pub gold: bool,
pub special_chance: f64,
}
impl SentinelPack {
/// The v1 sentinel: empty, free, non-openable. `_pack_body` additionally forces
/// `state="active"` and `unopened=false` (owned) — documented here, applied by
/// the shaping layer, not this module.
pub fn v1() -> Self {
SentinelPack {
id: SENTINEL_PACK_ID,
price: 0,
count: 0,
gold: true,
special_chance: 0.0,
}
}
}
#[derive(Debug, Clone)]
struct Session {
ip: Option<String>,
persona: i64,
resolver: Option<u32>,
mode: Option<StoreMode>,
last_seen: f64,
}
#[derive(Debug, Clone)]
struct Pending {
resolver: u32,
ts: f64,
}
/// The per-session capability/store-mode authority. Not `Sync` itself; the host
/// wraps it in a `Mutex` exactly as the Python oracle guards its tables with a lock.
#[derive(Debug, Default)]
pub struct SessionStore {
sessions: HashMap<String, Session>,
pending: HashMap<(Option<String>, i64), Pending>,
}
impl SessionStore {
/// A fresh, empty store.
pub fn new() -> Self {
Self::default()
}
/// Reap idle sessions (> [`SESSION_TTL_SECS`]) and expired pendings
/// (> [`PENDING_TTL_SECS`]). Called at the start of every mutating operation,
/// mirroring the oracle's lazy reap.
fn reap(&mut self, now: f64) {
self.sessions
.retain(|_, r| now - r.last_seen <= SESSION_TTL_SECS);
self.pending.retain(|_, p| now - p.ts <= PENDING_TTL_SECS);
}
/// Single-use: remove and return a fresh pending resolver for `(ip, persona)`.
fn take_pending(&mut self, ip: &Option<String>, persona: i64, now: f64) -> Option<u32> {
let key = (ip.clone(), persona);
if let Some(p) = self.pending.get(&key) {
if now - p.ts <= PENDING_TTL_SECS {
let resolver = p.resolver;
self.pending.remove(&key);
return Some(resolver);
}
}
None
}
/// `/ut/auth`: open a per-login session and bind any pending launcher capability
/// for `(ip, persona)` that arrived before login. An empty `sid` is a no-op.
pub fn open_session(&mut self, sid: &str, ip: Option<String>, persona: i64, now: f64) {
if sid.is_empty() {
return;
}
self.reap(now);
let resolver = self.take_pending(&ip, persona, now);
self.sessions.insert(
sid.to_string(),
Session {
ip,
persona,
resolver,
mode: None,
last_seen: now,
},
);
}
/// `/openfut/account/sync` hygiene: drop any stale pending for this machine so a
/// new launch's unverified session cannot inherit a leftover capability.
pub fn clear_pending(&mut self, ip: &str, now: f64) {
self.reap(now);
let ip_key = Some(ip.to_string());
self.pending.retain(|(k_ip, _), _| *k_ip != ip_key);
}
/// Launcher capability registration. Never authorizes more than one session:
/// binds iff exactly one live, unfrozen, unbound session for `(ip, persona)`
/// exists; otherwise stages a single-use pending (no session yet) or fails
/// closed as ignored-late (a session exists but is frozen/ambiguous).
pub fn register_capability(
&mut self,
ip: Option<String>,
persona: i64,
version: u32,
now: f64,
) -> RegisterOutcome {
self.reap(now);
let mut any_for_key = false;
let mut candidate: Option<String> = None;
let mut candidate_count = 0usize;
for (sid, r) in &self.sessions {
if r.ip == ip && r.persona == persona {
any_for_key = true;
if r.mode.is_none() && r.resolver.is_none() {
candidate = Some(sid.clone());
candidate_count += 1;
}
}
}
if candidate_count == 1 {
let sid = candidate.expect("exactly one candidate");
self.sessions
.get_mut(&sid)
.expect("candidate session present")
.resolver = Some(version);
return RegisterOutcome::Bound;
}
if any_for_key {
return RegisterOutcome::IgnoredLate;
}
self.pending.insert(
(ip, persona),
Pending {
resolver: version,
ts: now,
},
);
RegisterOutcome::Pending
}
/// True if `sid` is a live session. (The legacy constant SID is accepted only by
/// the retired security-question gate in Python — never used to grant clean
/// mode — and is intentionally not modelled here.)
pub fn session_known(&self, sid: &str) -> bool {
self.sessions.contains_key(sid)
}
/// Freeze (once) and return the empty-My-Packs mode for FIFA session `sid`.
/// Freeze point = the first `/store/purchasegroup` of the session. Fail-closed:
/// an unknown session, or a `sid` presented from a different IP than it was
/// opened on, resolves to [`StoreMode::Sentinel`] (and a mismatch does NOT freeze
/// the real session, so a later correct-IP request can still freeze it).
pub fn empty_mypacks_mode(&mut self, sid: &str, ip: Option<&str>, now: f64) -> StoreMode {
self.reap(now);
// Resolve/take pending without holding a mutable borrow across the call.
let (session_ip, persona, already_frozen, resolver) = match self.sessions.get(sid) {
None => return StoreMode::Sentinel,
Some(r) => (r.ip.clone(), r.persona, r.mode, r.resolver),
};
if let Some(r) = self.sessions.get_mut(sid) {
r.last_seen = now;
}
// Fail-closed sid/ip sanity check (does not freeze).
if let (Some(sess_ip), Some(req_ip)) = (session_ip.as_deref(), ip) {
if sess_ip != req_ip {
return StoreMode::Sentinel;
}
}
if let Some(mode) = already_frozen {
return mode; // immutable per SID
}
// First store request for this session: consume a still-pending capability
// if the session was opened before the launcher registered, then freeze.
let resolver = match resolver {
Some(v) => Some(v),
None => self.take_pending(&session_ip, persona, now),
};
let mode = if resolver == Some(EMPTY_MYPACKS_RESOLVER_VERSION) {
StoreMode::CleanV1
} else {
StoreMode::Sentinel
};
if let Some(r) = self.sessions.get_mut(sid) {
r.resolver = resolver;
r.mode = Some(mode);
}
mode
}
/// Store-catalogue decision for a `/store/purchasegroup` request. Mirrors the
/// oracle's `if not owned_ids:` gate: with owned unopened packs the real
/// `mypacks` group is served and no freeze occurs (`None`); only an *empty*
/// My Packs freezes and returns the topology mode.
pub fn store_empty_mypacks(
&mut self,
sid: &str,
ip: Option<&str>,
has_unopened_packs: bool,
now: f64,
) -> Option<StoreMode> {
if has_unopened_packs {
return None;
}
Some(self.empty_mypacks_mode(sid, ip, now))
}
}
#[cfg(test)]
mod tests {
//! Ports the Python capability-negotiation matrix A–R
//! (`fifa17-recon/tools/test_capability_negotiation.py`). Python is the oracle;
//! these assertions must stay in lockstep with it.
use super::*;
const IP1: &str = "10.10.0.105";
const IP2: &str = "10.10.0.106";
const PERSONA: i64 = 111001;
fn ip(s: &str) -> Option<String> {
Some(s.to_string())
}
// A: no-capability, zero packs -> sentinel
#[test]
fn a_no_capability_zero_packs_sentinel() {
let mut s = SessionStore::new();
s.open_session("sidA", ip(IP1), PERSONA, 0.0);
assert_eq!(
s.store_empty_mypacks("sidA", Some(IP1), false, 1.0),
Some(StoreMode::Sentinel)
);
}
// B: verified v1, zero packs -> clean
#[test]
fn b_verified_v1_zero_packs_clean() {
let mut s = SessionStore::new();
s.open_session("sidB", ip(IP1), PERSONA, 0.0);
assert_eq!(
s.register_capability(ip(IP1), PERSONA, 1, 1.0),
RegisterOutcome::Bound
);
assert_eq!(
s.store_empty_mypacks("sidB", Some(IP1), false, 2.0),
Some(StoreMode::CleanV1)
);
}
// C: real unopened pack + no capability -> genuine packs (no freeze/sentinel)
#[test]
fn c_real_pack_no_capability_genuine() {
let mut s = SessionStore::new();
s.open_session("sidC", ip(IP1), PERSONA, 0.0);
assert_eq!(s.store_empty_mypacks("sidC", Some(IP1), true, 1.0), None);
}
// D: real unopened pack + capability -> genuine packs (no freeze)
#[test]
fn d_real_pack_with_capability_genuine() {
let mut s = SessionStore::new();
s.open_session("sidD", ip(IP1), PERSONA, 0.0);
assert_eq!(
s.register_capability(ip(IP1), PERSONA, 1, 1.0),
RegisterOutcome::Bound
);
assert_eq!(s.store_empty_mypacks("sidD", Some(IP1), true, 2.0), None);
}
// E: unsupported version / capability -> endpoint rejects AND mode sentinel
#[test]
fn e_unsupported_capability_rejected_and_sentinel() {
assert_eq!(validate_capability(CAPABILITY_NAME, 1), Ok(()));
assert_eq!(
validate_capability(CAPABILITY_NAME, 2),
Err(CapabilityError::Unsupported)
);
assert_eq!(
validate_capability("something_else", 1),
Err(CapabilityError::Unsupported)
);
let mut s = SessionStore::new();
s.open_session("sidE", ip(IP1), PERSONA, 0.0);
// A rejected registration records nothing, so the session stays sentinel.
assert_eq!(
s.empty_mypacks_mode("sidE", Some(IP1), 1.0),
StoreMode::Sentinel
);
}
// F: late capability after sentinel freeze -> stays sentinel
#[test]
fn f_late_capability_after_sentinel_freeze_stays() {
let mut s = SessionStore::new();
s.open_session("sidF", ip(IP1), PERSONA, 0.0);
assert_eq!(
s.empty_mypacks_mode("sidF", Some(IP1), 1.0),
StoreMode::Sentinel
);
assert_eq!(
s.register_capability(ip(IP1), PERSONA, 1, 2.0),
RegisterOutcome::IgnoredLate
);
assert_eq!(
s.empty_mypacks_mode("sidF", Some(IP1), 3.0),
StoreMode::Sentinel
);
}
// G: capability "disappears" after clean freeze -> stays clean (immutable)
#[test]
fn g_clean_freeze_immutable() {
let mut s = SessionStore::new();
s.open_session("sidG", ip(IP1), PERSONA, 0.0);
assert_eq!(
s.register_capability(ip(IP1), PERSONA, 1, 1.0),
RegisterOutcome::Bound
);
assert_eq!(
s.empty_mypacks_mode("sidG", Some(IP1), 2.0),
StoreMode::CleanV1
);
assert_eq!(
s.empty_mypacks_mode("sidG", Some(IP1), 3.0),
StoreMode::CleanV1
);
}
// H: two IPs (A verified, B none) -> A clean, B sentinel (no global leak)
#[test]
fn h_two_ips_isolated() {
let mut s = SessionStore::new();
s.open_session("sidHa", ip(IP1), PERSONA, 0.0);
s.open_session("sidHb", ip(IP2), PERSONA, 0.0);
assert_eq!(
s.register_capability(ip(IP1), PERSONA, 1, 1.0),
RegisterOutcome::Bound
);
assert_eq!(
s.empty_mypacks_mode("sidHa", Some(IP1), 2.0),
StoreMode::CleanV1
);
assert_eq!(
s.empty_mypacks_mode("sidHb", Some(IP2), 2.0),
StoreMode::Sentinel
);
}
// I: new session after reset -> fresh unpatched -> sentinel
#[test]
fn i_fresh_session_sentinel() {
let mut s = SessionStore::new();
s.open_session("sidI", ip(IP1), PERSONA, 10.0);
assert_eq!(
s.empty_mypacks_mode("sidI", Some(IP1), 11.0),
StoreMode::Sentinel
);
}
// J: autopatch mismatch => never registers -> sentinel
#[test]
fn j_no_registration_sentinel() {
let mut s = SessionStore::new();
s.open_session("sidJ", ip(IP1), PERSONA, 0.0);
// (no register_capability call at all)
assert_eq!(
s.empty_mypacks_mode("sidJ", Some(IP1), 1.0),
StoreMode::Sentinel
);
}
// K: SAME IP, two sessions (A patched, B not) -> A clean, B sentinel
#[test]
fn k_same_ip_two_sessions_isolated() {
let mut s = SessionStore::new();
s.open_session("sidKa", ip(IP1), PERSONA, 0.0);
assert_eq!(
s.register_capability(ip(IP1), PERSONA, 1, 1.0),
RegisterOutcome::Bound
);
s.open_session("sidKb", ip(IP1), PERSONA, 2.0);
assert_eq!(
s.empty_mypacks_mode("sidKa", Some(IP1), 3.0),
StoreMode::CleanV1
);
assert_eq!(
s.empty_mypacks_mode("sidKb", Some(IP1), 3.0),
StoreMode::Sentinel
);
}
// L: SAME IP+persona relaunch (old ok, new not) -> new session sentinel
#[test]
fn l_same_ip_persona_relaunch() {
let mut s = SessionStore::new();
s.open_session("sidLold", ip(IP1), PERSONA, 0.0);
assert_eq!(
s.register_capability(ip(IP1), PERSONA, 1, 1.0),
RegisterOutcome::Bound
);
assert_eq!(
s.empty_mypacks_mode("sidLold", Some(IP1), 2.0),
StoreMode::CleanV1
);
s.open_session("sidLnew", ip(IP1), PERSONA, 3.0);
assert_eq!(
s.empty_mypacks_mode("sidLnew", Some(IP1), 4.0),
StoreMode::Sentinel
);
assert_eq!(
s.empty_mypacks_mode("sidLold", Some(IP1), 5.0),
StoreMode::CleanV1
);
}
// M: SAME IP, failed-patch second session -> first clean, second sentinel
#[test]
fn m_same_ip_failed_patch_second() {
let mut s = SessionStore::new();
s.open_session("sidMa", ip(IP1), PERSONA, 0.0);
assert_eq!(
s.register_capability(ip(IP1), PERSONA, 1, 1.0),
RegisterOutcome::Bound
);
s.open_session("sidMb", ip(IP1), PERSONA, 2.0); // failed patch: never registers
assert_eq!(
s.empty_mypacks_mode("sidMa", Some(IP1), 3.0),
StoreMode::CleanV1
);
assert_eq!(
s.empty_mypacks_mode("sidMb", Some(IP1), 3.0),
StoreMode::Sentinel
);
}
// N: late registration when sessions are frozen -> does not modify active,
// and does not stage a pending that a later session could inherit.
#[test]
fn n_late_registration_no_effect() {
let mut s = SessionStore::new();
s.open_session("sidN", ip(IP1), PERSONA, 0.0);
assert_eq!(
s.empty_mypacks_mode("sidN", Some(IP1), 1.0),
StoreMode::Sentinel
);
assert_eq!(
s.register_capability(ip(IP1), PERSONA, 1, 2.0),
RegisterOutcome::IgnoredLate
);
assert_eq!(
s.empty_mypacks_mode("sidN", Some(IP1), 3.0),
StoreMode::Sentinel
);
// No pending was staged, so a brand-new session cannot inherit it.
s.open_session("sidN2", ip(IP1), PERSONA, 4.0);
assert_eq!(
s.empty_mypacks_mode("sidN2", Some(IP1), 5.0),
StoreMode::Sentinel
);
}
// O: session cleanup / TTL expiry -> capability gone, sentinel
#[test]
fn o_ttl_expiry() {
// Pending expiry: registered before login, but login arrives too late.
let mut s = SessionStore::new();
assert_eq!(
s.register_capability(ip(IP1), PERSONA, 1, 0.0),
RegisterOutcome::Pending
);
s.open_session("sidO", ip(IP1), PERSONA, PENDING_TTL_SECS + 1.0);
assert_eq!(
s.empty_mypacks_mode("sidO", Some(IP1), PENDING_TTL_SECS + 2.0),
StoreMode::Sentinel
);
// Session expiry: a known session reaped after idle TTL becomes unknown.
let mut s2 = SessionStore::new();
s2.open_session("sidO2", ip(IP1), PERSONA, 0.0);
assert_eq!(
s2.empty_mypacks_mode("sidO2", Some(IP1), SESSION_TTL_SECS + 1.0),
StoreMode::Sentinel
);
assert!(!s2.session_known("sidO2"));
}
// P: duplicate registration for a session -> idempotent; no post-freeze change
#[test]
fn p_duplicate_registration_idempotent() {
let mut s = SessionStore::new();
s.open_session("sidP", ip(IP1), PERSONA, 0.0);
assert_eq!(
s.register_capability(ip(IP1), PERSONA, 1, 1.0),
RegisterOutcome::Bound
);
// Second registration: the session is now bound (resolver set), so it is no
// longer an unbound candidate -> ignored-late, and the outcome is unchanged.
assert_eq!(
s.register_capability(ip(IP1), PERSONA, 1, 2.0),
RegisterOutcome::IgnoredLate
);
assert_eq!(
s.empty_mypacks_mode("sidP", Some(IP1), 3.0),
StoreMode::CleanV1
);
}
// Q: register-before-login (pending consumed) -> clean; single-use
#[test]
fn q_register_before_login_pending_consumed() {
let mut s = SessionStore::new();
assert_eq!(
s.register_capability(ip(IP1), PERSONA, 1, 0.0),
RegisterOutcome::Pending
);
s.open_session("sidQ", ip(IP1), PERSONA, 1.0);
assert_eq!(
s.empty_mypacks_mode("sidQ", Some(IP1), 2.0),
StoreMode::CleanV1
);
// Single-use: a second login for the same (ip,persona) gets no capability.
s.open_session("sidQ2", ip(IP1), PERSONA, 3.0);
assert_eq!(
s.empty_mypacks_mode("sidQ2", Some(IP1), 4.0),
StoreMode::Sentinel
);
}
// R: topology freeze immutable per SID -> no flip either way; new SID fresh
#[test]
fn r_topology_freeze_immutable_per_sid() {
let mut s = SessionStore::new();
s.open_session("sidR", ip(IP1), PERSONA, 0.0);
assert_eq!(
s.register_capability(ip(IP1), PERSONA, 1, 1.0),
RegisterOutcome::Bound
);
assert_eq!(
s.empty_mypacks_mode("sidR", Some(IP1), 2.0),
StoreMode::CleanV1
);
assert_eq!(
s.empty_mypacks_mode("sidR", Some(IP1), 3.0),
StoreMode::CleanV1
);
// A brand-new SID from the same client is a fresh, unverified session.
s.open_session("sidRnew", ip(IP1), PERSONA, 4.0);
assert_eq!(
s.empty_mypacks_mode("sidRnew", Some(IP1), 5.0),
StoreMode::Sentinel
);
}
// Extra: sid/ip mismatch is fail-closed and does NOT freeze the real session.
#[test]
fn sid_ip_mismatch_fails_closed_without_freezing() {
let mut s = SessionStore::new();
s.open_session("sidX", ip(IP1), PERSONA, 0.0);
assert_eq!(
s.register_capability(ip(IP1), PERSONA, 1, 1.0),
RegisterOutcome::Bound
);
// Presented from the wrong IP: sentinel, but the session is not frozen.
assert_eq!(
s.empty_mypacks_mode("sidX", Some(IP2), 2.0),
StoreMode::Sentinel
);
// The genuine client (correct IP) still freezes clean.
assert_eq!(
s.empty_mypacks_mode("sidX", Some(IP1), 3.0),
StoreMode::CleanV1
);
}
// Extra: account_sync clears a stale pending for the machine.
#[test]
fn clear_pending_drops_stale_hand_off() {
let mut s = SessionStore::new();
assert_eq!(
s.register_capability(ip(IP1), PERSONA, 1, 0.0),
RegisterOutcome::Pending
);
s.clear_pending(IP1, 1.0);
s.open_session("sidC1", ip(IP1), PERSONA, 2.0);
assert_eq!(
s.empty_mypacks_mode("sidC1", Some(IP1), 3.0),
StoreMode::Sentinel
);
}
// Extra: format_sid shape matches the legacy constant length.
#[test]
fn format_sid_shape() {
assert_eq!(
format_sid(0x42C15A6F78DC6E74),
"OPENFUT-SID-42C15A6F78DC6E74"
);
assert_eq!(format_sid(0).len(), "OPENFUT-SID-".len() + 16);
}
}
+79
View File
@@ -0,0 +1,79 @@
//! # openfut-adapter-fifa17
//!
//! The FIFA 17 game adapter: everything that is true of *FIFA 17 specifically*
//! and must therefore stay out of OpenFUT Core and out of the generic protocol
//! crates.
//!
//! ## Layering
//!
//! ```text
//! openfut-protocol-blaze generic Blaze: Fire2 framing, Heat2/TDF codec
//! ▲
//! openfut-adapter-fifa17 THIS: FIFA 17 command tables, response bodies,
//! ▲ dispatch ordering, session identity
//! OpenFUT Core game-independent FUT domain (not yet wired)
//! ```
//!
//! A second title gets its own adapter crate and reuses the protocol layer
//! underneath. Nothing here is written to be shared with one; if something in
//! this crate turns out to be title-independent, it belongs one layer down.
//!
//! ## Modules
//!
//! * [`blaze`] — the Blaze/Fire2 RPC surface. Implemented, runtime validated.
//! * [`redirector`] — the first hop's `<serverinstanceinfo>` response.
//! Implemented; runtime validated against the retail client.
//! * [`roster`] — the FUT roster-update response, the last gate before the hub.
//! Implemented; not yet runtime validated.
//! * [`fut`] — FUT/RS4 (UTAS) wire → Core semantic mappings; currently the
//! owned-player ("My Squad") search: query parse + FIFA-id→name resolution +
//! semantic filter/pagination. Pure mapping; no Rust UTAS host yet.
//!
//! Still served only by the Python backend: LSX/Origin (`:4216`), UTAS/RS4
//! (`:8099`) and POW/EASFC (`:8094`). Roster XML (`:8081`) has an adapter here
//! but no Rust host yet.
//!
//! **Nucleus (`:42131`) is deliberately not ported.** Instrumentation across
//! every live session showed the client never dials it: the listener is bound,
//! the handler logs unconditionally on connect, the client fetches the
//! `OSDK_NUCLEUS` config that carries the URL — and makes zero requests. It is
//! dead code on the observed path, so porting it would add an untested
//! component for no parity gain. See the vault's Protocol Findings.
//!
//! ## Provenance
//!
//! Ported from `fifa17-recon/tools/blaze_responder_v3b.py`, the implementation
//! that drove a retail FIFA 17 client from Origin login to an opened FUT pack.
//! Parity is tested, not asserted: `fixtures/blaze_transactions.jsonl` records
//! real request→response(s) transactions produced by the Python dispatcher, and
//! `tests/oracle_parity.rs` replays them byte-for-byte.
//!
//! ## Not a server
//!
//! This crate answers frames. It opens no socket, terminates no TLS and owns no
//! runtime. Hosting it is a separate, later decision — the Python backend
//! remains the live runtime and nothing here is wired into it.
//!
//! ```
//! use openfut_adapter_fifa17::blaze::{Adapter, AdapterConfig, Session};
//! use openfut_protocol_blaze::fire2::{Header, MsgType};
//! use openfut_protocol_blaze::heat2::Struct;
//!
//! let adapter = Adapter::new(AdapterConfig::loopback());
//! let mut session = Session::new("session-key", 0x656E5553);
//!
//! // Util::ping
//! let request = Header::new(0x0009, 0x0002, 1, MsgType::Message);
//! let out = adapter.dispatch(&request, &Struct::new(), &mut session, 1_754_870_400);
//!
//! assert_eq!(out.len(), 1);
//! assert_eq!(out[0].header.msg_type, MsgType::Reply);
//! ```
pub mod blaze;
pub mod fut;
pub mod redirector;
pub mod roster;
pub mod tls;
pub use blaze::{Adapter, AdapterConfig, Session};
@@ -0,0 +1,194 @@
//! FIFA 17 Blaze redirector: the first hop.
//!
//! ```text
//! FIFA 17 ──TLS──> redirector ──"connect to <ip>:<port>"──> plaintext Fire2 Blaze
//! ```
//!
//! A different protocol from Blaze itself: HTTPS with an XML body (DirtySDK's
//! ProtoHttp), not Fire2. Exactly one request is ever seen —
//! `POST /redirector/getServerInstance` — observed 9 times across every live
//! session with no other path.
//!
//! # Scope
//!
//! This module owns the **response**, which is game-specific. It does not own
//! TLS or HTTP transport; that belongs to a host, exactly as the Blaze adapter
//! owns dispatch while `openfut-blaze-host` owns the socket.
//!
//! # A TLS constraint that is not this module's problem, but is recorded here
//!
//! Every observed handshake negotiated `AES256-GCM-SHA384` — TLS 1.2 with
//! **static RSA key exchange** (`TLS_RSA_WITH_AES_256_GCM_SHA384`), against a
//! Python server offering `ALL:@SECLEVEL=0` and an RSA-2048 certificate
//! (DirtySDK rejects ECDSA). `rustls` supports only forward-secret (EC)DHE
//! suites, so it cannot serve that negotiation. Whether the client *offers*
//! ECDHE at all is UNKNOWN — OpenSSL follows client preference by default, so
//! preferring static RSA does not prove it is the only option. Instrument a
//! real ClientHello before choosing a TLS stack.
use std::fmt::Write as _;
use crate::blaze::config::AdapterConfig;
/// The only request path the client ever uses.
pub const REQUEST_PATH: &str = "/redirector/getServerInstance";
/// Where the client is told to find Blaze.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlazeEndpoint {
/// Advertised hostname or dotted-quad.
pub host: String,
pub port: u16,
/// Whether the Blaze hop is TLS. **0 for FIFA 17** — the second hop is
/// plaintext Fire2, which this field independently confirms.
pub secure: bool,
}
impl BlazeEndpoint {
/// Both host and port come from configuration. Neither is a protocol
/// constant: the client goes wherever this response sends it, so hardcoding
/// either would make the deployment un-relocatable.
pub fn from_config(cfg: &AdapterConfig) -> BlazeEndpoint {
BlazeEndpoint {
host: cfg.endpoints.advertise.clone(),
port: cfg.endpoints.blaze_port,
secure: false,
}
}
}
/// Dotted-quad to the decimal `u32` the client expects in `<ip>`.
///
/// Host byte order, so `127.0.0.1` is `2130706433` (`0x7F000001`). A non-IPv4
/// advertise value (a hostname) has no numeric form; the oracle falls back to
/// loopback rather than failing, and that behaviour is reproduced — the client
/// reads `<hostname>` too, so the numeric field is not the only route.
pub fn ip_to_u32(addr: &str) -> u32 {
let octets: Vec<u32> = addr
.split('.')
.filter_map(|p| p.parse::<u32>().ok())
.filter(|n| *n <= 255)
.collect();
if octets.len() == 4 {
(octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]
} else {
(127 << 24) | 1
}
}
/// The `<serverinstanceinfo>` XML body.
///
/// `<address member="0">` is a `ServerAddress` union; member 0 selects the
/// `ipAddress` variant `{hostname, ip, port}`. Tabs and newlines are part of
/// the byte-exact output — the client does not care, but parity does.
pub fn server_instance_info_xml(endpoint: &BlazeEndpoint) -> String {
let mut s = String::with_capacity(320);
s.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
s.push_str("<serverinstanceinfo>\n");
s.push_str("\t<address member=\"0\">\n");
s.push_str("\t\t<valu>\n");
let _ = writeln!(s, "\t\t\t<hostname>{}</hostname>", endpoint.host);
let _ = writeln!(s, "\t\t\t<ip>{}</ip>", ip_to_u32(&endpoint.host));
let _ = writeln!(s, "\t\t\t<port>{}</port>", endpoint.port);
s.push_str("\t\t</valu>\n");
s.push_str("\t</address>\n");
let _ = writeln!(s, "\t<secure>{}</secure>", u8::from(endpoint.secure));
s.push_str("\t<trialservicename></trialservicename>\n");
s.push_str("\t<defaultdnsaddress>0</defaultdnsaddress>\n");
s.push_str("</serverinstanceinfo>\n");
s
}
/// The complete HTTP response, headers included.
///
/// `Connection: close` is the oracle's behaviour and the client accepts it —
/// the redirector is a one-shot hop, unlike the long-lived Blaze connection.
pub fn redirect_response(cfg: &AdapterConfig) -> Vec<u8> {
let body = server_instance_info_xml(&BlazeEndpoint::from_config(cfg));
let mut out = String::with_capacity(body.len() + 128);
out.push_str("HTTP/1.1 200 OK\r\n");
out.push_str("Content-Type: application/xml\r\n");
let _ = write!(out, "Content-Length: {}\r\n", body.len());
out.push_str("Connection: close\r\n\r\n");
out.push_str(&body);
out.into_bytes()
}
/// Is this request line the one the client sends?
///
/// Diagnostics only: the oracle answers *any* request with the same body, so
/// dispatch does not branch on this. Reproduced as-is — a redirector that
/// started 404ing unexpected paths would be a behaviour change, not a fix.
pub fn is_get_server_instance(request_line: &str) -> bool {
request_line.starts_with("POST ") && request_line.contains(REQUEST_PATH)
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg(advertise: &str) -> AdapterConfig {
let mut c = AdapterConfig::loopback();
c.endpoints.advertise = advertise.into();
c
}
#[test]
fn ip_encoding_is_host_order_decimal() {
assert_eq!(ip_to_u32("127.0.0.1"), 2_130_706_433);
assert_eq!(ip_to_u32("198.51.100.7"), 3_325_256_711);
assert_eq!(ip_to_u32("203.0.113.42"), 3_405_803_818);
}
#[test]
fn a_non_ipv4_advertise_falls_back_to_loopback_like_the_oracle() {
// The hostname element still carries the real value, so this is not a
// dead end for the client.
assert_eq!(ip_to_u32("blaze.example.com"), 2_130_706_433);
assert_eq!(ip_to_u32("10.0.0"), 2_130_706_433);
assert_eq!(ip_to_u32("999.1.1.1"), 2_130_706_433);
}
#[test]
fn secure_is_zero_confirming_the_plaintext_second_hop() {
let body = server_instance_info_xml(&BlazeEndpoint::from_config(&cfg("203.0.113.42")));
assert!(body.contains("<secure>0</secure>"));
}
#[test]
fn response_advertises_the_configured_address_not_a_hardcoded_one() {
let r = String::from_utf8(redirect_response(&cfg("198.51.100.7"))).unwrap();
assert!(r.contains("<hostname>198.51.100.7</hostname>"));
assert!(r.contains("<ip>3325256711</ip>"));
assert!(r.contains("<port>42130</port>"));
assert!(!r.contains("127.0.0.1"));
}
#[test]
fn content_length_matches_the_body_exactly() {
let bytes = redirect_response(&cfg("203.0.113.42"));
let text = String::from_utf8(bytes).unwrap();
let (head, body) = text.split_once("\r\n\r\n").expect("header/body split");
let declared: usize = head
.lines()
.find_map(|l| l.strip_prefix("Content-Length: "))
.and_then(|v| v.trim().parse().ok())
.expect("content-length present");
assert_eq!(
declared,
body.len(),
"a wrong length would truncate the XML"
);
}
#[test]
fn recognises_the_only_observed_request_line() {
assert!(is_get_server_instance(
"POST /redirector/getServerInstance HTTP/1.1"
));
assert!(!is_get_server_instance(
"GET /redirector/getServerInstance HTTP/1.1"
));
assert!(!is_get_server_instance("POST /something/else HTTP/1.1"));
}
}
+219
View File
@@ -0,0 +1,219 @@
//! The FUT roster-update response — "is there a squad update to download?".
//!
//! ```text
//! FIFA 17 ──HTTPS GET /fifa17/fut/rosterupdate.xml──> <rosterupdate version="0"/>
//! ```
//!
//! # Why this matters more than its size suggests
//!
//! `checkFUTRostersFlow` downloads this before entering FUT. On success it
//! advances to the hub; on failure it aborts with *"An error occurred
//! downloading the FUT Squad Update"*. It is the last gate before the hub, and
//! because it is a separate TLS connection from the redirector it is also where
//! a certificate mismatch elsewhere in the stack first becomes visible. Three
//! redirector gates were lost to exactly that.
//!
//! # What the oracle actually puts on the wire
//!
//! `roster_server.py` uses `http.server.BaseHTTPRequestHandler`, which shapes
//! the response in ways the handler code does not show:
//!
//! * the status line is **HTTP/1.0**, because `protocol_version` is left at its
//! default — not HTTP/1.1, despite the client asking for 1.1
//! * `send_response` emits `Server:` and `Date:` *before* any header the
//! handler sets, so header order is Server, Date, Content-Type,
//! Content-Length, Connection
//! * **POST answers with headers only.** The handler writes the body `if method
//! == "GET"`, so a POST advertises `Content-Length: 67` and then sends
//! nothing. That is preserved here rather than corrected: it is the behaviour
//! the retail client was proven against, and "obviously a bug" is exactly the
//! kind of judgement that has been wrong before in this port.
//!
//! Any path gets the same answer — the oracle logs `self.path` and never routes
//! on it. A reimplementation that started 404ing unknown paths would be a
//! behaviour change, not a fix.
/// The path the client requests. Recorded for diagnostics; **not** used for
/// routing, because the oracle does not route.
pub const REQUEST_PATH: &str = "/fifa17/fut/rosterupdate.xml";
/// The "no update available" body, byte-for-byte from the oracle.
pub const ROSTER_XML: &[u8] =
b"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<rosterupdate version=\"0\"/>\n";
/// The `Server:` string the oracle emits.
///
/// Environment-derived, not protocol-derived: it is Python's version string,
/// so it changes when the container's Python does. Kept as a default rather
/// than hardcoded into the response builder so a host can match whatever the
/// deployed oracle actually sends — the same reasoning that makes the
/// advertised address configuration rather than a constant.
pub const ORACLE_SERVER: &str = "BaseHTTP/0.6 Python/3.12.13";
/// Which of the oracle's three handlers a request lands in.
///
/// `Post` is distinct from `Head` in the oracle's *code* (it drains the request
/// body first) but identical in its *response*, so the distinction is kept here
/// for the host's benefit rather than the response builder's.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Method {
Get,
Head,
Post,
}
impl Method {
/// Parse the request line's method. Unknown methods are `None` — the oracle
/// only defines `do_GET`/`do_HEAD`/`do_POST`, and `BaseHTTPRequestHandler`
/// answers anything else with its own 501, which is a different response
/// this module deliberately does not claim to reproduce.
pub fn parse(line0: &str) -> Option<Method> {
match line0.split_whitespace().next()? {
"GET" => Some(Method::Get),
"HEAD" => Some(Method::Head),
"POST" => Some(Method::Post),
_ => None,
}
}
/// Only GET carries the body, per the oracle.
pub fn carries_body(self) -> bool {
matches!(self, Method::Get)
}
}
/// Build the response exactly as the oracle would.
///
/// `date` is supplied by the caller rather than read from the clock here, so
/// this stays a pure function and the fixtures can pin it. Format is the one
/// `BaseHTTPRequestHandler.date_time_string` produces: RFC 7231 IMF-fixdate,
/// always GMT.
pub fn roster_response(method: Method, server: &str, date: &str) -> Vec<u8> {
let mut out = Vec::with_capacity(256);
out.extend_from_slice(b"HTTP/1.0 200 OK\r\n");
out.extend_from_slice(format!("Server: {server}\r\n").as_bytes());
out.extend_from_slice(format!("Date: {date}\r\n").as_bytes());
out.extend_from_slice(b"Content-Type: application/xml\r\n");
// Always the body's length, even when no body follows. See the module note.
out.extend_from_slice(format!("Content-Length: {}\r\n", ROSTER_XML.len()).as_bytes());
out.extend_from_slice(b"Connection: close\r\n");
out.extend_from_slice(b"\r\n");
if method.carries_body() {
out.extend_from_slice(ROSTER_XML);
}
out
}
/// `Date:` in the oracle's format, from a Unix timestamp.
///
/// Implemented rather than pulled from a date crate to keep this crate
/// dependency-free, matching the protocol layer's constraint. Civil-date
/// conversion is the standard days-from-epoch algorithm.
pub fn http_date(unix_secs: i64) -> String {
const DAYS: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const MONTHS: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
let days = unix_secs.div_euclid(86_400);
let secs = unix_secs.rem_euclid(86_400);
// 1970-01-01 was a Thursday (index 3).
let dow = DAYS[(days + 3).rem_euclid(7) as usize];
// days-from-civil, inverted (Howard Hinnant's algorithm).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!(
"{dow}, {d:02} {mon} {y} {h:02}:{mi:02}:{s:02} GMT",
mon = MONTHS[(m - 1) as usize],
h = secs / 3600,
mi = (secs % 3600) / 60,
s = secs % 60
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_body_is_the_oracles_67_bytes() {
assert_eq!(ROSTER_XML.len(), 67);
assert!(ROSTER_XML.starts_with(b"<?xml version=\"1.0\" encoding=\"utf-8\"?>"));
assert!(ROSTER_XML.ends_with(b"<rosterupdate version=\"0\"/>\n"));
}
#[test]
fn only_get_carries_the_body() {
let d = "Tue, 11 Aug 2026 05:15:13 GMT";
let get = roster_response(Method::Get, ORACLE_SERVER, d);
let head = roster_response(Method::Head, ORACLE_SERVER, d);
let post = roster_response(Method::Post, ORACLE_SERVER, d);
assert_eq!(get.len(), head.len() + ROSTER_XML.len());
assert_eq!(head, post, "the oracle answers POST exactly as HEAD");
}
/// The quirk, asserted so a future "cleanup" has to argue with a test.
#[test]
fn a_bodiless_response_still_advertises_the_body_length() {
let r = roster_response(Method::Head, ORACLE_SERVER, "Tue, 11 Aug 2026 05:15:13 GMT");
let text = String::from_utf8_lossy(&r);
assert!(text.contains("Content-Length: 67"), "{text}");
assert!(text.ends_with("\r\n\r\n"), "no body may follow");
}
#[test]
fn header_order_matches_basehttprequesthandler() {
let r = roster_response(Method::Get, ORACLE_SERVER, "Tue, 11 Aug 2026 05:15:13 GMT");
let text = String::from_utf8_lossy(&r);
let order: Vec<&str> = [
"Server:",
"Date:",
"Content-Type:",
"Content-Length:",
"Connection:",
]
.iter()
.map(|h| text.find(h).map(|_| *h).unwrap_or("MISSING"))
.collect();
assert!(!order.contains(&"MISSING"), "{text}");
let positions: Vec<usize> = order.iter().map(|h| text.find(h).unwrap()).collect();
let mut sorted = positions.clone();
sorted.sort_unstable();
assert_eq!(
positions, sorted,
"headers out of the oracle's order:\n{text}"
);
assert!(
text.starts_with("HTTP/1.0 200 OK\r\n"),
"must be HTTP/1.0: {text}"
);
}
#[test]
fn methods_parse_and_unknown_ones_are_refused() {
assert_eq!(Method::parse("GET /x HTTP/1.1"), Some(Method::Get));
assert_eq!(Method::parse("HEAD /x HTTP/1.1"), Some(Method::Head));
assert_eq!(Method::parse("POST /x HTTP/1.1"), Some(Method::Post));
// Not reproduced on purpose: the oracle answers these with its own 501.
assert_eq!(Method::parse("PUT /x HTTP/1.1"), None);
assert_eq!(Method::parse(""), None);
}
#[test]
fn http_date_matches_the_oracles_format() {
// 1786425313 == Tue, 11 Aug 2026 05:15:13 GMT, the captured fixture time.
assert_eq!(http_date(1_786_425_313), "Tue, 11 Aug 2026 05:15:13 GMT");
assert_eq!(http_date(0), "Thu, 01 Jan 1970 00:00:00 GMT");
// A leap day, because the civil-date maths is the only real logic here.
assert_eq!(http_date(1_709_164_800), "Thu, 29 Feb 2024 00:00:00 GMT");
}
}
+141
View File
@@ -0,0 +1,141 @@
//! FIFA 17's TLS profile — what the retail client was *observed* to offer.
//!
//! # Evidence, not assumption
//!
//! A retail FIFA 17 client was captured through a passive proxy while reaching
//! the FUT hub. It offers **exactly eight suites, every one static-RSA**:
//!
//! ```text
//! 0x009D TLS_RSA_WITH_AES_256_GCM_SHA384 0x0035 TLS_RSA_WITH_AES_256_CBC_SHA
//! 0x009C TLS_RSA_WITH_AES_128_GCM_SHA256 0x002F TLS_RSA_WITH_AES_128_CBC_SHA
//! 0x003D TLS_RSA_WITH_AES_256_CBC_SHA256 0x0005 TLS_RSA_WITH_RC4_128_SHA
//! 0x003C TLS_RSA_WITH_AES_128_CBC_SHA256 0x0004 TLS_RSA_WITH_RC4_128_MD5
//!
//! client_version TLS 1.2 extensions: server_name, signature_algorithms only
//! ```
//!
//! Zero forward-secret suites — which is why `rustls` cannot serve this client,
//! and why the transport hosts link OpenSSL directly.
//!
//! # Deliberately not enabled
//!
//! * **RC4 and MD5.** The client offers them; it does not need them. It already
//! negotiates `AES256-GCM-SHA384` against the Python oracle, so resurrecting
//! RC4 for completeness would weaken the service for nothing.
//! * **SSLv3.** Never.
//! * **A lowered security level.** Not applied pre-emptively. The default
//! policy is tried first; if a retail handshake fails because OpenSSL rejects
//! something genuinely required, the narrowest possible change is made and
//! documented — not a blanket `SECLEVEL=0`.
//!
//! # Why this is data and not code
//!
//! These are facts about FIFA 17, so they live in the FIFA 17 adapter rather
//! than in `openfut-tls`, which must stay game-independent. They are plain
//! strings so this crate keeps its lean dependency list: an adapter should not
//! drag OpenSSL into the build of everything that reads a card table.
//!
//! Confirmed live on 2026-08-11: the retail client reached the FUT hub through
//! a Rust host configured from exactly these values, negotiating
//! `TLSv1.2 / AES256-GCM-SHA384` with `sni=winter15.gosredirector.ea.com`.
/// The suites enabled for FIFA 17: the six RSA+AES options the client offers,
/// strongest first, in OpenSSL's pre-TLS-1.3 naming.
///
/// Order expresses preference; the client's own order put AES-256-GCM first
/// anyway, which is what the live handshake selected.
pub const CIPHER_LIST: &str =
"AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:AES256-SHA:AES128-SHA";
/// Suites the observed client offers that are deliberately refused.
pub const REFUSED_SUITES: [&str; 2] = ["RC4-SHA", "RC4-MD5"];
/// All eight suites the captured ClientHello offered, for handshake rehearsals.
pub const OBSERVED_CLIENT_SUITES: &str =
"AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:\
AES256-SHA:AES128-SHA:RC4-SHA:RC4-MD5";
/// The SNI the retail client sends to the redirector.
///
/// Nothing routes on it — recorded so a rehearsal handshake matches the real
/// one, and so a log line can show whether a connection came from the game or
/// from a probe.
pub const CLIENT_SNI: &str = "winter15.gosredirector.ea.com";
/// Protocol floor, as OpenSSL spells it.
///
/// The floor is NOT dropped to TLS 1.0 pre-emptively. The Python oracle permits
/// it, but no evidence shows this client needs it, and "the oracle permits it"
/// is not "the client requires it".
pub const MIN_VERSION: &str = "TLSv1.2";
/// Protocol ceiling. The client offers no TLS 1.3, so the window is exact.
pub const MAX_VERSION: &str = "TLSv1.2";
/// The suite the live retail handshake selected, and which a rehearsal is
/// expected to reproduce.
pub const EXPECTED_SUITE: &str = "AES256-GCM-SHA384";
#[cfg(test)]
mod tests {
use super::*;
/// The enabled list must be exactly the client's offer minus the refusals.
/// Stated as a derivation so adding a suite to one constant without the
/// other is a test failure rather than a silent divergence.
#[test]
fn the_enabled_list_is_the_offer_minus_the_refusals() {
let offered: Vec<&str> = OBSERVED_CLIENT_SUITES.split(':').collect();
let enabled: Vec<&str> = CIPHER_LIST.split(':').collect();
let expected: Vec<&str> = offered
.iter()
.copied()
.filter(|s| !REFUSED_SUITES.contains(s))
.collect();
assert_eq!(enabled, expected);
assert_eq!(offered.len(), 8, "the capture showed eight suites");
assert_eq!(enabled.len(), 6);
}
/// Every enabled suite must be static RSA. An ECDHE suite slipping in would
/// be silently useless to this client, which offers none.
#[test]
fn nothing_forward_secret_is_enabled() {
for s in CIPHER_LIST.split(':') {
assert!(
!s.contains("ECDHE") && !s.contains("DHE"),
"{s} is forward-secret; FIFA 17 offers no such suite"
);
}
}
#[test]
fn rc4_and_md5_are_refused_not_merely_absent() {
for s in REFUSED_SUITES {
assert!(
OBSERVED_CLIENT_SUITES.contains(s),
"{s} must be one the client actually offers"
);
assert!(!CIPHER_LIST.contains(s), "{s} must not be enabled");
}
}
#[test]
fn the_expected_suite_is_one_we_enable_and_the_client_offers() {
assert!(CIPHER_LIST.split(':').any(|s| s == EXPECTED_SUITE));
assert!(OBSERVED_CLIENT_SUITES
.split(':')
.any(|s| s == EXPECTED_SUITE));
assert_eq!(
CIPHER_LIST.split(':').next(),
Some(EXPECTED_SUITE),
"preference order should put the observed selection first"
);
}
#[test]
fn the_protocol_window_is_exactly_tls12() {
assert_eq!(MIN_VERSION, "TLSv1.2");
assert_eq!(MAX_VERSION, "TLSv1.2");
}
}
@@ -0,0 +1,218 @@
//! Deployment-address audit: the configured address must reach every
//! client-visible endpoint, and nothing may quietly substitute its own.
//!
//! OpenFUT has to run on arbitrary addresses. The development topology is
//! deployment configuration, not architecture, so no crate may contain a
//! production destination, an advertised address, or a hidden localhost
//! fallback.
//!
//! Two TEST-NET addresses are used throughout (RFC 5737), deliberately not the
//! lab's real LAN addresses: a test that passes only because its constant
//! happens to match the current lab proves nothing about relocatability.
use openfut_adapter_fifa17::blaze::{client_config, AdapterConfig, Endpoints};
use openfut_adapter_fifa17::redirector;
/// TEST-NET-2 and TEST-NET-3. Never routable, never ours, and obviously not a
/// lab address to anyone reading a failure.
const ADDR_A: &str = "198.51.100.7";
const ADDR_B: &str = "203.0.113.42";
fn cfg(advertise: &str) -> AdapterConfig {
AdapterConfig::advertising(advertise)
}
/// Every client-visible string a config produces, for wholesale comparison.
fn client_visible_surface(cfg: &AdapterConfig) -> Vec<String> {
let mut out = vec![
cfg.utas_base(),
cfg.nucleus_base(),
cfg.pow_content_url(),
String::from_utf8(redirector::redirect_response(cfg)).unwrap(),
];
for section in client_config::known_sections() {
for (k, v) in client_config::rows_for(section, cfg) {
out.push(format!("{section}/{k}={v}"));
}
}
out
}
/// (5) Changing the advertised host must update every applicable generated URL,
/// with no recompilation and no leftovers.
#[test]
fn changing_the_advertised_host_updates_every_client_visible_url() {
let a = client_visible_surface(&cfg(ADDR_A));
let b = client_visible_surface(&cfg(ADDR_B));
assert_eq!(a.len(), b.len(), "the surface itself must not change shape");
let a_has = a.iter().filter(|s| s.contains(ADDR_A)).count();
let b_has = b.iter().filter(|s| s.contains(ADDR_B)).count();
assert!(a_has > 200, "expected the address throughout, saw {a_has}");
assert_eq!(a_has, b_has, "the same entries must carry the new address");
// Nothing may retain the old address after reconfiguration.
let stragglers: Vec<&String> = b.iter().filter(|s| s.contains(ADDR_A)).collect();
assert!(
stragglers.is_empty(),
"these kept the previous address: {:?}",
&stragglers[..stragglers.len().min(5)]
);
}
/// (1) A remote configuration must not silently become localhost anywhere.
#[test]
fn remote_configuration_never_silently_becomes_localhost() {
let c = cfg(ADDR_A);
// Allowlisted: two OAuth redirect targets that are literals in the oracle
// and are never dialled (see the compatibility exceptions in the vault).
const ALLOWED_LOOPBACK_KEYS: [&str; 2] = ["identityRedirectUri", "redirect_uri"];
for entry in client_visible_surface(&c) {
if entry.contains("127.0.0.1") || entry.contains("localhost") {
assert!(
ALLOWED_LOOPBACK_KEYS.iter().any(|k| entry.contains(k)),
"unexpected loopback in a remote configuration: {entry}"
);
}
}
// POW hosts in particular must derive from advertise, not fall back alone.
assert!(c.endpoints.pow_content_host.starts_with(ADDR_A));
assert!(c.endpoints.pow_host.starts_with(ADDR_A));
assert!(c.pow_content_url().contains(ADDR_A));
}
/// (3) Bind and advertise are different concepts and must never be conflated.
#[test]
fn bind_can_differ_from_advertise() {
let mut c = cfg(ADDR_A);
c.endpoints.bind = "0.0.0.0".into();
assert_eq!(c.endpoints.advertise, ADDR_A);
assert_eq!(c.endpoints.bind, "0.0.0.0");
// The advertised surface follows advertise, not bind.
assert!(c.utas_base().contains(ADDR_A));
assert!(!c.utas_base().contains("0.0.0.0"));
// COMPATIBILITY EXCEPTION, reproduced deliberately: nucleusConnect follows
// BIND in the oracle. Instrumentation showed the client never dials it, so
// this is cosmetic on the observed path. Asserted so the exception cannot
// be "fixed" by accident without this test failing and forcing the decision
// to be made explicitly.
assert_eq!(c.nucleus_base(), "http://0.0.0.0:42131");
}
/// (4) The advertised Blaze port must reach the redirect result.
#[test]
fn changing_the_blaze_port_changes_the_redirect() {
let mut c = cfg(ADDR_A);
let before = String::from_utf8(redirector::redirect_response(&c)).unwrap();
assert!(before.contains("<port>42130</port>"));
c.endpoints.blaze_port = 42999;
let after = String::from_utf8(redirector::redirect_response(&c)).unwrap();
assert!(after.contains("<port>42999</port>"), "{after}");
assert!(!after.contains("<port>42130</port>"));
// And the advertised host still follows config.
assert!(after.contains(&format!("<hostname>{ADDR_A}</hostname>")));
}
/// The UTAS port is deployment configuration too, not a constant we own.
#[test]
fn changing_the_utas_port_changes_every_rs4_url() {
let mut c = cfg(ADDR_A);
assert!(c.utas_base().contains(":8099/"));
c.endpoints.utas_port = 9099;
assert_eq!(c.utas_base(), format!("http://{ADDR_A}:9099/"));
let rows = client_config::rows_for("BlazeSDK", &c);
let base = rows.iter().find(|(k, _)| k == "FUT_RS4_BASE_URL").unwrap();
assert_eq!(base.1, format!("http://{ADDR_A}:9099/"));
assert!(!rows.iter().any(|(_, v)| v.contains(":8099")));
}
/// (6) No service-specific helper may construct an endpoint from a different
/// source of truth than the central configuration.
#[test]
fn no_helper_bypasses_the_central_configuration() {
// bind MUST differ from advertise here. With them equal, a helper that
// wrongly reads `bind` is indistinguishable from one that reads
// `advertise` — and reading `bind` is the single most likely bypass,
// because the oracle really does it for nucleusConnect. Mutation-tested:
// with bind == advertise this test could not detect that substitution.
let mut c = cfg(ADDR_B);
c.endpoints.bind = "0.0.0.0".into();
// Every URL-shaped helper resolves through the same Endpoints.
assert!(c.utas_base().contains(ADDR_B));
assert!(c.pow_content_url().contains(ADDR_B));
let ep = redirector::BlazeEndpoint::from_config(&c);
assert_eq!(
ep.host, c.endpoints.advertise,
"the redirector must advertise the ADVERTISED host, not the bind address"
);
assert_ne!(
ep.host, c.endpoints.bind,
"bind must not leak into the wire"
);
let xml = String::from_utf8(redirector::redirect_response(&c)).unwrap();
assert!(xml.contains(ADDR_B));
assert!(
!xml.contains("0.0.0.0"),
"the bind address must never reach the client"
);
assert_eq!(ep.port, c.endpoints.blaze_port);
// And the config table's URL tokens resolve through those same helpers,
// rather than re-deriving a URL shape of their own.
let rows = client_config::rows_for("BlazeSDK", &c);
let base = rows.iter().find(|(k, _)| k == "FUT_RS4_BASE_URL").unwrap();
assert_eq!(base.1, c.utas_base());
let nucleus = rows.iter().find(|(k, _)| k == "nucleusConnect").unwrap();
assert_eq!(nucleus.1, c.nucleus_base());
}
/// (7) Mutating the configuration must make these tests fail — a suite that
/// passes regardless of the configured address would prove nothing.
#[test]
fn configuration_mutations_are_detectable() {
let a = cfg(ADDR_A);
let b = cfg(ADDR_B);
// Each of these is what a mutation would have to defeat.
assert_ne!(a.utas_base(), b.utas_base());
assert_ne!(a.pow_content_url(), b.pow_content_url());
assert_ne!(
redirector::redirect_response(&a),
redirector::redirect_response(&b)
);
assert_ne!(
client_config::rows_for("BlazeSDK", &a),
client_config::rows_for("BlazeSDK", &b)
);
let mut port_changed = a.clone();
port_changed.endpoints.blaze_port += 1;
assert_ne!(
redirector::redirect_response(&a),
redirector::redirect_response(&port_changed)
);
}
/// Loopback must be a named, deliberate choice — not something a caller can
/// reach by omission.
#[test]
fn loopback_is_explicit_not_a_default() {
let l = Endpoints::loopback();
assert_eq!(l.advertise, "127.0.0.1");
assert!(l.pow_content_host.starts_with("127.0.0.1"));
// `Endpoints::default()` and `AdapterConfig::default()` deliberately do not
// exist; this test documents that, and the crate would not compile if they
// were reintroduced and used by accident elsewhere.
let explicit = AdapterConfig::loopback();
assert_eq!(explicit.endpoints.advertise, "127.0.0.1");
}
@@ -0,0 +1,199 @@
{
"purchase": [
{
"assetId": 1,
"currencies": [
{
"finalFunds": 400,
"funds": 400,
"name": "coins"
}
],
"description": "Bronze Pack",
"displayGroup": {
"value": "bronze"
},
"extPrice": {
"finalPrice": {
"amount": 4,
"currency": "mtx"
},
"originalPrice": {
"amount": 4,
"currency": "mtx"
}
},
"id": 1,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 5,
"goldQuantity": 0,
"itemQuantity": 5,
"rareQuantity": 0,
"silverQuantity": 0
},
"packType": "BRONZE",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 1,
"state": "active",
"unopened": false
},
{
"assetId": 5,
"currencies": [
{
"finalFunds": 5000,
"funds": 5000,
"name": "coins"
}
],
"description": "Gold Pack",
"displayGroup": {
"value": "gold"
},
"extPrice": {
"finalPrice": {
"amount": 50,
"currency": "mtx"
},
"originalPrice": {
"amount": 50,
"currency": "mtx"
}
},
"id": 5,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 0,
"goldQuantity": 7,
"itemQuantity": 7,
"rareQuantity": 7,
"silverQuantity": 0
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 2,
"state": "active",
"unopened": false
},
{
"assetId": 6,
"currencies": [
{
"finalFunds": 15000,
"funds": 15000,
"name": "coins"
}
],
"description": "Premium Gold",
"displayGroup": {
"value": "gold"
},
"extPrice": {
"finalPrice": {
"amount": 150,
"currency": "mtx"
},
"originalPrice": {
"amount": 150,
"currency": "mtx"
}
},
"id": 6,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 0,
"goldQuantity": 11,
"itemQuantity": 11,
"rareQuantity": 11,
"silverQuantity": 0
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 3,
"state": "active",
"unopened": false
},
{
"assetId": 7,
"currencies": [
{
"finalFunds": 25000,
"funds": 25000,
"name": "coins"
}
],
"description": "Special Players Pack",
"displayGroup": {
"value": "special"
},
"extPrice": {
"finalPrice": {
"amount": 250,
"currency": "mtx"
},
"originalPrice": {
"amount": 250,
"currency": "mtx"
}
},
"id": 7,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 0,
"goldQuantity": 11,
"itemQuantity": 11,
"rareQuantity": 11,
"silverQuantity": 0
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 4,
"state": "active",
"unopened": false
},
{
"assetId": 70,
"description": "Reward Special Players Pack",
"displayGroup": {
"priority": 1,
"value": "mypacks"
},
"id": 70,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 0,
"goldQuantity": 11,
"itemQuantity": 11,
"rareQuantity": 11,
"silverQuantity": 0
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 1,
"state": "active",
"unopened": true
}
],
"timestamp": 1596326400
}
@@ -0,0 +1,173 @@
{
"purchase": [
{
"assetId": 1,
"currencies": [
{
"finalFunds": 400,
"funds": 400,
"name": "coins"
}
],
"description": "Bronze Pack",
"displayGroup": {
"value": "bronze"
},
"extPrice": {
"finalPrice": {
"amount": 4,
"currency": "mtx"
},
"originalPrice": {
"amount": 4,
"currency": "mtx"
}
},
"id": 1,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 5,
"goldQuantity": 0,
"itemQuantity": 5,
"rareQuantity": 0,
"silverQuantity": 0
},
"packType": "BRONZE",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 1,
"state": "active",
"unopened": false
},
{
"assetId": 5,
"currencies": [
{
"finalFunds": 5000,
"funds": 5000,
"name": "coins"
}
],
"description": "Gold Pack",
"displayGroup": {
"value": "gold"
},
"extPrice": {
"finalPrice": {
"amount": 50,
"currency": "mtx"
},
"originalPrice": {
"amount": 50,
"currency": "mtx"
}
},
"id": 5,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 0,
"goldQuantity": 7,
"itemQuantity": 7,
"rareQuantity": 7,
"silverQuantity": 0
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 2,
"state": "active",
"unopened": false
},
{
"assetId": 6,
"currencies": [
{
"finalFunds": 15000,
"funds": 15000,
"name": "coins"
}
],
"description": "Premium Gold",
"displayGroup": {
"value": "gold"
},
"extPrice": {
"finalPrice": {
"amount": 150,
"currency": "mtx"
},
"originalPrice": {
"amount": 150,
"currency": "mtx"
}
},
"id": 6,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 0,
"goldQuantity": 11,
"itemQuantity": 11,
"rareQuantity": 11,
"silverQuantity": 0
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 3,
"state": "active",
"unopened": false
},
{
"assetId": 7,
"currencies": [
{
"finalFunds": 25000,
"funds": 25000,
"name": "coins"
}
],
"description": "Special Players Pack",
"displayGroup": {
"value": "special"
},
"extPrice": {
"finalPrice": {
"amount": 250,
"currency": "mtx"
},
"originalPrice": {
"amount": 250,
"currency": "mtx"
}
},
"id": 7,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 0,
"goldQuantity": 11,
"itemQuantity": 11,
"rareQuantity": 11,
"silverQuantity": 0
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 4,
"state": "active",
"unopened": false
}
],
"timestamp": 1596326400
}
@@ -0,0 +1,199 @@
{
"purchase": [
{
"assetId": 1,
"currencies": [
{
"finalFunds": 400,
"funds": 400,
"name": "coins"
}
],
"description": "Bronze Pack",
"displayGroup": {
"value": "bronze"
},
"extPrice": {
"finalPrice": {
"amount": 4,
"currency": "mtx"
},
"originalPrice": {
"amount": 4,
"currency": "mtx"
}
},
"id": 1,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 5,
"goldQuantity": 0,
"itemQuantity": 5,
"rareQuantity": 0,
"silverQuantity": 0
},
"packType": "BRONZE",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 1,
"state": "active",
"unopened": false
},
{
"assetId": 5,
"currencies": [
{
"finalFunds": 5000,
"funds": 5000,
"name": "coins"
}
],
"description": "Gold Pack",
"displayGroup": {
"value": "gold"
},
"extPrice": {
"finalPrice": {
"amount": 50,
"currency": "mtx"
},
"originalPrice": {
"amount": 50,
"currency": "mtx"
}
},
"id": 5,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 0,
"goldQuantity": 7,
"itemQuantity": 7,
"rareQuantity": 7,
"silverQuantity": 0
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 2,
"state": "active",
"unopened": false
},
{
"assetId": 6,
"currencies": [
{
"finalFunds": 15000,
"funds": 15000,
"name": "coins"
}
],
"description": "Premium Gold",
"displayGroup": {
"value": "gold"
},
"extPrice": {
"finalPrice": {
"amount": 150,
"currency": "mtx"
},
"originalPrice": {
"amount": 150,
"currency": "mtx"
}
},
"id": 6,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 0,
"goldQuantity": 11,
"itemQuantity": 11,
"rareQuantity": 11,
"silverQuantity": 0
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 3,
"state": "active",
"unopened": false
},
{
"assetId": 7,
"currencies": [
{
"finalFunds": 25000,
"funds": 25000,
"name": "coins"
}
],
"description": "Special Players Pack",
"displayGroup": {
"value": "special"
},
"extPrice": {
"finalPrice": {
"amount": 250,
"currency": "mtx"
},
"originalPrice": {
"amount": 250,
"currency": "mtx"
}
},
"id": 7,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 0,
"goldQuantity": 11,
"itemQuantity": 11,
"rareQuantity": 11,
"silverQuantity": 0
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 4,
"state": "active",
"unopened": false
},
{
"assetId": 65534,
"description": "",
"displayGroup": {
"priority": 1,
"value": "mypacks"
},
"id": 65534,
"isPremium": false,
"limitType": "NONE",
"packContentInfo": {
"bronzeQuantity": 0,
"goldQuantity": 0,
"itemQuantity": 0,
"rareQuantity": 0,
"silverQuantity": 0
},
"packType": "GOLD",
"purchaseCount": 0,
"purchaseLimit": 0,
"quantity": 0,
"saleType": "promo",
"sortPriority": 1,
"state": "active",
"unopened": false
}
],
"timestamp": 1596326400
}
@@ -0,0 +1,402 @@
//! Differential tests: the Rust adapter against the Python Blaze responder.
//!
//! `openfut-protocol-blaze` proves the *codec* matches. This proves the layer
//! that decides **what to say**: for each inbound frame, the exact frames that
//! go back and their order.
//!
//! Every vector in `fixtures/blaze_transactions.jsonl` was produced by calling
//! the real `blaze_responder_v3b.dispatch()`. Transactions replay in file order
//! against a shared session per connection, so ordering-dependent behaviour is
//! exercised rather than assumed — preAuth captures the locale that later ALOC
//! fields echo, and login sets the auth code getAuthToken returns afterwards.
//!
//! Comparison is byte-for-byte, including frame count and order. A missing
//! post-login notification or a reply where the oracle stays silent is a
//! failure here, which is the whole point.
//!
//! Regenerate after any oracle change: python3 fixtures/generate.py
use std::collections::HashMap;
use openfut_adapter_fifa17::blaze::{Adapter, AdapterConfig, Endpoints, Identity, Session};
use openfut_protocol_blaze::fire2::Frame;
use openfut_protocol_blaze::heat2;
use serde_json::Value as J;
fn records() -> Vec<J> {
let path = format!(
"{}/fixtures/blaze_transactions.jsonl",
env!("CARGO_MANIFEST_DIR")
);
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("cannot read {path}: {e}\nrun: python3 fixtures/generate.py"));
text.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| serde_json::from_str(l).expect("fixture line is valid JSON"))
.collect()
}
fn unhex(s: &str) -> Vec<u8> {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("valid hex"))
.collect()
}
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
fn st(j: &J, k: &str) -> String {
j[k].as_str()
.unwrap_or_else(|| panic!("{k} is a string"))
.to_string()
}
fn n(j: &J, k: &str) -> i64 {
j[k].as_i64().unwrap_or_else(|| panic!("{k} is a number"))
}
/// Rebuild the exact configuration the fixtures were generated under.
///
/// The generator uses deliberately non-loopback addresses, so an adapter that
/// hardcoded one instead of reading its config fails loudly here rather than
/// coincidentally matching a default.
fn config_from(record: &J) -> (AdapterConfig, i64) {
let id = &record["identity"];
let identity = Identity {
persona_id: n(id, "persona_id"),
persona_name: st(id, "persona_name"),
user_id: n(id, "user_id"),
ext_id: n(id, "ext_id"),
email: st(id, "email"),
namespace: st(id, "namespace"),
client_platform: n(id, "client_platform"),
persona_status: n(id, "persona_status"),
user_session_type: n(id, "user_session_type"),
account_locale: n(id, "account_locale_int"),
locale: st(id, "locale"),
content_id: st(id, "content_id"),
entitlement_tag: st(id, "entitlement_tag"),
entitlement_group: st(id, "entitlement_group"),
title_id: st(id, "title_id"),
client_id: st(id, "client_id"),
platform: st(id, "platform"),
};
let endpoints = Endpoints {
advertise: st(record, "advertise"),
bind: st(record, "bind"),
pow_content_host: st(record, "pow_content_host"),
pow_host: st(record, "pow_host"),
..Endpoints::loopback()
};
let cfg = AdapterConfig {
identity,
endpoints,
server_version: st(&record["identity"], "server_version"),
};
(cfg, n(record, "now"))
}
struct Replay {
adapter: Adapter,
now: i64,
sessions: HashMap<String, Session>,
records: Vec<J>,
}
fn setup() -> Replay {
let records = records();
let cfg_rec = records
.iter()
.find(|r| r["kind"] == "config")
.expect("fixture carries a config record")
.clone();
let (cfg, now) = config_from(&cfg_rec);
let mut sessions = HashMap::new();
for r in &records {
if r["kind"] == "session" {
// The session key is injected, not generated: it appears verbatim
// in three responses, so a self-minted one could never match.
sessions.insert(
st(r, "id"),
Session::new(st(r, "session_key"), n(r, "account_locale")),
);
}
}
Replay {
adapter: Adapter::new(cfg),
now,
sessions,
records,
}
}
/// The headline test: replay every transaction and require identical frames.
#[test]
fn dispatch_matches_python_oracle_byte_for_byte() {
let mut rp = setup();
let records = rp.records.clone();
let mut checked = 0usize;
for rec in records.iter().filter(|r| r["kind"] == "tx") {
let name = st(rec, "name");
let sid = st(rec, "session");
let request = unhex(&st(rec, "request_hex"));
let expected: Vec<String> = rec["responses"]
.as_array()
.expect("responses array")
.iter()
.map(|f| f.as_str().unwrap().to_string())
.collect();
let (frame, used) = Frame::parse(&request)
.unwrap_or_else(|e| panic!("{name}: fixture request does not parse: {e}"));
assert_eq!(used, request.len(), "{name}: trailing bytes in request");
let body = if frame.payload.is_empty() {
heat2::Struct::new()
} else {
heat2::decode(&frame.payload)
.unwrap_or_else(|e| panic!("{name}: request body is not valid TDF: {e}"))
};
let session = rp.sessions.get_mut(&sid).expect("session declared");
let out = rp.adapter.dispatch(&frame.header, &body, session, rp.now);
assert_eq!(
out.len(),
expected.len(),
"\n{name}: produced {} frame(s), oracle produced {}",
out.len(),
expected.len()
);
for (idx, (got, want)) in out.iter().zip(expected.iter()).enumerate() {
let got_hex = hex(&got.encode());
if &got_hex != want {
// Narrow the failure to header vs body before dumping bytes.
let want_bytes = unhex(want);
let got_bytes = got.encode();
assert_eq!(
hex(&got_bytes[..16.min(got_bytes.len())]),
hex(&want_bytes[..16.min(want_bytes.len())]),
"\n{name} frame {idx}: HEADER differs"
);
panic!(
"\n{name} frame {idx}: BODY differs\n got {} bytes\n want {} bytes",
got_bytes.len().saturating_sub(16),
want_bytes.len().saturating_sub(16)
);
}
}
checked += 1;
}
assert!(
checked >= 40,
"expected the full script, replayed {checked}"
);
}
/// Frame counts and ordering are part of the contract, so assert them
/// separately from bytes — a rewrite that answered correctly but dropped a
/// notification would otherwise fail with an unhelpful byte diff.
#[test]
fn frame_counts_and_ordering_match() {
let mut rp = setup();
let records = rp.records.clone();
for rec in records.iter().filter(|r| r["kind"] == "tx") {
let name = st(rec, "name");
let request = unhex(&st(rec, "request_hex"));
let expected = rec["responses"].as_array().unwrap();
let (frame, _) = Frame::parse(&request).unwrap();
let body = if frame.payload.is_empty() {
heat2::Struct::new()
} else {
heat2::decode(&frame.payload).unwrap()
};
let session = rp.sessions.get_mut(&st(rec, "session")).unwrap();
let out = rp.adapter.dispatch(&frame.header, &body, session, rp.now);
assert_eq!(out.len(), expected.len(), "{name}: frame count");
for (got, want_hex) in out.iter().zip(expected.iter()) {
let want = Frame::parse(&unhex(want_hex.as_str().unwrap())).unwrap().0;
assert_eq!(
got.header.component, want.header.component,
"{name}: component"
);
assert_eq!(got.header.command, want.header.command, "{name}: command");
assert_eq!(got.header.msg_type, want.header.msg_type, "{name}: msgType");
assert_eq!(got.header.msg_num, want.header.msg_num, "{name}: msgNum");
assert_eq!(
got.header.user_index, want.header.user_index,
"{name}: userIndex"
);
}
}
}
/// The login burst is the sequence most likely to be silently wrong, so pin it
/// explicitly rather than relying on it being buried in the byte comparison.
#[test]
fn login_emits_reply_then_exactly_three_pushes() {
let rp = setup();
let login = rp
.records
.iter()
.find(|r| r["kind"] == "tx" && r["name"] == "login")
.expect("login transaction present");
let frames: Vec<Frame> = login["responses"]
.as_array()
.unwrap()
.iter()
.map(|h| Frame::parse(&unhex(h.as_str().unwrap())).unwrap().0)
.collect();
assert_eq!(frames.len(), 4, "reply + three UserSessions pushes");
assert_eq!(
frames[0].header.msg_type,
openfut_protocol_blaze::fire2::MsgType::Reply
);
let notify_ids: Vec<u16> = frames[1..].iter().map(|f| f.header.command).collect();
assert_eq!(notify_ids, vec![0x0008, 0x0001, 0x0002]);
}
/// The generator uses non-loopback addresses, so any loopback literal left in a
/// response means the adapter hardcoded something it should have read from
/// config — the exact regression the client/server split was meant to prevent.
///
/// Two keys are genuine literals in the oracle, not substitution failures.
/// Both are OAuth redirect targets the client never actually dials (the flow is
/// forged), so the loopback is inert; they are allowlisted by key rather than
/// by pattern so a third one cannot slip in unnoticed.
const ALLOWED_LOOPBACK_KEYS: [&str; 2] = ["identityRedirectUri", "redirect_uri"];
#[test]
fn no_response_hardcodes_a_loopback_address() {
let mut rp = setup();
let records = rp.records.clone();
let advertise = "198.51.100.7";
for rec in records.iter().filter(|r| r["kind"] == "tx") {
let name = st(rec, "name");
let request = unhex(&st(rec, "request_hex"));
let (frame, _) = Frame::parse(&request).unwrap();
let body = if frame.payload.is_empty() {
heat2::Struct::new()
} else {
heat2::decode(&frame.payload).unwrap()
};
let session = rp.sessions.get_mut(&st(rec, "session")).unwrap();
let out = rp.adapter.dispatch(&frame.header, &body, session, rp.now);
for f in &out {
let text = String::from_utf8_lossy(&f.payload);
for (at, _) in text.match_indices("127.0.0.1") {
// TDF strings are length-prefixed and NUL-terminated, so the
// owning key sits shortly before the value. Look back far
// enough to name it, and require it to be allowlisted.
let start = at.saturating_sub(80);
let context = &text[start..text.len().min(at + 64)];
assert!(
ALLOWED_LOOPBACK_KEYS.iter().any(|k| context.contains(k)),
"\n{name}: unexpected loopback literal, context {context:?}"
);
}
}
// The advertised address must actually appear somewhere in the config
// responses, or substitution silently did nothing.
if name.starts_with("fetch_config") || name == "preauth" {
let text = String::from_utf8_lossy(&out[0].payload);
assert!(
text.contains(advertise),
"{name}: advertised address missing from the config payload"
);
}
}
}
/// Session state must survive across RPCs on one connection, and must NOT leak
/// between connections.
#[test]
fn session_state_is_per_connection() {
let mut rp = setup();
let records = rp.records.clone();
for rec in records.iter().filter(|r| r["kind"] == "tx") {
let request = unhex(&st(rec, "request_hex"));
let (frame, _) = Frame::parse(&request).unwrap();
let body = if frame.payload.is_empty() {
heat2::Struct::new()
} else {
heat2::decode(&frame.payload).unwrap()
};
let session = rp.sessions.get_mut(&st(rec, "session")).unwrap();
rp.adapter.dispatch(&frame.header, &body, session, rp.now);
}
// "main" logged in with an auth code and an enUS preAuth.
let main = &rp.sessions["main"];
assert!(main.logged_in);
assert_eq!(main.auth_code, "OPENFUT-TEST-AUTHCODE");
assert_eq!(main.account_locale, 0x656E_5553);
// "locale" ran a deDE preAuth and a login carrying no AUTH member.
let loc = &rp.sessions["locale"];
assert_eq!(loc.account_locale, 0x6465_4445, "deDE locale captured");
assert_eq!(loc.service_name, "fifa-2017-pc-de");
assert!(loc.auth_code.is_empty());
// "fallbacks" never logged in.
assert!(!rp.sessions["fallbacks"].logged_in);
}
// ────────────────────────────── redirector ──────────────────────────────
//
// The first hop. A different protocol from Blaze — HTTPS with an XML body —
// but the same rule: byte-for-byte against the oracle.
#[test]
fn redirect_response_matches_python_oracle_byte_for_byte() {
use openfut_adapter_fifa17::redirector;
let path = format!("{}/fixtures/redirector.json", env!("CARGO_MANIFEST_DIR"));
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("cannot read {path}: {e}"));
let table: serde_json::Map<String, J> = serde_json::from_str(&text).expect("valid JSON");
assert!(table.len() >= 3, "expected several advertised addresses");
for (advertise, want_hex) in &table {
let mut cfg = AdapterConfig::loopback();
cfg.endpoints.advertise = advertise.clone();
let got = redirector::redirect_response(&cfg);
assert_eq!(
hex(&got),
want_hex.as_str().unwrap(),
"\nredirector response differs for advertise={advertise}"
);
}
}
/// The advertised Blaze endpoint must follow config, and the fixtures use
/// deliberately different addresses so a hardcoded one cannot pass.
#[test]
fn redirect_response_is_configurable_not_baked() {
use openfut_adapter_fifa17::redirector;
let mut a = AdapterConfig::loopback();
a.endpoints.advertise = "10.0.0.5".into();
let mut b = AdapterConfig::loopback();
b.endpoints.advertise = "10.0.0.6".into();
assert_ne!(
redirector::redirect_response(&a),
redirector::redirect_response(&b),
"the advertised address must reach the wire"
);
}
@@ -0,0 +1,122 @@
//! The roster response, held against bytes captured from the live oracle.
//!
//! The fixture masks the two volatile fields (`Date:`, `Server:`) and records
//! the observed `Server` string separately, so this can assert the full byte
//! layout while still failing loudly if the oracle's Python version drifts away
//! from the adapter's `ORACLE_SERVER` constant.
use openfut_adapter_fifa17::roster::{self, Method};
const MASK: &str = "<MASKED>";
fn fixture() -> String {
let path = format!("{}/fixtures/roster.json", env!("CARGO_MANIFEST_DIR"));
std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("roster fixtures missing at {path}: {e}"))
}
/// Minimal extraction: the fixture is written by our own generator and is a
/// flat object, so a JSON dependency would be overkill in a crate that has none.
fn field(text: &str, key: &str) -> String {
let needle = format!("\"{key}\"");
let at = text
.find(&needle)
.unwrap_or_else(|| panic!("fixture has no {key}"));
let rest = &text[at + needle.len()..];
let colon = rest.find(':').expect("key: value");
let open = rest[colon..].find('"').expect("value opens") + colon;
let close = rest[open + 1..].find('"').expect("value closes");
rest[open + 1..open + 1 + close].to_string()
}
fn expected(method: &str) -> Vec<u8> {
// Scope the search to the responses object so a key never matches elsewhere.
let text = fixture();
let at = text.find("\"responses\"").expect("responses");
let hex = field(&text[at..], method);
(0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("hex"))
.collect()
}
/// Re-apply the generator's masking so the comparison is like-for-like.
fn mask(raw: &[u8]) -> Vec<u8> {
let text = String::from_utf8_lossy(raw);
let masked: String = text
.split("\r\n")
.map(|line| {
if line.starts_with("Date: ") {
format!("Date: {MASK}")
} else if line.starts_with("Server: ") {
format!("Server: {MASK}")
} else {
line.to_string()
}
})
.collect::<Vec<_>>()
.join("\r\n");
masked.into_bytes()
}
fn check(method: Method, name: &str) {
let got = roster::roster_response(
method,
roster::ORACLE_SERVER,
"Tue, 11 Aug 2026 05:15:13 GMT",
);
assert_eq!(
String::from_utf8_lossy(&mask(&got)),
String::from_utf8_lossy(&expected(name)),
"{name} differs from the oracle"
);
}
#[test]
fn get_matches_the_oracle() {
check(Method::Get, "GET");
}
#[test]
fn head_matches_the_oracle() {
check(Method::Head, "HEAD");
}
/// Including the quirk: headers advertising a body that never arrives.
#[test]
fn post_matches_the_oracle() {
check(Method::Post, "POST");
}
/// If the container's Python changes, `ORACLE_SERVER` is stale and every
/// response this adapter builds is wrong in a byte the oracle would have got
/// right. The fixture records what was actually observed so that drift is a
/// test failure rather than a silent divergence.
#[test]
fn the_server_constant_still_matches_the_observed_oracle() {
let observed = field(&fixture(), "observed_server");
assert_eq!(
roster::ORACLE_SERVER,
observed,
"roster::ORACLE_SERVER is stale — the oracle now sends {observed:?}. \
Regenerate fixtures and update the constant."
);
}
/// The masking must not be able to hide a real difference. If `Date:` were
/// dropped rather than masked, a response missing it entirely would still pass.
#[test]
fn masking_does_not_hide_a_missing_header() {
let good = roster::roster_response(Method::Get, roster::ORACLE_SERVER, "X");
let without_date: Vec<u8> = String::from_utf8_lossy(&good)
.split("\r\n")
.filter(|l| !l.starts_with("Date: "))
.collect::<Vec<_>>()
.join("\r\n")
.into_bytes();
assert_ne!(
mask(&good),
mask(&without_date),
"masking collapsed a missing Date into a match"
);
}
@@ -0,0 +1,457 @@
//! Adapter-level squad read-after-write / round-trip fidelity, driven entirely by
//! committed sanitized capture evidence.
//!
//! Pipeline exercised end to end, with NO database, socket, or Core:
//!
//! ```text
//! captured PUT ─parse─▶ Fifa17SquadPut
//! ─build─▶ ProposedSquad (canonical) + Fifa17SquadExtensionV1
//! (simulate committed canonical state: the ProposedSquad IS what Core stored)
//! ─project─▶ FIFA 17 squad wire object
//! ```
//!
//! Fidelity is asserted by ownership class:
//! CANONICAL player instance per index, formation, captain, bench split
//! EXTENSION custom, kicktakers, manager, kit numbers (by player), squadType
//! SHADOW chemistry/rating/starRating (client-reported, round-tripped as-is)
//! DERIVED correct FIFA 17 item identity (wire id + resourceId)
//!
//! We assert *semantic wire fidelity*, not byte equality: the read oracle was
//! produced by the pre-migration Python backend, whose display-only shadow fields
//! (`untradeable`, `discardValue`) and server-*recomputed* chemistry are not the
//! adapter's to reproduce.
use std::collections::HashMap;
use openfut_adapter_fifa17::fut::item::{CoreOwnedItem, Fifa17Identity, ItemIdentityResolver};
use openfut_adapter_fifa17::fut::squad::{parse_squad_put, Fifa17SquadPut, SquadWireResolver};
use openfut_adapter_fifa17::fut::squad_ext::{build_squad_write, SquadWriteBuild};
use openfut_adapter_fifa17::fut::squad_projection::{
project_squad, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput,
SquadProjection, SquadProjectionInput,
};
use serde_json::Value;
const PUT_BASELINE: &str = include_str!("../fixtures/utas/squad_put_f442.json");
const PUT_SWAP: &str = include_str!("../fixtures/utas/squad_put_swap_f442.json");
const PUT_F433: &str = include_str!("../fixtures/utas/squad_put_f433.json");
const READ_ORACLE: &str = include_str!("../fixtures/utas/squad_read_usermassinfo.json");
// ---- host-role stand-ins (identity resolution, entity resolver) -------------
/// Wire→owned reverse map. In production the host builds this from the identity
/// store; here every occupied wire id maps to a stable `oc-<wire>`.
struct OcResolver;
impl SquadWireResolver for OcResolver {
fn owned_id_for_wire(&self, wire: i64) -> Option<String> {
Some(format!("oc-{wire}"))
}
}
/// owned_card_id → FIFA identity, so two copies of one definition stay distinct.
struct TableIdentity(HashMap<String, Fifa17Identity>);
impl ItemIdentityResolver for TableIdentity {
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
self.0.get(&it.owned_card_id).copied()
}
}
/// Neutral entity resolver — badge/flag ids are covered by `fut::item` tests; the
/// projector round-trip asserts item *identity* (wire id + asset), not entity ids.
struct NoEntities;
impl openfut_adapter_fifa17::fut::entities::ReverseEntityResolver for NoEntities {
fn league_id(&self, _: &str) -> Option<u32> {
None
}
fn team_id(&self, _: &str) -> Option<u32> {
None
}
fn nation_id(&self, _: &str) -> Option<u32> {
None
}
}
/// Build the owned-item map + identity table from the persisted read oracle, so
/// every wire id used by the captures resolves to its real FIFA asset id/rating.
/// Keyed by `oc-<wire>` to match `OcResolver`.
fn oracle_tables() -> (HashMap<String, CoreOwnedItem>, TableIdentity) {
let oracle: Value = serde_json::from_str(READ_ORACLE).unwrap();
let mut owned = HashMap::new();
let mut ident = HashMap::new();
for p in oracle["players"].as_array().unwrap() {
let it = &p["itemData"];
let wire = it["id"].as_i64().unwrap();
if wire == 0 {
continue; // empty slot
}
let oc = format!("oc-{wire}");
let asset = it["resourceId"].as_u64().unwrap() as u32;
let attrs: Vec<u8> = it["attributeList"]
.as_array()
.unwrap()
.iter()
.map(|a| a["value"].as_u64().unwrap() as u8)
.collect();
owned.insert(
oc.clone(),
CoreOwnedItem {
owned_card_id: oc.clone(),
card_id: format!("def-{asset}"),
rating: it["rating"].as_u64().unwrap() as u8,
position: it["preferredPosition"].as_str().unwrap().to_string(),
nation: String::new(),
league: String::new(),
club: String::new(),
attributes: [attrs[0], attrs[1], attrs[2], attrs[3], attrs[4], attrs[5]],
},
);
ident.insert(
oc,
Fifa17Identity {
item_id: wire as u32,
asset_id: asset,
resource_id: asset,
rareflag: 1,
},
);
}
(owned, TableIdentity(ident))
}
/// The full pipeline: parse a captured PUT, build the canonical + extension, then
/// project — treating the just-built canonical squad as Core's committed state.
fn project_put(
put: &Fifa17SquadPut,
owned: &HashMap<String, CoreOwnedItem>,
ident: &TableIdentity,
) -> Value {
let SquadWriteBuild {
canonical,
extension,
} = build_squad_write(put, &OcResolver).expect("build must succeed for a full valid squad");
let slots: Vec<ProjectionSlot> = canonical
.slots
.iter()
.map(|s| ProjectionSlot {
owned_card_id: s.owned_card_id.clone(),
index: s.index,
is_captain: s.is_captain,
is_on_bench: s.is_on_bench,
})
.collect();
let input = SquadProjectionInput {
fifa_squad_id: canonical.squad_id,
name: canonical.name.clone().unwrap_or_default(),
formation: canonical.formation.clone().unwrap(),
slots,
ext: SquadExtInput::Fresh(extension),
owned,
};
match project_squad(&input, ident, &NoEntities).unwrap() {
SquadProjection::Projected(v) => v,
other => panic!("expected Projected, got {other:?}"),
}
}
/// Map FIFA-array index → (wire item id, kit number) for the occupied slots of a
/// projected or captured squad object.
fn occupied(v: &Value) -> HashMap<i64, (i64, i64)> {
v["players"]
.as_array()
.unwrap()
.iter()
.filter(|p| p["itemData"]["id"].as_i64().unwrap() != 0)
.map(|p| {
(
p["index"].as_i64().unwrap(),
(
p["itemData"]["id"].as_i64().unwrap(),
p["kitNumber"].as_i64().unwrap(),
),
)
})
.collect()
}
#[test]
fn baseline_projects_the_known_squad_round_trip() {
let (owned, ident) = oracle_tables();
let put = parse_squad_put(PUT_BASELINE.as_bytes()).unwrap();
let projected = project_put(&put, &owned, &ident);
// Fixed 23-slot array; 11 occupied at 0..=10.
assert_eq!(projected["players"].as_array().unwrap().len(), 23);
let put_v: Value = serde_json::from_str(PUT_BASELINE).unwrap();
assert_eq!(
occupied(&projected),
occupied(&put_v),
"wire id + kit per index round-trip"
);
// CANONICAL: formation verbatim, captain follows the semantic player.
assert_eq!(projected["formation"], "f442");
assert_eq!(
projected["captain"], 100000001,
"captain is the player's WIRE id"
);
// EXTENSION: custom byte-identical, manager + squadType preserved.
assert_eq!(projected["custom"], put_v["custom"]);
assert_eq!(projected["manager"], put_v["manager"]);
assert_eq!(projected["squadType"], "REGULAR_SQUAD");
// SHADOW: client-reported values carried as-is (baseline chemistry 52).
assert_eq!(projected["chemistry"], 52);
assert_eq!(projected["rating"], 90);
assert_eq!(projected["starRating"], 90);
}
#[test]
fn swap_moves_two_players_with_their_kits_and_round_trips() {
// The swap PUT is baseline with two players rotated between slots. Projecting
// build(swap) must reproduce the swap wire exactly, and the affected players'
// kit numbers must have travelled with them (kit follows the player).
let (owned, ident) = oracle_tables();
let projected = project_put(
&parse_squad_put(PUT_SWAP.as_bytes()).unwrap(),
&owned,
&ident,
);
let swap_v: Value = serde_json::from_str(PUT_SWAP).unwrap();
let base_v: Value = serde_json::from_str(PUT_BASELINE).unwrap();
// CANONICAL: the projected occupancy per index matches the swap PUT exactly.
assert_eq!(
occupied(&projected),
occupied(&swap_v),
"player+kit per index round-trip"
);
// The swap is real: at least two indices carry a different player than baseline.
let (proj_occ, base_occ) = (occupied(&projected), occupied(&base_v));
let moved: Vec<i64> = proj_occ
.iter()
.filter(|(idx, pair)| base_occ.get(idx).map(|b| b.0) != Some(pair.0))
.map(|(idx, _)| *idx)
.collect();
assert!(
moved.len() >= 2,
"a swap changes at least two slots, got {moved:?}"
);
// kit follows the PLAYER: for every player, its kit in baseline == its kit
// in the swap projection, regardless of which slot it now occupies.
let kit_by_player = |occ: &HashMap<i64, (i64, i64)>| -> HashMap<i64, i64> {
occ.values().map(|(id, kit)| (*id, *kit)).collect()
};
assert_eq!(
kit_by_player(&proj_occ),
kit_by_player(&base_occ),
"each player kept its kit number through the swap"
);
assert_eq!(
projected["captain"], 100000001,
"captain follows the semantic player"
);
assert_eq!(
projected["custom"], swap_v["custom"],
"opaque custom unchanged by the swap"
);
// SHADOW: the client-reported chemistry from THIS PUT (58) is round-tripped
// as-is — never reconciled to a server recompute.
assert_eq!(projected["chemistry"], 58);
}
#[test]
fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
// Simulate Core's committed state for the persisted (post-relaunch) squad by
// reconstructing the canonical slots + FIFA extension straight from the read
// evidence, then project and require the read back — the strongest fidelity
// check across all four ownership classes.
use openfut_adapter_fifa17::fut::squad::ClientReportedSquadEval;
use openfut_adapter_fifa17::fut::squad_ext::{
Fifa17SquadExtensionV1, KicktakerRef, WireItemRef,
};
use std::collections::BTreeMap;
let oracle: Value = serde_json::from_str(READ_ORACLE).unwrap();
let (owned, ident) = oracle_tables();
let captain = oracle["captain"].as_i64().unwrap();
let mut slots = Vec::new();
let mut kit_numbers = BTreeMap::new();
for p in oracle["players"].as_array().unwrap() {
let wire = p["itemData"]["id"].as_i64().unwrap();
if wire == 0 {
continue;
}
let index = p["index"].as_i64().unwrap();
let oc = format!("oc-{wire}");
kit_numbers.insert(oc.clone(), p["kitNumber"].as_i64().unwrap());
slots.push(ProjectionSlot {
owned_card_id: oc,
index,
is_captain: wire == captain,
is_on_bench: index >= 11,
});
}
let manager: Vec<WireItemRef> = serde_json::from_value(oracle["manager"].clone()).unwrap();
let kicktakers: Vec<KicktakerRef> =
serde_json::from_value(oracle["kicktakers"].clone()).unwrap();
let ext = Fifa17SquadExtensionV1 {
custom: oracle["custom"].as_str().map(str::to_string),
squad_type: oracle["squadType"].as_str().map(str::to_string),
kit_numbers,
manager,
kicktakers,
client_reported: ClientReportedSquadEval {
chemistry: oracle["chemistry"].as_i64(),
rating: oracle["rating"].as_i64(),
star_rating: oracle["starRating"].as_i64(),
},
};
let input = SquadProjectionInput {
fifa_squad_id: oracle["id"].as_i64().unwrap(),
name: oracle["squadName"].as_str().unwrap().to_string(),
formation: oracle["formation"].as_str().unwrap().to_string(),
slots,
ext: SquadExtInput::Fresh(ext),
owned: &owned,
};
let SquadProjection::Projected(projected) = project_squad(&input, &ident, &NoEntities).unwrap()
else {
panic!("expected Projected");
};
// CANONICAL + DERIVED: identity and placement per slot match the read.
assert_eq!(
occupied(&projected),
occupied(&oracle),
"player+kit per index"
);
assert_eq!(projected["captain"], oracle["captain"]);
assert_eq!(projected["formation"], oracle["formation"]);
for (pp, op) in projected["players"]
.as_array()
.unwrap()
.iter()
.zip(oracle["players"].as_array().unwrap())
{
assert_eq!(
pp["itemData"]["id"], op["itemData"]["id"],
"wire id per slot"
);
assert_eq!(
pp["itemData"]["resourceId"], op["itemData"]["resourceId"],
"asset id per slot"
);
assert_eq!(pp["itemData"]["rating"], op["itemData"]["rating"]);
assert_eq!(
pp["itemData"]["preferredPosition"],
op["itemData"]["preferredPosition"]
);
}
// EXTENSION + SHADOW: sourced from the read, so they round-trip identically.
assert_eq!(projected["custom"], oracle["custom"]);
assert_eq!(projected["manager"], oracle["manager"]);
assert_eq!(projected["kicktakers"], oracle["kicktakers"]);
assert_eq!(projected["squadType"], oracle["squadType"]);
assert_eq!(projected["chemistry"], oracle["chemistry"]);
assert_eq!(projected["rating"], oracle["rating"]);
assert_eq!(projected["starRating"], oracle["starRating"]);
}
#[test]
fn formation_change_reindexes_without_deriving_slots_and_kit_follows_player() {
let (owned, ident) = oracle_tables();
let swap = project_put(
&parse_squad_put(PUT_SWAP.as_bytes()).unwrap(),
&owned,
&ident,
);
let f433 = project_put(
&parse_squad_put(PUT_F433.as_bytes()).unwrap(),
&owned,
&ident,
);
assert_eq!(f433["formation"], "f433");
assert_eq!(swap["formation"], "f442");
// Same 11 starters (same set of wire ids), reindexed.
let set = |v: &Value| {
let mut ids: Vec<i64> = occupied(v).values().map(|(id, _)| *id).collect();
ids.sort();
ids
};
assert_eq!(
set(&swap),
set(&f433),
"same 11 players survive the formation change"
);
// The captain (wire 100000001) moved from index 8 (f442) to index 10 (f433) —
// proof indices are round-tripped, not derived from the formation.
let idx_of = |v: &Value, wire: i64| -> i64 {
occupied(v)
.into_iter()
.find(|(_, (id, _))| *id == wire)
.unwrap()
.0
};
assert_eq!(idx_of(&swap, 100000001), 8);
assert_eq!(idx_of(&f433, 100000001), 10);
// kit follows the PLAYER, not the slot: captain keeps kit 8 across the reindex.
let kit_of = |v: &Value, wire: i64| -> i64 {
occupied(v)
.into_iter()
.find(|(_, (id, _))| *id == wire)
.unwrap()
.1
.1
};
assert_eq!(kit_of(&swap, 100000001), 8);
assert_eq!(
kit_of(&f433, 100000001),
8,
"kit stayed with the player despite the reindex"
);
assert_eq!(f433["captain"], 100000001, "captain still the same player");
}
#[test]
fn one_projector_serves_every_endpoint_no_divergence() {
let (owned, ident) = oracle_tables();
let projected = project_put(
&parse_squad_put(PUT_SWAP.as_bytes()).unwrap(),
&owned,
&ident,
);
// userMassInfo.squad = the projected object + session envelope.
let ummi = user_mass_info_squad(projected.clone(), 33068179);
assert_eq!(ummi["personaId"], 33068179);
assert_eq!(ummi["changed"], 0);
assert!(ummi["actives"].is_array());
assert_eq!(ummi["players"], projected["players"], "same projected body");
assert_eq!(ummi["formation"], projected["formation"]);
// squad/list = a summary SUBSET of the SAME object, not a second projection.
let list = squad_list(&projected);
let entry = &list["squad"][0];
for k in [
"id",
"squadName",
"formation",
"squadType",
"rating",
"chemistry",
] {
assert_eq!(
entry[k], projected[k],
"summary field {k} derived from the one projection"
);
}
// The summary carries only those six keys — no divergent squad shape.
assert_eq!(entry.as_object().unwrap().len(), 6);
}
+38
View File
@@ -0,0 +1,38 @@
[package]
name = "openfut-blaze-host"
version = "0.1.0"
edition = "2021"
license = "MIT"
description = "Thin TCP host for the FIFA 17 Blaze RPC surface; runs beside the Python backend"
publish = false
[dependencies]
openfut-protocol-blaze = { path = "../openfut-protocol-blaze" }
openfut-adapter-fifa17 = { path = "../openfut-adapter-fifa17" }
# Session keys. The client never validates them, but they must be distinct per
# connection; seeding from the clock would not be.
rand = "0.8"
# The probe replays the adapter's recorded fixture conversation.
serde_json = "1"
# No async runtime and no TLS, both deliberate:
# * A FIFA client opens a handful of connections, so a thread each mirrors the
# Python oracle and keeps the host readable.
# * The Blaze main port is plaintext — verified against the running backend.
# TLS belongs to the redirector phase.
[[bin]]
name = "openfut-blaze-host"
path = "src/main.rs"
[[bin]]
name = "blaze-probe"
path = "src/bin/blaze-probe.rs"
[[bin]]
name = "tls-observe"
path = "src/bin/tls-observe.rs"
[[bin]]
name = "blaze-sanitize"
path = "src/bin/blaze-sanitize.rs"
+293
View File
@@ -0,0 +1,293 @@
# openfut-blaze-host
A deliberately thin TCP host for the FIFA 17 Blaze RPC surface. Runs **beside**
the working Python backend, never instead of it.
```
listener → Fire2 stream framing → per-connection Session
│
openfut-adapter-fifa17::dispatch
│
write returned frames, in order
```
## Scope
Owns: a socket, a read buffer, one `Session` per connection, diagnostics.
That is the complete list.
Must never acquire: coins, club state, packs, profiles, market state, UTAS
logic. Those belong to OpenFUT Core, reached later through the adapter. A
transport host that starts holding game state becomes a second backend — the
architecture this migration exists to avoid.
## No TLS
The Blaze main port is **plaintext**. Verified against the running Python
backend by sending a raw Fire2 `Util::ping` and getting a plaintext
`PingResponse` back; `blaze_responder_v3b.py::blaze_handle` uses the raw socket
and only `redir_handle` wraps `ssl`. TLS belongs to the redirector phase.
## Running it
Both critical settings are required — there is no default port anywhere in this
crate, so it cannot silently collide with the Python container.
```bash
cargo build -p openfut-blaze-host
OPENFUT_ADVERTISE=<backend LAN ip> \
OPENFUT_BIND=0.0.0.0 \
POW_CONTENT_HOST=<backend LAN ip>:8085 \
OPENFUT_BLAZE_HOST_BIND=0.0.0.0 \
OPENFUT_BLAZE_HOST_PORT=<free port> \
OPENFUT_BLAZE_TRACE=/tmp/rust-blaze.trace \
./target/debug/openfut-blaze-host
```
`OPENFUT_BIND` is the **advertised-config** bind, not the listener's. The
adapter derives `nucleusConnect` from it (reproducing the oracle — see the
adapter README and the vault's known-issue entry), so it must mirror whatever
the Python container runs with, or the two will not compare. The listener has
its own `OPENFUT_BLAZE_HOST_BIND`.
For a FIFA test from another machine, `OPENFUT_BLAZE_HOST_BIND` must be `0.0.0.0`
(or the LAN address); the default follows `OPENFUT_BIND`.
## Live A/B against Python
```bash
./check-live-parity.sh 127.0.0.1:42130 127.0.0.1:<rust port>
```
Replays the recorded conversations against both endpoints over real sockets and
diffs the normalized traces. Session keys and server timestamps are masked, so
anything that differs is a real behavioural difference.
**The diff is the test, not the probe's exit code.** The probe only detects
anomalies visible live — missing frames, an early close. A same-length content
change deep inside a notification body shows up *only* as a digest difference in
the trace. Both cases were verified by mutation.
For the comparison to mean anything both servers must run the same config —
same `OPENFUT_ADVERTISE`, `OPENFUT_BIND`, `POW_CONTENT_HOST` and active persona
— or legitimate config differences read as parity failures.
Current result against the live container:
```
main: IDENTICAL (82 frames)
fallbacks: IDENTICAL (12 frames)
locale: IDENTICAL (7 frames)
LIVE PARITY OK — 101 frames, identical normalized traces.
```
## Tests
`cargo test -p openfut-blaze-host` — 18 tests. The integration suite runs the
real host on an ephemeral port and replays the recorded conversations over TCP,
covering what fixtures cannot:
* byte-for-byte replay over a socket
* requests dribbled **one byte at a time** (fragmentation)
* several requests in **one write** (coalescing)
* the four-frame login burst arriving in order on the wire
* session state persisting across frames, and *not* leaking between connections
* an absurd payload length closing the connection instead of allocating
* an undecodable body still getting a reply
Byte-exactness is possible only because `Hooks` injects the session key and
clock — they appear inside response bodies, so with the real ones no live run
could reproduce a recording. That is the crate's only test seam.
## Promotion gates
Automated, re-runnable now:
1. ✅ All migration tests pass (116 across the three crates).
2. ✅ Python contract suite still 446/446.
3. ✅ Scripted connection to the Rust host succeeds.
4. ✅ Recorded conversations work through the real TCP host, and the live A/B
against Python is identical over 101 frames.
Requiring a FIFA client on the game machine — **not yet done**:
5. ⬜ FIFA reaches FUT with Rust Blaze.
6. ⬜ Open a pack — exercise a known-working FUT action, not just bootstrap.
7. ⬜ Close FIFA completely.
8. ⬜ Repeat 5–6 with Rust Blaze.
9. ⬜ Switch back to Python Blaze and verify FUT still works.
10. ⬜ Switch to Rust once more and verify again.
Gates 9 and 10 matter as much as 5: `Python → Rust → Rust → Python → Rust`
proves the rollback path rather than asserting one exists.
## Process and switch safety
Both were built after real incidents, not speculatively.
* **Orphaned sidecars.** A previous session's mutation runs left four sidecars
listening, two serving deliberately broken builds. `sidecar.sh` refuses to
start when any sidecar is already running, and `stop` verifies both that the
PID is gone and that the port is free — failing if either check does not hold.
Orphan detection matches the resolved *executable*, not the command line:
`pgrep -f` was tried first and matched any shell whose arguments merely
mentioned the name.
* **A rollback that lied.** `blaze-switch.sh off` once reported success while
two rules remained active, because it matched `--comment "tag"` with quotes
that this iptables does not emit — and the verification used the same broken
matcher, so it confirmed its own failure. Rules are now matched on the bare
tag string, and `off` verifies with `iptables-save` plus a tag-independent
check that nothing still redirects the port.
The general lesson, now applied throughout: **a verification must not share the
failure mode of the thing it verifies.**
### Running gates 5–10
The Python redirector advertises a hardcoded `BLAZE_PORT = 42130`
(`blaze_responder_v3b.py:173`), so redirecting Blaze by reconfiguring it would
mean editing the frozen oracle and rebuilding the container. `blaze-switch.sh`
does it with a scoped NAT rule instead: no Python change, instant rollback.
Rules match only `<LAN_IP>:42130`. Traffic to `127.0.0.1:42130` is deliberately
left alone, so Python stays directly reachable on loopback and the A/B keeps
comparing real Python against real Rust.
```bash
cargo build -p openfut-blaze-host
export OPENFUT_ADVERTISE=<LAN_IP> OPENFUT_BIND=0.0.0.0 \
POW_CONTENT_HOST=<LAN_IP>:8085 \
OPENFUT_BLAZE_HOST_BIND=0.0.0.0 OPENFUT_BLAZE_HOST_PORT=<FREE_PORT> \
OPENFUT_BLAZE_TRACE=/tmp/rust-blaze.trace
./sidecar.sh start # refuses if an orphan or the port is busy
./blaze-switch.sh on <LAN_IP> <FREE_PORT> # Blaze -> Rust
./blaze-switch.sh status # confirm before launching FIFA
# … launch FIFA, reach FUT, OPEN A PACK, close FIFA, repeat …
./blaze-switch.sh off # Blaze -> Python (rollback)
./sidecar.sh stop # refuses while the switch is on
```
`sidecar.sh stop` **refuses** while the switch is on: stopping the sidecar then
would leave Blaze pointed at a dead port. Use `stop --force` only deliberately.
Evidence to keep from each live run:
* `/tmp/rust-blaze.trace` — the normalized trace, which begins with the build
banner, so a session is attributable to an exact binary and config table
* the sidecar log — connection accepted, preAuth, login, the three
notifications, subsequent commands, close reason
* whether the pack opened, not just whether the hub loaded
Then compare the Rust trace against a Python session trace. Message numbers and
timestamps are session-dependent and masked; the semantic sequence and payload
shapes must match.
## Diagnostics
The log mirrors the Python responder's shape so the two can be read side by
side: connection id, peer, frame number, route, msgType, msgNum, userIndex,
options, payload and metadata sizes, every response emitted, and a close reason.
`OPENFUT_BLAZE_TRACE=<path>` additionally writes the normalized structural
trace — the same format the probe emits, so a live FIFA session against Rust can
be diffed against one against Python.
No credential or token is logged. Volatile values are replaced before they reach
the line, not truncated after, and a test asserts a known secret never appears
in trace output.
## Evidence capture per gate
```bash
./gate-evidence.sh <gate-label> # after each gate; never modifies anything
```
Writes a timestamped bundle (switch rules, sidecar status, log, trace, Python
contract result) and reports **configured** and **observed** state separately.
That separation is the point. `blaze-switch.sh status = ON` is an assertion from
the same tooling that performs the switch — and that tooling reported a
successful rollback once when it had not happened. The observed half comes from
a different source: the sidecar's own record of which peers connected to it. A
non-loopback peer in the sidecar log is proof the client's Blaze traffic landed
on Rust that does not depend on reading an iptables rule correctly.
The script says so explicitly, in one of two forms:
```
REMOTE peer(s) reached the Rust sidecar: 10.10.0.x
=> the client's Blaze traffic observably landed on Rust
```
```
no remote peer connected — only loopback (or nothing) reached Rust
=> a FIFA session did NOT land here
```
## Raw frame capture (opt-in)
Evidence infrastructure, off unless `OPENFUT_BLAZE_CAPTURE` names a file.
```bash
OPENFUT_BLAZE_CAPTURE=/home/alex/OpenFUT/captures/gate7.ofcap ./sidecar.sh start
```
Two layers, deliberately:
```
live FIFA traffic
├── raw capture exact RX/TX bytes, mode 0600, gitignored — NEVER commit
└── blaze-sanitize → repository-safe, replayable fixtures
```
The raw file is forensic evidence and does contain session material; that is
what makes it worth keeping and why it never leaves the machine unsanitized.
**Format.** Deterministic, big-endian: a 20-byte file header, then per-frame
records with connection id, a global monotonic sequence, timestamp, direction
and the exact frame bytes. RX is recorded as received; TX only *after* a
successful write, so a record means the bytes were sent, not intended.
Component, command, msgNum, msgType and payload length are **not** stored beside
the frame — they are already in its 16-byte header, and a redundant copy can
disagree with the bytes, leaving a reader unable to tell which is true.
`Record::header()` derives them.
### Sanitizing
```bash
./target/debug/blaze-sanitize captures/gate7.ofcap -o gate7.jsonl --report gate7.txt
```
Redacts only the named tags (`KEY`, `AUTH`, `SESS`, `MAIL`, `PML`) and reports
every substitution with path, kind and byte length. Replacement is
**length-preserving**, so the TDF varint, payload length and Fire2 header are
unchanged and the sanitized frame is exactly the size of the captured one —
asserted per frame, failing rather than emitting a subtly different
conversation. Frames with nothing sensitive keep their exact wire bytes.
Payloads that will not decode are passed through *and reported*, so a reader
knows they were never inspected rather than assuming they were checked clean.
Real run: 101 frames in, 9 redacted, 13 redactions; two live session keys
present in the raw capture, zero in the sanitized output.
### What the tests do and do not cover
Nine cases, all passing: capture off produces no artefact; RX and TX captured
exactly; ordering preserved; fragmented input (one byte at a time) reconstructs
the same frames as a single write; coalesced input is captured per frame rather
than per read; capture does not alter wire output; sanitization removes a real
session key from a real captured login; malformed/truncated/wrong-version
captures fail clearly; every listed sensitive tag is provably reachable.
Mutation-tested: dropping TX capture, truncating captured frames to their
header, and removing `KEY` from the sensitive list each turn the suite red.
**One mutation is not caught:** moving the TX capture above the write. It is
indistinguishable while writes succeed and diverges only when one fails, where
it would record a frame the client never received. That invariant is held by
code placement and a comment, not by a test.
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Blaze switch — COMPATIBILITY WRAPPER over openfut-switch.sh.
#
# blaze-switch.sh status
# blaze-switch.sh on <LAN_IP> <RUST_PORT> Blaze -> Rust sidecar
# blaze-switch.sh off Blaze -> Python (rollback)
#
# The CLI and output are unchanged from the version used for gates 5–10, so the
# validated Blaze runbook and `sidecar.sh`'s cross-check keep working exactly as
# before. All iptables logic now lives in `openfut-switch.sh`: one
# implementation, because two scripts editing the same table diverge and then
# disagree about what is installed.
#
# The only Blaze-specific knowledge left here is the intercepted port, 42130,
# which the generic tool never assumes.
set -uo pipefail
HERE="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
GENERIC="$HERE/openfut-switch.sh"
BLAZE_PORT=42130
NAME=blaze
# Tag used before the switch was generalised. Rules installed by the gate 5-10
# tooling still carry it, so they must remain removable — a rename that orphans
# live NAT rules is worse than no rename at all.
LEGACY_TAG=openfut-blaze-switch
[[ -x "$GENERIC" ]] || { echo "blaze-switch: missing $GENERIC" >&2; exit 2; }
case "${1:-}" in
status)
# Translate the generic report into the wording the Blaze runbook and
# sidecar.sh already match on ("redirected to the RUST" / "served by
# PYTHON"). Kept verbatim so the proven tooling does not change.
out="$("$GENERIC" status --name "$NAME" --legacy-tag "$LEGACY_TAG" --intercept-port "$BLAZE_PORT" 2>&1)"
rc=$?
if grep -q '^INACTIVE' <<<"$out"; then
echo "Blaze is served by PYTHON (no switch rules)"
else
echo "Blaze is redirected to the RUST sidecar:"
grep -E '^\s+(blaze|openfut-switch)' <<<"$out" | sed 's/^/ /'
grep -E '^\s+!!|\?\?' <<<"$out" >&2 || true
fi
exit $rc
;;
on)
shift
ip="${1:-}"; port="${2:-}"
[[ -n "$ip" && -n "$port" ]] || {
echo "usage: blaze-switch.sh on <LAN_IP> <RUST_PORT>" >&2; exit 2; }
"$GENERIC" on --name "$NAME" --legacy-tag "$LEGACY_TAG" --server-ip "$ip" \
--intercept-port "$BLAZE_PORT" --target-port "$port" >/dev/null || exit 1
echo "Blaze -> RUST: $ip:$BLAZE_PORT now lands on local port $port"
echo " 127.0.0.1:$BLAZE_PORT still reaches PYTHON (unmatched by design)"
echo " roll back with: $0 off"
echo
echo " NOTE: while this is on, the sidecar MUST stay up. Stopping it without"
echo " switching off leaves Blaze pointing at a dead port."
;;
off)
out="$("$GENERIC" off --name "$NAME" --legacy-tag "$LEGACY_TAG" --intercept-port "$BLAZE_PORT" 2>&1)"
rc=$?
if [[ $rc -ne 0 ]]; then
echo "$out" >&2
exit $rc
fi
n="$(sed -nE 's/.*removed ([0-9]+) rule.*/\1/p' <<<"$out")"
echo "Blaze -> PYTHON: removed ${n:-0} rule(s), verified none remain"
;;
*)
sed -n '2,8p' "$0" | sed 's/^# \?//'
exit 2
;;
esac
+78
View File
@@ -0,0 +1,78 @@
//! Stamp build identity into the binary.
//!
//! A live FIFA trace has to be attributable to an exact binary. During the
//! mutation runs for the previous step, four sidecars were left listening —
//! two of them serving deliberately broken builds — and nothing in their output
//! said so. A later A/B against one of those would have read as a genuine
//! parity failure.
//!
//! So the host prints its commit, working-tree cleanliness and profile at
//! startup, and `dirty` is the important one: a mutation-tested build is a
//! dirty build, and now it announces itself.
use std::process::Command;
fn git(args: &[&str]) -> Option<String> {
let out = Command::new("git").args(args).output().ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
fn main() {
let commit = git(&["rev-parse", "--short=7", "HEAD"]).unwrap_or_else(|| "unknown".into());
// Scoped to the crates this binary is actually built from.
//
// A whole-repo check reads DIRTY permanently here, because unrelated
// submodules carry pre-existing modifications. A warning that is always on
// is a warning nobody reads — which would defeat the point, since the whole
// job of this flag is to make a mutated build announce itself.
//
// Untracked files are excluded: scratch output is not a build difference,
// but an edited source file certainly is.
let dirty = match git(&[
"status",
"--porcelain",
"--untracked-files=no",
"--",
"openfut-blaze-host",
"openfut-adapter-fifa17",
"openfut-protocol-blaze",
]) {
Some(s) if !s.is_empty() => "DIRTY",
Some(_) => "clean",
None => "unknown",
};
println!("cargo:rustc-env=OPENFUT_BUILD_COMMIT={commit}");
println!("cargo:rustc-env=OPENFUT_BUILD_DIRTY={dirty}");
// Re-stamp when HEAD moves.
//
// IMPORTANT LIMITATION: this flag is best-effort and CAN BE STALE. Cargo
// will not re-run a build script because some other crate's source changed,
// so editing the adapter and rebuilding the host can leave `dirty` reading
// "clean". Verified: appending a line to the adapter and rebuilding did not
// flip it.
//
// So the compiled-in value is useful for naming the commit, and is NOT the
// safeguard. `sidecar.sh` re-checks the working tree at launch and
// `check-live-parity.sh` refuses to produce evidence from a dirty tree —
// those run at the right moment and cannot go stale.
for p in ["../.git/HEAD", "../.git/index"] {
if std::path::Path::new(p).exists() {
println!("cargo:rerun-if-changed={p}");
}
}
// Committing updates refs/heads/<branch>, NOT the HEAD file, so watching
// HEAD alone leaves the stamp one commit behind. Observed: the banner read
// a84a72e immediately after committing 2337431.
if let Some(rf) = git(&["symbolic-ref", "-q", "HEAD"]) {
let path = format!("../.git/{rf}");
if std::path::Path::new(&path).exists() {
println!("cargo:rerun-if-changed={path}");
}
}
}
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# Live A/B: the Python Blaze responder vs the Rust sidecar, over real sockets.
#
# ./check-live-parity.sh <python-host:port> <rust-host:port> [outdir]
#
# Replays the recorded conversations against BOTH endpoints and diffs the
# normalized traces. Session keys and server timestamps are masked, so anything
# that differs is a real behavioural difference.
#
# IMPORTANT: the diff is the test, not the probe's exit code. The probe only
# detects structural anomalies it can see live (missing frames, early close); a
# same-length content change deep inside a notification body shows up ONLY as a
# digest difference in the trace. Verified by mutation.
#
# Read-only against both backends: it opens client connections and sends
# recorded requests. Safe to run while the Python backend is serving.
#
# For the comparison to mean anything, BOTH servers must run the same config —
# same OPENFUT_ADVERTISE, OPENFUT_BIND, POW_CONTENT_HOST and active persona.
# Otherwise legitimate config differences read as parity failures.
set -uo pipefail
PY="${1:-}"
RS="${2:-}"
OUT="${3:-$(mktemp -d)}"
if [[ -z "$PY" || -z "$RS" ]]; then
echo "usage: $0 <python-host:port> <rust-host:port> [outdir]" >&2
echo "example: $0 127.0.0.1:42130 127.0.0.1:42230" >&2
exit 2
fi
cd "$(dirname "$(readlink -f "$0")")/.."
PROBE="./target/debug/blaze-probe"
[[ -x "$PROBE" ]] || PROBE="./target/release/blaze-probe"
if [[ ! -x "$PROBE" ]]; then
echo "blaze-probe not built; run: cargo build -p openfut-blaze-host" >&2
exit 2
fi
# This script produces the artefact a migration decision is made from, so it
# refuses to run against a tree that does not correspond to a commit. The
# compiled-in build stamp cannot be trusted for this (cargo will not re-run
# build.rs for another crate's edit), so the check happens here, now.
if git rev-parse --git-dir >/dev/null 2>&1; then
DIRT="$(git status --porcelain --untracked-files=no -- \
openfut-blaze-host openfut-adapter-fifa17 openfut-protocol-blaze 2>/dev/null)"
if [[ -n "$DIRT" && "${ALLOW_DIRTY:-}" != "1" ]]; then
echo "REFUSING: migration crates have uncommitted changes:" >&2
echo "$DIRT" | sed 's/^/ /' >&2
echo >&2
echo "A parity result from an unidentifiable build is not evidence." >&2
echo "Commit first, or re-run with ALLOW_DIRTY=1 for a throwaway check." >&2
exit 2
fi
fi
mkdir -p "$OUT"
fail=0
frames=0
for sess in main fallbacks locale; do
"$PROBE" "$PY" --session "$sess" > "$OUT/python-$sess.trace" 2>"$OUT/python-$sess.err" || true
"$PROBE" "$RS" --session "$sess" > "$OUT/rust-$sess.trace" 2>"$OUT/rust-$sess.err" || true
n=$(grep -c '^conn-' "$OUT/python-$sess.trace" || true)
if diff -u "$OUT/python-$sess.trace" "$OUT/rust-$sess.trace" > "$OUT/diff-$sess.txt"; then
echo " $sess: IDENTICAL ($n frames)"
frames=$((frames + n))
else
echo " $sess: DIFFERS -> $OUT/diff-$sess.txt"
head -30 "$OUT/diff-$sess.txt"
fail=1
fi
done
echo
if [[ $fail -eq 0 ]]; then
echo "LIVE PARITY OK — $frames frames, identical normalized traces."
echo "traces: $OUT"
else
echo "LIVE PARITY FAILED — see $OUT"
fi
exit $fail
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Is the FIFA client currently connected to anything?
#
# client-state.sh [client-ip]
#
# WHY THIS EXISTS
#
# Host-side `ss` CANNOT see the Python backend's connections. The Python
# responders run inside a container, so a client session terminates at
# 172.20.0.2:42130 in the container's network namespace; the host only ever sees
# the NAT'd flow, and a plain `ss | grep <client>` on the host reports nothing.
#
# That produced a wrong precondition check: "no .105 Blaze session — closed" was
# reported while FIFA was mid-session on Python, and gate 9 was armed against a
# client that had never exited. Python's own log gave it away — it logs closes
# reliably (45 of them) and there was no close for that session.
#
# So this looks in BOTH namespaces, and reports the Rust sidecar and the Python
# container separately.
#
# Exit 0 when the client has no live session anywhere (safe to start a gate),
# 1 when it does.
set -uo pipefail
CLIENT="${1:-${OPENFUT_CLIENT_IP:-}}"
if [[ -z "$CLIENT" ]]; then
echo "usage: client-state.sh <client-ip> (or set OPENFUT_CLIENT_IP)" >&2
echo " no default: the lab's address is deployment config, not architecture," >&2
echo " and a default that matches the current lab hides the coupling." >&2
exit 2
fi
CONTAINER="${OPENFUT_PY_CONTAINER:-openfut-fut-backend}"
live=0
decode_tcp() {
# /proc/net/tcp rows -> "local remote state", little-endian hex addresses.
python3 -c "
import sys
def d(x):
ip, port = x.split(':')
return '.'.join(str(int(ip[i:i+2], 16)) for i in (6, 4, 2, 0)) + ':' + str(int(port, 16))
for line in sys.stdin:
f = line.split()
if len(f) < 4 or not f[0].endswith(':'):
continue
try:
print(d(f[1]), d(f[2]), f[3])
except Exception:
pass
"
}
echo "client: $CLIENT"
# ---- Rust sidecar (host namespace)
rust="$(ss -tn state established 2>/dev/null | grep -F "$CLIENT" | grep -E ':42230' || true)"
if [[ -n "$rust" ]]; then
echo " RUST sidecar : LIVE session(s)"
sed 's/^/ /' <<<"$rust"
live=1
else
echo " RUST sidecar : none"
fi
# ---- Python backend (container namespace)
if docker exec "$CONTAINER" true 2>/dev/null; then
py="$(docker exec "$CONTAINER" cat /proc/net/tcp 2>/dev/null | decode_tcp \
| awk '$3=="01"' | grep -F "$CLIENT" || true)"
if [[ -n "$py" ]]; then
echo " PYTHON backend: LIVE session(s)"
awk '{printf " %-22s <- %-22s ESTABLISHED\n", $1, $2}' <<<"$py"
live=1
else
echo " PYTHON backend: none"
fi
else
echo " PYTHON backend: container '$CONTAINER' not reachable — CANNOT confirm"
live=1 # unknown is not the same as clear
fi
echo
if [[ $live -eq 0 ]]; then
echo "RESULT: no live client session — safe to begin a gate"
else
echo "RESULT: client still connected — close FIFA before starting a gate"
fi
exit $live
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env bash
# Capture evidence for one live-FIFA gate.
#
# gate-evidence.sh <gate-label> [outdir]
#
# Records what the system was configured to do AND what it observably did, and
# reports them separately.
#
# WHY BOTH
#
# `blaze-switch.sh status = ON` is an assertion produced by the same tooling
# that performs the switch. If that tooling is wrong — and it has been once
# already, reporting a rollback that had not happened — the assertion is
# worthless. The observed half comes from a different source entirely: the
# sidecar's own record of which peers connected to it. A remote peer appearing
# in the sidecar log is proof the client reached Rust that does not depend on
# reading an iptables rule correctly.
#
# Run it after each gate. It never modifies anything.
set -uo pipefail
HERE="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
ROOT="$(cd "$HERE/.." && pwd)"
RUNDIR="${OPENFUT_SIDECAR_RUNDIR:-${TMPDIR:-/tmp}/openfut-sidecar}"
LOGFILE="${OPENFUT_SIDECAR_LOG:-$RUNDIR/sidecar.log}"
TRACE="${OPENFUT_BLAZE_TRACE:-}"
LABEL="${1:-}"
OUT="${2:-$ROOT/gate-evidence}"
[[ -n "$LABEL" ]] || { echo "usage: gate-evidence.sh <gate-label> [outdir]" >&2; exit 2; }
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
DEST="$OUT/${STAMP}-${LABEL}"
mkdir -p "$DEST"
say() { echo "$@"; }
both() { echo "$@" | tee -a "$DEST/summary.txt" >/dev/null; echo "$@"; }
both "=== gate evidence: $LABEL ($STAMP) ==="
both ""
# ---------------------------------------------------- configured (asserted)
both "--- CONFIGURED (asserted by tooling) ---"
{
"$HERE/blaze-switch.sh" status 2>&1
echo
"$HERE/sidecar.sh" status 2>&1
} > "$DEST/configured.txt"
sed 's/^/ /' "$DEST/configured.txt" | tee -a "$DEST/summary.txt"
# Raw rules, straight from the kernel, not via our parser.
{ sudo iptables -t nat -S 2>/dev/null || true; } > "$DEST/iptables-nat.txt"
both ""
# ------------------------------------------------------ observed (measured)
both "--- OBSERVED (measured from the sidecar's own record) ---"
if [[ ! -f "$LOGFILE" ]]; then
both " no sidecar log at $LOGFILE — nothing observed"
else
cp "$LOGFILE" "$DEST/sidecar.log" 2>/dev/null
banner="$(grep -m1 'openfut-blaze-host v' "$LOGFILE" 2>/dev/null || true)"
both " build: ${banner:-<none>}"
if grep -q 'tree=DIRTY' <<<"$banner"; then
both " !! DIRTY BUILD — this run is NOT parity evidence"
fi
conns="$(grep -c 'CONNECT from' "$LOGFILE" 2>/dev/null || echo 0)"
both " connections accepted: $conns"
# THE INDEPENDENT ASSERTION: which peers actually reached this process.
peers="$(grep -o 'CONNECT from [0-9.]*' "$LOGFILE" 2>/dev/null | awk '{print $3}' | sort -u || true)"
remote="$(grep -v '^127\.' <<<"$peers" | grep -v '^$' || true)"
both " peers: $(tr '\n' ' ' <<<"$peers")"
if [[ -n "$remote" ]]; then
both " REMOTE peer(s) reached the Rust sidecar: $(tr '\n' ' ' <<<"$remote")"
both " => the client's Blaze traffic observably landed on Rust"
else
both " no remote peer connected — only loopback (or nothing) reached Rust"
both " => a FIFA session did NOT land here"
fi
# Session shape, straight from the log.
logins="$(grep -c 'Authentication::login .*REPLY' "$LOGFILE" 2>/dev/null || echo 0)"
notifs="$(grep -c 'UserSessions::<' "$LOGFILE" 2>/dev/null || echo 0)"
both " login replies: $logins UserSessions notifications: $notifs"
both " close reasons:"
grep -o 'CLOSE after [0-9]* frame(s): .*' "$LOGFILE" 2>/dev/null \
| sort | uniq -c | sed 's/^/ /' | tee -a "$DEST/summary.txt" || true
# Anything that looks wrong.
probs="$(grep -E 'DECODE FAILED|REJECT|FAILED|absurd' "$LOGFILE" 2>/dev/null | head -20 || true)"
if [[ -n "$probs" ]]; then
both " ANOMALIES:"
sed 's/^/ /' <<<"$probs" | tee -a "$DEST/summary.txt"
else
both " no anomalies in the log"
fi
fi
# ------------------------------------------------------------------ trace
both ""
both "--- TRACE ---"
if [[ -n "$TRACE" && -f "$TRACE" ]]; then
cp "$TRACE" "$DEST/rust-blaze.trace"
# Count FRAME lines only. `^conn-` also matches the OPEN/CLOSE markers, and
# counting those inflated the figure by one or two — which then looked like a
# capture/trace divergence when compared against the capture's record count.
frames="$(grep -cE '^conn-[0-9]+ (RX|TX) ' "$TRACE" 2>/dev/null || echo 0)"
markers="$(grep -cE '^conn-[0-9]+ (OPEN|CLOSE)' "$TRACE" 2>/dev/null || echo 0)"
both " $TRACE -> $DEST/rust-blaze.trace ($frames traced frames, $markers lifecycle markers)"
# Capture and trace are written continuously; on a LIVE session they are only
# comparable if read at the same instant.
if [[ -n "${OPENFUT_BLAZE_CAPTURE:-}" && -f "${OPENFUT_BLAZE_CAPTURE}" ]]; then
cp "$OPENFUT_BLAZE_CAPTURE" "$DEST/raw.ofcap" 2>/dev/null && chmod 600 "$DEST/raw.ofcap"
both " raw capture -> $DEST/raw.ofcap (0600, NEVER commit)"
fi
both " routes seen:"
grep -o '^conn-[0-9]* \(RX\|TX\) [^ ]* [A-Za-z]*::[^ ]*' "$TRACE" 2>/dev/null \
| awk '{print $2, $4}' | sort | uniq -c | sort -rn | head -20 \
| sed 's/^/ /' | tee -a "$DEST/summary.txt" || true
else
both " no trace configured (set OPENFUT_BLAZE_TRACE before starting the sidecar)"
fi
# --------------------------------------------- python side (the other half)
#
# "Rust did not receive it" is weaker than "Python did". The redirector always
# runs on Python and is never switched, so it advertises the Blaze endpoint on
# every run; whether Python then receives the Blaze CONNECT it just advertised
# is the positive observation that says where the hop actually went.
both ""
both "--- PYTHON SIDE (positive/negative observation) ---"
PYLOG=/tmp/blaze_responder.log
if docker exec openfut-fut-backend test -f "$PYLOG" 2>/dev/null; then
docker exec openfut-fut-backend sh -c "grep -E 'REDIR SENT|BLAZE CONNECT from|closed' $PYLOG" \
> "$DEST/python-blaze.log" 2>/dev/null || true
CLIENT="${OPENFUT_CLIENT_IP:-}"
if [[ -z "$CLIENT" ]]; then
both " OPENFUT_CLIENT_IP not set — skipping the client-specific correlation"
both " (set it to the FIFA machine's address for positive/negative observation)"
fi
redirs="$(grep -c "REDIR SENT ('$CLIENT'" "$DEST/python-blaze.log" 2>/dev/null || echo 0)"
blazes="$(grep -c "BLAZE CONNECT from ('$CLIENT'" "$DEST/python-blaze.log" 2>/dev/null || echo 0)"
both " client $CLIENT — redirector hops served by Python: $redirs"
both " client $CLIENT — Blaze connections received by Python: $blazes"
both " most recent:"
grep -E "\('$CLIENT'" "$DEST/python-blaze.log" 2>/dev/null | tail -4 | sed 's/^/ /' \
| tee -a "$DEST/summary.txt" || true
both ""
both " interpretation: a redirector hop with NO following Python Blaze CONNECT"
both " means the Blaze hop went elsewhere (the Rust sidecar). A Python Blaze"
both " CONNECT means it went to Python."
else
both " python blaze log not readable (container not running?)"
fi
# ------------------------------------------------- FUT actions (UTAS side)
#
# "Known FUT action succeeded" is a client-side fact, but it leaves an
# independent trace: FUT actions go over UTAS (Python, never switched), so the
# UTAS log confirms them without relying on anyone's recollection of what they
# clicked.
both ""
both "--- FUT ACTIONS (observed on UTAS, which is always Python) ---"
UTASLOG=/tmp/utas_server.log
if docker exec openfut-fut-backend test -f "$UTASLOG" 2>/dev/null; then
docker exec openfut-fut-backend sh -c "grep -E 'STORE:|SQUAD:|SBC:|TRADE:' $UTASLOG" \
> "$DEST/utas-actions.log" 2>/dev/null || true
# Window to THIS gate. The UTAS log is cumulative across the whole
# deployment, so a raw count reads as if 45 packs were opened in this gate.
# The sidecar is restarted per gate, so its first log line is the window
# start. Both numbers are reported, labelled, because a bare count in a gate
# report is read as belonging to that gate.
since="$(head -1 "$LOGFILE" 2>/dev/null | sed -E 's/^\[([0-9]+)\..*/\1/')"
since_hm="$(date -d "@${since:-0}" '+%H:%M' 2>/dev/null || echo '00:00')"
all_packs="$(grep -c 'opened pack' "$DEST/utas-actions.log" 2>/dev/null || echo 0)"
gate_packs="$(awk -F'[][]' -v t="$since_hm" '$2>=t' "$DEST/utas-actions.log" 2>/dev/null \
| grep -c 'opened pack' || echo 0)"
both " pack opens THIS GATE (since $since_hm): $gate_packs"
both " pack opens in the whole log (cumulative, all deployments): $all_packs"
both " actions this gate:"
awk -F'[][]' -v t="$since_hm" '$2>=t' "$DEST/utas-actions.log" 2>/dev/null | tail -6 \
| sed 's/^/ /' | tee -a "$DEST/summary.txt" || true
else
both " utas log not readable"
fi
# ----------------------------------------------------------- python health
both ""
both "--- PYTHON BACKEND (must stay healthy throughout) ---"
if contract="$(cd "$ROOT/fifa17-recon" && timeout 120 python3 tools/test_fut_contract.py 2>&1 | tail -1)"; then
both " contract suite: $contract"
else
both " contract suite: FAILED TO RUN"
fi
both ""
both "evidence bundle: $DEST"
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env bash
# Passive connection-attempt observer for one client address.
#
# openfut-observe.sh on <CLIENT_IP>
# openfut-observe.sh off
# openfut-observe.sh status
# openfut-observe.sh mark record the current counters as a baseline
# openfut-observe.sh delta attempts since the last mark
#
# WHY THIS EXISTS
#
# A live gate can fail in two very different ways that look identical in every
# server log: the client tried to connect and could not, or the client never
# tried at all. No server log can separate those, because both produce silence.
#
# Worked example, and the reason this exists: two redirector gates failed with
# "An error occurred downloading the FUT Squad Update" while the roster server
# logged nothing at all. "Nothing" was consistent with a broken roster service,
# a wrong roster URL, and a client that never asked — three very different bugs.
#
# HOW
#
# This box has no tcpdump, no conntrack, and no readable kernel log (iptables
# LOG rules match but their output goes nowhere — verified, not assumed). What
# does work is iptables PACKET COUNTERS, so the observation is built from those:
# a dedicated raw-table chain, one counting rule per interesting port, each with
# no target so it counts and falls through.
#
# Only SYNs are counted, so one line per connection attempt, and no payload is
# recorded — this cannot see message contents even in principle.
#
# SAFETY
#
# A counting rule has no target: it cannot drop, rewrite or delay a packet. All
# state lives in one custom chain, so `off` is "unhook, flush, delete" and its
# verification re-reads the table rather than trusting the delete's exit code.
# No rule value contains a space, which is what made an earlier LOG-based
# version impossible to delete by reconstructed spec.
set -uo pipefail
TAG=openfut-observe
CHAIN=OPENFUT_OBS
TABLE=raw
STATE="${OPENFUT_OBSERVE_STATE:-${TMPDIR:-/tmp}/openfut-observe.mark}"
# Ports worth separating. The final catch-all counts EVERY attempt, so it is a
# TOTAL and not a remainder: attempts to an untracked port show up as the gap
# between TOTAL and the sum of the named ports, rather than vanishing.
PORTS=(42127 42227 42130 8081 8099 8080 8094 9988 8999 4216 80 443 17502)
die() { echo "observe: $*" >&2; exit 1; }
ipt() { sudo iptables -t "$TABLE" "$@"; }
chain_exists() { ipt -S "$CHAIN" >/dev/null 2>&1; }
hooks() { sudo iptables-save -t "$TABLE" 2>/dev/null | grep -cF -- "--comment $TAG"; }
cmd_on() {
local ip="${1:-}"
[[ -n "$ip" ]] || die "usage: openfut-observe.sh on <CLIENT_IP>"
chain_exists && die "already on — run 'off' first"
ipt -N "$CHAIN" || die "could not create $CHAIN"
local p
for p in "${PORTS[@]}"; do
ipt -A "$CHAIN" -p tcp --dport "$p" || { cmd_off >/dev/null; die "rule for $p failed"; }
done
ipt -A "$CHAIN" -p tcp || { cmd_off >/dev/null; die "catch-all rule failed"; }
# --syn is SYN without ACK: one match per connection ATTEMPT, retries included.
ipt -I PREROUTING -s "$ip" -p tcp --syn -m comment --comment "$TAG" -j "$CHAIN" \
|| { cmd_off >/dev/null; die "could not hook $CHAIN into PREROUTING"; }
[[ "$(hooks)" == "1" ]] || { cmd_off >/dev/null; die "hook not installed"; }
rm -f "$STATE"
echo "observing $ip: ${#PORTS[@]} ports + catch-all, hooked into $TABLE/PREROUTING"
}
cmd_off() {
local removed=0
# Unhook by parsed fields as argv elements, then flush and delete. No value
# here contains a space, so this round trip is safe.
while read -r ip; do
[[ -n "$ip" ]] || continue
ipt -D PREROUTING -s "$ip" -p tcp --syn -m comment --comment "$TAG" -j "$CHAIN" \
2>/dev/null && removed=$((removed + 1))
done < <(sudo iptables-save -t "$TABLE" 2>/dev/null \
| grep -F -- "--comment $TAG" \
| sed -nE 's/.* -s ([0-9.]+)(\/32)? .*/\1/p')
chain_exists && { ipt -F "$CHAIN"; ipt -X "$CHAIN"; }
local left_hooks left_chain
left_hooks="$(hooks)"; chain_exists && left_chain=yes || left_chain=no
if [[ "$left_hooks" != "0" || "$left_chain" != "no" ]]; then
echo "observe: REFUSING to report success — hooks=$left_hooks chain=$left_chain" >&2
return 1
fi
rm -f "$STATE"
echo "observing off: removed $removed hook(s) and the chain, verified none remain"
}
# "port<TAB>packets". The final catch-all is reported as TOTAL, not "other":
# every packet reaching the chain counts there, including ones already counted
# by a named-port rule above it.
counters() {
ipt -L "$CHAIN" -v -n -x 2>/dev/null | awk '
/dpt:/ { for(i=1;i<=NF;i++) if($i ~ /^dpt:/){ sub(/dpt:/,"",$i); print $i "\t" $1 } ; next }
/^ *[0-9]+ +[0-9]+ +/ && !/dpt:/ && NR>2 { print "TOTAL\t" $1 }'
}
cmd_status() {
chain_exists || { echo "INACTIVE (no observe chain)"; return 0; }
echo "ACTIVE, hooked for: $(sudo iptables-save -t "$TABLE" | grep -F -- "--comment $TAG" \
| sed -nE 's/.* -s ([0-9.]+)(\/32)? .*/\1/p' | tr '\n' ' ')"
counters | awk -F'\t' '$2>0 {printf " %-8s %s attempt(s)\n", $1, $2}'
counters | awk -F'\t' '$2>0' | grep -q . || echo " (no connection attempts yet)"
}
cmd_mark() {
chain_exists || die "not observing"
counters > "$STATE" || die "could not write $STATE"
echo "baseline recorded ($(wc -l <"$STATE") counters)"
}
cmd_delta() {
chain_exists || die "not observing"
[[ -f "$STATE" ]] || die "no baseline — run 'mark' first"
join -t$'\t' -a2 -e 0 -o '0,1.2,2.2' <(sort "$STATE") <(counters | sort) \
| awk -F'\t' '{ d=$3-$2; if (d>0) printf " %-8s %s attempt(s)\n", $1, d }' \
| sort -k2 -rn
echo " ---"
join -t$'\t' -a2 -e 0 -o '0,1.2,2.2' <(sort "$STATE") <(counters | sort) \
| awk -F'\t' '{ if ($3-$2 > 0) n++ } END { print " ports contacted since mark: " n+0 }'
}
case "${1:-}" in
on) shift; cmd_on "$@" ;;
off) cmd_off ;;
status) cmd_status ;;
mark) cmd_mark ;;
delta) cmd_delta ;;
*) sed -n '2,9p' "$0" | sed 's/^# \?//'; exit 2 ;;
esac
+346
View File
@@ -0,0 +1,346 @@
#!/usr/bin/env bash
# Generic client-side service interception for OpenFUT.
#
# openfut-switch.sh on --server-ip <IP> --intercept-port <P> --target-port <Q> \
# --name <ID> [--client-ip <IP>]
# openfut-switch.sh off --name <ID>
# openfut-switch.sh status [--name <ID>]
#
# ONE implementation. `blaze-switch.sh` is a thin compatibility wrapper over
# this; there is deliberately no second copy of the iptables logic, because two
# scripts manipulating the same table diverge and then disagree about what is
# installed.
#
# WHAT IT DOES
#
# Redirects traffic destined for <server-ip>:<intercept-port> to a local
# <target-port>, so a replacement or observer can sit in front of a service
# without touching that service. Loopback is never matched: the rule is scoped
# to the server address, so 127.0.0.1:<intercept-port> keeps reaching the
# original process and stays usable as an oracle.
#
# TWO INDEPENDENT VIEWS, ON PURPOSE
#
# Rules are CREATED and DELETED by their comment tag. They are VERIFIED by
# parsing the kernel's own rule fields — chain, destination, dport, to-ports —
# with no reference to the comment. An earlier version of the Blaze switch
# matched `--comment "tag"` with quotes this iptables does not emit, so removal
# found nothing AND the verification used the same broken matcher, confirming a
# rollback that had not happened. A verifier must not share the failure mode of
# the thing it verifies.
set -uo pipefail
TAG_PREFIX="openfut-switch"
SUDO=""
[[ $EUID -eq 0 ]] || SUDO=sudo
die() { echo "openfut-switch: $*" >&2; exit 2; }
# ------------------------------------------------------------------ parsing
#
# Structured view of the nat table. Parses FIELDS, not comment text — this is
# the independent verifier referred to above.
rules_json() {
$SUDO iptables-save -t nat 2>/dev/null | python3 -c '
import json, re, sys
out = []
for line in sys.stdin:
line = line.strip()
if not line.startswith("-A "):
continue
parts = line.split()
def val(flag):
try:
return parts[parts.index(flag) + 1]
except (ValueError, IndexError):
return None
# Comment may be quoted or bare depending on iptables version; correctness
# never depends on which, because every check below can use the fields.
m = re.search(r"--comment\s+(\"([^\"]*)\"|(\S+))", line)
comment = (m.group(2) or m.group(3)) if m else None
out.append({
"chain": parts[1],
"dest": (val("-d") or "").split("/")[0],
"src": (val("-s") or "").split("/")[0],
"dport": val("--dport"),
"target": val("-j"),
"to_ports": val("--to-ports"),
"comment": comment,
"spec": line,
})
print(json.dumps(out))
'
}
# Rules bearing a given switch name.
rules_named() {
rules_json | python3 -c '
import json, sys
name = sys.argv[1]
print(json.dumps([r for r in json.load(sys.stdin) if r["comment"] == name]))
' "$1"
}
# Any REDIRECT touching a port, whoever owns it. Used to spot conflicts and
# stale rules that lost or never had our tag.
redirects_on_port() {
rules_json | python3 -c '
import json, sys
port = sys.argv[1]
print(json.dumps([r for r in json.load(sys.stdin)
if r["target"] == "REDIRECT" and r["dport"] == port]))
' "$1"
}
# ------------------------------------------------------------------- args
CMD="${1:-}"; shift || true
NAME=""; SERVER=""; CLIENT=""; IPORT=""; TPORT=""; LEGACY=""
while [[ $# -gt 0 ]]; do
case "$1" in
--name) NAME="${2:-}"; shift 2 ;;
--server-ip) SERVER="${2:-}"; shift 2 ;;
--client-ip) CLIENT="${2:-}"; shift 2 ;;
--intercept-port) IPORT="${2:-}"; shift 2 ;;
--target-port) TPORT="${2:-}"; shift 2 ;;
# A tag this switch previously used. Rules carrying it are OURS and must
# still be removable, otherwise renaming a switch orphans live NAT rules
# that no tool can clean up while `off` cheerfully reports success.
--legacy-tag) LEGACY="${2:-}"; shift 2 ;;
*) die "unknown argument: $1" ;;
esac
done
tag_for() { echo "${TAG_PREFIX}:$1"; }
is_port() { [[ "$1" =~ ^[0-9]+$ ]] && (( $1 > 0 && $1 < 65536 )); }
# ------------------------------------------------------------------ status
cmd_status() {
local tag all
if [[ -n "$NAME" ]]; then
tag="$(tag_for "$NAME")"
all="$(rules_json | python3 -c '
import json,sys
tags=[t for t in sys.argv[1:] if t]
print(json.dumps([r for r in json.load(sys.stdin) if r["comment"] in tags]))
' "$tag" "$LEGACY")"
else
tag=""
all="$(rules_json | python3 -c '
import json,sys
print(json.dumps([r for r in json.load(sys.stdin)
if (r["comment"] or "").startswith("'"$TAG_PREFIX"':")]))')"
fi
python3 - "$all" "$tag" <<'PY'
import json, sys
rules = json.loads(sys.argv[1])
tag = sys.argv[2]
if not rules:
print("INACTIVE: no switch rules%s" % (f" named {tag}" if tag else ""))
raise SystemExit(0)
# Group by the SEMANTIC identity of the redirect, not by comment text.
groups = {}
for r in rules:
key = (r["dest"], r["dport"], r["to_ports"], r["comment"])
groups.setdefault(key, []).append(r)
print("ACTIVE:")
problems = []
for (dest, dport, to, comment), rs in sorted(groups.items()):
chains = ",".join(sorted(r["chain"] for r in rs))
print(f" {comment}: {dest}:{dport} -> :{to} [{chains}]")
# A healthy switch installs exactly one PREROUTING and one OUTPUT rule.
per_chain = {}
for r in rs:
per_chain[r["chain"]] = per_chain.get(r["chain"], 0) + 1
for chain, n in per_chain.items():
if n > 1:
problems.append(f"DUPLICATE: {n} identical rules in {chain} for {comment}")
for want in ("PREROUTING", "OUTPUT"):
if want not in per_chain:
problems.append(f"INCOMPLETE: {comment} has no {want} rule")
# Several different targets for one name is inconsistent state.
by_name = {}
for (dest, dport, to, comment), _ in groups.items():
by_name.setdefault(comment, set()).add((dest, dport, to))
for comment, variants in by_name.items():
if len(variants) > 1:
problems.append(f"CONFLICT: {comment} has {len(variants)} different redirects: {sorted(variants)}")
if problems:
print()
for p in problems:
print(f" !! {p}")
raise SystemExit(1)
PY
local rc=$?
# Foreign or untagged redirects on the same port are reported, never touched.
if [[ -n "$IPORT" ]]; then
local foreign
foreign="$(redirects_on_port "$IPORT" | python3 -c '
import json,sys
tag=sys.argv[1]
tags=set(sys.argv[1:])
o=[r for r in json.load(sys.stdin) if r["comment"] not in tags]
print("\n".join(" ?? untagged/foreign: %s" % r["spec"] for r in o))' "$(tag_for "$NAME")" "$LEGACY")"
[[ -n "$foreign" ]] && { echo "$foreign" >&2; rc=1; }
fi
return $rc
}
# ---------------------------------------------------------------------- on
cmd_on() {
[[ -n "$NAME" ]] || die "--name is required"
[[ -n "$SERVER" ]] || die "--server-ip is required (the backend the client dials)"
is_port "${IPORT:-}" || die "--intercept-port must be a port"
is_port "${TPORT:-}" || die "--target-port must be a port"
[[ "$IPORT" != "$TPORT" ]] || die "--intercept-port and --target-port must differ"
# REFUSE to arm at a port nothing is listening on. Arming a switch whose
# target is dead silently breaks the client path: the redirect happens, the
# connection is refused, and the proven Python service is bypassed for no
# benefit. This has happened three times, always the same way — a build guard
# correctly refuses to start the replacement, and the `on` that follows in the
# same script arms anyway because it never checked.
#
# Overridable for the rare case of arming ahead of a service that is about to
# start, but it must be deliberate rather than the default.
if [[ "${ALLOW_DEAD_TARGET:-0}" != "1" ]]; then
if ! ss -ltn 2>/dev/null | grep -qE "[:.]${TPORT}[[:space:]]"; then
die "REFUSING: nothing is listening on target port $TPORT.
Arming would break the client path — the redirect would land on a closed
socket and the working service would be bypassed.
Start the replacement first, or set ALLOW_DEAD_TARGET=1 if that is intended."
fi
fi
local tag; tag="$(tag_for "$NAME")"
# Never stack: start from a known state for THIS name only.
cmd_off_quiet "$tag"
[[ -n "$LEGACY" ]] && cmd_off_quiet "$LEGACY"
local scope=()
[[ -n "$CLIENT" ]] && scope=(-s "$CLIENT")
$SUDO iptables -t nat -I PREROUTING 1 -p tcp "${scope[@]}" -d "$SERVER" --dport "$IPORT" \
-m comment --comment "$tag" -j REDIRECT --to-ports "$TPORT" \
|| die "failed to add PREROUTING rule"
# OUTPUT covers this host's own connections so the switch can be smoke tested
# locally. Loopback is still unmatched: it is scoped to the server address.
$SUDO iptables -t nat -I OUTPUT 1 -p tcp -d "$SERVER" --dport "$IPORT" \
-m comment --comment "$tag" -j REDIRECT --to-ports "$TPORT" \
|| die "failed to add OUTPUT rule"
# Verify from the kernel's fields, not from what we think we just ran.
local ok
ok="$(rules_named "$tag" | python3 -c '
import json,sys
rs=json.load(sys.stdin); dest,dport,to=sys.argv[1:4]
good=[r for r in rs if r["dest"]==dest and r["dport"]==dport and r["to_ports"]==to
and r["target"]=="REDIRECT"]
chains={r["chain"] for r in good}
print("yes" if len(good)==2 and chains=={"PREROUTING","OUTPUT"} else "no:%d:%s"%(len(good),sorted(chains)))
' "$SERVER" "$IPORT" "$TPORT")"
[[ "$ok" == "yes" ]] || die "rule verification failed after install ($ok)"
echo "$NAME ON: $SERVER:$IPORT -> local :$TPORT"
echo " 127.0.0.1:$IPORT still reaches the original service (scoped to $SERVER)"
echo " roll back with: $0 off --name $NAME"
}
# --------------------------------------------------------------------- off
#
# Removes ONLY rules bearing this switch's exact tag. Anything else that
# redirects the same port is reported, never deleted — precise removal, not
# broad deletion.
cmd_off_quiet() {
local tag="$1"
# Delete by RECONSTRUCTED FIELDS, never by re-feeding the raw `iptables-save`
# line through the shell.
#
# `iptables-save` prints `--comment "tag"` WITH quotes on this version. Word-
# splitting that back into an argv leaves the quote characters inside the
# comment value, so iptables looks for a rule whose comment literally contains
# `"` and finds nothing — a silent no-op delete, and the same
# comment-formatting trap that produced the original lying rollback. Rules
# with a bare comment deleted fine, which is exactly what made it look like it
# worked.
#
# Fields are passed as argv elements, so no quoting survives to be
# misinterpreted.
# Unit Separator, NOT tab. Tab is an IFS *whitespace* character, so bash
# collapses runs of it and drops empties — an absent `-s` therefore shifted
# every later field left, producing `-s <dport> --dport <to_ports>
# --to-ports ''`. Those deletes failed harmlessly here, but a shifted spec
# that happened to match a real rule would delete the wrong one.
local chain dest src dport to
while IFS=$'\x1f' read -r chain dest src dport to; do
[[ -n "$chain" ]] || continue
local args=(-t nat -D "$chain" -p tcp)
[[ -n "$src" ]] && args+=(-s "$src")
[[ -n "$dest" ]] && args+=(-d "$dest")
args+=(--dport "$dport" -m comment --comment "$tag" -j REDIRECT --to-ports "$to")
$SUDO iptables "${args[@]}" 2>/dev/null
done < <(rules_named "$tag" | python3 -c '
import json,sys
for r in json.load(sys.stdin):
print("\x1f".join([r["chain"], r["dest"] or "", r["src"] or "",
r["dport"] or "", r["to_ports"] or ""]))')
}
cmd_off() {
[[ -n "$NAME" ]] || die "--name is required"
local tag; tag="$(tag_for "$NAME")"
local before legacy_before=0
before="$(rules_named "$tag" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)))')"
if [[ -n "$LEGACY" ]]; then
legacy_before="$(rules_named "$LEGACY" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)))')"
fi
cmd_off_quiet "$tag"
[[ -n "$LEGACY" ]] && cmd_off_quiet "$LEGACY"
before=$(( before + legacy_before ))
# Independent verification: re-read the table and check the FIELDS.
local after
after="$(rules_named "$tag" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)))')"
if [[ -n "$LEGACY" ]]; then
after=$(( after + $(rules_named "$LEGACY" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)))') ))
fi
if [[ "$after" != "0" ]]; then
echo "FAILED: $after rule(s) named $tag still present after removing $before" >&2
rules_named "$tag" | python3 -c 'import json,sys
for r in json.load(sys.stdin): print(" "+r["spec"])' >&2
return 1
fi
# A redirect on that port owned by someone else is a conflict to report, not
# something this switch may delete.
if [[ -n "$IPORT" ]]; then
local others
others="$(redirects_on_port "$IPORT" | python3 -c 'import json,sys
rs=json.load(sys.stdin)
print("\n".join(" "+r["spec"] for r in rs))')"
if [[ -n "$others" ]]; then
echo "WARNING: other REDIRECT rule(s) still target port $IPORT (not ours, not removed):" >&2
echo "$others" >&2
return 1
fi
fi
echo "$NAME OFF: removed $before rule(s), verified none remain"
}
case "$CMD" in
on) cmd_on ;;
off) cmd_off ;;
status) cmd_status ;;
*) sed -n '2,12p' "$0" | sed 's/^# \?//'; exit 2 ;;
esac
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env bash
# Keep an armed switch honest: if the Rust service stops answering, roll back.
#
# openfut-watchdog.sh --name <switch> --probe <host:port> --kind <tls|tcp> \
# [--interval 60] [--failures 3] [--log <path>]
#
# WHY
#
# `openfut-switch.sh on` prints a warning that the service MUST stay up, because
# an armed switch pointing at a dead port means the client hits a closed socket
# with no fallback. A warning is not a safeguard when nobody is at the terminal.
#
# This turns the documented rollback into an automatic one. It fails toward the
# PYTHON oracle, which is the proven-good path, so the worst case of a spurious
# trip is that a gate needs re-arming — never that the client is left broken.
#
# It only ever removes the switch. It never installs one, never restarts the
# Rust service, and never touches the Python backend. Recovery is deliberately
# a human decision: an unexplained rollback is a finding to read in the morning,
# not something to paper over by flapping the switch back on.
set -uo pipefail
HERE="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
SWITCH="$HERE/openfut-switch.sh"
NAME=""; PROBE=""; KIND="tls"; INTERVAL=60; MAX_FAIL=3; LOG=""
while [[ $# -gt 0 ]]; do
case "$1" in
--name) NAME="$2"; shift 2 ;;
--probe) PROBE="$2"; shift 2 ;;
--kind) KIND="$2"; shift 2 ;;
--interval) INTERVAL="$2"; shift 2 ;;
--failures) MAX_FAIL="$2"; shift 2 ;;
--log) LOG="$2"; shift 2 ;;
*) echo "watchdog: unknown argument $1" >&2; exit 2 ;;
esac
done
[[ -n "$NAME" && -n "$PROBE" ]] || { echo "watchdog: --name and --probe are required" >&2; exit 2; }
[[ -x "$SWITCH" ]] || { echo "watchdog: missing $SWITCH" >&2; exit 2; }
say() {
local line; line="[$(date +%H:%M:%S)] watchdog($NAME): $*"
echo "$line"
[[ -n "$LOG" ]] && echo "$line" >> "$LOG"
}
# A probe must exercise the SAME path the client uses — through the switch, and
# through TLS where the client speaks TLS. A bare TCP connect would succeed
# against a process that has wedged mid-handshake.
probe_once() {
local host="${PROBE%:*}" port="${PROBE##*:}"
if [[ "$KIND" == "tcp" ]]; then
timeout 8 python3 -c "
import socket,sys
try:
socket.create_connection((sys.argv[1],int(sys.argv[2])),timeout=6).close()
except Exception:
sys.exit(1)" "$host" "$port"
else
timeout 12 python3 -c "
import socket,ssl,sys
try:
c=ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT); c.check_hostname=False
c.verify_mode=ssl.CERT_NONE; c.set_ciphers('ALL:@SECLEVEL=0')
s=c.wrap_socket(socket.create_connection((sys.argv[1],int(sys.argv[2])),timeout=6),
server_hostname='watchdog')
s.sendall(b'POST /redirector/getServerInstance HTTP/1.1\r\nContent-Length: 0\r\n\r\n')
d=b''
while True:
x=s.recv(4096)
if not x: break
d+=x
sys.exit(0 if len(d)>0 else 1)
except Exception:
sys.exit(1)" "$host" "$port"
fi
}
# A pidfile, because stopping this by command-line match is unsafe: any shell
# whose arguments merely mention the script name matches too. That mistake has
# killed the wrong process twice in this project.
PIDFILE="${OPENFUT_WATCHDOG_PIDFILE:-${TMPDIR:-/tmp}/openfut-watchdog-$NAME.pid}"
if [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null; then
echo "watchdog: already running for '$NAME' (pid $(cat "$PIDFILE"))" >&2; exit 1
fi
echo $$ > "$PIDFILE"
trap 'rm -f "$PIDFILE"' EXIT
say "started: probing $PROBE every ${INTERVAL}s ($KIND); rolls back after $MAX_FAIL consecutive failures"
fails=0
while true; do
# Nothing to guard once the switch is off — exit rather than spin forever.
if "$SWITCH" status --name "$NAME" 2>/dev/null | grep -q '^INACTIVE'; then
say "switch is no longer armed; nothing to guard, exiting"
exit 0
fi
if probe_once; then
[[ $fails -gt 0 ]] && say "recovered after $fails failure(s)"
fails=0
else
fails=$((fails + 1))
say "probe FAILED ($fails/$MAX_FAIL)"
if [[ $fails -ge $MAX_FAIL ]]; then
say "rolling back to the Python oracle"
if "$SWITCH" off --name "$NAME" >/dev/null 2>&1; then
say "ROLLED BACK — switch removed and verified. Not re-arming; this needs a human."
else
say "ROLLBACK FAILED — switch rules may remain. NEEDS IMMEDIATE ATTENTION."
fi
exit 1
fi
fi
sleep "$INTERVAL"
done
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Send the routes a real FIFA session used to BOTH backends and diff the replies.
./route-ab.py [python-host:port] [rust-host:port]
WHY
The gate 5-6 live session exercised 14 RPCs the recorded fixtures never covered
-- Stats, Clubs, OSDKSettings, SponsoredEvents, Messaging::fetchMessages,
Util::getTelemetryServer, the transport PING -- and every one took the
empty-reply fallback. That Python does the same was an INFERENCE from reading
its dispatch table. This turns it into evidence, using the routes FIFA actually
sent.
None of these RPCs read their request body (they are unhandled on both sides),
so sending them with an empty payload exercises the same path the client did.
Read-only: opens a client connection to each backend and sends requests. All
routes here are unimplemented on both sides, so nothing mutates.
"""
import socket, struct, sys
# (component, command, msg_type) observed in the live FIFA session.
MESSAGE, PING = 0, 4
ROUTES = [
(0x0009, 0x0005, MESSAGE, "Util::getTelemetryServer"),
(0x0009, 0x000C, MESSAGE, "Util::userSettingsLoadAll"),
(0x0009, 0x001C, MESSAGE, "Util::setClientState"),
(0x7802, 0x0008, MESSAGE, "UserSessions::cmd:0x0008"),
(0x000F, 0x0002, MESSAGE, "Messaging::fetchMessages"),
(0x000B, 0x0640, MESSAGE, "Clubs::cmd:0x0640"),
(0x000B, 0x0A28, MESSAGE, "Clubs::cmd:0x0a28"),
(0x08C9, 0x0001, MESSAGE, "OSDKSettings::cmd:0x0001"),
(0x08C9, 0x0002, MESSAGE, "OSDKSettings::cmd:0x0002"),
(0x0007, 0x0003, MESSAGE, "Stats::cmd:0x0003"),
(0x0007, 0x000F, MESSAGE, "Stats::cmd:0x000f"),
(0x0007, 0x0014, MESSAGE, "Stats::cmd:0x0014"),
(0x081C, 0x0003, MESSAGE, "SponsoredEvents::cmd:0x0003"),
(0x0000, 0x0000, PING, "transport PING"),
]
def frame(comp, cmd, num, mtype, payload=b""):
h = bytearray(16)
struct.pack_into(">I", h, 0, len(payload))
struct.pack_into(">H", h, 4, 0)
struct.pack_into(">H", h, 6, comp)
struct.pack_into(">H", h, 8, cmd)
h[10], h[11], h[12] = (num >> 16) & 0xFF, (num >> 8) & 0xFF, num & 0xFF
h[13] = (mtype & 7) << 5
return bytes(h) + payload
def ask(host, port, routes):
s = socket.create_connection((host, port), timeout=10)
s.settimeout(10)
out, buf = [], b""
for i, (comp, cmd, mtype, name) in enumerate(routes):
s.sendall(frame(comp, cmd, i + 1, mtype))
while len(buf) < 16:
d = s.recv(65536)
if not d:
out.append((name, "NO REPLY")); s.close(); return out
buf += d
total = 16 + struct.unpack_from(">H", buf, 4)[0] + struct.unpack_from(">I", buf, 0)[0]
while len(buf) < total:
buf += s.recv(65536)
out.append((name, buf[:total].hex()))
buf = buf[total:]
s.close()
return out
def endpoint(arg, default_port):
if ":" in arg:
h, p = arg.rsplit(":", 1)
return h, int(p)
return arg, default_port
args = sys.argv[1:]
PY_HOST, PY_PORT = endpoint(args[0], 42130) if len(args) > 0 else ("127.0.0.1", 42130)
RS_HOST, RS_PORT = endpoint(args[1], 42230) if len(args) > 1 else ("127.0.0.1", 42230)
print("python %s:%d rust %s:%d" % (PY_HOST, PY_PORT, RS_HOST, RS_PORT))
py = ask(PY_HOST, PY_PORT, ROUTES)
rs = ask(RS_HOST, RS_PORT, ROUTES)
bad = 0
for (n1, a), (n2, b) in zip(py, rs):
same = (a == b)
if not same:
bad += 1
print(" %-32s %s" % (n1, "identical" if same else "DIFFERS\n py=%s\n rs=%s" % (a, b)))
print("\n%d/%d identical" % (len(ROUTES) - bad, len(ROUTES)))
sys.exit(1 if bad else 0)
+287
View File
@@ -0,0 +1,287 @@
#!/usr/bin/env bash
# Lifecycle manager for the Blaze sidecar.
#
# sidecar.sh start start in the background, wait until it is listening
# sidecar.sh stop stop it, then VERIFY it is gone
# sidecar.sh status report
# sidecar.sh check-orphans fail if any sidecar is listening unexpectedly
# sidecar.sh with -- CMD… start, run CMD, always stop and verify
#
# WHY THIS EXISTS
#
# A previous session's mutation runs left four sidecars listening, two of them
# serving deliberately broken builds, because `kill %1` does not carry across
# shell invocations. A later A/B against one of those would have looked like a
# genuine parity failure. Ad-hoc backgrounding is not good enough before a live
# FIFA test.
#
# So stopping is not "send a signal and hope". It kills, waits, and then proves
# both that the PID is gone AND that the port is no longer listening. If either
# check fails, this script fails — a leaked sidecar must never be silent.
set -uo pipefail
HERE="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
ROOT="$(cd "$HERE/.." && pwd)"
RUNDIR="${OPENFUT_SIDECAR_RUNDIR:-${TMPDIR:-/tmp}/openfut-sidecar}"
PIDFILE="$RUNDIR/sidecar.pid"
PORTFILE="$RUNDIR/sidecar.port"
LOGFILE="${OPENFUT_SIDECAR_LOG:-$RUNDIR/sidecar.log}"
BIN="$ROOT/target/debug/openfut-blaze-host"
[[ -x "$BIN" ]] || BIN="$ROOT/target/release/openfut-blaze-host"
die() { echo "sidecar: $*" >&2; exit 1; }
# Authoritative working-tree check, run at LAUNCH.
#
# The commit stamped into the binary by build.rs can be stale — cargo does not
# re-run a build script when another crate's source changes — so the compiled-in
# "dirty" flag is not a safeguard. This is. It runs now, against the tree as it
# is now, over exactly the crates the binary is built from.
#
# Echoes "DIRTY" or "clean" (or "unknown" outside a git tree).
tree_state() {
git -C "$ROOT" rev-parse --git-dir >/dev/null 2>&1 || { echo unknown; return; }
local out
out="$(git -C "$ROOT" status --porcelain --untracked-files=no -- \
openfut-blaze-host openfut-adapter-fifa17 openfut-protocol-blaze 2>/dev/null)"
[[ -n "$out" ]] && echo DIRTY || echo clean
}
port_listening() {
local port="$1"
if command -v ss >/dev/null 2>&1; then
ss -ltn 2>/dev/null | grep -qE "[:.]${port}[[:space:]]"
elif command -v lsof >/dev/null 2>&1; then
lsof -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1
else
# No way to check is not the same as "it is clean" — refuse to guess.
die "neither ss nor lsof available; cannot verify port state"
fi
}
pid_alive() { kill -0 "$1" 2>/dev/null; }
# ---------------------------------------------------------------- orphans
# Any sidecar process at all, whether or not this script started it.
#
# Matches the resolved EXECUTABLE, not the command line. `pgrep -f` was tried
# first and was wrong: it matched any process whose arguments merely mentioned
# the name — including the shell running this script, and any editor or script
# with the string in it. That is a false positive that refuses legitimate
# starts, which during a FIFA test is worse than the leak it guards against.
#
# `pgrep -x` is also unusable here: Linux truncates the process name to 15
# characters, so the binary appears as "openfut-blaze-h".
list_sidecars() {
local self=$$ pid exe
for d in /proc/[0-9]*; do
pid="${d#/proc/}"
[[ "$pid" == "$self" ]] && continue
# readlink, NOT readlink -f: once the binary is rebuilt the link reads
# "<path> (deleted)", and -f resolves that to something that matches
# nothing. The orphan check would then be blind to exactly the long-lived
# processes it exists to find — verified: two orphans (a stale-cert
# redirector and a Blaze sidecar) were both invisible to this until the
# suffix was stripped.
exe="$(readlink "$d/exe" 2>/dev/null)" || continue
exe="${exe% (deleted)}"
[[ "${exe##*/}" == "openfut-blaze-host" ]] && echo "$pid"
done
return 0
}
cmd_check_orphans() {
local found
found="$(list_sidecars)"
if [[ -z "$found" ]]; then
echo "no sidecar processes running"
return 0
fi
echo "ORPHANED SIDECAR PROCESS(ES) FOUND:" >&2
for p in $found; do
echo " pid $p: $(tr '\0' ' ' < "/proc/$p/cmdline" 2>/dev/null || echo '?')" >&2
done
echo >&2
echo "Refusing to proceed: a stale sidecar may be serving a mutated build," >&2
echo "and an A/B against it would read as a real parity failure." >&2
echo "Stop them with: pkill -f openfut-blaze-host" >&2
return 1
}
# ------------------------------------------------------------------ start
cmd_start() {
[[ -x "$BIN" ]] || die "binary not built; run: cargo build -p openfut-blaze-host"
: "${OPENFUT_BLAZE_HOST_PORT:?set OPENFUT_BLAZE_HOST_PORT (no default, so the sidecar cannot collide with the Python backend)}"
: "${OPENFUT_ADVERTISE:?set OPENFUT_ADVERTISE to the address the game machine uses to reach this host}"
cmd_check_orphans >/dev/null 2>&1 || { cmd_check_orphans; die "clean up first"; }
if port_listening "$OPENFUT_BLAZE_HOST_PORT"; then
die "port $OPENFUT_BLAZE_HOST_PORT is already in use"
fi
# The binary's stamp can lag the tree (cargo cannot know about every source
# change). Compare it with the tree's real HEAD at launch and say so, because
# an evidence artefact that names the wrong commit is worse than one that
# names none.
HEAD_NOW="$(git -C "$ROOT" rev-parse --short=7 HEAD 2>/dev/null || echo unknown)"
local tree
tree="$(tree_state)"
if [[ "$tree" == "DIRTY" ]]; then
echo "WARNING: migration crates have uncommitted changes — this binary may not" >&2
echo " match any commit. Do not treat its output as parity evidence." >&2
fi
mkdir -p "$RUNDIR"
echo "$OPENFUT_BLAZE_HOST_PORT" > "$PORTFILE"
"$BIN" >"$LOGFILE" 2>&1 &
local pid=$!
echo "$pid" > "$PIDFILE"
# Wait for the listener rather than sleeping a guess.
local waited=0
while (( waited < 100 )); do
if ! pid_alive "$pid"; then
echo "sidecar died during startup; log:" >&2
tail -20 "$LOGFILE" >&2
rm -f "$PIDFILE"
return 1
fi
if port_listening "$OPENFUT_BLAZE_HOST_PORT"; then
echo "sidecar started: pid $pid, port $OPENFUT_BLAZE_HOST_PORT"
local banner stamped
banner="$(grep -m1 'openfut-blaze-host v' "$LOGFILE" 2>/dev/null)"
echo " ${banner}"
stamped="$(sed -n 's/.*commit=\([0-9a-f]*\).*/\1/p' <<<"$banner")"
if [[ -n "$stamped" && "$stamped" != "unknown" && "$stamped" != "$HEAD_NOW" ]]; then
echo " !! STALE BUILD STAMP: binary says $stamped, HEAD is $HEAD_NOW" >&2
echo " Rebuild before treating this run as evidence:" >&2
echo " touch openfut-blaze-host/build.rs && cargo build -p openfut-blaze-host" >&2
fi
if grep -q 'WARNING: built from a modified working tree' "$LOGFILE" 2>/dev/null; then
echo " !! DIRTY BUILD — results are not parity evidence" >&2
fi
return 0
fi
sleep 0.1
waited=$((waited + 1))
done
echo "sidecar did not begin listening within 10s; log:" >&2
tail -20 "$LOGFILE" >&2
kill "$pid" 2>/dev/null
rm -f "$PIDFILE"
return 1
}
# ------------------------------------------------------------------- stop
#
# Kill, wait, then PROVE it. Both conditions must hold or this fails.
cmd_stop() {
local rc=0
local pid="" port=""
[[ -f "$PIDFILE" ]] && pid="$(cat "$PIDFILE")"
[[ -f "$PORTFILE" ]] && port="$(cat "$PORTFILE")"
# Stopping the sidecar while the Blaze switch is still on leaves the client
# pointed at a dead port — Blaze breaks and nothing says why. This exact state
# was created once during development, so this REFUSES rather than warning:
# a warning on stderr that is followed by doing the dangerous thing anyway is
# not a safeguard.
if [[ "${1:-}" != "--force" && -x "$HERE/blaze-switch.sh" ]]; then
if "$HERE/blaze-switch.sh" status 2>/dev/null | grep -q "redirected to the RUST"; then
echo "REFUSING to stop: the Blaze switch is still ON." >&2
echo " Stopping now would leave Blaze pointing at a dead port." >&2
echo " Roll back first: ./blaze-switch.sh off" >&2
echo " Or override: ./sidecar.sh stop --force" >&2
return 1
fi
fi
if [[ -n "$pid" ]] && pid_alive "$pid"; then
kill "$pid" 2>/dev/null
local waited=0
while pid_alive "$pid" && (( waited < 50 )); do sleep 0.1; waited=$((waited+1)); done
if pid_alive "$pid"; then
echo "sidecar $pid ignored SIGTERM; escalating to SIGKILL" >&2
kill -9 "$pid" 2>/dev/null
waited=0
while pid_alive "$pid" && (( waited < 50 )); do sleep 0.1; waited=$((waited+1)); done
fi
fi
# Verification, not optimism.
if [[ -n "$pid" ]] && pid_alive "$pid"; then
echo "FAILED to stop sidecar pid $pid" >&2
rc=1
fi
if [[ -n "$port" ]] && port_listening "$port"; then
echo "FAILED: port $port is still listening after stop" >&2
rc=1
fi
local strays
strays="$(list_sidecars)"
if [[ -n "$strays" ]]; then
echo "FAILED: sidecar process(es) still running: $strays" >&2
rc=1
fi
rm -f "$PIDFILE" "$PORTFILE"
if [[ $rc -eq 0 ]]; then
echo "sidecar stopped and verified gone${pid:+ (pid $pid)}${port:+, port $port free}"
fi
return $rc
}
cmd_status() {
if [[ -f "$PIDFILE" ]] && pid_alive "$(cat "$PIDFILE")"; then
echo "running: pid $(cat "$PIDFILE"), port $(cat "$PORTFILE" 2>/dev/null || echo '?')"
else
echo "not running (per pidfile)"
fi
local strays
strays="$(list_sidecars)"
[[ -n "$strays" ]] && echo "sidecar processes on this host: $strays"
return 0
}
# ------------------------------------------------------------------- with
#
# Start, run a command, and stop+verify no matter how the command exits.
cmd_with() {
cmd_start || return 1
# shellcheck disable=SC2317
cleanup() { cmd_stop || echo "sidecar: CLEANUP VERIFICATION FAILED" >&2; }
trap cleanup EXIT INT TERM
"$@"
local rc=$?
trap - EXIT INT TERM
cmd_stop || { echo "sidecar: cleanup verification failed" >&2; return 1; }
return $rc
}
case "${1:-}" in
start) shift; cmd_start "$@" ;;
stop) shift; cmd_stop "$@" ;;
status) shift; cmd_status "$@" ;;
check-orphans) shift; cmd_check_orphans "$@" ;;
with)
shift
[[ "${1:-}" == "--" ]] && shift
[[ $# -gt 0 ]] || die "usage: sidecar.sh with -- COMMAND [ARGS…]"
cmd_with "$@"
;;
*)
sed -n '2,10p' "$0" | sed 's/^# \?//'
exit 2
;;
esac
+205
View File
@@ -0,0 +1,205 @@
//! Replay a recorded Blaze conversation against a LIVE endpoint and emit a
//! normalized trace.
//!
//! This is the A/B instrument. Point it at the Python backend, then at the Rust
//! sidecar, and diff the two traces:
//!
//! ```text
//! blaze-probe 127.0.0.1:42130 > /tmp/python.trace
//! blaze-probe 127.0.0.1:42230 > /tmp/rust.trace
//! diff /tmp/python.trace /tmp/rust.trace
//! ```
//!
//! Byte comparison is impossible across two live servers — session keys and
//! server timestamps differ by design — so the trace masks known-volatile
//! values while preserving their tag, type and length. Everything else must
//! match exactly, including frame counts and notification ordering.
//!
//! The requests come from `openfut-adapter-fifa17/fixtures/blaze_transactions.jsonl`,
//! so this exercises the same conversation the offline parity suite does, but
//! over a real socket against a real server process.
//!
//! Read-only: it opens a client connection and sends recorded requests. It
//! writes nothing and mutates no state beyond the server's own per-connection
//! session.
use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::Duration;
use openfut_blaze_host::trace;
use openfut_protocol_blaze::fire2::{Frame, Header, HEADER_LEN};
fn usage() -> ! {
eprintln!("usage: blaze-probe <host:port> [--verbose] [--session main|fallbacks|locale]");
eprintln!();
eprintln!("Replays the recorded Blaze conversation against a live endpoint and");
eprintln!("prints a normalized, volatile-masked trace on stdout.");
std::process::exit(2);
}
fn fixtures_path() -> String {
format!(
"{}/../openfut-adapter-fifa17/fixtures/blaze_transactions.jsonl",
env!("CARGO_MANIFEST_DIR")
)
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.is_empty() || args[0].starts_with("--") {
usage();
}
let endpoint = args[0].clone();
let verbose = args.iter().any(|a| a == "--verbose");
let want_session = args
.windows(2)
.find(|w| w[0] == "--session")
.map(|w| w[1].clone())
.unwrap_or_else(|| "main".to_string());
let path = fixtures_path();
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| {
eprintln!("cannot read {path}: {e}");
std::process::exit(1)
});
let records: Vec<serde_json::Value> = text
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| serde_json::from_str(l).expect("fixture line is valid JSON"))
.collect();
let requests: Vec<(String, Vec<u8>)> = records
.iter()
.filter(|r| r["kind"] == "tx" && r["session"] == want_session.as_str())
.map(|r| {
(
r["name"].as_str().unwrap().to_string(),
unhex(r["request_hex"].as_str().unwrap()),
)
})
.collect();
if requests.is_empty() {
eprintln!("no transactions for session {want_session:?}");
std::process::exit(1);
}
eprintln!(
"blaze-probe: {} requests from session {want_session:?} -> {endpoint}",
requests.len()
);
let mut stream = TcpStream::connect(&endpoint).unwrap_or_else(|e| {
eprintln!("cannot connect to {endpoint}: {e}");
std::process::exit(1);
});
let _ = stream.set_nodelay(true);
// Generous but finite: a server that answers nothing must not hang the probe.
let _ = stream.set_read_timeout(Some(Duration::from_secs(10)));
println!("# blaze-probe conversation: session={want_session}");
println!("# endpoint intentionally omitted so traces from different hosts diff cleanly");
let mut buf: Vec<u8> = Vec::new();
let mut mismatched = 0usize;
for (n, (name, request)) in requests.iter().enumerate() {
let req_frame = Frame::parse(request).expect("fixture request parses").0;
println!("--- {:02} {name}", n + 1);
println!(
"{}",
trace::trace_line(0, "TX", &format!("{}", n + 1), &req_frame)
);
if let Err(e) = stream.write_all(request) {
println!("!! send failed: {e}");
mismatched += 1;
break;
}
let _ = stream.flush();
// How many frames to expect is recorded; a server that sends fewer is
// the failure this probe exists to catch, so read with a timeout rather
// than blocking forever.
let expected = records
.iter()
.find(|r| r["kind"] == "tx" && r["name"] == name.as_str())
.and_then(|r| r["responses"].as_array())
.map(|a| a.len())
.unwrap_or(0);
let mut got = 0usize;
while got < expected {
match read_frame(&mut stream, &mut buf) {
Ok(Some(frame)) => {
println!(
"{}",
trace::trace_line(0, "RX", &format!("{}.{}", n + 1, got), &frame)
);
if verbose && !frame.payload.is_empty() {
if let Ok(body) = openfut_protocol_blaze::heat2::decode(&frame.payload) {
for line in trace::masked_dump(&body).lines() {
println!(" {line}");
}
}
}
got += 1;
}
Ok(None) => {
println!("!! connection closed after {got}/{expected} frame(s)");
mismatched += 1;
break;
}
Err(e) => {
println!("!! read failed after {got}/{expected} frame(s): {e}");
mismatched += 1;
break;
}
}
}
if got != expected {
println!("!! frame count {got}, recorded {expected}");
mismatched += 1;
}
}
println!(
"# done: {} request(s), {mismatched} anomaly/anomalies",
requests.len()
);
if mismatched > 0 {
std::process::exit(1);
}
}
fn read_frame(stream: &mut TcpStream, buf: &mut Vec<u8>) -> std::io::Result<Option<Frame>> {
let mut chunk = [0u8; 65536];
while buf.len() < HEADER_LEN {
match stream.read(&mut chunk)? {
0 => return Ok(None),
got => buf.extend_from_slice(&chunk[..got]),
}
}
let header =
Header::parse(&buf[..HEADER_LEN]).map_err(|e| std::io::Error::other(e.to_string()))?;
let total = header.frame_len();
while buf.len() < total {
match stream.read(&mut chunk)? {
0 => return Ok(None),
got => buf.extend_from_slice(&chunk[..got]),
}
}
let (frame, used) =
Frame::parse(&buf[..total]).map_err(|e| std::io::Error::other(e.to_string()))?;
buf.drain(..used);
Ok(Some(frame))
}
fn unhex(s: &str) -> Vec<u8> {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("valid hex"))
.collect()
}
@@ -0,0 +1,170 @@
//! Read a raw capture and emit a repository-safe, replayable fixture.
//!
//! ```text
//! blaze-sanitize <capture.ofcap> [-o out.jsonl] [--report report.txt]
//! ```
//!
//! Prints an audit of exactly what was replaced. Nothing is redacted silently:
//! every substitution appears in the report with its path, kind and length, and
//! untouched frames keep their exact wire bytes.
//!
//! The output is a JSONL conversation that replays against either backend —
//! see `blaze-probe --capture`.
use std::io::Write;
use openfut_blaze_host::capture::{self, Direction};
use openfut_blaze_host::sanitize::{self, SENSITIVE_TAGS};
use openfut_protocol_blaze::fire2::Header;
fn usage() -> ! {
eprintln!("usage: blaze-sanitize <capture.ofcap> [-o out.jsonl] [--report report.txt]");
eprintln!();
eprintln!("Reads a raw frame capture and writes a sanitized, replayable JSONL");
eprintln!("conversation plus an audit of every redaction made.");
std::process::exit(2);
}
fn arg_after(args: &[String], flag: &str) -> Option<String> {
args.windows(2).find(|w| w[0] == flag).map(|w| w[1].clone())
}
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
fn json_escape(s: &str) -> String {
s.chars()
.flat_map(|c| match c {
'"' => "\\\"".chars().collect::<Vec<_>>(),
'\\' => "\\\\".chars().collect(),
'\n' => "\\n".chars().collect(),
c => vec![c],
})
.collect()
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.is_empty() || args[0].starts_with('-') {
usage();
}
let input = args[0].clone();
let out_path = arg_after(&args, "-o").unwrap_or_else(|| format!("{input}.sanitized.jsonl"));
let report_path = arg_after(&args, "--report");
let records = match capture::read_file(&input) {
Ok(r) => r,
Err(e) => {
eprintln!("blaze-sanitize: cannot read {input}: {e}");
std::process::exit(1);
}
};
eprintln!("read {} frame(s) from {input}", records.len());
let (out, report) = match sanitize::sanitize(&records) {
Ok(v) => v,
Err(e) => {
eprintln!("blaze-sanitize: {e}");
std::process::exit(1);
}
};
// ------------------------------------------------------------- fixture
let mut f = match std::fs::File::create(&out_path) {
Ok(f) => f,
Err(e) => {
eprintln!("blaze-sanitize: cannot write {out_path}: {e}");
std::process::exit(1);
}
};
let _ = writeln!(
f,
"{{\"kind\":\"capture\",\"source\":\"{}\",\"frames\":{},\"redacted_frames\":{},\
\"sensitive_tags\":[{}],\"note\":\"sanitized; redacted values are \
length-preserving fillers, all other bytes are exact\"}}",
json_escape(&input),
report.frames_out,
report.frames_redacted,
SENSITIVE_TAGS
.iter()
.map(|t| format!("\"{t}\""))
.collect::<Vec<_>>()
.join(",")
);
for (rec, bytes) in &out {
let h = Header::parse(bytes).ok();
let (comp, cmd, mtype, mnum, uidx, plen) = match h {
Some(h) => (
h.component,
h.command,
h.msg_type.as_bits(),
h.msg_num,
h.user_index,
h.payload_len,
),
None => (0, 0, 0, 0, 0, 0),
};
let _ = writeln!(
f,
"{{\"kind\":\"frame\",\"conn\":{},\"seq\":{},\"dir\":\"{}\",\
\"component\":{},\"command\":{},\"msg_type\":{},\"msg_num\":{},\
\"user_index\":{},\"payload_len\":{},\"frame_hex\":\"{}\"}}",
rec.conn_id,
rec.seq,
rec.dir.label(),
comp,
cmd,
mtype,
mnum,
uidx,
plen,
hex(bytes)
);
}
eprintln!("wrote {out_path}");
// -------------------------------------------------------------- report
let mut lines = Vec::new();
lines.push(format!("sanitization report for {input}"));
lines.push(format!(" frames in: {}", report.frames_in));
lines.push(format!(" frames out: {}", report.frames_out));
lines.push(format!(" frames redacted: {}", report.frames_redacted));
lines.push(format!(" redactions: {}", report.redactions.len()));
lines.push(format!(" sensitive tags: {}", SENSITIVE_TAGS.join(", ")));
if !report.undecodable.is_empty() {
lines.push(format!(
" NOT decodable as TDF (passed through unchanged, NOT inspected): seq {:?}",
report.undecodable
));
}
lines.push(String::new());
if report.redactions.is_empty() {
lines.push(" no sensitive values found".into());
} else {
lines.push(" seq conn path kind bytes".into());
for r in &report.redactions {
lines.push(format!(
" {:<5} {:<5} {:<20} {:<7} {} -> {}",
r.seq, r.conn_id, r.path, r.kind, r.original_len, r.replacement_len
));
}
}
// Direction/route summary, so a reviewer can see the conversation shape.
lines.push(String::new());
lines.push(" conversation shape:".into());
let rx = out.iter().filter(|(r, _)| r.dir == Direction::Rx).count();
let tx = out.len() - rx;
lines.push(format!(" {rx} RX, {tx} TX"));
let text = lines.join("\n") + "\n";
print!("{text}");
if let Some(p) = report_path {
if std::fs::write(&p, &text).is_ok() {
eprintln!("wrote {p}");
}
}
}
+477
View File
@@ -0,0 +1,477 @@
//! Passive TLS ClientHello observer for the Blaze redirector.
//!
//! ```text
//! FIFA ──> tls-observe ──(raw bytes, unmodified)──> Python redirector
//! │
//! └── parses and reports the ClientHello
//! ```
//!
//! # Why this exists
//!
//! Every observed redirector handshake selected `AES256-GCM-SHA384` — TLS 1.2
//! with **static RSA key exchange**. `rustls` supports only forward-secret
//! (EC)DHE suites, so if that is all the client offers, rustls is ruled out for
//! a Rust redirector and an OpenSSL-backed stack is required.
//!
//! But **the selected cipher does not reveal what was offered.** OpenSSL's
//! server follows the client's preference order by default, so preferring
//! static RSA does not prove ECDHE was unavailable. Choosing a TLS stack on
//! that inference would be exactly the kind of guess this project keeps
//! refusing to make. So: read the actual ClientHello.
//!
//! # Passive by construction
//!
//! Bytes are forwarded verbatim in both directions and nothing is injected,
//! rewritten or delayed beyond a parse of the first record. The handshake is
//! still terminated by the untouched Python redirector, so a FIFA session runs
//! exactly as it otherwise would. If parsing fails, the proxy still relays —
//! observation must never be able to break the path it is observing.
//!
//! ```text
//! tls-observe --listen 0.0.0.0:42227 --upstream 127.0.0.1:42127
//! ```
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
// ---------------------------------------------------------------- TLS names
/// Cipher suites we care about naming. Not exhaustive: unknown values are
/// reported as hex so nothing is silently dropped.
fn cipher_name(id: u16) -> &'static str {
match id {
0x0004 => "TLS_RSA_WITH_RC4_128_MD5",
0x0005 => "TLS_RSA_WITH_RC4_128_SHA",
0x000A => "TLS_RSA_WITH_3DES_EDE_CBC_SHA",
0x002F => "TLS_RSA_WITH_AES_128_CBC_SHA",
0x0035 => "TLS_RSA_WITH_AES_256_CBC_SHA",
0x003C => "TLS_RSA_WITH_AES_128_CBC_SHA256",
0x003D => "TLS_RSA_WITH_AES_256_CBC_SHA256",
0x009C => "TLS_RSA_WITH_AES_128_GCM_SHA256",
0x009D => "TLS_RSA_WITH_AES_256_GCM_SHA384",
0x0033 => "TLS_DHE_RSA_WITH_AES_128_CBC_SHA",
0x0039 => "TLS_DHE_RSA_WITH_AES_256_CBC_SHA",
0x009E => "TLS_DHE_RSA_WITH_AES_128_GCM_SHA256",
0x009F => "TLS_DHE_RSA_WITH_AES_256_GCM_SHA384",
0xC013 => "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA",
0xC014 => "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA",
0xC027 => "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256",
0xC028 => "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384",
0xC02F => "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
0xC030 => "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
0xC009 => "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA",
0xC00A => "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA",
0xC02B => "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
0xC02C => "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
0xCCA8 => "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256",
0x1301 => "TLS_AES_128_GCM_SHA256 (1.3)",
0x1302 => "TLS_AES_256_GCM_SHA384 (1.3)",
0x1303 => "TLS_CHACHA20_POLY1305_SHA256 (1.3)",
0x00FF => "TLS_EMPTY_RENEGOTIATION_INFO_SCSV",
_ => "",
}
}
/// Does this suite use an ephemeral (forward-secret) key exchange?
///
/// This is the whole decision: rustls offers ECDHE/DHE suites only.
fn is_forward_secret(id: u16) -> bool {
let n = cipher_name(id);
n.contains("ECDHE") || n.contains("DHE_") || n.ends_with("(1.3)")
}
fn tls_version_name(v: u16) -> &'static str {
match v {
0x0300 => "SSL 3.0",
0x0301 => "TLS 1.0",
0x0302 => "TLS 1.1",
0x0303 => "TLS 1.2",
0x0304 => "TLS 1.3",
_ => "unknown",
}
}
fn extension_name(id: u16) -> &'static str {
match id {
0 => "server_name",
5 => "status_request",
10 => "supported_groups",
11 => "ec_point_formats",
13 => "signature_algorithms",
16 => "ALPN",
23 => "extended_master_secret",
35 => "session_ticket",
43 => "supported_versions",
45 => "psk_key_exchange_modes",
51 => "key_share",
65281 => "renegotiation_info",
_ => "",
}
}
// ------------------------------------------------------------- parsing
#[derive(Debug, Default)]
struct ClientHello {
record_version: u16,
client_version: u16,
cipher_suites: Vec<u16>,
extensions: Vec<u16>,
server_name: Option<String>,
supported_versions: Vec<u16>,
supported_groups: Vec<u16>,
}
fn be16(b: &[u8], i: usize) -> Option<u16> {
Some(u16::from_be_bytes([*b.get(i)?, *b.get(i + 1)?]))
}
/// Parse a ClientHello from the first TLS record.
///
/// Returns `None` rather than erroring: an unparseable hello must still be
/// relayed, so the caller treats this as "nothing learned", never as a failure.
fn parse_client_hello(buf: &[u8]) -> Option<ClientHello> {
// TLS record: type(1) version(2) length(2)
if *buf.first()? != 0x16 {
return None; // not a handshake record
}
let mut h = ClientHello {
record_version: be16(buf, 1)?,
..Default::default()
};
// Handshake: type(1) length(3) then the body.
let hs = buf.get(5..)?;
if *hs.first()? != 0x01 {
return None; // not a ClientHello
}
let mut i = 4; // skip handshake header
h.client_version = be16(hs, i)?;
i += 2;
i += 32; // random
let sid_len = *hs.get(i)? as usize;
i += 1 + sid_len;
let cs_len = be16(hs, i)? as usize;
i += 2;
for k in (0..cs_len).step_by(2) {
if let Some(c) = be16(hs, i + k) {
h.cipher_suites.push(c);
}
}
i += cs_len;
let comp_len = *hs.get(i)? as usize;
i += 1 + comp_len;
// Extensions are optional (SSL3/TLS1.0 clients may omit them).
let Some(ext_total) = be16(hs, i) else {
return Some(h);
};
i += 2;
let end = i + ext_total as usize;
while i + 4 <= end.min(hs.len()) {
let etype = be16(hs, i)?;
let elen = be16(hs, i + 2)? as usize;
let body = hs.get(i + 4..i + 4 + elen).unwrap_or(&[]);
h.extensions.push(etype);
match etype {
0 => {
// server_name: list(2) type(1) len(2) host
if body.len() > 5 {
let n = be16(body, 3).unwrap_or(0) as usize;
if let Some(s) = body.get(5..5 + n) {
h.server_name = Some(String::from_utf8_lossy(s).into_owned());
}
}
}
43 => {
if let Some(&n) = body.first() {
for k in (0..n as usize).step_by(2) {
if let Some(v) = be16(body, 1 + k) {
h.supported_versions.push(v);
}
}
}
}
10 => {
let n = be16(body, 0).unwrap_or(0) as usize;
for k in (0..n).step_by(2) {
if let Some(g) = be16(body, 2 + k) {
h.supported_groups.push(g);
}
}
}
_ => {}
}
i += 4 + elen;
}
Some(h)
}
fn report(conn: u64, peer: &str, h: &ClientHello) -> String {
let mut s = String::new();
s.push_str(&format!("=== ClientHello #{conn} from {peer} ===\n"));
s.push_str(&format!(
" record version : 0x{:04x} {}\n",
h.record_version,
tls_version_name(h.record_version)
));
s.push_str(&format!(
" client version : 0x{:04x} {}\n",
h.client_version,
tls_version_name(h.client_version)
));
if !h.supported_versions.is_empty() {
let v: Vec<String> = h
.supported_versions
.iter()
.map(|v| tls_version_name(*v).to_string())
.collect();
s.push_str(&format!(" supported_versions: {}\n", v.join(", ")));
}
if let Some(sni) = &h.server_name {
s.push_str(&format!(" SNI : {sni}\n"));
}
s.push_str(&format!(" cipher suites : {}\n", h.cipher_suites.len()));
for c in &h.cipher_suites {
let name = cipher_name(*c);
s.push_str(&format!(
" 0x{:04x} {:<45} {}\n",
c,
if name.is_empty() { "<unknown>" } else { name },
if is_forward_secret(*c) {
"[forward-secret]"
} else {
""
}
));
}
let fs: Vec<u16> = h
.cipher_suites
.iter()
.copied()
.filter(|c| is_forward_secret(*c))
.collect();
s.push_str(&format!(
" extensions : {}\n",
h.extensions
.iter()
.map(|e| {
let n = extension_name(*e);
if n.is_empty() {
format!("{e}")
} else {
n.to_string()
}
})
.collect::<Vec<_>>()
.join(", ")
));
s.push('\n');
s.push_str(" VERDICT:\n");
if fs.is_empty() {
s.push_str(" NO forward-secret suite offered.\n");
s.push_str(" => rustls is RULED OUT for the redirector; an OpenSSL-backed\n");
s.push_str(" stack (native-tls / openssl) is required.\n");
} else {
s.push_str(&format!(
" {} forward-secret suite(s) OFFERED:\n",
fs.len()
));
for c in &fs {
s.push_str(&format!(" 0x{:04x} {}\n", c, cipher_name(*c)));
}
s.push_str(" => rustls is VIABLE in principle. Confirm with a real handshake\n");
s.push_str(" against a rustls listener before deciding.\n");
}
s
}
// --------------------------------------------------------------- proxy
fn pump(mut from: TcpStream, mut to: TcpStream) {
let mut buf = [0u8; 32768];
loop {
match from.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
if to.write_all(&buf[..n]).is_err() {
break;
}
let _ = to.flush();
}
}
}
let _ = to.shutdown(std::net::Shutdown::Write);
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let get = |flag: &str| -> Option<String> {
args.windows(2).find(|w| w[0] == flag).map(|w| w[1].clone())
};
let listen = get("--listen").unwrap_or_else(|| {
eprintln!(
"usage: tls-observe --listen <addr:port> --upstream <addr:port> [--report <path>]"
);
eprintln!();
eprintln!("Passively observes the TLS ClientHello and relays every byte to the");
eprintln!("upstream redirector unmodified. Never terminates TLS itself.");
std::process::exit(2);
});
let upstream = get("--upstream").unwrap_or_else(|| {
eprintln!("--upstream is required (the real redirector, e.g. 127.0.0.1:42127)");
std::process::exit(2);
});
let report_path = get("--report");
let listener = TcpListener::bind(&listen).unwrap_or_else(|e| {
eprintln!("cannot bind {listen}: {e}");
std::process::exit(1);
});
eprintln!("tls-observe: {listen} -> {upstream} (passive; TLS terminated upstream)");
let counter = Arc::new(AtomicU64::new(0));
for incoming in listener.incoming() {
let Ok(mut client) = incoming else { continue };
let id = counter.fetch_add(1, Ordering::Relaxed) + 1;
let upstream = upstream.clone();
let report_path = report_path.clone();
std::thread::spawn(move || {
let peer = client
.peer_addr()
.map(|a| a.to_string())
.unwrap_or_else(|_| "<unknown>".into());
let _ = client.set_nodelay(true);
// Read the first chunk: the ClientHello. Observation only — these
// bytes are forwarded verbatim regardless of what we make of them.
let mut head = vec![0u8; 8192];
let _ = client.set_read_timeout(Some(Duration::from_secs(15)));
let n = match client.read(&mut head) {
Ok(0) | Err(_) => return,
Ok(n) => n,
};
head.truncate(n);
let _ = client.set_read_timeout(None);
match parse_client_hello(&head) {
Some(h) => {
let text = report(id, &peer, &h);
print!("{text}");
use std::io::Write as _;
let _ = std::io::stdout().flush();
if let Some(p) = &report_path {
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(p)
{
let _ = f.write_all(text.as_bytes());
}
}
}
None => {
eprintln!("conn {id} from {peer}: first record is not a ClientHello ({n}B)")
}
}
let Ok(mut server) = TcpStream::connect(&upstream) else {
eprintln!("conn {id}: cannot reach upstream {upstream}");
return;
};
let _ = server.set_nodelay(true);
if server.write_all(&head).is_err() {
return;
}
let _ = server.flush();
let (c2, s2) = match (client.try_clone(), server.try_clone()) {
(Ok(a), Ok(b)) => (a, b),
_ => return,
};
let up = std::thread::spawn(move || pump(client, server));
pump(s2, c2);
let _ = up.join();
});
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A minimal TLS 1.2 ClientHello offering one ECDHE and one static-RSA suite.
fn synthetic_hello() -> Vec<u8> {
let mut body = Vec::new();
body.extend_from_slice(&[0x03, 0x03]); // client_version TLS 1.2
body.extend_from_slice(&[0u8; 32]); // random
body.push(0); // session id len
body.extend_from_slice(&2u16.to_be_bytes().map(|b| b)); // placeholder
let cs: [u16; 2] = [0xC030, 0x009D];
let cs_bytes: Vec<u8> = cs.iter().flat_map(|c| c.to_be_bytes()).collect();
let l = body.len();
body.truncate(l - 2);
body.extend_from_slice(&(cs_bytes.len() as u16).to_be_bytes());
body.extend_from_slice(&cs_bytes);
body.push(1); // compression len
body.push(0); // null
body.extend_from_slice(&0u16.to_be_bytes()); // no extensions
let mut hs = vec![0x01];
hs.extend_from_slice(&(body.len() as u32).to_be_bytes()[1..]);
hs.extend_from_slice(&body);
let mut rec = vec![0x16, 0x03, 0x01];
rec.extend_from_slice(&(hs.len() as u16).to_be_bytes());
rec.extend_from_slice(&hs);
rec
}
#[test]
fn parses_ciphers_and_classifies_forward_secrecy() {
let h = parse_client_hello(&synthetic_hello()).expect("parses");
assert_eq!(h.client_version, 0x0303);
assert_eq!(h.cipher_suites, vec![0xC030, 0x009D]);
assert!(
is_forward_secret(0xC030),
"ECDHE must count as forward-secret"
);
assert!(!is_forward_secret(0x009D), "static RSA must not");
}
#[test]
fn verdict_reflects_what_was_offered() {
let h = parse_client_hello(&synthetic_hello()).unwrap();
let text = report(1, "test", &h);
assert!(text.contains("rustls is VIABLE"), "{text}");
let only_static = ClientHello {
cipher_suites: vec![0x009D, 0x002F],
..Default::default()
};
let text = report(1, "test", &only_static);
assert!(text.contains("RULED OUT"), "{text}");
}
#[test]
fn non_tls_input_is_declined_rather_than_misparsed() {
assert!(parse_client_hello(b"GET / HTTP/1.1\r\n\r\n").is_none());
assert!(parse_client_hello(&[]).is_none());
// A handshake record that is not a ClientHello.
assert!(parse_client_hello(&[0x16, 0x03, 0x01, 0x00, 0x04, 0x02, 0, 0, 0]).is_none());
}
#[test]
fn truncated_hello_does_not_panic() {
let full = synthetic_hello();
for cut in 1..full.len() {
let _ = parse_client_hello(&full[..cut]);
}
}
}
+430
View File
@@ -0,0 +1,430 @@
//! Opt-in raw Fire2 frame capture.
//!
//! **Evidence infrastructure, not protocol functionality.** Disabled unless
//! `OPENFUT_BLAZE_CAPTURE` names a file. Nothing in the dispatch path consults
//! it, and it never changes a byte, an ordering, or a session.
//!
//! # Why this exists
//!
//! The wire captures every RE finding cites are gitignored and gone from disk,
//! and a complete live FIFA Blaze session ran past during gates 5–6 without its
//! bytes being recorded. Those sessions cannot be reproduced: a later run is a
//! different session, and the migration-validation runs in particular happen
//! once.
//!
//! # Two layers
//!
//! ```text
//! live FIFA traffic
//! ├── raw capture exact RX/TX bytes, gitignored, mode 0600
//! └── blaze-sanitize → repository-safe, auditable fixtures
//! ```
//!
//! Raw captures are forensic evidence and may carry session material. They are
//! never committed and never printed to the ordinary log.
//!
//! # Format
//!
//! Deterministic and self-describing, big-endian throughout to match the
//! protocol.
//!
//! ```text
//! file header (20 bytes)
//! magic 8 "OFUTCAP1"
//! version u16
//! flags u16
//! created u64 unix seconds
//!
//! record (repeated)
//! len u32 bytes after this field
//! conn_id u64
//! seq u64 global, monotonic — total order across connections
//! ts_ms u64
//! dir u8 0 = RX (from client), 1 = TX (to client)
//! pad u8×3
//! frame_len u32
//! frame [frame_len] EXACT bytes, one whole Fire2 frame
//! ```
//!
//! Component, command, message number, message type and payload length are
//! deliberately **not** stored alongside the frame: they are already in its
//! 16-byte header, and a redundant copy can disagree with the bytes, leaving a
//! reader unable to tell which is true. The frame is authoritative and readers
//! derive those fields from it — [`Record::header`] does exactly that, so every
//! field named in the capture requirements is available without duplicating it.
use std::fs::File;
use std::io::{self, Read, Write};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use openfut_protocol_blaze::fire2::Header;
pub const MAGIC: &[u8; 8] = b"OFUTCAP1";
pub const VERSION: u16 = 1;
pub const FILE_HEADER_LEN: usize = 20;
/// Bytes of a record after its own length field, excluding the frame.
const RECORD_FIXED: usize = 8 + 8 + 8 + 1 + 3 + 4;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
/// Received from the client.
Rx,
/// Sent to the client.
Tx,
}
impl Direction {
pub fn as_byte(self) -> u8 {
match self {
Direction::Rx => 0,
Direction::Tx => 1,
}
}
pub fn from_byte(b: u8) -> Option<Direction> {
match b {
0 => Some(Direction::Rx),
1 => Some(Direction::Tx),
_ => None,
}
}
pub fn label(self) -> &'static str {
match self {
Direction::Rx => "RX",
Direction::Tx => "TX",
}
}
}
/// A capture sink. Cheap and inert when disabled.
pub struct Capture {
sink: Option<Mutex<File>>,
seq: AtomicU64,
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
impl Capture {
/// Disabled sink.
pub fn disabled() -> Capture {
Capture {
sink: None,
seq: AtomicU64::new(0),
}
}
/// Create a capture file, replacing any existing one.
///
/// Created mode 0600: these bytes are forensic evidence and may contain
/// session material.
pub fn create(path: &str) -> io::Result<Capture> {
let mut opts = File::options();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut file = opts.open(path)?;
let mut hdr = Vec::with_capacity(FILE_HEADER_LEN);
hdr.extend_from_slice(MAGIC);
hdr.extend_from_slice(&VERSION.to_be_bytes());
hdr.extend_from_slice(&0u16.to_be_bytes()); // flags
hdr.extend_from_slice(
&SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
.to_be_bytes(),
);
file.write_all(&hdr)?;
file.flush()?;
Ok(Capture {
sink: Some(Mutex::new(file)),
seq: AtomicU64::new(0),
})
}
pub fn enabled(&self) -> bool {
self.sink.is_some()
}
/// Record one whole frame.
///
/// Callers pass the exact bytes that crossed the socket, and call this
/// AFTER the I/O has happened — so a TX record means "these bytes were
/// written", not "these bytes were intended". Errors are swallowed
/// deliberately: a capture problem must never take down a live session that
/// is otherwise healthy.
pub fn record(&self, conn_id: u64, dir: Direction, frame: &[u8]) {
let Some(sink) = &self.sink else { return };
let seq = self.seq.fetch_add(1, Ordering::SeqCst);
let mut buf = Vec::with_capacity(4 + RECORD_FIXED + frame.len());
buf.extend_from_slice(&((RECORD_FIXED + frame.len()) as u32).to_be_bytes());
buf.extend_from_slice(&conn_id.to_be_bytes());
buf.extend_from_slice(&seq.to_be_bytes());
buf.extend_from_slice(&now_ms().to_be_bytes());
buf.push(dir.as_byte());
buf.extend_from_slice(&[0u8; 3]);
buf.extend_from_slice(&(frame.len() as u32).to_be_bytes());
buf.extend_from_slice(frame);
if let Ok(mut f) = sink.lock() {
let _ = f.write_all(&buf);
// Flushed per record: a crash mid-session must not cost the
// evidence collected so far. Volume is a few dozen frames per FIFA
// session, so this is not a hot path.
let _ = f.flush();
}
}
}
/// One captured frame.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Record {
pub conn_id: u64,
pub seq: u64,
pub ts_ms: u64,
pub dir: Direction,
/// Exact bytes as they crossed the socket.
pub frame: Vec<u8>,
}
impl Record {
/// Parse the Fire2 header from the captured bytes.
///
/// Component, command, msgNum, msgType, userIndex and payload length all
/// come from here rather than from stored copies.
pub fn header(&self) -> Option<Header> {
Header::parse(&self.frame).ok()
}
}
#[derive(Debug)]
pub enum ReadError {
NotACapture,
UnsupportedVersion(u16),
Truncated { at: usize, want: usize, have: usize },
Io(String),
}
impl std::fmt::Display for ReadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ReadError::NotACapture => write!(f, "not an OpenFUT capture (bad magic)"),
ReadError::UnsupportedVersion(v) => write!(f, "unsupported capture version {v}"),
ReadError::Truncated { at, want, have } => {
write!(
f,
"truncated capture at offset {at}: want {want} bytes, have {have}"
)
}
ReadError::Io(e) => write!(f, "io error: {e}"),
}
}
}
impl std::error::Error for ReadError {}
/// Read a whole capture. Fails clearly rather than returning partial data.
pub fn read_file(path: &str) -> Result<Vec<Record>, ReadError> {
let mut buf = Vec::new();
File::open(path)
.and_then(|mut f| f.read_to_end(&mut buf))
.map_err(|e| ReadError::Io(e.to_string()))?;
parse(&buf)
}
pub fn parse(buf: &[u8]) -> Result<Vec<Record>, ReadError> {
if buf.len() < FILE_HEADER_LEN || &buf[..8] != MAGIC {
return Err(ReadError::NotACapture);
}
let version = u16::from_be_bytes([buf[8], buf[9]]);
if version != VERSION {
return Err(ReadError::UnsupportedVersion(version));
}
let mut out = Vec::new();
let mut i = FILE_HEADER_LEN;
while i < buf.len() {
if i + 4 > buf.len() {
return Err(ReadError::Truncated {
at: i,
want: 4,
have: buf.len() - i,
});
}
let len = u32::from_be_bytes([buf[i], buf[i + 1], buf[i + 2], buf[i + 3]]) as usize;
i += 4;
if len < RECORD_FIXED || i + len > buf.len() {
return Err(ReadError::Truncated {
at: i,
want: len,
have: buf.len().saturating_sub(i),
});
}
let rec = &buf[i..i + len];
let conn_id = u64::from_be_bytes(rec[0..8].try_into().unwrap());
let seq = u64::from_be_bytes(rec[8..16].try_into().unwrap());
let ts_ms = u64::from_be_bytes(rec[16..24].try_into().unwrap());
let dir = Direction::from_byte(rec[24]).ok_or(ReadError::Truncated {
at: i + 24,
want: 1,
have: 1,
})?;
let frame_len = u32::from_be_bytes(rec[28..32].try_into().unwrap()) as usize;
if 32 + frame_len != len {
return Err(ReadError::Truncated {
at: i + 32,
want: frame_len,
have: len.saturating_sub(32),
});
}
out.push(Record {
conn_id,
seq,
ts_ms,
dir,
frame: rec[32..32 + frame_len].to_vec(),
});
i += len;
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use openfut_protocol_blaze::fire2::{Frame, MsgType};
fn tmp(name: &str) -> String {
let dir = std::env::var("TMPDIR").unwrap_or_else(|_| "/tmp".into());
format!("{dir}/ofcap-test-{}-{name}.ofcap", std::process::id())
}
#[test]
fn disabled_capture_writes_nothing() {
let c = Capture::disabled();
assert!(!c.enabled());
c.record(1, Direction::Rx, &[1, 2, 3]); // must not panic or create anything
}
#[test]
fn round_trips_frames_exactly_and_in_order() {
let path = tmp("roundtrip");
let c = Capture::create(&path).unwrap();
let a = Frame::new(0x0009, 0x0007, 1, MsgType::Message, vec![1, 2, 3]).encode();
let b = Frame::new(0x0009, 0x0007, 1, MsgType::Reply, vec![4, 5]).encode();
c.record(7, Direction::Rx, &a);
c.record(7, Direction::Tx, &b);
drop(c);
let recs = read_file(&path).unwrap();
assert_eq!(recs.len(), 2);
assert_eq!(recs[0].frame, a, "RX bytes must be exact");
assert_eq!(recs[1].frame, b, "TX bytes must be exact");
assert_eq!(recs[0].dir, Direction::Rx);
assert_eq!(recs[1].dir, Direction::Tx);
assert_eq!(recs[0].conn_id, 7);
assert!(recs[0].seq < recs[1].seq, "sequence preserves order");
// Metadata is derivable rather than stored.
let h = recs[0].header().unwrap();
assert_eq!(h.component, 0x0009);
assert_eq!(h.command, 0x0007);
assert_eq!(h.msg_type, MsgType::Message);
assert_eq!(h.payload_len, 3);
let _ = std::fs::remove_file(&path);
}
#[test]
fn interleaved_connections_keep_a_total_order() {
let path = tmp("interleaved");
let c = Capture::create(&path).unwrap();
for i in 0..6u64 {
let f = Frame::new(9, 2, i as u32, MsgType::Message, vec![i as u8]).encode();
c.record(i % 2, Direction::Rx, &f);
}
drop(c);
let recs = read_file(&path).unwrap();
assert_eq!(recs.len(), 6);
let seqs: Vec<u64> = recs.iter().map(|r| r.seq).collect();
assert_eq!(seqs, (0..6).collect::<Vec<_>>());
// And per-connection order is recoverable from the same total order.
let conn0: Vec<u8> = recs
.iter()
.filter(|r| r.conn_id == 0)
.map(|r| r.frame[16])
.collect();
assert_eq!(conn0, vec![0, 2, 4]);
let _ = std::fs::remove_file(&path);
}
#[cfg(unix)]
#[test]
fn capture_files_are_owner_only() {
use std::os::unix::fs::PermissionsExt;
let path = tmp("perms");
let _c = Capture::create(&path).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "captures may contain session material");
let _ = std::fs::remove_file(&path);
}
#[test]
fn rejects_a_file_that_is_not_a_capture() {
assert!(matches!(
parse(b"hello world padding.."),
Err(ReadError::NotACapture)
));
assert!(matches!(parse(b""), Err(ReadError::NotACapture)));
}
#[test]
fn rejects_an_unsupported_version() {
let mut buf = MAGIC.to_vec();
buf.extend_from_slice(&99u16.to_be_bytes());
buf.extend_from_slice(&0u16.to_be_bytes());
buf.extend_from_slice(&0u64.to_be_bytes());
assert!(matches!(
parse(&buf),
Err(ReadError::UnsupportedVersion(99))
));
}
#[test]
fn truncation_fails_clearly_rather_than_returning_partial_data() {
let path = tmp("truncated");
let c = Capture::create(&path).unwrap();
c.record(
1,
Direction::Rx,
&Frame::new(9, 2, 1, MsgType::Message, vec![7; 40]).encode(),
);
drop(c);
let full = std::fs::read(&path).unwrap();
for cut in [FILE_HEADER_LEN + 2, FILE_HEADER_LEN + 10, full.len() - 5] {
match parse(&full[..cut]) {
Err(ReadError::Truncated { .. }) => {}
other => panic!("cut at {cut} should be Truncated, got {other:?}"),
}
}
// The whole file still parses.
assert_eq!(parse(&full).unwrap().len(), 1);
let _ = std::fs::remove_file(&path);
}
}
+248
View File
@@ -0,0 +1,248 @@
//! Host configuration, entirely from the environment.
//!
//! Two rules carried over from the Python deployment:
//!
//! * **No silent loopback.** `OPENFUT_ADVERTISE` is required, exactly as the
//! Python entrypoint requires it. A backend that guesses its own reachable
//! address is the bug the client/server split removed.
//! * **No default port.** The sidecar runs beside the working Python container
//! and must never collide with it, so the listen port is explicit. There is
//! no "test port" constant anywhere in this crate.
use std::env;
use std::fmt;
use openfut_adapter_fifa17::blaze::config as adapter_config;
use openfut_adapter_fifa17::blaze::{AdapterConfig, Endpoints, Identity};
#[derive(Debug)]
pub struct ConfigError(String);
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for ConfigError {}
#[derive(Debug, Clone)]
pub struct HostConfig {
/// Address the listener binds.
pub listen_addr: String,
/// Port the listener binds. Required; no default.
pub listen_port: u16,
/// Seconds of inactivity before a connection is dropped. Matches the
/// oracle's 300s socket timeout.
pub idle_timeout_secs: u64,
/// Reject a frame claiming a larger payload than this, as the oracle does.
pub max_payload_bytes: u32,
/// Optional path for the normalized structural trace.
pub trace_path: Option<String>,
/// Optional path for the raw frame capture. Opt-in; forensic evidence.
pub capture_path: Option<String>,
/// What the adapter answers with.
pub adapter: AdapterConfig,
}
fn required(key: &str, why: &str) -> Result<String, ConfigError> {
match env::var(key) {
Ok(v) if !v.trim().is_empty() => Ok(v),
_ => Err(ConfigError(format!("{key} must be set — {why}"))),
}
}
fn optional_opt(key: &str) -> Option<String> {
env::var(key).ok().filter(|v| !v.trim().is_empty())
}
/// An optional numeric port. A present-but-invalid value is an ERROR, not a
/// silent fallback — a typo must not quietly leave the previous port in place.
fn optional_port(key: &str) -> Result<Option<u16>, ConfigError> {
match optional_opt(key) {
None => Ok(None),
Some(v) => v
.trim()
.parse()
.map(Some)
.map_err(|_| ConfigError(format!("{key} is not a valid port: {v:?}"))),
}
}
fn optional(key: &str, default: &str) -> String {
env::var(key)
.ok()
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| default.to_string())
}
impl HostConfig {
pub fn from_env() -> Result<HostConfig, ConfigError> {
// The address handed to the CLIENT for every next hop.
let advertise = required(
"OPENFUT_ADVERTISE",
"it is the address the game machine uses to reach this host; \
there is no loopback fallback in remote mode",
)?;
// NOTE: this is the *advertised-config* bind, not the listener bind.
// The adapter derives nucleusConnect from it, reproducing the oracle
// (see the adapter's config docs and the vault's known-issue entry), so
// it must mirror whatever the Python container runs with if the two are
// to be compared. The listener has its own setting below.
let config_bind = optional("OPENFUT_BIND", "127.0.0.1");
let listen_port_raw = required(
"OPENFUT_BLAZE_HOST_PORT",
"the sidecar runs beside the working Python backend and must not \
collide with it, so the port is explicit and has no default",
)?;
let listen_port: u16 = listen_port_raw.trim().parse().map_err(|_| {
ConfigError(format!(
"OPENFUT_BLAZE_HOST_PORT is not a valid port: {listen_port_raw:?}"
))
})?;
let listen_addr = optional("OPENFUT_BLAZE_HOST_BIND", &config_bind);
// Derived from the ADVERTISED address, never loopback. The deployed
// Python entrypoint does the same (`POW_HOST="${POW_HOST:-$ADV:8094}"`),
// and an independent loopback fallback here would leave a remote
// deployment emitting loopback POW URLs while every other URL was right
// — a failure that surfaces far from its cause.
let mut endpoints = Endpoints::advertising(&advertise);
endpoints.bind = config_bind;
if let Some(v) = optional_opt("POW_CONTENT_HOST") {
endpoints.pow_content_host = v;
}
if let Some(v) = optional_opt("POW_HOST") {
endpoints.pow_host = v;
}
if let Some(p) = optional_port("OPENFUT_BLAZE_ADVERTISED_PORT")? {
endpoints.blaze_port = p;
}
if let Some(p) = optional_port("OPENFUT_UTAS_PORT")? {
endpoints.utas_port = p;
}
Ok(HostConfig {
listen_addr,
listen_port,
idle_timeout_secs: optional("OPENFUT_BLAZE_IDLE_TIMEOUT", "300")
.parse()
.unwrap_or(300),
max_payload_bytes: 4 * 1024 * 1024,
trace_path: env::var("OPENFUT_BLAZE_TRACE")
.ok()
.filter(|v| !v.trim().is_empty()),
capture_path: env::var("OPENFUT_BLAZE_CAPTURE")
.ok()
.filter(|v| !v.trim().is_empty()),
adapter: AdapterConfig {
identity: Identity::default(),
endpoints,
server_version: adapter_config::DEFAULT_SERVER_VERSION.into(),
},
})
}
pub fn listen_on(&self) -> String {
format!("{}:{}", self.listen_addr, self.listen_port)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Env is process-global, so these run under one lock rather than as
/// separate tests that would race each other.
#[test]
fn env_contract() {
let keys = [
"OPENFUT_ADVERTISE",
"OPENFUT_BIND",
"OPENFUT_BLAZE_HOST_PORT",
"OPENFUT_BLAZE_HOST_BIND",
"POW_CONTENT_HOST",
"POW_HOST",
"OPENFUT_BLAZE_ADVERTISED_PORT",
"OPENFUT_UTAS_PORT",
];
let saved: Vec<_> = keys.iter().map(|k| (*k, env::var(k).ok())).collect();
for k in keys {
env::remove_var(k);
}
// Missing advertise is refused, not defaulted.
let err = HostConfig::from_env().unwrap_err().to_string();
assert!(err.contains("OPENFUT_ADVERTISE"), "{err}");
// Missing port is refused too — no default that could collide.
env::set_var("OPENFUT_ADVERTISE", "198.51.100.7");
let err = HostConfig::from_env().unwrap_err().to_string();
assert!(err.contains("OPENFUT_BLAZE_HOST_PORT"), "{err}");
// (2) A missing advertised address FAILS CLEARLY — never defaulted.
// Re-asserted here because it is the single most important rule:
// a backend that guesses its own reachable address advertises a
// wrong one to a remote client and fails far from the cause.
// A non-numeric port is a clear error, not a silent fallback.
env::set_var("OPENFUT_BLAZE_HOST_PORT", "not-a-port");
let err = HostConfig::from_env().unwrap_err().to_string();
assert!(err.contains("not a valid port"), "{err}");
// Happy path: listener bind defaults to the config bind.
env::set_var("OPENFUT_BLAZE_HOST_PORT", "42230");
env::set_var("OPENFUT_BIND", "0.0.0.0");
let cfg = HostConfig::from_env().expect("configured");
assert_eq!(cfg.listen_on(), "0.0.0.0:42230");
assert_eq!(cfg.adapter.endpoints.advertise, "198.51.100.7");
// The adapter's nucleus URL follows the CONFIG bind, reproducing the
// oracle's behaviour rather than the listener's address.
assert_eq!(cfg.adapter.nucleus_base(), "http://0.0.0.0:42131");
// The listener bind can differ from the advertised-config bind.
env::set_var("OPENFUT_BLAZE_HOST_BIND", "127.0.0.1");
let cfg = HostConfig::from_env().expect("configured");
assert_eq!(cfg.listen_on(), "127.0.0.1:42230");
assert_eq!(cfg.adapter.nucleus_base(), "http://0.0.0.0:42131");
// (1) POW endpoints DERIVE from advertise; no independent loopback
// fallback. This was a real defect: they defaulted to 127.0.0.1
// while every other URL followed the advertised address, so a
// remote deployment emitted loopback POW URLs.
env::remove_var("POW_CONTENT_HOST");
env::remove_var("POW_HOST");
env::set_var("OPENFUT_ADVERTISE", "198.51.100.7");
let cfg = HostConfig::from_env().expect("configured");
assert_eq!(cfg.adapter.endpoints.pow_content_host, "198.51.100.7:8080");
assert_eq!(cfg.adapter.endpoints.pow_host, "198.51.100.7:8094");
assert!(cfg.adapter.pow_content_url().contains("198.51.100.7"));
assert!(!cfg.adapter.pow_content_url().contains("127.0.0.1"));
// Explicit overrides still win (the deployment remaps POW content).
env::set_var("POW_CONTENT_HOST", "203.0.113.42:8085");
let cfg = HostConfig::from_env().expect("configured");
assert_eq!(cfg.adapter.endpoints.pow_content_host, "203.0.113.42:8085");
env::remove_var("POW_CONTENT_HOST");
// (4) The advertised Blaze port is configurable, and a bad value is an
// error rather than a silent fallback to the old one.
env::set_var("OPENFUT_BLAZE_ADVERTISED_PORT", "42999");
let cfg = HostConfig::from_env().expect("configured");
assert_eq!(cfg.adapter.endpoints.blaze_port, 42999);
env::set_var("OPENFUT_BLAZE_ADVERTISED_PORT", "not-a-port");
let err = HostConfig::from_env().unwrap_err().to_string();
assert!(err.contains("not a valid port"), "{err}");
env::remove_var("OPENFUT_BLAZE_ADVERTISED_PORT");
for (k, v) in saved {
match v {
Some(v) => env::set_var(k, v),
None => env::remove_var(k),
}
}
}
}
+281
View File
@@ -0,0 +1,281 @@
//! Per-connection Fire2 stream handling.
//!
//! This is the layer fixtures cannot test: a socket delivers bytes, not frames.
//! Frames arrive split across reads and coalesced into one, several arrive per
//! connection, and the connection outlives every individual RPC.
//!
//! The loop mirrors the Python oracle's exactly — read a 16-byte header, derive
//! the total length from it, read the rest, consume, dispatch, write every
//! returned frame in order — because that behaviour is part of what was proven
//! against the real client.
use std::io::{self, Read, Write};
use std::net::TcpStream;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use openfut_adapter_fifa17::blaze::{Adapter, Session};
use openfut_protocol_blaze::fire2::{Frame, Header, HEADER_LEN};
use openfut_protocol_blaze::heat2::{self, Struct};
use crate::capture::{Capture, Direction};
use crate::config::HostConfig;
use crate::trace::{self, Tracer};
use crate::Hooks;
/// Why a connection ended. Logged so a live run can be compared with Python's.
#[derive(Debug, PartialEq, Eq)]
pub enum CloseReason {
/// Client closed cleanly between frames.
ClientClosed,
/// Stream ended part-way through a frame.
EofMidFrame { want: usize, have: usize },
/// A header claimed a payload larger than the configured ceiling.
AbsurdPayload { claimed: u32 },
/// No bytes within the idle timeout.
IdleTimeout,
/// Socket error.
Io(String),
}
impl std::fmt::Display for CloseReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CloseReason::ClientClosed => write!(f, "client closed"),
CloseReason::EofMidFrame { want, have } => {
write!(f, "EOF mid-frame (want {want}, have {have})")
}
CloseReason::AbsurdPayload { claimed } => {
write!(f, "absurd payload_len {claimed}, dropping connection")
}
CloseReason::IdleTimeout => write!(f, "idle timeout"),
CloseReason::Io(e) => write!(f, "io error: {e}"),
}
}
}
pub(crate) fn unix_now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// Mint a Blaze-shaped session key.
///
/// The client never validates the format, so this only has to be stable within
/// a connection and distinct between them.
pub(crate) fn mint_session_key() -> String {
use rand::Rng;
let mut rng = rand::thread_rng();
const ALPHA: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789$*";
let tail: String = (0..44)
.map(|_| ALPHA[rng.gen_range(0..ALPHA.len())] as char)
.collect();
openfut_adapter_fifa17::blaze::session::format_session_key(rng.gen::<u64>(), &tail)
}
/// Serve one connection until it closes.
pub fn handle(
mut stream: TcpStream,
conn_id: u64,
cfg: &HostConfig,
adapter: &Adapter,
tracer: &Tracer,
hooks: &Hooks,
capture: &Capture,
) -> CloseReason {
let peer = stream
.peer_addr()
.map(|a| a.to_string())
.unwrap_or_else(|_| "<unknown>".into());
let _ = stream.set_read_timeout(Some(Duration::from_secs(cfg.idle_timeout_secs)));
// Blaze is request/response with server pushes; Nagle would add latency to
// every burst for no benefit.
let _ = stream.set_nodelay(true);
let mut session = Session::new((hooks.session_key)(), cfg.adapter.identity.account_locale);
trace::log(&format!(
"conn-{conn_id:04} CONNECT from {peer} (session key minted: [REDACTED])"
));
tracer.write_line(&format!("conn-{conn_id:04} OPEN"));
let mut buf: Vec<u8> = Vec::with_capacity(16 * 1024);
let mut frame_no: u64 = 0;
let reason = loop {
// ---- header
match fill_to(&mut stream, &mut buf, HEADER_LEN) {
Ok(true) => {}
Ok(false) => {
break if buf.is_empty() {
CloseReason::ClientClosed
} else {
CloseReason::EofMidFrame {
want: HEADER_LEN,
have: buf.len(),
}
};
}
Err(e) => break classify(e),
}
let header = match Header::parse(&buf[..HEADER_LEN]) {
Ok(h) => h,
// Unreachable: fill_to guaranteed 16 bytes. Treated as a close
// rather than a panic because this is a network path.
Err(e) => break CloseReason::Io(e.to_string()),
};
if header.payload_len > cfg.max_payload_bytes {
trace::log(&format!(
"conn-{conn_id:04} REJECT payload_len {} exceeds {} — {}",
header.payload_len,
cfg.max_payload_bytes,
hex_prefix(&buf)
));
break CloseReason::AbsurdPayload {
claimed: header.payload_len,
};
}
// ---- body
let total = header.frame_len();
match fill_to(&mut stream, &mut buf, total) {
Ok(true) => {}
Ok(false) => {
break CloseReason::EofMidFrame {
want: total,
have: buf.len(),
}
}
Err(e) => break classify(e),
}
let (frame, used) = match Frame::parse(&buf[..total]) {
Ok(v) => v,
Err(e) => break CloseReason::Io(e.to_string()),
};
buf.drain(..used);
frame_no += 1;
// Capture the exact bytes as received, before anything interprets them.
// `&buf[..total]` was already consumed above, so re-encode from the
// parsed frame — which is byte-identical by construction and covered by
// the protocol crate's round-trip tests.
capture.record(conn_id, Direction::Rx, &frame.encode());
trace::log(&format!(
"conn-{conn_id:04} RX #{frame_no} {}",
trace::describe(&frame.header)
));
tracer.frame(conn_id, "RX", &frame_no.to_string(), &frame);
// A body that will not decode is NOT fatal: the oracle logs it and
// dispatches with no fields, letting the RPC fall back to defaults.
// An empty Struct is equivalent to the oracle's `None` on every path
// dispatch takes (verified: every read is guarded or defaulted).
let body = if frame.payload.is_empty() {
Struct::new()
} else {
match heat2::decode(&frame.payload) {
Ok(s) => s,
Err(e) => {
trace::log(&format!(
"conn-{conn_id:04} RX #{frame_no} TDF DECODE FAILED: {e}"
));
Struct::new()
}
}
};
let out = adapter.dispatch(&frame.header, &body, &mut session, (hooks.now)());
for (k, resp) in out.iter().enumerate() {
let bytes = resp.encode();
if let Err(e) = stream.write_all(&bytes) {
trace::log(&format!("conn-{conn_id:04} TX #{frame_no}.{k} FAILED: {e}"));
break;
}
// AFTER the write: a TX record means these bytes were sent, not
// merely intended. Capturing here also keeps the client's latency
// untouched — it already has the bytes.
//
// NOT COVERED BY A TEST: moving this above the write is
// indistinguishable while writes succeed, and only diverges when
// one fails (it would record a frame that never reached the
// client). Mutation-tested and confirmed undetected. The invariant
// is held by this placement and this comment; do not move it.
capture.record(conn_id, Direction::Tx, &bytes);
trace::log(&format!(
"conn-{conn_id:04} TX #{frame_no}.{k} {} ({}B total)",
trace::describe(&resp.header),
bytes.len()
));
tracer.frame(conn_id, "TX", &format!("{frame_no}.{k}"), resp);
}
if let Err(e) = stream.flush() {
break CloseReason::Io(e.to_string());
}
};
trace::log(&format!(
"conn-{conn_id:04} CLOSE after {frame_no} frame(s): {reason}"
));
tracer.write_line(&format!("conn-{conn_id:04} CLOSE frames={frame_no}"));
reason
}
/// Read until `buf` holds at least `n` bytes. `Ok(false)` on clean EOF.
fn fill_to(stream: &mut TcpStream, buf: &mut Vec<u8>, n: usize) -> io::Result<bool> {
let mut chunk = [0u8; 65536];
while buf.len() < n {
match stream.read(&mut chunk) {
Ok(0) => return Ok(false),
Ok(got) => buf.extend_from_slice(&chunk[..got]),
Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e),
}
}
Ok(true)
}
fn classify(e: io::Error) -> CloseReason {
match e.kind() {
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut => CloseReason::IdleTimeout,
_ => CloseReason::Io(e.to_string()),
}
}
fn hex_prefix(buf: &[u8]) -> String {
buf.iter()
.take(16)
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn minted_keys_have_the_blaze_shape_and_differ() {
let a = mint_session_key();
let b = mint_session_key();
assert_eq!(a.len(), 16 + 1 + 44);
assert_ne!(a, b, "a session key must be distinct per connection");
assert!(a[..16].chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(&a[16..17], "_");
}
#[test]
fn close_reasons_render_readably() {
assert!(CloseReason::ClientClosed.to_string().contains("closed"));
assert!(CloseReason::EofMidFrame { want: 20, have: 5 }
.to_string()
.contains("want 20"));
assert!(CloseReason::AbsurdPayload { claimed: 99 }
.to_string()
.contains("99"));
}
}

Some files were not shown because too many files have changed in this diff Show More