Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf6db98f0d | |||
| fc55de19fa | |||
| 6ae3364bd0 | |||
| 5c40b4993f | |||
| fbc0da2a1b | |||
| 750d6c2e18 | |||
| 082246c085 | |||
| 16771b0b33 | |||
| 022634704a | |||
| 96ca7c0484 | |||
| 202366611e | |||
| ea92057e53 | |||
| c71593b286 | |||
| 413ad901fb | |||
| e0e46d8a57 | |||
| fbe29da05b | |||
| 9ffbd651b1 | |||
| aa5fb2cc40 | |||
| 468bc0fba9 | |||
| 571c5f9261 | |||
| cb32fe9b84 | |||
| fbe9804d3e | |||
| f6606accb3 | |||
| 0a007f4941 | |||
| 0c4aee6164 | |||
| a57f4930f0 | |||
| f9ca901a50 | |||
| 3ce69f8951 | |||
| acd1def00d | |||
| 5ec9c7f8bf | |||
| 11c028e6eb | |||
| afadb13de4 | |||
| b6398c44e6 | |||
| a2bd048ace | |||
| 2e97ff1461 | |||
| 7f37b37be3 | |||
| 4e31fb98a2 | |||
| 6cc22e5cc5 |
Generated
+43
@@ -114,6 +114,17 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
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]]
|
[[package]]
|
||||||
name = "ahash"
|
name = "ahash"
|
||||||
version = "0.8.12"
|
version = "0.8.12"
|
||||||
@@ -810,6 +821,16 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "clipboard-win"
|
name = "clipboard-win"
|
||||||
version = "5.4.1"
|
version = "5.4.1"
|
||||||
@@ -2307,6 +2328,15 @@ dependencies = [
|
|||||||
"hashbrown 0.17.1",
|
"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]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.12.1"
|
version = "2.12.1"
|
||||||
@@ -3118,6 +3148,10 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openfut-autopatch"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openfut-blaze-host"
|
name = "openfut-blaze-host"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -3232,11 +3266,20 @@ dependencies = [
|
|||||||
"eframe",
|
"eframe",
|
||||||
"egui",
|
"egui",
|
||||||
"openfut-common",
|
"openfut-common",
|
||||||
|
"parking_lot",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openfut-lsx"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"aes",
|
||||||
|
"parking_lot",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openfut-protocol-blaze"
|
name = "openfut-protocol-blaze"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ members = [
|
|||||||
"openfut-import-fifa17",
|
"openfut-import-fifa17",
|
||||||
"openfut-bridge",
|
"openfut-bridge",
|
||||||
"openfut-launcher",
|
"openfut-launcher",
|
||||||
|
# The two companion services the launcher used to shell out to Python for.
|
||||||
|
"openfut-lsx",
|
||||||
|
"openfut-autopatch",
|
||||||
"fifa-blaze/crates/blaze-proto",
|
"fifa-blaze/crates/blaze-proto",
|
||||||
"fifa-blaze/crates/server",
|
"fifa-blaze/crates/server",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
# FIFA 17 FUT — Card Taxonomy (single source of truth)
|
|
||||||
|
|
||||||
Status: verified 2026-08-12. This document supersedes every earlier scattered
|
|
||||||
taxonomy claim in the repo (see "Superseded taxonomy" at the bottom).
|
|
||||||
|
|
||||||
Evidence labels: **OBSERVED** = read directly from a shipped table or decompiled
|
|
||||||
function; **INFERRED** = derived from OBSERVED facts; **HYPOTHESIS** = plausible,
|
|
||||||
not yet proven. `UNKNOWN` is a valid answer and is stated as such.
|
|
||||||
|
|
||||||
## Provenance
|
|
||||||
|
|
||||||
- Authoritative tables: `10.10.0.105:/home/alex/Documents/OpenFUT/fifa17-recon/data/tables/`,
|
|
||||||
copied byte-identical into this repo at `fifa17-recon/data/tables/`.
|
|
||||||
SHA-256 manifest + verification: `docs/evidence/fifa17-recon/table-hashes.sha256`
|
|
||||||
(36 files: 31 `fcc_*.json` + 5 staff tables; combined hash-of-hashes
|
|
||||||
`10f239add919089354d8dbff873fc9737b0a0f80f6ac41b1aa2a096c0ec8d331`).
|
|
||||||
- Each table file is a DECODED dump `{table, source, rowcount, schema[], rows[]}`;
|
|
||||||
card instances are in `rows[]`, one object per card. Row counts and id ranges
|
|
||||||
below were re-extracted from the in-repo `.120` copies.
|
|
||||||
- Semantic (subtype→kind) labels are decompiler-derived, carried in
|
|
||||||
`fifa17-recon/tools/fut_consumables.py` (`BY_SUBTYPE`, `_DOC`), generated by
|
|
||||||
`tools/build_consumables.py` from `FUN_1800d8330`, `FUN_18013f4d0`,
|
|
||||||
`FUN_1801bfac0`, `FUN_1801aa230`, `FUN_180048780` + the fcc tables.
|
|
||||||
- Staff family selection: `fifa17-recon/docs/CARD_SYSTEM.md` (2026-08-04/05 blocks)
|
|
||||||
and `fifa17-recon/tools/fut_staff.py` / `fut_coaches.py`.
|
|
||||||
|
|
||||||
## Family selector (OBSERVED, binary)
|
|
||||||
|
|
||||||
Before registering an item, `FUN_180141660` merges the client's local DB. It
|
|
||||||
switches on record `+0x4c` (`cardtype`), which `FUN_1800d8330` derives from JSON
|
|
||||||
atom `0x6c cardsubtypeid` alone:
|
|
||||||
|
|
||||||
```
|
|
||||||
cardsubtypeid 0..3 -> cardtype 1 players
|
|
||||||
4 -> cardtype 2 managercards
|
|
||||||
5 -> cardtype 3 headcoachcards
|
|
||||||
6 -> cardtype 10 gkcoachcards
|
|
||||||
7 -> cardtype 5 physiocards
|
|
||||||
8 -> cardtype 4 fitnesscoachcards
|
|
||||||
0x1e,0x1f,0x91..0x96,0xe7..0xe9,0xec -> cardtype 9 club items + misc
|
|
||||||
absent (default 342) -> cardtype 0 no merge
|
|
||||||
```
|
|
||||||
|
|
||||||
Key: `carddbid == resourceId` for non-player families (queried RAW, no mask);
|
|
||||||
players are queried by `playerid = resourceId & 0xffffff` (atom `0x287`), and
|
|
||||||
`assetId` (atom `0x23`) is never read by the merge.
|
|
||||||
|
|
||||||
## Consumable & staff families — evidence table (OBSERVED, `.120` tables)
|
|
||||||
|
|
||||||
| Family | Table | Rows | carddbid range | cardsubtype set | cardassetid (ART) |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| Contracts | `fcc_contractcards.json` | 13 | 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.
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
# openfut-core — Correctness Issues (audit 2026-08-17)
|
|
||||||
|
|
||||||
Read-only audit of `openfut-core` (game-agnostic axum + SQLite/sqlx economy authority).
|
|
||||||
Four reported issue classes confirmed with exact `file:line` evidence, **plus a bonus
|
|
||||||
HIGH-severity SBC duplicate-card economy exploit**. Nothing here is fixed yet —
|
|
||||||
fixing bumps Core off the frozen P1 reference (`fbb54ea`, the current known-good
|
|
||||||
production Core) and one item needs a DB migration + prod backfill, so this needs a
|
|
||||||
**go/no-go** before rebuild+redeploy.
|
|
||||||
|
|
||||||
> **Zero live users right now**, so the HIGH-severity economy exploits are not
|
|
||||||
> currently exploitable — but they are the exact class (coin overspend + card
|
|
||||||
> duplication) that crashed live clients earlier (project memory), so they should be
|
|
||||||
> fixed before any real play.
|
|
||||||
|
|
||||||
## Architecture context (why the bugs cluster)
|
|
||||||
|
|
||||||
Two generations of economy code coexist:
|
|
||||||
- **NEW** `services/economy.rs` + `routes/economy.rs` — **fully atomic + validated**:
|
|
||||||
every compound op acquires a connection, `BEGIN IMMEDIATE`, composes
|
|
||||||
transaction-scoped primitives (`debit`/`credit`/`add_item`/`remove_item`/
|
|
||||||
`consume_entitlement`), commits/rolls back via `finish()`, rejects negative amounts,
|
|
||||||
and has an extensive in-module test suite. **This is the reference fix pattern.**
|
|
||||||
- **OLD** per-feature services (`club`, `pack`, `market`, `sbc`, `checkin`, `upgrades`,
|
|
||||||
`match_service`, `season`) — predate it, still do read/check/write directly against
|
|
||||||
the `&Pool` with each statement on its own connection: no transaction. `economy.rs`'s
|
|
||||||
own module doc explicitly warns these "cannot offer that guarantee".
|
|
||||||
|
|
||||||
Issues 2 and 3 live entirely in the OLD generation; the fix is to route them through
|
|
||||||
`economy.rs`'s proven atomic ops (or give the pool helpers `&mut SqliteConnection`
|
|
||||||
transactional variants). Request flow is `X-OpenFUT-Game` header → active profile →
|
|
||||||
club → service; clients never pass a `club_id`, so cross-club access is **not** a
|
|
||||||
vector — the risks are intra-club concurrency + unvalidated payloads.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Issue 1 — `sbc_submissions` missing `club_id` (milestone always 0) · **LOW**
|
|
||||||
|
|
||||||
- **Root cause:** `migrations/0001_initial.sql:96-102` creates `sbc_submissions(id,
|
|
||||||
profile_id, sbc_id, submitted_card_ids, passed, submitted_at)` — no `club_id`, and no
|
|
||||||
later migration adds one. But `src/routes/club.rs:94-99` (`get_milestones`) runs
|
|
||||||
`SELECT COUNT(*) FROM sbc_submissions WHERE club_id = ? AND passed = 1`. SQLite errors
|
|
||||||
`no such column: club_id`; the error is swallowed by `.unwrap_or(0)` → the
|
|
||||||
`sbcs_completed` milestone is **always 0**. Writer `services/sbc.rs:74-83` inserts
|
|
||||||
`profile_id`, not `club_id`.
|
|
||||||
- **Impact:** wrong milestone stat only. No crash, no economy corruption.
|
|
||||||
- **Fix (matches the ticket — needs migration + backfill):** new migration `0019`:
|
|
||||||
`ALTER TABLE sbc_submissions ADD COLUMN club_id TEXT;` then backfill
|
|
||||||
`UPDATE sbc_submissions SET club_id = (SELECT c.id FROM clubs c WHERE c.profile_id =
|
|
||||||
sbc_submissions.profile_id);` and bind `club_id` in `sbc.rs:submit_sbc`'s INSERT
|
|
||||||
(`club_id` is already a param at `sbc.rs:41`).
|
|
||||||
- **Simpler alternative (no migration):** change the `club.rs:95` query to
|
|
||||||
`WHERE profile_id = ?` (column already exists). Ticket asks for the column, so both
|
|
||||||
are recorded.
|
|
||||||
- **Migration required:** YES (for the ticket's fix); NO (for the alternative).
|
|
||||||
|
|
||||||
## Issue 2 — Non-atomic check-then-act economy mutations (TOCTOU) · **HIGH**
|
|
||||||
|
|
||||||
Each does read → check → write across multiple pool round-trips with no
|
|
||||||
`BEGIN IMMEDIATE`, so concurrent requests race → overspend / duplication / double reward:
|
|
||||||
|
|
||||||
| Site | Race |
|
|
||||||
|---|---|
|
|
||||||
| `services/club.rs:88-115` `spend_coins` | SELECT coins → `if balance<amount` → UPDATE; two concurrent spends both pass → **overspend / negative balance**. Shared primitive used by all callers below. |
|
|
||||||
| `services/pack.rs:58-141` `open_pack` | read `pack.opened` → INSERT cards loop → UPDATE opened=1; concurrent double-open → **card duplication** |
|
|
||||||
| `services/pack.rs:144-160` `buy_pack` | `spend_coins` then `grant_pack`, separate ops → crash between = coins gone, no pack |
|
|
||||||
| `services/market.rs:129-186` `buy_listing` | SELECT sold=0 → spend → UPDATE sold=1 → INSERT; concurrent double-buy → **two cards minted** |
|
|
||||||
| `services/market.rs:188+` `sell_card` | SELECT owned → DELETE → add_coins; concurrent double-sell → **double credit** |
|
|
||||||
| `services/sbc.rs:35-107` `submit_sbc` | validate → DELETE cards → INSERT submission → add_coins/grant_pack; same cards to two SBCs → **double reward** |
|
|
||||||
| `services/checkin.rs:60-120` `claim` | SELECT last → same-day check → reward → INSERT; concurrent → **double claim** |
|
|
||||||
| `services/upgrades.rs:64-95` `change_position` | fetch → spend_coins → UPDATE |
|
|
||||||
| `services/match_service.rs:97-235` + `season.rs` reward | many sequential writes; partial failure leaves partial rewards |
|
|
||||||
|
|
||||||
- **Impact:** corrupts economy state (coin overspend + card duplication).
|
|
||||||
- **Fix:** wrap each compound op in one `BEGIN IMMEDIATE`…`finish()` transaction
|
|
||||||
exactly as `services/economy.rs:214-236` already does; route pack/market/sbc/checkin/
|
|
||||||
upgrades through `economy.rs`'s composed atomic ops (or add `&mut SqliteConnection`
|
|
||||||
variants of `spend_coins`/`add_coins`/`grant_pack`/`add_item`).
|
|
||||||
- **Migration required:** NO (code-only).
|
|
||||||
|
|
||||||
## Issue 3 — Missing input validation: SBC duplicate-card exploit · **HIGH**
|
|
||||||
|
|
||||||
- **Root cause:** `services/sbc.rs:53-70` iterates `req.owned_card_ids` with **no dedup
|
|
||||||
and no length bound**. The same `owned_card_id` repeated N times resolves the same card
|
|
||||||
N times (each `fetch_optional` succeeds); `validate_sbc` (`sbc.rs:110-116`) counts it
|
|
||||||
toward `squad_size` and passes; the DELETE loop deletes it once → **a user satisfies
|
|
||||||
any SBC with ONE card duplicated → free rewards**. Entry point `routes/sbc.rs:30-49`
|
|
||||||
forwards `req` unvalidated. Unbounded Vec length is also a DoS.
|
|
||||||
- **Fix:** in `submit_sbc`, reject duplicate ids (collect into a `HashSet`, compare
|
|
||||||
`len`) and bound the list (e.g. ≤ 30) before resolving → `AppError::BadRequest`.
|
|
||||||
- **Already-good validation (no change):** `economy.rs:64-67,84-87` reject negative
|
|
||||||
amounts; `match_service.rs:100-104` clamps goals 0..99; `upgrades.rs` validates
|
|
||||||
boost 1..3 / positions. Minor: `routes/economy.rs post_grant_reward` forwards an
|
|
||||||
unbounded `amount` (trusted host caller; add an upper-bound sanity guard).
|
|
||||||
- **Migration required:** NO.
|
|
||||||
|
|
||||||
## Issue 4 — `season.rs` panics + checkin index panic · **LOW**
|
|
||||||
|
|
||||||
- **Root cause:** `services/season.rs` `.expect()` on `fetch_optional` Options at
|
|
||||||
`:23` (`get_or_create`), `:69` and `:144` (`record_match`) — panic if the `seasons`
|
|
||||||
row is absent when expected. `seasons.profile_id` is PRIMARY KEY
|
|
||||||
(`migrations/0006_seasons_loans_packs.sql:2`), so the concurrent-insert case surfaces
|
|
||||||
as a UNIQUE error via `?` (not the panic), lowering probability — but it still
|
|
||||||
panics-on-invariant, aborting that request (axum → 500 for the request; not a full
|
|
||||||
server crash).
|
|
||||||
- **Secondary:** `services/checkin.rs:~52,~88` index `STREAK_COINS[idx]` with
|
|
||||||
`idx = ((streak-1)%7) as usize`; Rust `%` can be negative → a corrupt/negative
|
|
||||||
persisted `streak_day` yields a negative index → **panic**.
|
|
||||||
- **Fix:** replace each `.expect(...)` with
|
|
||||||
`.ok_or_else(|| AppError::Internal("season row missing".into()))?`; guard the checkin
|
|
||||||
index with `.rem_euclid(7)` (or clamp `streak_day >= 1` on read).
|
|
||||||
- **Migration required:** NO.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fix plan summary
|
|
||||||
|
|
||||||
| Issue | Severity | Migration | Files |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 sbc_submissions club_id | LOW | YES (or none via alt) | `migrations/0001` (+new 0019), `routes/club.rs`, `services/sbc.rs` |
|
|
||||||
| 2 non-atomic mutations | HIGH | NO | `services/{club,pack,market,sbc,checkin,upgrades,match_service,season}.rs` → route through `services/economy.rs` |
|
|
||||||
| 3 SBC duplicate-card exploit | HIGH | NO | `services/sbc.rs`, `routes/sbc.rs` |
|
|
||||||
| 4 season/checkin panics | LOW | NO | `services/season.rs`, `services/checkin.rs` |
|
|
||||||
|
|
||||||
**Recommended order:** 3 (smallest, highest-value: kills the free-reward exploit) → 2
|
|
||||||
(the transactional refactor, largest) → 4 (defensive hygiene) → 1 (cosmetic; do with
|
|
||||||
the alt query unless the column is wanted).
|
|
||||||
|
|
||||||
**Deployment note:** all of these change `openfut-core`, which is currently frozen at
|
|
||||||
the P1 reference (`fbb54ea`) in production. Fixing + rebuilding + redeploying prod-core
|
|
||||||
is a deliberate step off that reference — get a go/no-go first. Only Issue 1's
|
|
||||||
column-add needs a migration + prod backfill; 2/3/4 are code-only.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Resolution (2026-08-17, on `openfut-core` @ `fbb54ea` + these edits)
|
|
||||||
|
|
||||||
All four issue classes fixed in the canonical superproject submodule
|
|
||||||
`openfut-core`; full test suite green (179 tests) + clippy clean + a new
|
|
||||||
regression test `tests/integration_test.rs::test_sbc_rejects_duplicate_cards`.
|
|
||||||
|
|
||||||
| Issue | Fix | Files |
|
|
||||||
|---|---|---|
|
|
||||||
| 3 SBC dup-card exploit | dedup (`HashSet`) + `MAX_SBC_CARDS`=30 bound in `submit_sbc`, before card resolution → `BadRequest` | `services/sbc.rs` |
|
|
||||||
| 1 sbc_submissions club_id | migration `0019` adds `club_id` + backfills from `clubs`; `submit_sbc` INSERT now binds `club_id` | `migrations/0019_*.sql`, `services/sbc.rs` |
|
|
||||||
| 4 season/checkin panics | `.expect()` → `.ok_or_else(AppError::Internal)?` (3 sites); checkin index `% 7` → `.rem_euclid(7)` (2 sites) | `services/season.rs`, `services/checkin.rs` |
|
|
||||||
| 2 non-atomic mutations | statement-level compare-and-swap (see below) | `services/{club,pack,market,checkin}.rs` |
|
|
||||||
|
|
||||||
### Issue 2 — how it was fixed, and the residual
|
|
||||||
|
|
||||||
Rather than the full transaction refactor (threading `&mut SqliteConnection`
|
|
||||||
through every service), the concurrency-exploitable races were closed with
|
|
||||||
single-statement **compare-and-swap** — the atomic unit SQLite already gives us,
|
|
||||||
no transaction plumbing, minimal blast radius on the working prod economy path:
|
|
||||||
|
|
||||||
- `club::spend_coins` — `UPDATE … SET coins = coins - ? WHERE id = ? AND coins >= ?`
|
|
||||||
+ `rows_affected` guard; also rejects negative amounts. Kills **overspend** for
|
|
||||||
every caller (the shared root primitive).
|
|
||||||
- `pack::open_pack` — claims the pack (`UPDATE … opened = 1 WHERE … AND opened = 0`)
|
|
||||||
**before** minting cards; loser aborts. Kills **card duplication** via double-open.
|
|
||||||
- `market::buy_listing` — claims the listing (`sold 0→1`) before charging; releases
|
|
||||||
the claim if the debit fails. Kills **double-mint**.
|
|
||||||
- `market::sell_card` — `DELETE … WHERE id = ? AND club_id = ?` + `rows_affected`
|
|
||||||
guard before crediting. Kills **double-credit** via double-sell.
|
|
||||||
- `checkin::claim` — conditional `INSERT … SELECT … WHERE NOT EXISTS (today's row)`
|
|
||||||
+ `rows_affected` guard; pays out only if the claim landed. Kills **double-claim**.
|
|
||||||
|
|
||||||
**Residual (accepted, documented):** the *multi-statement all-or-nothing* edges that
|
|
||||||
need a real transaction to close — `pack::buy_pack` (spend then grant: a crash between
|
|
||||||
loses coins with no pack), `sbc::submit_sbc` (concurrent submits sharing cards could
|
|
||||||
double-consume mid-loop), and `match_service`/`season` reward chains (partial writes on
|
|
||||||
crash). These are **partial-failure durability edges, not statement-level races**, and
|
|
||||||
require concurrency that a single-player FIFA17 client does not generate. Closing them
|
|
||||||
is the `&mut SqliteConnection` transaction refactor originally proposed; deferred as
|
|
||||||
low-value for single-player. Overspend + duplication + double-credit — the vectors that
|
|
||||||
corrupt economy state — are all closed.
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# FIFA17 Content Completeness (33-record gap)
|
|
||||||
|
|
||||||
Evidence: ContentGapMap scout vs import-input.json (persona 33068179, 1995 items),
|
|
||||||
fifa17-recon/data/tables, manifest/fifa17-import-manifest.json (OBSERVED).
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
| Category | Expected | Resolvable | Unrecoverable |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Legend players | 13 instances (10 defs) | 0 | 13 (need .105 Legends locale names) |
|
|
||||||
| Consumables | 17 | 17 | 0 |
|
|
||||||
| Staff | 3 | 3 | 0 |
|
|
||||||
|
|
||||||
## Consumables (17/17 RESOLVABLE — carddbid present in fcc tables; semantics from cardsubtypeid)
|
|
||||||
| wire_id | resourceId | subtype | kind | table |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 100000239 | 5003012 | 54 | gk_training | fcc_trainingcards |
|
|
||||||
| 100000249 | 5003011 | 54 | gk_training | fcc_trainingcards |
|
|
||||||
| 100000260 | 5003004 | 52 | gk_training | fcc_trainingcards |
|
|
||||||
| 100000272 | 5003059 | 91 | position/playstyle mod | fcc_trainingcards |
|
|
||||||
| 100000250 | 5003060 | 92 | position/playstyle mod | fcc_trainingcards |
|
|
||||||
| 100000327 | 5003065 | 97 | position/playstyle mod | fcc_trainingcards |
|
|
||||||
| 100000238 | 5003066 | 98 | position/playstyle mod | fcc_trainingcards |
|
|
||||||
| 100000261 | 5003068 | 100 | position/playstyle mod | fcc_trainingcards |
|
|
||||||
| 100000316 | 5003068 | 100 | position/playstyle mod | fcc_trainingcards |
|
|
||||||
| 100000293 | 5003103 | 258 | player_playstyle | fcc_trainingcards |
|
|
||||||
| 100000326 | 5003112 | 267 | player_playstyle | fcc_trainingcards |
|
|
||||||
| 100000283 | 5003116 | 271 | gk_playstyle | fcc_trainingcards |
|
|
||||||
| 100000294 | 5001004 | 201 | player_contract | fcc_contractcards |
|
|
||||||
| 100000304 | 5001008 | 202 | manager_contract | fcc_contractcards |
|
|
||||||
| 100000305 | 5001009 | 202 | manager_contract | fcc_contractcards |
|
|
||||||
| 100000315 | 5002027 | 217 | healing | fcc_healingcards |
|
|
||||||
| 100000426 | 5002013 | 213 | healing | fcc_healingcards |
|
|
||||||
|
|
||||||
## Staff (3/3 RESOLVABLE)
|
|
||||||
| wire_id | resourceId | subtype | role | table |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| 100000271 | 3000083 | 8 | fitnesscoach | fitnesscoachcards |
|
|
||||||
| 100000427 | 3000083 | 8 | fitnesscoach | fitnesscoachcards |
|
|
||||||
| 100000282 | 9000081 | 6 | gkcoach | gkcoachcards |
|
|
||||||
|
|
||||||
## Legends (13 instances / 10 defs — UNRECOVERABLE from .120)
|
|
||||||
All defer via NoName gate (openfut-import-fifa17/src/lib.rs:476-478). 6 assets absent
|
|
||||||
from players.json; 236250/236253/236257 resolve only to placeholder nameid 24313='171918'.
|
|
||||||
dcplayernames.json/editedplayernames.json EMPTY in .120 dump. Version formula holds for all
|
|
||||||
(resourceId == (version<<24)|assetId) — so they auto-promote the instant a .105-derived
|
|
||||||
name row exists; NO code change needed, only name data.
|
|
||||||
|
|
||||||
| asset | resourceId(ver) | rareflag/rating/pos | wire copies |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 169193 | 169193(v0) | 1/r87/CDM | 100000057,100000083,100000146,100000157 |
|
|
||||||
| 211029 | 211029(v0) | 1/r73/CB | 100000165 |
|
|
||||||
| 224512 | 224512(v0) | 1/r67/LB | 100000170 |
|
|
||||||
| 227403 | 227403(v0) | 1/r64/ST | 100000160 |
|
|
||||||
| 236743 | 236743(v0) | 1/r60/LB | 100000163 |
|
|
||||||
| 237510 | 237510(v0) | 1/r59/CB | 100000162 |
|
|
||||||
| 236250 | 17013466(v1) | 3/r78/ST | 100001102 |
|
|
||||||
| 236253 | 17013469(v1) | 3/r77/CAM | 100001932 |
|
|
||||||
| 236253 | 100899549(v6) | 21/r77/CAM | 100001472 |
|
|
||||||
| 236257 | 117676769(v7) | 22/r78/LM | 100001043 |
|
|
||||||
|
|
||||||
### .105 read-only verdict (PROVEN 2026-08-14) — UNRECOVERABLE, confirmed
|
|
||||||
SSH read-only to the FIFA machine (10.10.0.105). Install `/mnt/games/FIFA 17`
|
|
||||||
is retail PC (FIFA17.exe, build 2017-06-09, changelist 3175939, sku FFA17PCC).
|
|
||||||
The resident FIFA17.exe database (recon `data/tables`) shows the DLC/Legend name
|
|
||||||
tables ship ZERO rows in the retail PC build:
|
|
||||||
- `dcplayernames.json`: rowcount:0, rows_emitted:0 (nameid range 30000-35000 = DLC names)
|
|
||||||
- `editedplayernames.json`: rowcount:0, rows_emitted:0
|
|
||||||
- `playernames.json`: 24314 rows (standard players only)
|
|
||||||
FUT Legends were Xbox-One-exclusive in FIFA 17; the PC client contains no Legend
|
|
||||||
names at all. The 13 Legend instances are therefore GENUINELY UNRECOVERABLE from
|
|
||||||
any PC install (.105 == .120, same retail build) — NOT a filtering artifact and
|
|
||||||
NOT fabricated. Architecture is already correct: they auto-promote the instant a
|
|
||||||
name row is supplied (e.g. from an Xbox FIFA17 DB), with no importer code change.
|
|
||||||
## Implementation
|
|
||||||
- Consumables+staff: NEW emit path in openfut-import-fifa17 (classify already buckets;
|
|
||||||
plan_definitions only ingests PlayerCard). Add definition builders + Core CardDefinition
|
|
||||||
rows to fifa17-production-cards.json + owned instances in apply.rs. Data 100% present.
|
|
||||||
- Legends: attempt read-only .105 Legends locale research; if names recoverable add roster
|
|
||||||
rows (auto-promote). Otherwise document as unrecoverable (do NOT fabricate names).
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,198 +0,0 @@
|
|||||||
# FIFA 17 Transfer Market — wire findings
|
|
||||||
|
|
||||||
Reverse-engineering record for the FIFA 17 UTAS transfer-market surface, kept so
|
|
||||||
future agents do not reopen settled questions or re-guess enum spellings.
|
|
||||||
|
|
||||||
Every claim carries a confidence tag:
|
|
||||||
|
|
||||||
| Tag | Meaning |
|
|
||||||
|---|---|
|
|
||||||
| **CONFIRMED** | Observed from our own FIFA17.exe client or live host capture |
|
|
||||||
| **FIFA17-HISTORICAL** | Supported by contemporaneous FIFA 17 implementations (`lorenzh/fut-api`, `futapi/fut` v0.2.18 — the last pre-FIFA-18 release) |
|
|
||||||
| **INFERRED** | Best explanation, not directly captured |
|
|
||||||
| **UNKNOWN** | Requires instrumentation; do NOT implement from guesswork |
|
|
||||||
|
|
||||||
Authority reminder: FIFA 17 field names, enum spellings, sentinel ids and
|
|
||||||
empty-state shapes come from captures or the Python oracle — never from a modern
|
|
||||||
FUT toolkit. Later-FIFA API drift is a known hazard, and reversing a container
|
|
||||||
type or inventing an enum is the documented client-freeze class.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The auction record (`auctionInfo[]`)
|
|
||||||
|
|
||||||
What we emit today, on `/tradePile`, `/trade/status` and market browse:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"tradeId": 1000000097,
|
|
||||||
"itemData": { "...full shaped card...": "", "itemState": "listFS" },
|
|
||||||
"tradeState": "active",
|
|
||||||
"buyNowPrice": 15000,
|
|
||||||
"startingBid": 150,
|
|
||||||
"currentBid": 0,
|
|
||||||
"offers": 0,
|
|
||||||
"bidState": "none",
|
|
||||||
"expires": 3600,
|
|
||||||
"tradeOwner": true,
|
|
||||||
"sellerId": 33068179,
|
|
||||||
"sellerName": "CAGE",
|
|
||||||
"sellerEstablished": 1,
|
|
||||||
"watched": false,
|
|
||||||
"coinsProcessed": 0
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Field | Confidence | Note |
|
|
||||||
|---|---|---|
|
|
||||||
| `tradeOwner` (bool) | **FIFA17-HISTORICAL** | Exists in FIFA 17 auctionInfo. That it is *the* Actions-panel gate is **UNKNOWN** pending live confirmation. |
|
|
||||||
| `sellerId` | **FIFA17-HISTORICAL** exists; type numeric is **INFERRED** | Set to the configured persona so it agrees with `tradeOwner`. Never baked in. |
|
|
||||||
| `sellerName` | **CONFIRMED** it must be the player | `fut_account.py` annotates the persona property as "Blaze PDTL.DSNM / LSX GetProfileResponse Persona / **UTAS sellerName**". EA's `"EASFC"` here is wrong for an own listing. |
|
|
||||||
| `offers` | **FIFA17-HISTORICAL** | `0` valid for active/unbid. |
|
|
||||||
| `bidState: "none"` | **FIFA17-HISTORICAL** | Valid for active/unbid. Other observed concepts: `highest`, `buyNow`. Do NOT "fix" this. |
|
|
||||||
| `expires` | **FIFA17-HISTORICAL** | **SECONDS REMAINING, not an epoch.** Historical durations: 3600, 10800, 21600, 43200, 86400, 259200. |
|
|
||||||
| `itemData.itemState: "listFS"` | **UNKNOWN** | Plausible and unchanged. Public FIFA 17 material gives no trustworthy enumeration. Do not guess replacements — capture. |
|
|
||||||
| `itemData.untradeable` | **FIFA17-HISTORICAL** field; our blanket `false` is **INFERRED** | See "Known debt" below. |
|
|
||||||
| `marketDataMinPrice` / `marketDataMaxPrice` | **do NOT add** | These entered the public parser only after its FIFA 18 migration. |
|
|
||||||
|
|
||||||
### Why the differential could not catch the missing fields
|
|
||||||
|
|
||||||
Our record's key set was **identical to the Python oracle's**, so field-for-field
|
|
||||||
parity was green. The oracle omits `tradeOwner` / `sellerId` / `offers` as well,
|
|
||||||
because *its* remove flow was never driven by a real client either — the only
|
|
||||||
historical live datapoint is a counts-tile bug. Oracle parity is therefore
|
|
||||||
**necessary but not sufficient** for any flow the oracle never actually served.
|
|
||||||
|
|
||||||
The differential now asserts we cover every oracle key AND that our extra keys are
|
|
||||||
exactly `{offers, sellerId, tradeOwner}`, so the deliberate superset is pinned
|
|
||||||
while a new unexplained divergence still fails.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Routes
|
|
||||||
|
|
||||||
| Route | Confidence | Behaviour |
|
|
||||||
|---|---|---|
|
|
||||||
| `GET …/trade/status` | **CONFIRMED** the client polls it continuously | Live auction-state refresh. It previously fell through `starts_with("trade")` into the buy/view arm, where the tail has no numeric id, so **every poll returned `{"auctionInfo": []}`**. Now a real handler: optional `tradeIds` filter, else the whole active pile. Unknown ids are absent, never an error. |
|
|
||||||
| `DELETE /ut/game/<sku>/trade/<id>` | **FIFA17-HISTORICAL** | The spelling contemporaneous FIFA 17 clients use, no body, no meaningful response body. Previously landed in the buy/view arm and **silently cancelled nothing while returning 200.** Now maps to MarketCancel. |
|
|
||||||
| `DELETE /ut/delete/game/<sku>/trade/<id>` | **CONFIRMED** (oracle) | The oracle's spelling; retained because the differential exercises it. Whether FIFA17.exe ever uses it is **UNKNOWN**. |
|
|
||||||
| `POST …/auctionhouse` | **CONFIRMED** | List for sale. The client sends only `itemData.id`; the server resolves wire id → Core instance → `card_id`/`resourceId` and enforces ownership. |
|
|
||||||
| `GET …/tradePile/counts` | **INFERRED** | Five scalar ints (`count`, `maxAuctionsAllowed`, `offered`, `selling`, `sold`); a DISTINCT deserializer from `/tradePile`. Exact FIFA 17 semantics of `count` (active auctions vs whole pile) is **UNKNOWN** — we report active auctions and deliberately did NOT speculate. |
|
|
||||||
| `PUT …/item` (move) | **FIFA17-HISTORICAL** | `{"itemData":[{"pile":"trade"|"club","id":ID}]}` → `{"itemData":[{id,pile,success}]}`. Transfer-List membership is a **separate operation from creating an auction**. |
|
|
||||||
|
|
||||||
### Pile encoding
|
|
||||||
|
|
||||||
* MOVE commands take a **string** pile (`"trade"`, `"club"`) — **FIFA17-HISTORICAL**.
|
|
||||||
* Returned `itemData.pile` is documented **numeric** in FIFA 17 auction data — **FIFA17-HISTORICAL**.
|
|
||||||
* The numeric mapping is **UNKNOWN**. Do not unify the two representations, and do
|
|
||||||
not derive a mapping from unrelated `pileSize` keys.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Q2 — Transfer List item that is not currently auctioned
|
|
||||||
|
|
||||||
A real FUT state: an item in the Transfer List with no active auction (freshly
|
|
||||||
moved, or expired unsold). **CONFIRMED** to exist as a concept (the external hub
|
|
||||||
spec §27, and move-vs-list being separate operations).
|
|
||||||
|
|
||||||
Its wire representation is **UNKNOWN**: `tradeId` 0 / omitted / null, `tradeState`
|
|
||||||
value or omission, and `itemData.itemState` are all unestablished.
|
|
||||||
|
|
||||||
Consequence, and the reason this matters: our `/tradePile` renders only `active`
|
|
||||||
listings, so keying the `/club` exclusion on the `trade` **pile** stranded 4 cards
|
|
||||||
in no screen at all (hidden from the club, absent from the Transfer List).
|
|
||||||
Commit `f2c4927` keys exclusion on the **active listing** instead, which is
|
|
||||||
self-healing. That is a workaround, not fidelity — the faithful model needs the
|
|
||||||
unlisted state represented.
|
|
||||||
|
|
||||||
**Required capture** (four states, full structural diff, not just a shortlist):
|
|
||||||
|
|
||||||
```
|
|
||||||
A. moved Club -> Transfer List, NEVER listed
|
|
||||||
B. actively listed
|
|
||||||
C. listing expired unsold
|
|
||||||
D. listing sold
|
|
||||||
```
|
|
||||||
|
|
||||||
Diff at least: `tradeId`, `tradeOwner`, `tradeState`, `bidState`, `expires`,
|
|
||||||
`offers`, `currentBid`, `startingBid`, `buyNowPrice`, `sellerId`, `sellerName`,
|
|
||||||
`itemData.id`, `itemData.itemState`, `itemData.pile`, `itemData.untradeable`.
|
|
||||||
|
|
||||||
Do NOT drop the unlisted state from the model just because its encoding is unknown.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deferred, with reasons
|
|
||||||
|
|
||||||
* **5% transfer tax** — **INFERRED** architecture only: auction closes → Core
|
|
||||||
settles → seller credited gross × 0.95, with `auctionInfo` continuing to carry
|
|
||||||
gross. No trustworthy FIFA 17 field named `tax`/`netPrice`/`sellerProceeds` was
|
|
||||||
recovered, and no separate settle operation. Not blocking; do not couple
|
|
||||||
settlement to clearing the sold auction without a capture.
|
|
||||||
* **Bid / Transfer Targets** — not implemented. Watched / active bid / winning /
|
|
||||||
outbid / won / expired are distinct states and must not collapse to a flat list.
|
|
||||||
* **Unassigned** — FIFA 17 had a dedicated Unassigned service; the exact FIFA 17
|
|
||||||
URL is **UNKNOWN**. Our 29-item `purchased` pile is this state and is currently
|
|
||||||
rendered inside `/club`. Do not manufacture a route from a modern toolkit.
|
|
||||||
* **Match CREATE / READY / PLAY** — **UNKNOWN** and explicitly not portable from
|
|
||||||
public FUT web-app work (the web app could not start matches). Instrument the
|
|
||||||
real client from Play Match to kickoff before implementing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Known debt
|
|
||||||
|
|
||||||
`shape_item` reports `untradeable: false` for **every** owned instance. Correct
|
|
||||||
today (Core models no untradeable items) and necessary — a hardcoded `true` greyed
|
|
||||||
out both list buttons — but it will misrepresent SBC / promo / loan rewards once
|
|
||||||
those exist. `untradeable` belongs on the owned-item instance as authoritative
|
|
||||||
state, not inferred from definition, resourceId or rarity.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## MEASURED in the live client — 2026-08-17
|
|
||||||
|
|
||||||
Read out of the running `FIFA17.exe` (pid-resolved, CardsDLL slide proven against
|
|
||||||
the on-disk FNV prologue) with `fifa17-recon/tools/trade_gate_probe.py`, which
|
|
||||||
extends `gate_byte_probe.py` to vtable slot `+0x270` as the transfer-market
|
|
||||||
analysis asked for. Read-only: `/proc/<pid>/mem` `O_RDONLY` + `pread`.
|
|
||||||
|
|
||||||
| Gate | Python era (2026-08-06) | Now | Owner |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `IS_TRADING_ENABLED` `model+0x1fd2e` (slot `+0x270`) | **0** | **1** | settings struct `+0x28`; was zeroed by `userInfo.feature.trade` |
|
|
||||||
| `TRADE_PILE_SIZE` `model+0x1fd1c` | **0** | **100** | `userMassInfo.pileSizeClientData` key 2 |
|
|
||||||
| watch-list size `model+0x1fd20` | **0** | **50** | same member, key 4 |
|
|
||||||
| `storeEnabled` `model+0x1fd2f` | 1 | 1 | control |
|
|
||||||
| `IS_FRIENDLY_SEASON` / `IS_DRAFT_MODE` / `packOpeningAnimation` | 1 | 1 | controls |
|
|
||||||
|
|
||||||
**CONFIRMED: every CardsDLL-supplied input the transfer-market analysis named as a
|
|
||||||
blocker is now open.** The Rust host does this by construction — it emits
|
|
||||||
`userInfo.feature` as `{}` (no `trade` member, so the kill switch at `0x180174f19`
|
|
||||||
never arms: it fires only when atom `0x330` inside `0x11c` parses as exactly 1) and
|
|
||||||
it already sends `pileSizeClientData` keys 2 and 4. Serving `tradingEnabled: 1` in
|
|
||||||
the settings `configs` array would NOT have worked, because that tail runs after
|
|
||||||
every member is parsed and would overwrite it.
|
|
||||||
|
|
||||||
### What this rules out
|
|
||||||
|
|
||||||
The Transfer List Actions panel not opening on an own listing is therefore **not**:
|
|
||||||
|
|
||||||
* an ownership field — FIFA 17's auctionInfo has no `tradeOwner`/`sellerId` atom;
|
|
||||||
* `IS_TRADING_ENABLED`, `TRADE_PILE_SIZE` or the watch-list size — all measured open;
|
|
||||||
* the cancel route — `DELETE ut/delete/{ns}/trade/{tradeId}` is the PE's spelling and
|
|
||||||
is what we serve;
|
|
||||||
* `tradeState` / `bidState` / `expires` spellings — all three are the PE's own
|
|
||||||
vocabularies and values.
|
|
||||||
|
|
||||||
Per the analysis's own falsifier ("if the byte reads 1 and the screen still refuses,
|
|
||||||
the exe-side predicate has a term we have not enumerated"), the remaining term is
|
|
||||||
**exe-side UI script**, which CardsDLL does not own and the server cannot set.
|
|
||||||
Status: **UNKNOWN**, and it is now the narrowest it has ever been.
|
|
||||||
|
|
||||||
### Confirmed fidelity bug found on the way
|
|
||||||
|
|
||||||
`expires` was a frozen `3600` on every poll, so the client's live countdown never
|
|
||||||
moved and an auction could never run out. Now derived from `created_at + duration`
|
|
||||||
(duration taken from the `ISStart` body), clamped at 0, with an aged-out active
|
|
||||||
listing projecting as `expired`/`none` — FIFA 17's relistable state. Verified live:
|
|
||||||
the standing listing correctly reads `expires: 0` once past its hour.
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
# FIFA 17 FUT Match Lifecycle
|
|
||||||
|
|
||||||
Design + contract reference for the **FUT match loop** as OpenFUT implements it on
|
|
||||||
the backend/responder side. Consolidates knowledge previously scattered across
|
|
||||||
`docs/PROJECT_STATE.md`, `fifa17-recon/tools/utas_server.py` (`match_route`),
|
|
||||||
`openfut-utas-host` (Rust economy END leg), `fifa17-recon/tools/test_match_lifecycle.py`,
|
|
||||||
and the vault (`Protocol Findings.md`, `Project State.md`, `Known Issues.md`).
|
|
||||||
|
|
||||||
Evidence labels: **OBSERVED** (live), **PROVEN** (test/static-analysis),
|
|
||||||
**HYPOTHESIS** (reasoned, not yet live-confirmed).
|
|
||||||
|
|
||||||
> ## Status caveat (read first)
|
|
||||||
> **No football match has ever started or completed in FIFA against this stack.**
|
|
||||||
> The lifecycle below is validated by CardsDLL static analysis (RPC descriptor
|
|
||||||
> blocks) + isolated persistence replay (`test_match_lifecycle.py`), **not** in-game
|
|
||||||
> acceptance. The reward amounts are FUT-plausible env-tunable defaults, **not**
|
|
||||||
> reversed values. Two blockers keep `FUT_MODES` **off by default** (see
|
|
||||||
> [Open questions](#open-questions--blockers)).
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
This documents the **UTAS responder handshake + economy reward** for a match — the
|
|
||||||
HTTP calls CardsDLL makes around a match and the state they mutate. It does **not**
|
|
||||||
cover the actual football simulation (Blaze game-server side, "past the FUT hub"),
|
|
||||||
which remains unverified.
|
|
||||||
|
|
||||||
## The loop: a four-call state machine
|
|
||||||
|
|
||||||
CardsDLL issues six match RPCs; four form the playable loop. All share the base
|
|
||||||
path `ut/<sku>/match`; the operation is discriminated by **suffix + body**, not by
|
|
||||||
HTTP verb (verb selection lives outside CardsDLL, so the classifier is verb-agnostic).
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
stateDiagram-v2
|
|
||||||
[*] --> Created: POST /match (no matchId)
|
|
||||||
Created --> Ready: POST|PUT /match/ready {matchId}
|
|
||||||
Ready --> Playing: POST /match {matchId} (bare path + int matchId)
|
|
||||||
Playing --> Ended: POST|PUT|DELETE /match/end {matchId, endReason, ...}
|
|
||||||
Ended --> [*]: rewards credited, W/D/L + matchesPlayed persisted
|
|
||||||
```
|
|
||||||
|
|
||||||
### Call classifier (`_match_call`, utas_server.py:3325)
|
|
||||||
|
|
||||||
The discriminator (**PROVEN** from CardsDLL RPC descriptors):
|
|
||||||
|
|
||||||
1. path ends `/match/end` → **END** (routed as `FutDestroyMatch` regardless of verb).
|
|
||||||
2. body has integer `matchId` on the **bare** `/match` path → **PLAY** (`FutPlayGame`).
|
|
||||||
CREATE and PLAY share `ut/<sku>/match`; the presence of an int `matchId` is the
|
|
||||||
only discriminator — this is why a bare `/match` carrying `matchId` must NOT
|
|
||||||
allocate a new match.
|
|
||||||
3. path ends `/match/ready` → **READY** (`FutMatchReady`).
|
|
||||||
4. otherwise → **CREATE** (`FutCreateMatch`).
|
|
||||||
|
|
||||||
## Per-call contracts
|
|
||||||
|
|
||||||
### CREATE — `POST /ut/<sku>/match` (empty/no `matchId`)
|
|
||||||
CardsDLL: `FutCreateMatch` @ `0x180120380`; deserializes `startDateTime`(740,int),
|
|
||||||
`reportIdEnabled`(641,bool). `squad`(717,nested) is a **FREEZE-RISK** and is omitted
|
|
||||||
(SKIP-safe).
|
|
||||||
|
|
||||||
Response (`match_route`, utas_server.py:3399):
|
|
||||||
```json
|
|
||||||
{"startDateTime": <unix_ts:int>, "reportIdEnabled": false, "id": <matchId:int>}
|
|
||||||
```
|
|
||||||
Effect: allocates a match id; **advances `nextItemId` by 1** (PROVEN,
|
|
||||||
`test_match_lifecycle.py:51`).
|
|
||||||
|
|
||||||
### READY — `POST|PUT /ut/<sku>/match/ready` (`{matchId}`)
|
|
||||||
CardsDLL: `FutMatchReady` — no deserializer at all on the request; the server
|
|
||||||
response parser has two scalar members + one nested member.
|
|
||||||
|
|
||||||
Response (`match_ready_body`, utas_server.py:3353):
|
|
||||||
```json
|
|
||||||
{"matchId": <int>, "opponentPersonaId": <int, default 0>}
|
|
||||||
```
|
|
||||||
- `opponentPersonaId` defaults to `0` — a neutral placeholder, **never** the
|
|
||||||
logged-in user's persona.
|
|
||||||
- The parser also has a nested `items` member (the opponent squad). It is **omitted
|
|
||||||
deliberately** until the opponent-squad item contract is recovered from a live
|
|
||||||
capture; unrecognized/absent members are skip-safe. **This omission is one of the
|
|
||||||
two blockers.**
|
|
||||||
|
|
||||||
### PLAY — `POST /ut/<sku>/match` (bare path, `{matchId:int}`)
|
|
||||||
CardsDLL: `FutPlayGame` — no request deserializer.
|
|
||||||
|
|
||||||
Response: `{}` (empty). Effect: **none** — must NOT allocate a match or advance
|
|
||||||
`nextItemId` (PROVEN, `test_match_lifecycle.py:62-63`). This is purely a client
|
|
||||||
keepalive/transition ack.
|
|
||||||
|
|
||||||
### END — `POST|PUT|DELETE /ut/<sku>/match/end` (`{matchId, endReason, myMatchStats, opponentMatchStats}`)
|
|
||||||
CardsDLL: `FutDestroyMatch` @ `0x180121b60` — **the rewards call**. Routed as
|
|
||||||
DestroyMatch regardless of verb (`FUT_MATCH_END`, default ON).
|
|
||||||
|
|
||||||
> **Wire path (Rust vs Python).** The Rust-owned reward is classified by `classify_economy`
|
|
||||||
> on **`POST /ut/delete/game/<sku>/match`** — EA/CardsDLL tunnels DELETE-semantics ops through the
|
|
||||||
> `/ut/delete/game/` prefix (`FutDestroyMatch` = `DELETE ut/%s/match/{id}`). Python's `match_route`
|
|
||||||
> additionally accepts `/ut/game/<sku>/match/end`. Which exact form the retail client emits is
|
|
||||||
> unverified (no match ever played); the Rust economy owns the `/ut/delete/game` form, and a
|
|
||||||
> `/ut/game/.../match/end` would fall to Python. Both credit the same reward shape.
|
|
||||||
|
|
||||||
Request fields that matter:
|
|
||||||
- `endReason` (atom 260) — **STRING enum, the AUTHORITATIVE result signal**. A score
|
|
||||||
comparison is NOT how the client reports the outcome. Nine values, mapped to a
|
|
||||||
win/draw/loss bucket:
|
|
||||||
|
|
||||||
| endReason | bucket |
|
|
||||||
|---|---|
|
|
||||||
| `WIN`, `DNF_WIN` | won |
|
|
||||||
| `DRAW`, `DNF_DRAW`, `NO_CONTEST` | draw |
|
|
||||||
| `LOSS`, `DNF_LOSS`, `DNF`, `QUIT` | loss |
|
|
||||||
| (missing/unknown) | draw (neutral fallback — credits without inventing a win) |
|
|
||||||
|
|
||||||
- `myMatchStats` / `opponentMatchStats` — literal-keyed objects, 15 int fields each,
|
|
||||||
first is `goals`. **Omitted by the client when `endReason` is `DNF`/`QUIT`**, so
|
|
||||||
nothing may require them. Used only as a fallback outcome probe if `endReason` is
|
|
||||||
absent.
|
|
||||||
|
|
||||||
Response (`destroy_match_body` / Rust `build_match_reward_body`) — every field a
|
|
||||||
**top-level scalar** (zero freeze risk) except the deliberately nested reward:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"allCoins": <post-credit balance:int>,
|
|
||||||
"matchCoins": <per-result coins:int>,
|
|
||||||
"seasonCoins": 0,
|
|
||||||
"tournamentCoins": 0,
|
|
||||||
"boostConis": 0, // EA's typo — exact key required
|
|
||||||
"participationAward": <int>,
|
|
||||||
"teamOfTournamentWinner": false,
|
|
||||||
"gameModeAward": { "coins": <total award:int> }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
> **Critical correction (2026-08-04):** the reward `coins` (atom 149) is read by the
|
|
||||||
> deserializer **only inside `gameModeAward`**, never as a top-level key. An earlier
|
|
||||||
> top-level `"coins"` was silently skipped and never reached the client — the one
|
|
||||||
> field most obviously named "the reward" was the one going nowhere.
|
|
||||||
|
|
||||||
Effect (persisted to the active FUT save): credit coins; increment the matching
|
|
||||||
`record.{won,draw,loss}`; increment `matchesPlayed` (PROVEN,
|
|
||||||
`test_match_lifecycle.py:74-81`).
|
|
||||||
|
|
||||||
## Reward policy
|
|
||||||
|
|
||||||
Env-tunable defaults (FUT-plausible, **not reversed** — `economy_policy.rs:20-36`,
|
|
||||||
`utas_server.py:3201-3206`):
|
|
||||||
|
|
||||||
| Result | Match coins | Participation | Total |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Win | 400 (`FUT_MATCH_COINS_WIN`) | 0 (`FUT_MATCH_PARTICIPATION`) | 400 |
|
|
||||||
| Draw | 200 (`FUT_MATCH_COINS_DRAW`) | 0 | 200 |
|
|
||||||
| Loss | 100 (`FUT_MATCH_COINS_LOSS`) | 0 | 100 |
|
|
||||||
|
|
||||||
`allCoins` = post-credit balance; `gameModeAward.coins` = per-result + participation.
|
|
||||||
|
|
||||||
## Ownership split (Rust vs Python)
|
|
||||||
|
|
||||||
The match loop is **partially migrated**. Only the coin-crediting END leg is
|
|
||||||
economy state, so only it is Rust/Core-owned; the CREATE/READY/PLAY legs are still
|
|
||||||
served by the Python oracle.
|
|
||||||
|
|
||||||
| Call | Owner | Where |
|
|
||||||
|---|---|---|
|
|
||||||
| CREATE | Python | `utas_server.py::match_route` (via host `Route::Passthrough`) |
|
|
||||||
| READY | Python | `utas_server.py::match_route` |
|
|
||||||
| PLAY | Python | `utas_server.py::match_route` |
|
|
||||||
| END (reward) | **Rust/Core** (on `POST /ut/delete/game/<sku>/match`) | `EconomyRoute::MatchEnd` → `handle_match_end` → `build_match_reward_body`; outcome via `economy_policy::match_result_from_reason`, coins via `match_result_coins`/`match_reward_total`. 503 on Core error, never Python. Python also accepts `/ut/game/<sku>/match/end`. |
|
|
||||||
|
|
||||||
END is classified in `classify_economy` (`openfut-utas-host/src/lib.rs`) and dispatched
|
|
||||||
by the economy authority barrier ahead of the general classifier — so it can never
|
|
||||||
also reach the Python passthrough (NEVER-BOTH). The Rust END writes the coin reward
|
|
||||||
through the single Core economy transaction (`grant_reward`); W/D/L record + match
|
|
||||||
count still live in the Python save until CREATE/READY/PLAY migrate.
|
|
||||||
|
|
||||||
## Test coverage
|
|
||||||
|
|
||||||
`fifa17-recon/tools/test_match_lifecycle.py` (PROVEN, isolated — temp profile, no
|
|
||||||
live server): drives CREATE→READY→PLAY→END and asserts:
|
|
||||||
- CREATE returns `reportIdEnabled:false` + advances `nextItemId` by 1.
|
|
||||||
- READY returns exactly `{matchId, opponentPersonaId:0}`.
|
|
||||||
- PLAY returns `{}` and does **not** advance `nextItemId`.
|
|
||||||
- END credits `MATCH_COINS["won"] + MATCH_PARTICIPATION`, persists
|
|
||||||
`record == {won:1,draw:0,loss:0}` and `matchesPlayed == 1`.
|
|
||||||
|
|
||||||
## Open questions / blockers
|
|
||||||
|
|
||||||
1. **READY `items` contract (opponent squad)** — the nested `items` member of
|
|
||||||
`FutMatchReadyServerResponse` is unserved pending a live capture. Whether the
|
|
||||||
client requires it present/non-empty to enter a match is unknown. **Blocker.**
|
|
||||||
2. **Client mode-entry gate** — reaching a match from the FUT hub UI is unverified;
|
|
||||||
`FUT_MODES` stays **off by default** (the season/tournament routes return `{}`).
|
|
||||||
3. **No in-game acceptance** — every claim here is static-analysis + isolated replay.
|
|
||||||
The first live match is expected to reveal whether the static read was complete
|
|
||||||
(every match body is logged for exactly this reason).
|
|
||||||
4. **Football simulation** — the actual gameplay (Blaze game-server) is out of scope
|
|
||||||
here and unverified.
|
|
||||||
|
|
||||||
## Sources
|
|
||||||
|
|
||||||
- `docs/PROJECT_STATE.md` (match lifecycle status), `docs/direction.md` (Tier-2 loop).
|
|
||||||
- `fifa17-recon/tools/utas_server.py`: `_match_call` (3325), `match_ready_body` (3346),
|
|
||||||
`match_route` (3357), `destroy_match_body` (3281), `_match_result` (3236),
|
|
||||||
`MATCH_COINS`/`MATCH_PARTICIPATION` (3201), `_END_REASON` (3229); CardsDLL RPC
|
|
||||||
descriptor block (3183-3197).
|
|
||||||
- `openfut-adapter-fifa17/src/fut/economy_policy.rs` (reward policy + outcome map).
|
|
||||||
- `openfut-utas-host/src/lib.rs`: `EconomyRoute::MatchEnd`, `handle_match_end`,
|
|
||||||
`build_match_reward_body`.
|
|
||||||
- `fifa17-recon/tools/test_match_lifecycle.py` (lifecycle regression).
|
|
||||||
- Vault: `02 Reverse Engineering/FIFA 17/Protocol Findings.md`,
|
|
||||||
`06 Agent Memory/{Project State,Known Issues}.md`.
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
# Overnight session handoff — 2026-08-17
|
|
||||||
|
|
||||||
Autonomous session while you slept. Low-ceremony per your instruction. Everything
|
|
||||||
below is verified as noted; nothing was committed or pushed (see
|
|
||||||
[Uncommitted work](#uncommitted-work--needs-your-review)).
|
|
||||||
|
|
||||||
## TL;DR
|
|
||||||
|
|
||||||
1. **Production promotion completed** (you chose "promotion"): prod-host swapped to the
|
|
||||||
post-P1 build, content-gap imported. Then I migrated the **remaining Python UTAS
|
|
||||||
routes that have a known contract** to Rust: account/sync, ut/auth (SID mint),
|
|
||||||
userMassInfo (full), clientdata, club/stats/{country,league,team}, and the trivial
|
|
||||||
static acks. The client's **observed FUT-hub/economy flow is now fully Rust**; a
|
|
||||||
tail of lower-traffic routes **without a captured wire shape** (item-defs,
|
|
||||||
user-identity, watchList/marketdata, non-active `squad/<n>`, draft, and mode-gated
|
|
||||||
season/tournament/champion/leaderboards/sbs) **still proxy to the Python oracle**.
|
|
||||||
2. **Launcher redesigned to a shareholder-grade egui UI** (your headline ask). Builds
|
|
||||||
clean; screenshots captured.
|
|
||||||
3. **New docs:** `MATCH_LIFECYCLE.md` (you asked), `CORE_CORRECTNESS_ISSUES.md`
|
|
||||||
(4 known Core bugs + 1 bonus exploit, ready to fix on your go/no-go).
|
|
||||||
4. **Nothing committed** — all work is in the working tree for your review (git state is
|
|
||||||
delicate: preserved-dirty Core submodule + a concurrent `funman300` actor + detached
|
|
||||||
launcher branch; I didn't want to entangle that unsupervised).
|
|
||||||
|
|
||||||
## What's live in production now (`10.10.0.120:8099`)
|
|
||||||
|
|
||||||
| Thing | State |
|
|
||||||
|---|---|
|
|
||||||
| prod-host binary | post-P1 `fda40d12` **+ my migration rebuild** (release, in `target/release/openfut-utas-host`) |
|
|
||||||
| prod-host pid | 3207781 (hub-managed, restart=no; retained spec points at the rebuilt binary) |
|
|
||||||
| Catalog | `9f6addaa` (post-P1) |
|
|
||||||
| Core content (cards) | `136d8d68` (post-P1, +18 content-gap defs) → Core loads **1710** defs |
|
|
||||||
| Core owned | **1982** (1962 players + 17 consumables + 3 staff) |
|
|
||||||
| Coins | **29,876,776** (baseline — reset from the P1 test value when the content-gap DB was swapped in; you said data isn't precious) |
|
|
||||||
| prod-core | **fixed build** from canonical submodule (`fbb54ea` + 4 correctness fixes), DB migrated ver 18 → 19; binary now `/home/alex/OpenFUT/target/release/openfut-core` |
|
|
||||||
| UTAS routes (Rust) | economy, club, squad (0/active/list/PUT), account/sync, ut/auth, userMassInfo, **user**, clientdata, hub, settings, accountinfo, leaderboards/options, match/reset, phishing, club/stats/{year,consumables,staff,country,league,team}, watchList, static acks (store/keepalive/captcha/tfa/livemessage/activeMessage) |
|
|
||||||
| Still Python (:8199) | item-defs (item/resource, defid), club-identity (clubUser, user/list, user/club), `squad/<n>` (n≠0), draft, marketdata, mode-gated (season/tournament/champion/leaderboards/sbs → `{}` while off), and match CREATE/READY/PLAY. See `docs/PRODUCTION_AUTHORITY_MATRIX.md`. |
|
|
||||||
|
|
||||||
Smoke-verified live in prod (in the prod netns): all migrated routes return
|
|
||||||
`owner=RUST`, coins consistent, clientdata round-trips, club/stats context modes emit
|
|
||||||
distinct nation/league/team buckets. Scripts:
|
|
||||||
`/home/alex/openfut-promotion/economy-2026-08-17-p2/{p2_precheck,smoke_migrated,smoke_clubstats}.py`.
|
|
||||||
|
|
||||||
## Changes made (all verified: builds clean, tests green)
|
|
||||||
|
|
||||||
### 1. Production promotion (deployed)
|
|
||||||
- Host binary `e5be8730` (P1) → `fda40d12` (post-P1) + catalog `9f6addaa`.
|
|
||||||
- Content-gap DB swapped in (owned 1962 → 1982). Backups in
|
|
||||||
`/home/alex/openfut-promotion/economy-2026-08-17-p2/backup/` + `ROLLBACK.txt`.
|
|
||||||
|
|
||||||
### 2. Route migration to Rust (deployed, rebuilt binary)
|
|
||||||
- `POST /ut/auth` — Rust mints the SID (`OPENFUT-SID-{:016X}`), opens the Rust session,
|
|
||||||
adopts persona from body. No Python. (`+ /ut/delete/auth`.)
|
|
||||||
- `POST /openfut/account/sync` — full Rust envelope; coins/unopenedPacks from Core.
|
|
||||||
- `GET /userMassInfo` — **full** Rust envelope (was a Python-proxy+overlay hybrid).
|
|
||||||
- `GET/PUT /clientdata/<key>` — new host `ClientDataStore` (JSON-persisted).
|
|
||||||
- `GET /club/stats/{country,league,team}` — made `club_stats_body` context-aware
|
|
||||||
(nation/league/team buckets); classify now routes all `club/stats/*` to Rust.
|
|
||||||
- `GET /squad/0` — routed to the Rust active-squad projection (verified structurally
|
|
||||||
identical to Python `squad/0`: same 15 keys, players=23).
|
|
||||||
- `GET /watchList` (+ no-op add/remove) — empty list + authoritative Core credits.
|
|
||||||
- Static acks (`store`, `match/keepalive`, `captcha`, `tfa`, `livemessage`,
|
|
||||||
`activeMessage`) — Rust constants (StaticAck route), byte-identical to the oracle.
|
|
||||||
- Captured the remaining routes' Python wire shapes as reference fixtures for later
|
|
||||||
migration: `docs/evidence/route-shapes-2026-08-17/` (user, defs, marketdata,
|
|
||||||
clubUser, watchList, squad/0, season/tournament/champion/sbs, draft).
|
|
||||||
- Files: `openfut-utas-host/src/{lib.rs,clientdata_store.rs(new),config.rs}`,
|
|
||||||
`openfut-adapter-fifa17/src/fut/{non_economy.rs,club_stats.rs}`,
|
|
||||||
`openfut-utas-host/tests/economy_integration.rs`.
|
|
||||||
- Tests: `openfut-utas-host` + `openfut-adapter-fifa17` full suites **GREEN**
|
|
||||||
(188 adapter + 76 host lib + all integration incl the 116s economy integration).
|
|
||||||
|
|
||||||
### 3. Launcher redesign + polish + live account panel (built, NOT deployed — client tool)
|
|
||||||
- **Redesign**: new `openfut-launcher/src/theme.rs` design system (palette, embedded
|
|
||||||
fonts, egui Visuals/Style, card/pill helpers). Branded hero header (OF monogram),
|
|
||||||
left nav rail, card-based dashboard with status pills, prominent accent Launch CTA,
|
|
||||||
console-style Logs. All existing launch/health/preflight/service/config logic preserved.
|
|
||||||
- **Polish**: OpenFUT window/taskbar icon (OF monogram `IconData`), Config tab rebuilt
|
|
||||||
into themed cards, consistency sweep.
|
|
||||||
- **Live "Your Club" panel** (new feature): a background `AccountMonitor` (mirrors
|
|
||||||
`HealthMonitor`, 5s poll, non-blocking) fetches the account summary and the Dashboard
|
|
||||||
shows a "Your Club" card — club name/abbr, Manager, **COINS hero number**, Level + XP
|
|
||||||
bar, unopened packs, account funds — with clean loading/offline/error states.
|
|
||||||
- Builds clean (0 warnings). Screenshots preserved (for your shareholder demo) in
|
|
||||||
`/home/alex/openfut-post-p1/launcher-screenshots-2026-08-17/` — `launcher_account.png`
|
|
||||||
(the populated "Your Club" card: COINS 29,876,776, Level 12, packs 3) is the headline;
|
|
||||||
plus dashboard/setup/logs/config + the offline state. All reviewed — product-quality.
|
|
||||||
- Files: `openfut-launcher/src/{theme.rs(new),account_monitor.rs(new),app.rs,main.rs,
|
|
||||||
account_sync.rs,config.rs}` + `assets/` (fonts + icon). All additive; behavior preserved.
|
|
||||||
|
|
||||||
### 4. Core correctness fixes (deployed 2026-08-17)
|
|
||||||
|
|
||||||
All four `CORE_CORRECTNESS_ISSUES.md` classes fixed in the canonical `openfut-core`
|
|
||||||
submodule and deployed to prod-core (see that doc's "Resolution" section):
|
|
||||||
- **Issue 3 (HIGH, exploit):** SBC duplicate-card free-reward — `submit_sbc` now dedups
|
|
||||||
ids + bounds the list (`MAX_SBC_CARDS`=30) → `BadRequest`. Regression test added.
|
|
||||||
- **Issue 2 (HIGH):** non-atomic economy mutations — closed the concurrency-exploit
|
|
||||||
races with single-statement compare-and-swap (`spend_coins` conditional debit,
|
|
||||||
`open_pack`/`buy_listing`/`sell_card`/`checkin` claim-then-act). Multi-statement
|
|
||||||
partial-failure edges (`buy_pack`, concurrent SBC, match/season chains) left as
|
|
||||||
documented residual — need the transaction refactor, negligible for single-player.
|
|
||||||
- **Issue 4 (LOW):** `season.rs` `.expect()` panics → graceful `AppError`; checkin index
|
|
||||||
`% 7` → `.rem_euclid(7)`.
|
|
||||||
- **Issue 1 (LOW):** `sbc_submissions.club_id` — migration `0019` (add + backfill) +
|
|
||||||
`submit_sbc` binds it; milestone query now correct.
|
|
||||||
- Verified: Core suite **179 green** + clippy clean; migration dry-run on a prod-DB copy;
|
|
||||||
post-deploy prod migration ver 19, owned 1982, coins 29,876,776, all Core + host
|
|
||||||
endpoints 200. Rollback: `backup/prod-core.preCoreFix.db` (ver 18) + old binary path —
|
|
||||||
see `backup/ROLLBACK_CORE_FIX.txt`.
|
|
||||||
|
|
||||||
## New / updated docs
|
|
||||||
|
|
||||||
- `docs/MATCH_LIFECYCLE.md` (NEW) — consolidated FUT match loop design (CREATE→READY→
|
|
||||||
PLAY→END), contracts, reward policy, ownership split, blockers. (You asked for this.)
|
|
||||||
- `docs/CORE_CORRECTNESS_ISSUES.md` (NEW) — 4 known Core bugs + 1 bonus SBC
|
|
||||||
duplicate-card exploit, each with file:line + concrete fix + severity. **Needs your
|
|
||||||
go/no-go** (fixing bumps Core off the frozen P1 reference).
|
|
||||||
- `docs/PRODUCTION_AUTHORITY_MATRIX.md` (UPDATED) — reflects the completed migration.
|
|
||||||
- Vault `06 Agent Memory/Current Priorities.md` (UPDATED).
|
|
||||||
|
|
||||||
## Uncommitted work — needs your review
|
|
||||||
|
|
||||||
**I committed nothing** (git state is delicate: openfut-core is intentionally
|
|
||||||
preserved-dirty; a concurrent `funman300` actor; launcher on detached HEAD `d1a71bd`).
|
|
||||||
Review + commit these when you're ready:
|
|
||||||
|
|
||||||
- Superproject (mine): `openfut-utas-host/src/{lib.rs,config.rs}`,
|
|
||||||
`openfut-utas-host/src/clientdata_store.rs`,
|
|
||||||
`openfut-utas-host/tests/{economy_integration.rs,host_test.rs}`,
|
|
||||||
`openfut-adapter-fifa17/src/fut/{club_stats.rs,non_economy.rs}`,
|
|
||||||
`docs/{PRODUCTION_AUTHORITY_MATRIX.md,MATCH_LIFECYCLE.md,CORE_CORRECTNESS_ISSUES.md,OVERNIGHT_HANDOFF_2026-08-17.md,PYTHON_RETIREMENT_PLAN.md}`,
|
|
||||||
and the reference fixtures `docs/evidence/route-shapes-2026-08-17/`.
|
|
||||||
- Launcher submodule (mine): `src/{app.rs,main.rs,theme.rs(new),account_monitor.rs(new),account_sync.rs,config.rs}`, `assets/` (fonts + icon).
|
|
||||||
- **Leave the pre-existing dirt alone** (not mine): `CLAUDE.md`, `README.md`,
|
|
||||||
`.env.example`, `docker-compose.yml`, `AGENTS.md`, `setup.sh`, `openfut-bridge`,
|
|
||||||
`fifa17-recon/docker/...`, `docs/{ARCHITECTURE,ROADMAP,docker,fifa17-emulation}.md`,
|
|
||||||
`docs/research/`, `scripts/utas-filter-diff.py`.
|
|
||||||
- `openfut-core` (mine, this session): `migrations/0019_sbc_submissions_club_id.sql` (new),
|
|
||||||
`src/services/{sbc.rs,club.rs,pack.rs,market.rs,checkin.rs,season.rs}`,
|
|
||||||
`tests/integration_test.rs`. Built + deployed to prod-core; still detached HEAD at
|
|
||||||
`fbb54ea` (edits uncommitted, per your branch strategy).
|
|
||||||
|
|
||||||
## Open decisions for you
|
|
||||||
|
|
||||||
1. ~~**Core correctness bugs**~~ — **DONE (2026-08-17):** all four classes fixed +
|
|
||||||
deployed to prod-core (see "Core correctness fixes" above and the Resolution section
|
|
||||||
of `docs/CORE_CORRECTNESS_ISSUES.md`). prod-core is now off the frozen P1 point,
|
|
||||||
running `fbb54ea` + fixes at migration ver 19. Residual (documented): the
|
|
||||||
multi-statement transaction refactor for partial-failure atomicity — negligible for
|
|
||||||
single-player; do it if/when concurrency matters.
|
|
||||||
2. **Match handshake legs** (CREATE/READY/PLAY) — the only routes still on Python.
|
|
||||||
Deferred: no match has ever been played in-game, READY `items` contract unknown,
|
|
||||||
`FUT_MODES` off (see `docs/MATCH_LIFECYCLE.md`). Migrating them risks the economy
|
|
||||||
`/match/end` routing for a never-exercised path — I judged it not worth it unmonitored.
|
|
||||||
3. **Aux service container cutover** (blaze/redirector/roster → Rust) — operator-gated
|
|
||||||
container change; unchanged.
|
|
||||||
|
|
||||||
## Rollback (still hot)
|
|
||||||
|
|
||||||
- Python P2 image `openfut-fut-backend:p2-rollback` (b1b929953f) + profile 39bb3e83 +
|
|
||||||
`rollback-to-python-p2.sh`.
|
|
||||||
- prod-host P1 binary + P1 catalog backed up in
|
|
||||||
`/home/alex/openfut-promotion/economy-2026-08-17-p2/backup/` (see `ROLLBACK.txt`).
|
|
||||||
- prod state (pre-content-gap) backed up: `backup/prod-core.preB.db`,
|
|
||||||
`prod-identity.preB.json`, plus P1 `fifa17-production-{catalog,cards}.p1.json`.
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
# Production Authority Matrix (post-P1 -> P2-routes promoted 2026-08-17)
|
|
||||||
|
|
||||||
> **P2-routes promoted to production 2026-08-17.** prod-host swapped P1 `e5be8730` -> post-P1
|
|
||||||
> `fda40d12`; catalog `35a0913b` -> `9f6addaa` (resolves all owned assets, dropped_no_asset=0).
|
|
||||||
> Every previously-Python non-economy route now RUST in prod (OBSERVED prod log). Rollback hot:
|
|
||||||
> restore P1 binary + backup catalog (`economy-2026-08-17-p2/backup/`).
|
|
||||||
|
|
||||||
Definitive inventory of every production-reachable FIFA17 route/service and its
|
|
||||||
current owner. Derived from the live `prod-host` dispatch log (owner= labels,
|
|
||||||
real Client A session 2026-08-14), `openfut-utas-host/ROUTE_AUTHORITY.md`, and the
|
|
||||||
prod container config (`OPENFUT_SERVERS="blaze roster pow"`).
|
|
||||||
|
|
||||||
Evidence labels: OBSERVED (live log/db), PROVEN (test), INFERRED, HYPOTHESIS.
|
|
||||||
|
|
||||||
Legend: owner R = Rust/Core, P = Python oracle (:8199 proxied via PYTHON_FALLBACK).
|
|
||||||
|
|
||||||
## UTAS HTTP (front door: openfut-utas-host :8099)
|
|
||||||
|
|
||||||
### ECONOMY — already Rust (Python economy hits = 0, OBSERVED)
|
|
||||||
| Method/path (tail) | Prod owner | Writes state | Rust handler | Py proxy |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| GET /user/credits | R | no | handle_credits | NO |
|
|
||||||
| GET /store/purchasegroup[/all] | R | no | handle_purchasegroup | NO |
|
|
||||||
| PUT /store/transaction[/<id>] | R | coins,inv,pile | handle_store_buy | NO |
|
|
||||||
| POST /purchased[/items] | R | coins,inv,ent,pile | handle_pack_open | NO |
|
|
||||||
| GET /purchased[/items] | R | no | shape_purchased_reveal | NO |
|
|
||||||
| DELETE /item/<id> | R | coins,inv | handle_quick_sell_path | NO |
|
|
||||||
| POST /ut/delete/../item | R | coins,inv | handle_quick_sell_body | NO |
|
|
||||||
| PUT /item | R | inv,pile | handle_move_items | NO |
|
|
||||||
| POST /ut/delete/../match | R | coins | handle_match_end | NO |
|
|
||||||
| POST /auctionhouse,/transfermarket | R | listings | handle_market_list | NO |
|
|
||||||
| GET /tradePile | R | no | handle_market_query | NO |
|
|
||||||
| GET /tradePile/counts | R | no | handle_market_counts | NO |
|
|
||||||
| /trade/<id> (POST/PUT/GET) | R | coins,inv,listings | handle_market_buy | NO |
|
|
||||||
| DELETE /ut/delete/../trade/<id> | R | listings | handle_market_cancel | NO |
|
|
||||||
|
|
||||||
**Transfer-market semantics (live-verified 2026-08-17).** Three things here are
|
|
||||||
load-bearing and were each a live defect:
|
|
||||||
1. `/tradePile` and `/tradePile/counts` are **different deserializers** and MUST NOT
|
|
||||||
share a handler. `/counts` is FutGetAuctionCount: five scalar ints
|
|
||||||
(`count`, `maxAuctionsAllowed`, `offered`, `selling`, `sold`) and nothing else.
|
|
||||||
Served the `auctionInfo` body it skips every field, leaving the counts at 0 — the
|
|
||||||
hub tile shows a listing while the Transfer List screen shows no active sale.
|
|
||||||
2. An auction record's `itemData` MUST be the **full card object** (the same shape
|
|
||||||
`/club` emits), not a stub. A listing therefore persists a shaped-card SNAPSHOT
|
|
||||||
(`listings.item_json`) at list time. The seller's own pile stamps
|
|
||||||
`itemState: listFS`; market search uses `forSale`.
|
|
||||||
3. `POST /auctionhouse` resolves the listed card **server-side** from the wire item
|
|
||||||
id (`wire → Core instance → card_id + resourceId`); the client's FutISStart body
|
|
||||||
carries only the id. This also enforces that you can only list what you own.
|
|
||||||
|
|
||||||
### NON-ECONOMY — Rust-owned (route migration 2026-08-17, OBSERVED prod log)
|
|
||||||
| Route | Owner | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| POST /ut/auth (+ /ut/delete/auth) | R | Rust mints the SID (OPENFUT-SID-{:016X}); opens Rust session; adopts persona from body. No Python. |
|
|
||||||
| POST /openfut/account/sync | R | full Rust envelope; coins/unopenedPacks from Core; clubName OpenFUT / clubAbbr OFC constants |
|
|
||||||
| GET /userMassInfo | R | FULL Rust envelope (userInfo+squad+settings+pileSizeClientData); no Python. coins from Core, squad == /squad/active |
|
|
||||||
| GET/PUT /clientdata/<key> | R | host ClientDataStore (JSON-persisted); PUT acks {}, GET returns blob or {} |
|
|
||||||
| capability (/openfut/fifa17/capability) | R | -> Bound (CleanV1) |
|
|
||||||
| GET /club, /club/* readers | R | Core-backed collection (dropped_no_asset=0). Cards with an **ACTIVE listing are excluded** — a listed card has left the club. Keyed on the listing, NOT on the `trade` pile: the pile can hold cards with no listing (bare move, or cancelled/sold) and `/tradePile` renders only ACTIVE listings, so hiding the pile would make those invisible in BOTH views. Keying on the listing is self-healing — cancel/sale restores club visibility with no extra transition. Pagination then runs over the club-visible set (Core cannot filter host-owned listing state, so letting it paginate would yield short pages); with nothing hidden the fast Core-paginated path is unchanged. |
|
|
||||||
| GET /squad/0, /squad/active, /squad/list; PUT /squad/<n> | R | Core squad projection + tx; GET /squad/0 == active squad (verified structurally identical) |
|
|
||||||
| GET /user/accountinfo | R | {} |
|
|
||||||
| GET /user | R | `{"userInfo": …}` — same userInfo builder as userMassInfo (shared); squad rating is Core-authoritative (DIFFERENT-BY-DESIGN vs Python's stale value) |
|
|
||||||
| GET /settings | R | {"configs":[]} |
|
|
||||||
| GET /leaderboards/options | R | {} |
|
|
||||||
| PUT /match/reset | R | {} |
|
|
||||||
| GET /phishing/trusteddevice | R | security-question stateless ack |
|
|
||||||
| GET /hub | R | Core-derived counts; `clubPlayers` excludes actively-listed cards, `auctionCount`/`tradePile` from the durable market store |
|
|
||||||
| GET /club/stats/{year,consumables,staff,country,league,team} | R | Core aggregation; context buckets keyed nation/league/team (owned=1982); staff={} |
|
|
||||||
| GET /store, /match/keepalive, /captcha, /tfa, /livemessage, /activeMessage | R | unconditional constant acks (byte-identical to the oracle; StaticAck route) |
|
|
||||||
| GET /watchList (+ PUT/POST/DELETE) | R | empty watch list + authoritative Core credits; add/remove is a no-op ack (oracle persists none) |
|
|
||||||
| GET /season, /tournament, /champion, /clubUser, /user/list | R | FUT modes + club-identity off → `{}` (FeatureOffEmpty; byte-identical to the flag-off oracle). Migrated + deployed 2026-08-17 |
|
|
||||||
| POST /ut/.../match/end (DestroyMatch) | R | economy reward (coins credited via Core grant_reward) |
|
|
||||||
| GET /item/resource, /defid | R | `{itemData:[item_def…]}` — asset=rid&0xffffff; hardcoded Ronaldo (20801) + placeholder ("Player",75,CM,attrs 70), mirroring the oracle's `item_def`. Client renders from its LOCAL DB, so the placeholder is exact parity. Migrated + deployed 2026-08-17 |
|
|
||||||
| GET /marketdata, /marketdata/pricelimits | R | suggested pricing, constant band 150..15000. `/pricelimits` = bare ARRAY (one per defId); plain `/marketdata` = OBJECT — container type is load-bearing (object-where-array froze a live client). Migrated + deployed 2026-08-17 |
|
|
||||||
|
|
||||||
### NON-ECONOMY — still Python (PYTHON_FALLBACK)
|
|
||||||
| Method/path | Owner | Reason |
|
|
||||||
|---|---|---|
|
|
||||||
| POST /user/club (rename) | P | mutating club rename; Core has `clubs` but rename needs a Core write (deferred). Reads `clubUser`/`user/list` are now Rust. |
|
|
||||||
| GET /squad/<n> (n≠0, non-active) | P | no multi-squad Core model (Rust owns squad/0, squad/active, /squad/list, PUT) |
|
|
||||||
| /squad/mode/draft/* | P | FUT Draft mode |
|
|
||||||
| GET /leaderboards, /sbs/* | P | mode-gated (FUT_MODES/_SBC off → `{}`/content; `/sbs/sets` ships content); real behavior needs the mode logic ported. `season`/`tournament`/`champion` are now Rust. |
|
|
||||||
| POST /ut/.../match (CREATE), /match/ready (READY), /match (PLAY) | P | match handshake legs; no match ever played in-game (see docs/MATCH_LIFECYCLE.md) |
|
|
||||||
|
|
||||||
## AUXILIARY SERVICES (prod container OPENFUT_SERVERS="blaze roster pow")
|
|
||||||
All aux services are **Python in production today** (container `entrypoint.sh` runs
|
|
||||||
`blaze_responder_v3b.py`/`roster_server.py`/`pow_server.py`). Rust equivalents exist
|
|
||||||
outside Docker; deploying them is operator-gated (production deployment forbidden here).
|
|
||||||
|
|
||||||
| Service | Rust crate | Completeness | Prod owner | Reachable | Blocker to candidate |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| Blaze (:42130) | openfut-blaze-host + openfut-protocol-blaze + adapter::blaze | COMPLETE, gate-proven to real FUT (Gate 10: 3 logins, 10 pack opens, 448/448 py suite) | Python | yes | ERRC error-reply placement unresolved; wire into candidate bring-up |
|
|
||||||
| Redirector (:42127 TLS) | openfut-redirector-host + openfut-tls (OpenSSL vendored) | COMPLETE, 1 live handshake 2026-08-11 (TLSv1.2/AES256-GCM-SHA384) | Python | yes | never full-path gated; cert consistency |
|
|
||||||
| Roster (:8081) | openfut-roster-host | COMPLETE, oracle-parity + lifecycle tests, unit-only | Python | yes | never live-gated |
|
|
||||||
| POW (:8094 + :8080) | NONE (only pow_server.py) | no Rust host; 58 templates, bodies placeholder | Python | yes | LARGEST: no crate + bodies un-reversed |
|
|
||||||
| Nucleus (:42131) | none (advertised string only) | n/a | Python stub (advertised, unused) | NO (0 live hits) | DEAD in current flow — keep advertising URL, no migration |
|
|
||||||
|
|
||||||
RETIRED/out-of-scope: fifa-blaze (FIFA23 capture stub), openfut-bridge (FIFA23). Not in FIFA17 flow.
|
|
||||||
|
|
||||||
## NON-ECONOMY UTAS TARGET OWNER (confirmed via scouts)
|
|
||||||
Core already exposes GET/PUT /settings, GET /club|/collection|/statistics|/profile, /squad/*.
|
|
||||||
New Rust arm = Route variant + classify() arm (lib.rs:118-140) + owner-labelled handler.
|
|
||||||
|
|
||||||
| Route | Port complexity | Target | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| user/accountinfo | trivial-static ({}) | R | host constant |
|
|
||||||
| settings | trivial-static ({"configs":[]}) | R | host constant / Core /settings |
|
|
||||||
| leaderboards/options | trivial-static | R | host constant |
|
|
||||||
| match/reset | trivial-static ({}) | R | host constant ack |
|
|
||||||
| phishing/trusteddevice | small (validate hex + constant) | R | security-question: stateless ack, always verified/trusted |
|
|
||||||
| clientdata/userHubData | small stateful | R | Core PUT /settings upsert keyed userHubData |
|
|
||||||
| hub | medium (derived counts) | R | Core collection + market counts |
|
|
||||||
| club/stats/year | medium (rating-tier aggregation) | R | Core /collection + /statistics |
|
|
||||||
| club/stats/consumables | medium | R | BLOCKED on 17-consumable import |
|
|
||||||
| club/stats/staff | trivial ({}) or derived | R | BLOCKED on 3-staff import |
|
|
||||||
| account/sync | small-medium (persona select + save) | R | hardest: no direct Core route; host session/persona logic |
|
|
||||||
| ut/auth envelope | small (SID mint) | R (currently OBSERVE) | Python still mints envelope; boundary decision |
|
|
||||||
|
|
||||||
## MIGRATION PRIORITY ORDER (Phase 12)
|
|
||||||
1. Content gap consumable+staff emit (unblocks club/stats/{consumables,staff}) — import crate.
|
|
||||||
2. Trivial-static host routes: accountinfo, settings, leaderboards/options, match/reset, phishing/trusteddevice.
|
|
||||||
3. clientdata/userHubData (Core settings upsert).
|
|
||||||
4. hub + club/stats/year (Core-derived aggregation).
|
|
||||||
5. userMassInfo full ownership (envelope scaffold; econ+squad already Rust).
|
|
||||||
6. account/sync + ut/auth envelope (account/session boundary).
|
|
||||||
7. Aux candidate wiring (blaze/roster/redirector already built) + POW (blocked) + Nucleus (dead).
|
|
||||||
|
|
||||||
## ECONOMY EXPECTATION
|
|
||||||
Python economy hits = 0 (OBSERVED live + PROVEN by NEVER-BOTH/no-fallback tests).
|
|
||||||
Any nonzero Python economy hit = P1 regression, priority zero.
|
|
||||||
|
|
||||||
## STATUS
|
|
||||||
Baseline + economy + Rust-owned non-economy rows: OBSERVED/PROVEN.
|
|
||||||
Aux + non-economy target owners: confirmed via HostSourceMap/PythonContractMap/AuxServiceMap/ContentGapMap scouts.
|
|
||||||
|
|
||||||
## CANDIDATE MIGRATION STATUS (post-P1 progress, off-production)
|
|
||||||
DONE (Rust-owned in candidate source; committed on top of 5020137; workspace tests green):
|
|
||||||
- Content gap: consumable+staff emit (20 instances / 18 defs; verified real-profile re-import 1962+20).
|
|
||||||
- Non-economy routes migrated to Rust: user/accountinfo, settings, leaderboards/options,
|
|
||||||
match/reset, phishing/{trusteddevice,question,validate}, club/stats/staff, hub,
|
|
||||||
club/stats/{year,consumables}.
|
|
||||||
(hub clubPlayers = owned player count from Core; may be < Python profile count by the deferred
|
|
||||||
Legend instances = DIFFERENT-BY-DESIGN.)
|
|
||||||
- Reachability reporter (scripts/openfut-reachability.py) gates Python-hit invariants.
|
|
||||||
|
|
||||||
RESIDUAL PYTHON (still proxied; each has a concrete blocker, not ordinary difficulty):
|
|
||||||
- club/stats/{country,league,team} — RESIDUAL (nation/league/team context sub-screens). The global
|
|
||||||
MY-CLUB stat set (club/stats/{year,consumables,staff}) is now Rust (adapter club_stats.rs faithful
|
|
||||||
port of fut_club_stats.py VOCAB + counts, Core-accurate over real imported content, staging-verified
|
|
||||||
RUST route=club-stats owned=1982 players=1962). The per-nation-bucket context sub-screens still proxy
|
|
||||||
Python (low value, unrecognized atoms inert); migrate with a live context capture if ever needed.
|
|
||||||
- account/sync — launcher-facing (POST /openfut/account/sync, pre-auth); envelope has fields not in
|
|
||||||
Core (clubAbbr, established, profilePath, accountFunds cap); changing it risks the launcher. Needs
|
|
||||||
a Core account endpoint or host account logic + launcher-contract verification.
|
|
||||||
- userMassInfo — SAFE hybrid today (Rust overlays economy+squad on Python envelope). Full ownership
|
|
||||||
needs a real full-envelope capture first (missing one scaffold field breaks a hot route); deferred.
|
|
||||||
- ut/auth — ORDERING-BLOCKED: still-proxied Python routes rely on Python's session table, so auth
|
|
||||||
must stay proxied (Python mints SID, Rust OBSERVEs) until every session-needing route is Rust. Migrate LAST.
|
|
||||||
- clientdata/userHubData — BLOCKED: Core /settings supports only 2 fixed keys (difficulty,
|
|
||||||
preferred_formation); storing an arbitrary blob needs a frozen-Core change or a new host store.
|
|
||||||
Low-value UI-pref blob; kept Python per Phase 23 (no Python-by-ideology).
|
|
||||||
|
|
||||||
AUX (Rust built, deploy = operator-gated container cutover, NOT this task): blaze/roster/redirector.
|
|
||||||
POW = blocked (no crate, bodies un-reversed). Nucleus = dead (0 live hits).
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
# OpenFUT — Project State
|
|
||||||
|
|
||||||
> **Canonical source:** `../OpenFUT-Vault/06 Agent Memory/Project State.md`
|
|
||||||
> This file is a mirror. If the two disagree, the vault wins. Update the vault first.
|
|
||||||
|
|
||||||
Factual snapshot. Prefer this over the stale root `README.md`/`CLAUDE.md` status tables (FIFA 23).
|
|
||||||
Last compiled from repository evidence during context initialization.
|
|
||||||
|
|
||||||
## Working
|
|
||||||
|
|
||||||
- **FIFA 17 offline FUT stack, end-to-end.** Proven 2026-08-01: auth → Blaze login → device-trust →
|
|
||||||
the FUT hub. Brought up by `fifa17-recon/tools/openfut-fut.sh start`. Evidence: `FUT-RUNBOOK.md`,
|
|
||||||
`fifa17-recon/README.md`, the five responder scripts, gate-ladder troubleshooting table.
|
|
||||||
- **ProtoSSL cert-pin defeat** — two live `/proc/PID/mem` patches (`autopatch.py`), VAs stable
|
|
||||||
across launches. gdb-verified which gate was the wall.
|
|
||||||
- **LSX / Origin layer** — crypto handshake reversed byte-exact and confirmed against the repack's
|
|
||||||
own emu disassembly (`docs/REPACK_INTEL.md`); Origin login gates cleared.
|
|
||||||
- **Blaze redirector + Fire2/Heat2** — both hops defeated; preAuth/login/personas answered.
|
|
||||||
- **UTAS/RS4 FUT API** — `ut/auth` + boot calls + device-trust reach the hub with hand-authored JSON.
|
|
||||||
- **Persistent FIFA 17 account selection** — the launcher synchronizes one configured EA persona to
|
|
||||||
the Python backend before starting LSX/FIFA. LSX, Blaze, POW/EASFC, and UTAS then share that
|
|
||||||
identity, while FUT coins, inventory, squads, progression, and unopened packs persist in an
|
|
||||||
isolated save beneath `fifa17-recon/docker/state/accounts/<persona-id>/`. The POW level/XP/funds
|
|
||||||
shown in FIFA's general account bar are account-scoped but remain distinct from FUT club coins.
|
|
||||||
A reversible server test on 2026-08-09 verified profile switching, POW values, a 400-coin pack
|
|
||||||
debit, five awarded items, and restoration of the original profile.
|
|
||||||
- **Account-scoped FUT security compatibility** — launcher account synchronization initializes a
|
|
||||||
persisted `securityQuestion` verification record in that persona's FIFA 17 profile. The UTAS
|
|
||||||
PHISHING handler returns the complete CardsDLL trusted-console response (`changed`, `exists`,
|
|
||||||
`locked`, `trusted`), accepts only well-formed legacy setup/validate requests under `X-UT-SID`,
|
|
||||||
and never stores or logs the client-transformed answer. This is server-side emulation; the hook
|
|
||||||
and launcher do not contain an answer or add UI automation. Automated contract coverage is in
|
|
||||||
`fifa17-recon/docker/ctx/tools/test_security_question.py`; live first/repeat-launch acceptance is
|
|
||||||
partially complete: the first launch entered FUT without a security dialog on 2026-08-09; a
|
|
||||||
second fresh-process FUT entry is still required to close persistence acceptance.
|
|
||||||
- **Safe responder diagnostics** — ordinary LSX logs redact challenge/session/auth-code attributes;
|
|
||||||
ordinary Blaze logs redact auth/session keys and no longer emit raw Fire2 hex, decoded TDF, or
|
|
||||||
config values. Forensic Blaze capture remains available only with the explicit
|
|
||||||
`OPENFUT_BLAZE_DUMP_FRAMES=1` opt-in. LSX and Blaze self-tests cover the new defaults.
|
|
||||||
- **OpenFUT Core** — Rust FUT economy backend, feature-complete for its scope and tested: profiles,
|
|
||||||
clubs, coins, packs, cards, squads, chemistry styles, SBCs, objectives, matches, market (NPC),
|
|
||||||
draft, FUT Champs, seasons, statistics, achievements, events, daily check-in, division
|
|
||||||
leaderboard, market trade history. 13 migrations. Integration suite (`tests/integration_test.rs`,
|
|
||||||
96 test fns) runs against in-memory SQLite; CI (fmt/clippy/build/test) green on `openfut-core`.
|
|
||||||
|
|
||||||
## Partially implemented
|
|
||||||
|
|
||||||
- **FIFA 17 FUT hub depth** — reaching the hub is proven, but how much of FUT is fully navigable
|
|
||||||
beyond it (playing matches, pack opening, SBC submission through the *game* UI vs. spinner/error
|
|
||||||
states) is not documented as complete. The runbook's gate ladder lists failure modes still
|
|
||||||
guarded against. Treat "past the hub" as unverified.
|
|
||||||
- **Pack opening through the game UI** — proven live on 2026-08-09 with the recovered CAGE test
|
|
||||||
profile: purchase, reveal, item assignment/quick-sell, wallet refresh, and return from the reveal
|
|
||||||
all completed. The Python transaction path also passes its 446-check contract suite. FIFA's
|
|
||||||
hardcoded post-reveal `mypacks` return is supported by a short-lived active grace record for every
|
|
||||||
opened pack; it is excluded from unopened-pack counts and retired at the next hub request.
|
|
||||||
- **FIFA 17 FUT match lifecycle** — CardsDLL static analysis and isolated responder tests now cover
|
|
||||||
CREATE→READY→PLAY→END. Bare `/match` requests carrying body `matchId` are classified as PLAY
|
|
||||||
instead of accidentally allocating another match; READY returns the verified scalar `matchId`
|
|
||||||
and `opponentPersonaId` fields; END persists W/D/L, matches played, and coin rewards per account.
|
|
||||||
The implementation is deployed and `test_match_lifecycle.py` passes, but no football match has
|
|
||||||
started or completed in FIFA yet. The READY opponent `items` contract and client mode-entry gate
|
|
||||||
remain unresolved; `FUT_MODES` therefore stays off by default.
|
|
||||||
- **Core ↔ emulation integration** — the two halves exist and wiring has **started**. First
|
|
||||||
slice (2026-08-11): the My Squad owned-player search. `openfut-core` gained a semantic,
|
|
||||||
game-independent owned-inventory query (`services::inventory::{OwnedItemQuery, apply_query}`
|
|
||||||
+ a `Quality` tier) that filters (AND) → orders deterministically → paginates, wired into
|
|
||||||
`GET /collection`; `openfut-adapter-fifa17::fut::owned_query` parses the FIFA17 `/club` wire
|
|
||||||
query and resolves numeric league/nation/team ids → semantic names (unknown id = hard error,
|
|
||||||
no raw-id passthrough). Intentional fix, not parity: Python applies only `league`+`team` and
|
|
||||||
ignores `level`/`rare`/`position`/`nation`/`start`/`count` (the request-amplification bug);
|
|
||||||
Core applies all proven filters and paginates. `rare=SP` semantics UNKNOWN, unimplemented.
|
|
||||||
Slice 2 (2026-08-11): `openfut-utas-host` — the first live UTAS host. Serves `GET …/club`
|
|
||||||
from Core through the adapter and reverse-proxies every other UTAS route verbatim to the
|
|
||||||
Python oracle (`utas_server.py`); plaintext HTTP/1.1 keep-alive, classify-before-execute,
|
|
||||||
no python-fallback after a Core error. `CoreAccess` is a host-owned boundary (the adapter
|
|
||||||
stays transport-agnostic). 11 host + 22 adapter tests; 10/10 mutations killed; fmt/clippy
|
|
||||||
clean. Slice 3 (2026-08-11, `3ef3bc3`): the real `Fifa17IdentityResolver` — catalog
|
|
||||||
(card id → real asset id) + persistent `openfut-identity` store (owned instance →
|
|
||||||
stable/reversible wire int) + wire-id policy, replacing all placeholders (one
|
|
||||||
production path). Wire-id namespace is globally monotonic within `(fifa17, owned-item)`,
|
|
||||||
not per-account (Core owned ids are UUIDs → unambiguous reverse). Slice 4 (2026-08-11,
|
|
||||||
core `36abd4b`): a curated 32-card real FIFA17 dev content pack
|
|
||||||
(`data/games/fifa17/dev/cards.json`, ids `fifa17_<asset>`), loaded only via opt-in
|
|
||||||
`Config.dev_content_games`; `seed-dev` grants a `game_id=fifa17` profile+club real
|
|
||||||
`OwnedCard`s (no FIFA wire ids — the resolver mints those at request time), idempotent,
|
|
||||||
default content untouched. Slice 5 (2026-08-11, `5276dd2`): the host sends
|
|
||||||
`X-OpenFUT-Game: fifa17` so `/club` resolves the all-mapped fifa17 profile —
|
|
||||||
**composition proven live** over HTTP (real Core+host, no FIFA client): FIFA wire query
|
|
||||||
→ real `resourceId`s (catalog) + stable/reversible wire `id`s (store); 33 renderable,
|
|
||||||
gold=22, Premier League=18, pagination page1=11/page2=7/overlap=0 (clean paging, no
|
|
||||||
drops). **The only remaining gate is the live retail FIFA A/B** (no FIFA client in the
|
|
||||||
build env; runbook `openfut-utas-host/README.md`). See the vault UTAS Endpoint Map +
|
|
||||||
Known Issues (incl. "identity resolution is NOT authorization" for later mutations).
|
|
||||||
**Slice 6 (2026-08-11): `/club` RUNTIME VALIDATED on retail FIFA 17** — operator-assisted
|
|
||||||
live A/B (`.105` client → `.120` backend via a source-scoped NAT redirect into a staged
|
|
||||||
`36abd4b` Core + `openfut-utas-host`). Every checkpoint passed on the real client:
|
|
||||||
transport, per-route Python fallback, Python-negative `/club`, Core `X-OpenFUT-Game`
|
|
||||||
scoping, real card + persistent owned-item identity (incl. the two-copy fixture),
|
|
||||||
no-filter/Gold/position/nation/league/team/combined-AND filtering, retail pagination
|
|
||||||
with no amplification, card selection, identity stability across relaunch, Python
|
|
||||||
rollback, and Rust re-enable (identity store byte-identical across the cycle, 0
|
|
||||||
reallocation). Live findings: the retail client steps `start += 10` with `count=11`
|
|
||||||
(sometimes bulk `count=100`) — Core honours `offset` and terminates; the My Club UI
|
|
||||||
nests team under league; the "~1900" club counter is Python `userMassInfo`, not `/club`.
|
|
||||||
Remaining is operational only (promote the staged stack to a durable deployment).
|
|
||||||
- **Slice 7 (2026-08-12): FUT squad authority (read + write) RUNTIME VALIDATED on retail FIFA 17.**
|
|
||||||
Staged operator-assisted A/B (`.105` retail client → `.120`, source-scoped utas switch
|
|
||||||
`:8099→:8199` into `openfut-utas-host` over a staged `615c5fd` Core seeded by `seed-dev` with the
|
|
||||||
dev 33-card inventory). FIFA itself consumed the Rust squad path end-to-end: FUT boot served
|
|
||||||
`RUST_OVERLAY userMassInfo` (squad overlay) and the squad screen rendered the dev XI; a controlled
|
|
||||||
in-game squad edit issued `PUT …/squad/0` → host `squad-replace` → Core → `{"id":0}`; an in-game
|
|
||||||
formation change f442→f433 persisted to Core (canonical fingerprint changed, `position_index`
|
|
||||||
remapped 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.
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# Python Retirement Readiness (post-P1)
|
|
||||||
|
|
||||||
Classification of every Python component still present. Evidence: prod-host
|
|
||||||
`owner=` dispatch log (OBSERVED), AuxServiceMap/PythonContractMap scouts,
|
|
||||||
container `entrypoint.sh`. **P2 rollback stays hot regardless** (do NOT remove
|
|
||||||
rollback-to-python-p2.sh, :p2-rollback image b1b929953f, profile 39bb3e83).
|
|
||||||
|
|
||||||
## A. LIVE PRODUCTION REQUIRED (still owns behavior in the live flow)
|
|
||||||
| Component | Role | Rust status | Retire when |
|
|
||||||
|---|---|---|---|
|
|
||||||
| oracle utas_server.py (:8199) NON-ECONOMY | **remaining**: user/club rename, non-active squad/<n>, draft/*, mode-gated leaderboards + /sbs/* | **Most non-economy migrated 2026-08-17** (account/sync, auth, userMassInfo, user, clientdata, hub, club/stats/*, settings, accountinfo, phishing, match/reset, leaderboards/options, watchList, static acks, item-defs (item/resource,defid), marketdata (+/pricelimits), and the flag-off empty reads season/tournament/champion/clubUser/user/list → `{}` — all Rust). See PRODUCTION_AUTHORITY_MATRIX | remaining tail is mutation (user/club), no-Core-model (squad/<n>), or unimplemented modes (draft/leaderboards/sbs) |
|
|
||||||
| blaze_responder_v3b.py (:42130 Blaze) | FUT Blaze transport | Rust openfut-blaze-host COMPLETE, gate-proven (Gate 10) | container cutover (operator-gated deploy) |
|
|
||||||
| blaze_responder_v3b.py (:42127 redirector TLS) | first-hop TLS redirect | Rust openfut-redirector-host COMPLETE (OpenSSL) | container cutover + cert consistency |
|
|
||||||
| roster_server.py (:8081) | roster-update XML | Rust openfut-roster-host COMPLETE (unit-only) | container cutover |
|
|
||||||
| pow_server.py (:8094/:8080) | Proof-of-Work / content | NO Rust crate; 58 templates, bodies un-reversed | needs Rust host + body RE (BLOCKED) |
|
|
||||||
|
|
||||||
## B. ORACLE ONLY (reference / differential, not production authority)
|
|
||||||
- utas_server.py economy handlers: now oracle-only (economy is Rust; Python economy hits = 0). Used by
|
|
||||||
`economy_differential.rs` as the parity oracle. KEEP.
|
|
||||||
- fut_store.py / fut_accounts.py / fut_consumables.py: reference models + Ghidra-derived taxonomy source. KEEP.
|
|
||||||
|
|
||||||
## C. MIGRATION TOOL ONLY (offline)
|
|
||||||
- scripts/*.py (seed_fifa17_cards.py, utas-observe/mutate/diff, check-*). KEEP (dev tooling).
|
|
||||||
- fifa17-recon/tools/db_dump.py etc.: table extraction. KEEP.
|
|
||||||
|
|
||||||
## D. ROLLBACK ONLY (hot, DO NOT REMOVE)
|
|
||||||
- rollback-to-python-p2.sh; image openfut-fut-backend:p2-rollback (b1b929953f) + :dev; profile 39bb3e83;
|
|
||||||
compose/env backup; tuple OPENFUT_SERVERS="blaze roster utas pow".
|
|
||||||
|
|
||||||
## E. DEAD (advertised but not followed in current FIFA17 flow)
|
|
||||||
- Nucleus /connect/token stub (:42131): 0 live hits across Gate 5-10 (client never POSTs). Keep the ADVERTISED
|
|
||||||
URL so the client can't reach real EA, but no migration needed. No Rust listener required.
|
|
||||||
- fifa-blaze (FIFA23 capture stub), openfut-bridge (FIFA23): retired lineage, not in FIFA17 flow.
|
|
||||||
|
|
||||||
## Retirement gating
|
|
||||||
1. **Mostly DONE (2026-08-17)**: account/sync, ut/auth (Rust SID mint), userMassInfo (full), clientdata,
|
|
||||||
club/stats/{country,league,team}, watchList, static acks, the flag-off empty reads
|
|
||||||
(season/tournament/champion/clubUser/user/list → `{}`), item-defs (item/resource,defid), and
|
|
||||||
marketdata (+/pricelimits, container-type-exact) migrated + deployed. **Remaining on Python**:
|
|
||||||
user/club rename (mutating; needs a Core write), non-active squad/<n> (no multi-squad Core model),
|
|
||||||
and the unimplemented modes draft/* + leaderboards + /sbs/* (need the mode logic ported, not a proxy).
|
|
||||||
2. Deploy Rust blaze/roster/redirector via container cutover (operator-gated) — then those Python responders
|
|
||||||
are class D/E only.
|
|
||||||
3. POW: build Rust host + reverse bodies (largest blocker) OR keep Python POW as class A indefinitely.
|
|
||||||
4. Only after a long stable window: consider retiring the P2 rollback (separate explicit decision — NOT now).
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
# OpenFUT — Direction Document
|
|
||||||
*The pivot: FUT lives in the app; FIFA 23 is the match renderer.*
|
|
||||||
*Supersedes the Blaze-backend approach as the primary plan. Last updated 2026-06-30.*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Goal (revised)
|
|
||||||
|
|
||||||
Deliver an **intuitive way to play a FUT-style experience with FIFA 23**, where:
|
|
||||||
|
|
||||||
- The entire **FUT experience** — cards, squads, packs, SBCs, coins, chemistry,
|
|
||||||
progression — lives in a **custom app** (web UI or desktop) built on the
|
|
||||||
already-complete OpenFUT Core economy backend.
|
|
||||||
- **FIFA 23 is demoted to a match renderer.** Its only job is to play a
|
|
||||||
single-player match using the squad the app built. No FUT mode, no online, no
|
|
||||||
Blaze, no EA servers.
|
|
||||||
|
|
||||||
This deliberately drops in-game FUT cards/UI (they live in the app) in exchange
|
|
||||||
for a project that **converges** instead of being gated behind months of
|
|
||||||
backend reverse-engineering.
|
|
||||||
|
|
||||||
### Why this replaces the backend plan
|
|
||||||
|
|
||||||
The status review confirmed the backend route (faking EA's online stack) is
|
|
||||||
blocked at an upstream in-process EbisuSDK gate, with Blaze/Fire2 unconfirmed
|
|
||||||
beyond it — realistically 3–6 months of expert RE that may not converge. The
|
|
||||||
app-centric route sidesteps **every** wall in that review by never making FIFA's
|
|
||||||
own FUT mode run.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Base mode: Career, not Kick-Off
|
|
||||||
|
|
||||||
**Career mode is the base.** Reasons:
|
|
||||||
|
|
||||||
- FLE's live-editing API (`EditDBTableField`, Freeze Lineup) is **confirmed to
|
|
||||||
work in career mode** and explicitly does NOT work in FUT/online modes.
|
|
||||||
- Career already provides the FUT-shaped scaffolding we'd otherwise fake:
|
|
||||||
persistent club, a fixture schedule, recorded results, progression across a
|
|
||||||
season.
|
|
||||||
- **Match results are written into the career DB**, making result capture a DB
|
|
||||||
read rather than a fragile live-memory grab.
|
|
||||||
|
|
||||||
**Kick-Off is the prototype sandbox.** Use it first to prove squad injection
|
|
||||||
works with nothing to corrupt (no save to break), then move the real loop onto
|
|
||||||
career. Run the foundational injection test in BOTH.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Core architecture: the bidirectional FLE bridge
|
|
||||||
|
|
||||||
The backbone is a **bidirectional channel between the app and a resident FLE Lua
|
|
||||||
script running inside the game.** Everything else is messages over this channel.
|
|
||||||
|
|
||||||
```
|
|
||||||
Custom App (FUT experience)
|
|
||||||
│ squad push ──────────────► ┌─────────────────────────────┐
|
|
||||||
│ │ Resident FLE Lua script │
|
|
||||||
│ ◄────────── game state │ (inside FIFA 23, career) │
|
|
||||||
│ ◄────────── match result │ - reads game state │
|
|
||||||
└────────────────────────────► │ - applies squad live │
|
|
||||||
(file-watch or local socket) │ - reads results from DB │
|
|
||||||
└─────────────────────────────┘
|
|
||||||
│
|
|
||||||
FIFA 23 plays the match
|
|
||||||
```
|
|
||||||
|
|
||||||
Three message types over the bridge:
|
|
||||||
|
|
||||||
1. **App → Game: squad push.** The app's chosen XI + stats applied LIVE via
|
|
||||||
`EditDBTableField`, replicating whatever DB write FLE's "Freeze Lineup"
|
|
||||||
feature performs (see `docs/foundational-xi-injection-test.md` — the exact
|
|
||||||
field(s) are found by diffing, not assumed). No restart, no
|
|
||||||
file-copy-reload. (File-load remains a fallback.)
|
|
||||||
|
|
||||||
2. **Game → App: game state.** The resident script polls the game's current
|
|
||||||
screen/menu state and reports "safe to apply" vs "not safe", driving a smart
|
|
||||||
Apply button in the app (see §5).
|
|
||||||
|
|
||||||
3. **Game → App: match result.** After full-time, the script reads the result
|
|
||||||
from the career DB and pushes score/scorers to the app, which awards
|
|
||||||
coins/progression. (Manual entry is the baseline fallback.)
|
|
||||||
|
|
||||||
The bridge transport can be a watched file the in-game Lua polls, or a local
|
|
||||||
socket — decided in build (see §7). Either way the *game keeps running*; a file,
|
|
||||||
if used, is just the message channel, not a reload.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Tiered mod scope
|
|
||||||
|
|
||||||
Build in tiers matched to risk. The core tier is all the SAME kind of DB write,
|
|
||||||
so it lands together once squad injection works.
|
|
||||||
|
|
||||||
### Tier 1 — Core writes (ride the same live DB-edit mechanism)
|
|
||||||
- **Squad / custom XI** — the load-bearing primitive (Freeze Lineup's
|
|
||||||
underlying write, replicated via script — see §6).
|
|
||||||
- **Player stats as "cards"** — card tiers, in-form versions, SBC upgrades all
|
|
||||||
expressed as written attribute values.
|
|
||||||
- **Chemistry as stat adjustment** — app computes FUT chemistry, applies it as
|
|
||||||
small stat bumps when writing players in (no in-game chem UI; that's in the app).
|
|
||||||
- **Appearance / identity** — kits, names, team assignment, so the club looks
|
|
||||||
like your club on the pitch.
|
|
||||||
- **Formation / tactics** — squad structure carries the app's build onto the pitch.
|
|
||||||
|
|
||||||
### Tier 2 — Confirm-then-add
|
|
||||||
- **Match difficulty per game** — to drive a Squad-Battles-style "this opponent is
|
|
||||||
World Class". Settable in-game trivially; programmatic drive needs confirming.
|
|
||||||
- **Match rules / modifiers** (half length, etc.) — for app-defined challenges.
|
|
||||||
|
|
||||||
### Tier 3 — Result capture (manual baseline + automated stretch)
|
|
||||||
- **Manual:** user enters the score in the app after the match. Zero RE, ships
|
|
||||||
first.
|
|
||||||
- **Automated:** resident script reads the career-DB result (or, for Kick-Off,
|
|
||||||
reads the in-match score from memory at full-time — precedent exists: the
|
|
||||||
CM cheat table's `export_season_stats.lua` already reads goals/cards from
|
|
||||||
memory via known offsets). Push to app → auto-award progression.
|
|
||||||
|
|
||||||
### Out of scope (stays in the app, by design)
|
|
||||||
- In-game FUT cards, FUT menus, pack-opening animation, chemistry board, FUT
|
|
||||||
presentation. The app is where it looks/feels like FUT.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. The smart Apply button (state-aware)
|
|
||||||
|
|
||||||
Live DB edits only "stick" in safe menu states (the in-game "Edit Player" screen,
|
|
||||||
for example, overwrites edits). So the bridge reads game state and gates applying:
|
|
||||||
|
|
||||||
- Resident Lua script polls the game's current-screen value (a few Hz),
|
|
||||||
classifies **safe / not safe**, reports to the app.
|
|
||||||
- App's **Apply button is enabled only when the script confirms a safe state**
|
|
||||||
(squad hub, main menu); greyed otherwise.
|
|
||||||
- **Safe-by-default-OFF:** unknown state → button greyed → never a risky write.
|
|
||||||
Expand the known-safe list incrementally as states are confirmed.
|
|
||||||
- **v2 (more seamless):** instead of greying, the app always lets you click and
|
|
||||||
the script **queues** the apply, executing the moment a safe state is entered,
|
|
||||||
then confirms back. Greying is v1; queue-and-apply is v2.
|
|
||||||
|
|
||||||
`IsInCM()` is a confirmed state-read; the specific screen-state address + the
|
|
||||||
value→screen mapping is one-time reconnaissance (same technique as result reading).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. What's confirmed vs what needs validating
|
|
||||||
|
|
||||||
**Confirmed (from FLE's own Lua API docs/wiki, checked 2026-06-30):**
|
|
||||||
- FLE live-edits the running career DB without restart, via `EditDBTableField`
|
|
||||||
(real signature: `EditDBTableField(cell)` where `cell = row["fieldname"]`
|
|
||||||
with `.value` mutated first — not the table/index/field/value form an
|
|
||||||
earlier draft of this doc assumed).
|
|
||||||
- FLE reads game state via `IsInCM()`.
|
|
||||||
- A `MEMORY` Lua class exists (`ReadInt`/`WriteInt`/`ReadMultilevelPointer`/
|
|
||||||
etc.) for arbitrary process memory — confirms the result-reading fallback
|
|
||||||
in §4 Tier 3 is a real, documented capability, not just cheat-table analogy.
|
|
||||||
- `GetPlayersStats()` is a documented function returning per-player
|
|
||||||
goals/assists/cards/etc. — a better confirmed path for match-result capture
|
|
||||||
than raw memory offsets.
|
|
||||||
- **Freeze Lineup** (Formation Editor → arrange XI → tick "Freeze Lineup" →
|
|
||||||
`Data → Save`) is FLE's actual documented mechanism for forcing a starting
|
|
||||||
XI in career mode. This **replaces** "selection bias" below.
|
|
||||||
- OpenFUT Core (economy) is complete and tested.
|
|
||||||
|
|
||||||
**Walked back — not actually confirmed:**
|
|
||||||
- "Selection bias forces specific players into the starting XI" — no such
|
|
||||||
field appears anywhere in FLE's documented Lua API or its own example
|
|
||||||
scripts. This was an unverified assumption carried over from general FIFA
|
|
||||||
modding precedent (other titles), not anything checked against FLE/FIFA 23.
|
|
||||||
See `docs/foundational-xi-injection-test.md` for the corrected plan, which
|
|
||||||
uses Freeze Lineup instead.
|
|
||||||
|
|
||||||
**Needs validating (the foundational tests — see §7):**
|
|
||||||
- Whether Freeze Lineup actually holds into a played match (FLE's wiki
|
|
||||||
documents the feature but not a live-match test of it).
|
|
||||||
- What DB table/field Freeze Lineup's `Data → Save` actually writes — it's
|
|
||||||
GUI-only and undocumented at that level; finding it is part of the
|
|
||||||
foundational test.
|
|
||||||
- Whether that write can be replicated by a script (`EditDBTableField`) well
|
|
||||||
enough to drive it from an EXTERNAL trigger, not just the Formation Editor
|
|
||||||
UI — required for the app↔game bridge.
|
|
||||||
- The app↔game bridge transport (file-watch vs socket) works cleanly under the
|
|
||||||
run setup.
|
|
||||||
- The screen-state address + safe/not-safe classification (FLE's `Events`
|
|
||||||
API page exists in the wiki index but its content is currently empty/
|
|
||||||
undocumented — this is more open than previously assumed).
|
|
||||||
- Result read-back from the career DB after a match.
|
|
||||||
|
|
||||||
**Standing caveat:** the whole stack rides on **EAAC staying neutralized**
|
|
||||||
(FLE's fake-launcher bypass). If a game update re-enables it, hooks fail. Keep
|
|
||||||
game updates off; confirm neutralized state each session.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Build order / next steps
|
|
||||||
|
|
||||||
Each is a bounded, verifiable step. Do them in order; later ones depend on
|
|
||||||
earlier answers.
|
|
||||||
|
|
||||||
1. **FOUNDATIONAL TEST — live custom XI in career.** Confirm Freeze Lineup
|
|
||||||
holds into a played match, reverse-engineer the DB write it makes, then
|
|
||||||
replicate that write from a script so it can be triggered externally
|
|
||||||
instead of through the Formation Editor UI. See
|
|
||||||
`docs/foundational-xi-injection-test.md` for the full procedure. *Done =
|
|
||||||
a script-driven write produces a match that fields the squad you
|
|
||||||
specified.* Everything rests on this.
|
|
||||||
|
|
||||||
2. **Pick the bridge transport.** Decide file-watch vs local socket for app↔game
|
|
||||||
messaging; implement the minimal app→game squad push. *Done = app sends a
|
|
||||||
squad, the resident script receives and applies it.*
|
|
||||||
|
|
||||||
3. **Game-state reader + smart Apply.** Find the screen-state address, classify
|
|
||||||
safe/not-safe, expose to the app, gate the Apply button. *Done = button greys
|
|
||||||
when you enter a match/edit screen, enables in the squad hub.*
|
|
||||||
|
|
||||||
4. **Result read-back.** Read the career-DB match result post-game, push to app,
|
|
||||||
award progression. Manual entry ships alongside as the fallback. *Done = app
|
|
||||||
updates coins from a played match.*
|
|
||||||
|
|
||||||
5. **Tier 1 breadth.** Extend the squad push to carry stats, appearance,
|
|
||||||
formation (same write mechanism). *Done = the club looks and plays like the
|
|
||||||
app's build.*
|
|
||||||
|
|
||||||
6. **Tier 2 + economy loop polish.** Difficulty drive, challenges, and the full
|
|
||||||
pack → SBC → squad → match → reward loop closed end-to-end.
|
|
||||||
|
|
||||||
### Decision still open
|
|
||||||
- **App form factor:** web UI vs desktop app. This affects the bridge transport
|
|
||||||
(a desktop app can hold a local socket more naturally; a web UI leans toward a
|
|
||||||
small local helper/file-watch). Decide before step 2.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Provenance
|
|
||||||
|
|
||||||
Clean-room throughout. This route relies on FLE's documented public API and the
|
|
||||||
game's own supported career mode — no EA backend, no Blaze, and nothing derived
|
|
||||||
from leaked EA source. The earlier backend RE remains clean-room and is preserved
|
|
||||||
as a spec artifact; it is simply no longer the primary path.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. One-paragraph summary
|
|
||||||
|
|
||||||
OpenFUT becomes a **FUT companion app that uses FIFA 23 as a match engine.** The
|
|
||||||
app owns the entire FUT experience; a resident FLE Lua script in career mode
|
|
||||||
applies the app's squad live (no restart), reports game state to drive a safe
|
|
||||||
Apply button, and reads match results back to feed progression. This sidesteps
|
|
||||||
every backend wall, runs on confirmed FLE capabilities, builds on the finished
|
|
||||||
economy core, and delivers the intuitive, offline, FUT-flavored loop that is the
|
|
||||||
actual goal.
|
|
||||||
@@ -1,443 +0,0 @@
|
|||||||
# FIFA 17 — Empty "My Packs" Client Contract (store/purchasegroup)
|
|
||||||
|
|
||||||
> **STATUS (2026-08-13): ROOT CAUSE ESTABLISHED; P2 backend compatibility workaround
|
|
||||||
> IMPLEMENTED.** Root cause: FIFA 17's Store/Scaleform path resolves the `mypacks`
|
|
||||||
> category even with zero unopened packs, and CardsDLL `FUN_1800147f0` assumes the
|
|
||||||
> resolved group is non-null (crash if absent). Backend decision: **P2** — emit an
|
|
||||||
> **active** non-openable synthetic `mypacks` placeholder (id 65534) when
|
|
||||||
> `unopenedPackIds == []` (`fifa17-recon/tools/utas_server.py` `store_catalog`; tests
|
|
||||||
> `fifa17-recon/tools/test_empty_mypacks.py`). Crash-safe + economy-safe; known UX
|
|
||||||
> limitations (fake tile, click-dialog, Browse→My-Packs nav quirk) are Scaleform-driven
|
|
||||||
> and require the client-side fix in `docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md`.
|
|
||||||
> This is a FIFA-17-specific compatibility shim, NOT an EA-authentic representation,
|
|
||||||
> and is confined to the FIFA-17 adapter/backend (NOT OpenFUT Core).
|
|
||||||
|
|
||||||
Evidence labels: **OBSERVED** (runtime capture / crash dump / already-decompiled RE
|
|
||||||
quoted in-repo), **INFERRED**, **HYPOTHESIS**, **UNKNOWN**. No server behavior is
|
|
||||||
changed by this document; it is analysis only.
|
|
||||||
|
|
||||||
Binaries (hashes verified 2026-08-13 on `.105`):
|
|
||||||
`CardsDLL_Win64_retail.dll` SHA-256 `4706a881ae1fc7b5769fd810b25a868d29d2b16a8e65a7513436327ef645573c`
|
|
||||||
(load base in the crash dump `0x00006FFFFC120000`; RE-space base `0x180000000`).
|
|
||||||
`FIFA17.exe` SHA-256 `29c31cef12b0c3c2a7305220617c7b4fa139ab76b8c857851bdbe88987962899` (packed).
|
|
||||||
|
|
||||||
## 1. Question
|
|
||||||
|
|
||||||
How must the server represent an account that owns **zero unopened packs** in
|
|
||||||
`GET /ut/game/fifa17/store/purchasegroup/all?ppInfo=true` so that FIFA 17 neither
|
|
||||||
(a) crashes nor (b) shows "The pack you've selected is currently not available",
|
|
||||||
**without granting the user a real/openable pack** and without breaking the normal
|
|
||||||
bronze/gold/special store? The current OpenFUT answer (a synthetic inactive `mypacks`
|
|
||||||
sentinel) only downgrades a crash to a dialog; it is not the correct contract.
|
|
||||||
|
|
||||||
## 2. Established experimental behavior (OBSERVED)
|
|
||||||
|
|
||||||
Profile-state ladder, all with normal packs 1/5/6/7 unchanged:
|
|
||||||
|
|
||||||
| profile `unopenedPackIds` | `mypacks` group in response | client outcome |
|
|
||||||
|---|---|---|
|
|
||||||
| `[]` (baseline) | one **inactive** sentinel pack `65534`, empty description | "pack not available" dialog → FUT Hub |
|
|
||||||
| `[70]` (Exp A) | one **active** real owned pack `70` | **store works**; My Packs visible |
|
|
||||||
| `[]` + sentinel suppressed (Exp B) | **no `mypacks` group at all** | **client CRASH** |
|
|
||||||
|
|
||||||
Captures: `store_purchasegroup_capture_2026-08-12.json` (baseline),
|
|
||||||
`…_mypacks70_2026-08-12.json` (A), `…_empty_no_sentinel_2026-08-12.json` (B).
|
|
||||||
Details in `STORE_TILE_6C.md` §14-§15.
|
|
||||||
|
|
||||||
## 3. Existing RE evidence (from `docs/plan-2026-08-05-store-subsystem.md`, decompiled)
|
|
||||||
|
|
||||||
All addresses RE-space (CardsDLL base `0x180000000`):
|
|
||||||
- **`FUN_18013af30`** — per-element pack deser; each `purchase[]` entry → a `0x158`
|
|
||||||
wire record. `displayGroup.value`(atom 0x377) → record `+0x00` (ctor default the
|
|
||||||
literal `"unknown"`). `displayGroup.priority`(0x250) → `+0x34`.
|
|
||||||
(store-subsystem §"wire record", :996.)
|
|
||||||
- **`FUN_1800150d0`** — group builder. Walks `purchase[]` in array order; for each
|
|
||||||
pack, finds-or-creates a `0x108` display group by **exact strcmp of
|
|
||||||
`displayGroup.value` against `group+0x70`** (`FUN_180014380`). New group
|
|
||||||
(`FUN_180012950`): `+0x00` = 1-based ordinal (groups-so-far+1), `+0x100` =
|
|
||||||
priority, **`+0x104` = (value == "mypacks")**, `+0x40` = vector of `0x1a8` tile
|
|
||||||
models. Pack→tile via `FUN_18002c3c0`. (:152-161, :1042-1049.)
|
|
||||||
- **`FUN_1800147f0`** — group **resolver/renderer**, called as
|
|
||||||
`FUN_1800147f0(model, screen+0x290, dataProvider, 0, 0)` from `FUN_18007dab0`.
|
|
||||||
`screen+0x290 == 0` → "list the group tiles" (`FUN_180014610`); **any other value
|
|
||||||
→ `FUN_180014420`, which exact-matches `group+0x00` (the ordinal) and returns NULL
|
|
||||||
on a miss, after which `FUN_1800147f0` dereferences `[RAX+0x40]` with NO guard**
|
|
||||||
("checked in raw disassembly … a real absence"). **"The only legal category values
|
|
||||||
are 0 and the ordinals 1..N."** (:163-169, :1051-1054.)
|
|
||||||
- **`FUN_180014580`** — the six-tab bar: switch 0..5 over the hardcoded lowercase
|
|
||||||
literals `mypacks, points, bronze, silver, gold, special`. `FUN_18007e5e0` gives
|
|
||||||
each panel a `PANEL_ID` = the matching group's ordinal, **or HIDES the panel** if no
|
|
||||||
matching group; `FUN_18007df60` publishes `MYPACK_/…/SPECIAL_CATEGORY_ID`.
|
|
||||||
(:202-206, :1058-1062.)
|
|
||||||
|
|
||||||
## 4. Store parser code path (OBSERVED, decompiled)
|
|
||||||
|
|
||||||
```
|
|
||||||
HTTP 200 {"purchase":[...]} (server: store_catalog / _pack_body)
|
|
||||||
→ per-element deser FUN_18013af30 → 0x158 wire records
|
|
||||||
→ FUN_1800150d0 → 0x108 display groups (by displayGroup.value),
|
|
||||||
tiles (0x1a8, FUN_18002c3c0) into group+0x40
|
|
||||||
→ render FUN_18007dab0 → FUN_1800147f0(model, screen+0x290, …)
|
|
||||||
screen+0x290 == 0 → FUN_180014610 list this group's tiles
|
|
||||||
screen+0x290 == N>0 → FUN_180014420 exact-match ordinal; NULL on miss
|
|
||||||
→ [RAX+0x40] dereference (NO NULL GUARD) ← crash site
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. My Packs group construction (OBSERVED)
|
|
||||||
|
|
||||||
A `mypacks` group exists **iff at least one `purchase[]` entry carries
|
|
||||||
`displayGroup.value == "mypacks"`** (the group is derived from packs; there is no
|
|
||||||
independent group object on the wire). Its ordinal is its 1-based creation position
|
|
||||||
in array order; `group+0x104` is set because the value is `"mypacks"`; its tiles live
|
|
||||||
in `group+0x40`. Consequently the server **cannot** emit an "empty `mypacks` group"
|
|
||||||
via `purchase[]` — removing the pack removes the group entirely.
|
|
||||||
|
|
||||||
## 6. Default / selected group logic (partly UNKNOWN)
|
|
||||||
|
|
||||||
- `FUN_1800147f0` resolves whatever category ordinal it is handed via `screen+0x290`;
|
|
||||||
legal values are `0` (list tiles) and `1..N` (existing ordinals). A value that is
|
|
||||||
not an existing ordinal (e.g. `-1` for a hidden/absent panel) → `FUN_180014420`
|
|
||||||
NULL → crash. (OBSERVED via §8 crash + RE.)
|
|
||||||
- **Whether the store defaults to / auto-resolves the `mypacks` category on open, and
|
|
||||||
why it does so even when the unopened count is 0, is UNKNOWN** — the store-screen
|
|
||||||
controller and default-tab selection are Scaleform/packed-FIFA17.exe
|
|
||||||
("Which tile the movie thinks you clicked | CLIENT | Scaleform, unread",
|
|
||||||
store-subsystem :695). Experiment B proves that in this configuration the client
|
|
||||||
DID resolve a `mypacks` ordinal that did not exist (it crashed), so the store is
|
|
||||||
reaching the My Packs category with zero owned packs. Root of "why" = UNKNOWN.
|
|
||||||
|
|
||||||
## 7. Pack availability predicate (UNKNOWN)
|
|
||||||
|
|
||||||
The predicate that turns the baseline inactive sentinel into "pack not available"
|
|
||||||
(while active pack 70 passes) is **in the packed FIFA17.exe and unread**
|
|
||||||
(`plan-2026-08-05-pack-opening.md:553`: `state/saleType/quantity/purchaseLimit/
|
|
||||||
purchaseCount/start/end` are parsed and copied to the tile, "the predicate that greys
|
|
||||||
a tile is in the packed exe and unread"). Candidate deciding fields, from the
|
|
||||||
baseline↔A diff (INFERRED, unproven): **`state` (`inactive`→`active`)** and/or
|
|
||||||
**`unopened` (`false`→`true`)**. The exact field is **UNKNOWN**.
|
|
||||||
|
|
||||||
## 8. Experiment B crash analysis (OBSERVED)
|
|
||||||
|
|
||||||
Minidump `CrashDump_…18.21.08…dmp` (the only crash in the hour; minute `:21` matches
|
|
||||||
the `00:21:07` store request; SHA-256 `fbddda18…`), parsed:
|
|
||||||
- Exception: **`0xC0000005` ACCESS_VIOLATION**, access type **READ**, **faulting VA
|
|
||||||
`0x0000000000000048`**.
|
|
||||||
- Faulting instruction: **`CardsDLL_Win64_retail.dll + 0x14882`** → RE-space
|
|
||||||
**`0x180014882`** = **`0x92` bytes into `FUN_1800147f0`** (entry `0x1800147f0`).
|
|
||||||
- Interpretation (OBSERVED crash ⋂ decompiled RE): the group pointer returned by
|
|
||||||
`FUN_180014420` was **NULL** (no `mypacks` group present with `unopenedPackIds==[]`
|
|
||||||
and the sentinel suppressed), and `FUN_1800147f0` dereferenced `[NULL+0x48]` → read
|
|
||||||
of address `0x48` → access violation. This is exactly the "no null guard" branch
|
|
||||||
the RE flagged (RE said `[RAX+0x40]`; the actual faulting offset is `+0x48`, same
|
|
||||||
member region — the group struct's `+0x40` vector accessed via a `+0x48` field).
|
|
||||||
- Note: the pre-built Ghidra project and `/tmp/fut/cardsdll.dll` were absent on `.105`
|
|
||||||
(tmp cleared); confirmation used the OBSERVED crash dump + the previously-decompiled
|
|
||||||
RE rather than a fresh (expensive) re-analysis. CardsDLL is unpacked, so this code
|
|
||||||
is statically readable if a fresh project is ever needed.
|
|
||||||
|
|
||||||
## 9. Empty My Packs client contract (the answer, as far as evidence allows)
|
|
||||||
|
|
||||||
- **The client has NO guard for a missing selected group.** If the store resolves the
|
|
||||||
`mypacks` category and no `mypacks` group exists, it null-derefs and crashes.
|
|
||||||
(OBSERVED.)
|
|
||||||
- **A `mypacks` group can only exist if a `purchase[]` entry carries
|
|
||||||
`displayGroup.value=="mypacks"`.** (OBSERVED.) There is no wire representation of an
|
|
||||||
"empty group."
|
|
||||||
- **If the `mypacks` group's selected pack is not a valid/active pack, the client
|
|
||||||
shows "pack not available".** (OBSERVED baseline vs A; deciding field UNKNOWN, §7.)
|
|
||||||
- Therefore, under the *current* client behavior, a zero-unopened-packs account is
|
|
||||||
only cleanly handled when the `mypacks` group contains a **valid active pack**
|
|
||||||
(Exp A). Whether an active-but-non-openable placeholder would also satisfy the
|
|
||||||
availability predicate is **UNKNOWN** (depends on §7).
|
|
||||||
- **The correct retail-EA representation of zero unopened packs is UNKNOWN.** It is
|
|
||||||
NOT "omit the group" (crash) and NOT "inactive placeholder" (dialog). It is most
|
|
||||||
likely one of: (i) the retail store does not auto-select My Packs when the unopened
|
|
||||||
count is 0 (a client/Scaleform decision, possibly gated by a count the server sets),
|
|
||||||
or (ii) retail sends a `mypacks` entry the client treats as an empty-but-valid state
|
|
||||||
via a field we have not identified. Neither is established.
|
|
||||||
|
|
||||||
## 10. Candidate server representations
|
|
||||||
|
|
||||||
| # | Candidate | Client evidence | Expected behavior | Confidence | Safe to test? |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| A | No `mypacks` pack, no `mypacks` group | Exp B crash (`0x180014882`, `[NULL+0x48]`) | **CRASH** | OBSERVED | Already tested — reject |
|
|
||||||
| B | `mypacks` group present but zero packs | Not representable via `purchase[]` (groups derive from packs, `FUN_1800150d0`) | UNKNOWN | INFERRED-not-representable | No server mechanism |
|
|
||||||
| C | Inactive placeholder pack (current sentinel) | Baseline dialog | "pack not available" → Hub | OBSERVED | Already tested — reject |
|
|
||||||
| C′ | **Active** placeholder pack, id absent from `PACK_CATALOG` (so open/buy handlers reject it) | Exp A shows an *active* mypacks pack works; sentinel id 65534 is already rejected by open/buy (not in `PACK_CATALOG`) | Group resolves (no crash); MIGHT pass availability (no dialog) while remaining non-openable → no free pack | HYPOTHESIS | Yes — code change, restart; economy-safe (non-openable) |
|
|
||||||
| D | Hide My Packs when unopened count == 0 | `FUN_18007e5e0` hides a panel with no group, but the store still resolved `mypacks` at count 0 (Exp B crash) | Hiding via absence CRASHES; a client count-gate is Scaleform/unknown | UNKNOWN | Not server-controllable as far as known |
|
|
||||||
| E | Different default category when count == 0 | Default-tab selection is Scaleform/packed | UNKNOWN | UNKNOWN | Not server-controllable as far as known |
|
|
||||||
| F | A count/quantities field (e.g. `ut/v2/store` `FutStorePackQuantities`, or `userInfo.unopenedPacks`) that suppresses the My Packs auto-select | eligibility gate exists (`ENDPOINT_MAP.md:60`); relationship to My Packs default UNKNOWN | UNKNOWN | HYPOTHESIS | Read-only RE first |
|
|
||||||
|
|
||||||
## 11. Recommended next controlled experiment
|
|
||||||
|
|
||||||
**Experiment C′ (economy-safe placeholder).** Keep `unopenedPackIds == []`; change the
|
|
||||||
synthetic sentinel `65534` ONLY in `state` (and, if needed, `unopened`) so the
|
|
||||||
`mypacks` group's single tile is **active** — but leave its id `65534` **absent from
|
|
||||||
`PACK_CATALOG`** so `store_buy`/`open_pack`/`consume_unopened_pack` still reject it
|
|
||||||
(no pack can be opened → **no free pack, no economy change**). Observe whether the
|
|
||||||
store then opens without the "pack not available" dialog (would identify `state` as
|
|
||||||
the availability field and give an economy-safe fix), or still shows the dialog
|
|
||||||
(implicating another field / packed predicate).
|
|
||||||
- Requires a temporary code change to `store_catalog` (sentinel construction) →
|
|
||||||
restart. Same experiment discipline as Experiment B (patch container copy, capture,
|
|
||||||
revert, restart). Economy-safe because the placeholder remains non-openable.
|
|
||||||
- If C′ still fails, escalate to read-only RE of the availability predicate / the
|
|
||||||
count-gated default-tab hypothesis (candidate F) before any further change.
|
|
||||||
|
|
||||||
## 12. Open questions
|
|
||||||
|
|
||||||
1. Exact field that flips the sentinel from "pack not available" to acceptable
|
|
||||||
(`state`? `unopened`? another). UNKNOWN — packed predicate. (Exp C′ targets this.)
|
|
||||||
2. Why does the store resolve/select `mypacks` with zero owned packs? Is there a
|
|
||||||
server-settable count that would stop it? UNKNOWN — Scaleform/packed.
|
|
||||||
3. Does retail FIFA 17 ever present an empty My Packs, and how? No capture on record.
|
|
||||||
4. Is an active-but-non-openable placeholder (C′) accepted by the availability
|
|
||||||
predicate? HYPOTHESIS — untested.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Permanent-fix requirements (Phase 11 — REPORT ONLY, not implemented)
|
|
||||||
|
|
||||||
A correct permanent fix MUST satisfy ALL of:
|
|
||||||
- Zero unopened packs must **NOT** grant the user a free pack.
|
|
||||||
- No synthetic **openable** reward may be created (any placeholder must be rejected by
|
|
||||||
`store_buy`/`open_pack`/`consume_unopened_pack`).
|
|
||||||
- Client must **not crash** (a resolvable `mypacks` group must exist, OR the client
|
|
||||||
must be kept from resolving `mypacks` when empty).
|
|
||||||
- Client must **not** show "The pack you've selected is currently not available".
|
|
||||||
- Normal Bronze/Gold/Special store categories must still work unchanged.
|
|
||||||
- When a genuine unopened pack exists, My Packs must continue to work (Exp A).
|
|
||||||
- Profile/economy semantics must remain correct (no coins/nextItemId/inventory drift).
|
|
||||||
|
|
||||||
Nothing implemented. The evidence favours investigating an **economy-safe active
|
|
||||||
placeholder (C′)** and/or the **count-gated My-Packs default (F)**; it explicitly does
|
|
||||||
NOT support "grant pack 70 whenever My Packs is empty".
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## UPDATE after Experiment C′ (2026-08-13) — active non-openable placeholder tested
|
|
||||||
|
|
||||||
Executed C′: sentinel 65534 `state "inactive"→"active"` only; `unopenedPackIds==[]`;
|
|
||||||
65534 kept out of `PACK_CATALOG`. Full record in `STORE_TILE_6C.md` §16. Capture:
|
|
||||||
`store_purchasegroup_capture_active_placeholder_2026-08-12.json` (C′-vs-baseline JSON
|
|
||||||
diff = only `65534.state`).
|
|
||||||
|
|
||||||
**Resolves §7 (availability predicate), partially:** `state` **DOES participate**
|
|
||||||
(OBSERVED). `state:"active"` removed the "pack not available" dialog while the group's
|
|
||||||
existence still prevented the crash. So the earlier §7 "deciding field UNKNOWN" is
|
|
||||||
updated: **`state` (inactive vs active) is (at least) a deciding field** for the
|
|
||||||
dialog. `unopened` was NOT varied and remains untested. The full predicate may still
|
|
||||||
involve other fields, but `state` alone flips dialog→no-dialog.
|
|
||||||
|
|
||||||
**Updated candidate table verdict:**
|
|
||||||
- **C′ (active placeholder, non-openable): SUPPORTED with UX caveats — best option so
|
|
||||||
far, but NOT adopted.** No crash, no dialog, store usable, and **no automatic
|
|
||||||
transaction/open for 65534** (only a routine boot `TRANSACTIONCANCEL` no-op).
|
|
||||||
Caveats (OBSERVED): (1) the placeholder renders as a **visible empty pack tile**
|
|
||||||
("0 items, 0 bronze, 0 rares", no cover) that a user could try to open (server-safe:
|
|
||||||
opening 65534 → no-op `{}`/stale `last_pack`, no value — §16.1); (2) **navigation
|
|
||||||
gate**: from the Store "Browse Packs" entry the Bronze/Gold/Special categories are
|
|
||||||
not reachable until "My Packs" is opened first (not present with a genuine owned
|
|
||||||
pack, Exp A).
|
|
||||||
- A (real active pack 70): works cleanly but grants a real openable pack → economy
|
|
||||||
risk; rejected as the permanent fix.
|
|
||||||
- Candidate **F (count-gated My-Packs default)** gains weight: C′'s visible-empty-tile
|
|
||||||
and Browse-Packs navigation gate suggest the client is being pushed to resolve/enter
|
|
||||||
My Packs when it should not with zero packs. If a server-settable count (e.g.
|
|
||||||
`userInfo.unopenedPacks` / `ut/v2/store` quantities) suppresses the My-Packs
|
|
||||||
default/tile, that could remove both the crash risk and the empty-tile artifact
|
|
||||||
without any placeholder. UNTESTED.
|
|
||||||
|
|
||||||
**Permanent fix: still NOT established.** Even though C′ is the first
|
|
||||||
crash-free/dialog-free representation, the empty-tile UX + navigation gate + the
|
|
||||||
untested "explicit placeholder selection" behavior bar adoption. Required next steps
|
|
||||||
(design/authorize separately): (a) controlled test of explicitly focusing/opening the
|
|
||||||
active placeholder; (b) investigate candidate F (count-gated My-Packs) to avoid a fake
|
|
||||||
tile entirely. Do NOT adopt `state:"active"` or "grant pack 70" as the fix on current
|
|
||||||
evidence.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Candidate F — Count-Gated My Packs Navigation (READ-ONLY investigation, 2026-08-13)
|
|
||||||
|
|
||||||
Question: can the server make FIFA decide **not** to resolve/default into My Packs
|
|
||||||
when the account owns zero unopened packs (avoiding any placeholder)?
|
|
||||||
|
|
||||||
## 1. Server-sent unopened-pack signals (inventory, OBSERVED code)
|
|
||||||
| field / endpoint | source | value source | when sent | client consumer | conf |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| `userInfo.unopenedPacks.recoveredPacks` (via `userMassInfo`) | `utas_server.py:409-414` | `len(unopenedPackIds)` | boot massinfo; only if count>0 **or** `_UI∈{packs,full}` (default `_UI=roster` → omitted at 0) | hub unopened-pack model / My Packs badge (`FUN…vtbl[0x4e0]`, pack-opening RE) | OBSERVED (code) |
|
|
||||||
| `/user/credits` `.unopenedPacks.recoveredPacks` | `utas_server.py:3542-3545` | `len(unopenedPackIds)` | on credits fetch; **only if count>0** | My Packs badge / CentralUnclaimedPack hub tile | OBSERVED (code+capture) |
|
|
||||||
| `/hub` body | `utas_server.py:1449-1453` | — | hub load | — (**no pack count present**) | OBSERVED |
|
|
||||||
| profile `unopenedPackIds` | `fut_store.py` | account state | internal | not wire-visible directly | OBSERVED |
|
|
||||||
`pileSize`/`store quantities` (`ut/v2/store` `FutStorePackQuantities`) exist as an
|
|
||||||
eligibility gate (`ENDPOINT_MAP.md:60`) but were **never requested** in any capture
|
|
||||||
(8h logs); they carry a store-open `result`, not a My-Packs count.
|
|
||||||
|
|
||||||
## 2. Pre-store request sequence (OBSERVED, captures)
|
|
||||||
Boot → `accountinfo → /ut/auth → settings → phishing → match/reset → userMassInfo →
|
|
||||||
PUT store/transaction/0 (TRANSACTIONCANCEL→{}) → /hub → clientdata → /user/credits →
|
|
||||||
GET /store/purchasegroup/all`. The only pack-count-bearing responses **before**
|
|
||||||
`purchasegroup` are `userMassInfo`(userInfo) and `/user/credits`.
|
|
||||||
|
|
||||||
## 3. Baseline([]) vs Experiment A([70]) pre-store diff (OBSERVED, captures)
|
|
||||||
The single profile change `[] → [70]` altered exactly one pre-store wire signal:
|
|
||||||
- `/user/credits`: **`[]` → no `unopenedPacks` member** (C′ capture, all 3 fetches:
|
|
||||||
`{"credits":…,"currencies":[…]}`); **`[70]` → `"unopenedPacks":{"preOrderPacks":0,
|
|
||||||
"recoveredPacks":1}`** (A capture line 25). OBSERVED.
|
|
||||||
- `userInfo.unopenedPacks`: same pattern (present at `[70]`, omitted at `[]` with
|
|
||||||
`_UI=roster`). INFERRED from code; A-capture credits corroborates.
|
|
||||||
- **No other pre-store field changed.**
|
|
||||||
|
|
||||||
Candidate signal:
|
|
||||||
```
|
|
||||||
Candidate: unopenedPacks.recoveredPacks (count)
|
|
||||||
Endpoint: /user/credits and userMassInfo(userInfo)
|
|
||||||
Baseline([]) value: ABSENT (i.e. zero)
|
|
||||||
Experiment A([70]) value: {preOrderPacks:0, recoveredPacks:1}
|
|
||||||
Source: utas_server.py:3542-3545 / :409-414 (= len(unopenedPackIds))
|
|
||||||
Client-visible before purchasegroup?: YES
|
|
||||||
Confidence: OBSERVED that it differs; its CONTROL over My-Packs nav = see §9
|
|
||||||
```
|
|
||||||
**Key point: this count is already CORRECT** — it reports zero (absent) when the
|
|
||||||
account is empty. OpenFUT is **not** misreporting a nonzero pack count.
|
|
||||||
|
|
||||||
## 4. Navigation / client call path (OBSERVED, decompiled RE)
|
|
||||||
`FUN_18007dab0 → FUN_1800147f0(model, screen+0x290, …)`. `screen+0x290==0` lists
|
|
||||||
group tiles; else `FUN_180014420` exact-matches the group ordinal (NULL on miss →
|
|
||||||
`[NULL+0x48]` crash). `screen+0x290` is written in exactly two CardsDLL sites: the
|
|
||||||
screen ctor `FUN_18007d1a0` writes `0`, and **`FUN_18007e7f0` case `0x7551` copies
|
|
||||||
the Flash movie message field `CATEGORY_ID` verbatim** into it
|
|
||||||
(store-subsystem :172-175, :1055-1056). So the resolved category is chosen by the
|
|
||||||
**Scaleform movie**, not by any server response field.
|
|
||||||
|
|
||||||
## 5. `GOTO_STORE_MYPACK` analysis (OBSERVED, RE)
|
|
||||||
`GOTO_STORE_MYPACK` is the **destination of the hub `CentralUnclaimedPack` tile**
|
|
||||||
(tile type 0x1c); "Nothing in the chain issues a request, and no request could
|
|
||||||
exist" (pack-opening :888-890). Whether that HUB tile appears is gated by the
|
|
||||||
unopened-pack count in the hub model (`model+0x20950`) — i.e. the count DOES control
|
|
||||||
the *hub unclaimed-pack tile*, but the operator reached the store via **Browse Packs**
|
|
||||||
/ the store screen, whose category resolution is the movie-driven `CATEGORY_ID` path
|
|
||||||
(§4), not `GOTO_STORE_MYPACK`. `GOTO_STORE_MYPACK` is a UI navigation command,
|
|
||||||
**not** a server-state-gated store-category selector.
|
|
||||||
|
|
||||||
## 6. Candidate count/flag fields — verdict per field
|
|
||||||
- `unopenedPacks.recoveredPacks`: correct at 0 when empty; controls the hub badge /
|
|
||||||
CentralUnclaimedPack tile, **not** the store's category resolver. Not a viable gate
|
|
||||||
for the store My-Packs entry.
|
|
||||||
- No other server field feeds `screen+0x290` (RE §4: only ctor-0 and movie
|
|
||||||
`CATEGORY_ID`).
|
|
||||||
|
|
||||||
## 7. EA-capture evidence
|
|
||||||
No EA-origin `purchasegroup`/`credits` capture for a zero-unopened-packs account
|
|
||||||
exists in the repo (all captures are OpenFUT-generated). EA count semantics for empty
|
|
||||||
My Packs remain **UNKNOWN**.
|
|
||||||
|
|
||||||
## 8. Where My Packs selection occurs (OBSERVED)
|
|
||||||
**Before** `purchasegroup` parsing decides content, the **Scaleform movie** decides
|
|
||||||
which category to resolve and writes it to `screen+0x290` (§4). The server's role is
|
|
||||||
limited to which groups EXIST in `purchase[]`. Therefore the sentinel is compensating
|
|
||||||
for a **movie-side** decision to resolve My Packs; it is not fixing an incorrect
|
|
||||||
server count (the count is already correct).
|
|
||||||
|
|
||||||
## 9. Candidate F verdict — **F3 (CONTRADICTED)** (with an F4 residue)
|
|
||||||
My Packs selection is **not** controlled by server-sent unopened-pack state:
|
|
||||||
- OBSERVED: the server count is correctly zero/absent when empty, yet the store still
|
|
||||||
resolved My Packs (baseline dialog, Exp-B crash). A correct zero signal did not stop
|
|
||||||
it.
|
|
||||||
- OBSERVED (RE): `screen+0x290` (the resolved category) comes from the movie's
|
|
||||||
`CATEGORY_ID`, with no server-field input; default is 0.
|
|
||||||
Residue (F4): the movie's internal logic for *why* it asks for My Packs on store open
|
|
||||||
is in packed Scaleform and is not statically readable — but no server lever into it
|
|
||||||
has been found. **Conclusion: there is no server-controlled count/flag that makes FIFA
|
|
||||||
skip resolving My Packs; the server can only ensure the `mypacks` group exists.** The
|
|
||||||
"clean count-gated fix" is therefore **not achievable server-side**.
|
|
||||||
|
|
||||||
## 10. Proposed next experiment
|
|
||||||
Because F is contradicted, a count experiment is NOT recommended (the count is already
|
|
||||||
correct and does not gate the store). No single-variable server signal will make FIFA
|
|
||||||
enter Browse Packs instead of My Packs. The realistic next step is the previously
|
|
||||||
deferred **explicit active-placeholder selection test**: with the C′ active
|
|
||||||
non-openable placeholder in place (sentinel 65534 at baseline otherwise), have the
|
|
||||||
operator explicitly focus/open the empty My-Packs tile and observe (server-safe per
|
|
||||||
§16.1 — opening 65534 is a no-op — but UX/navigation behavior unknown). That
|
|
||||||
characterizes the best available server-side option (C′) before any adoption.
|
|
||||||
- Would it change profile state? No (`unopenedPackIds=[]`).
|
|
||||||
- Would it change purchasegroup/sentinel behavior? Only `state:"active"` (as C′),
|
|
||||||
reverted after.
|
|
||||||
- Code change? Yes (same one-line C′ patch). Restart? Yes. (Not authorized here.)
|
|
||||||
If explicit selection proves unsafe/ugly, the remaining options are all **client-side
|
|
||||||
/ out-of-scope** (the decision is in the Scaleform movie), or accepting C′ with its
|
|
||||||
documented UX artifacts.
|
|
||||||
|
|
||||||
**Candidate F does NOT provide the hoped-for clean fix. The active non-openable
|
|
||||||
placeholder (C′) remains the best server-side representation; its empty-tile and
|
|
||||||
Browse-Packs navigation artifacts are movie-driven and not server-fixable.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Explicit Active-Placeholder Selection Test — FINAL backend-side result (2026-08-13)
|
|
||||||
|
|
||||||
With the C′ active placeholder in place (`unopenedPackIds=[]`, 65534 active/mypacks/
|
|
||||||
∉PACK_CATALOG), the operator explicitly opened the empty My-Packs tile once. Full
|
|
||||||
record in `STORE_TILE_6C.md` §17.
|
|
||||||
- **Outcome: S1 — pure client-side rejection.** Dialog **"This pack is no longer
|
|
||||||
available"** → back to My Packs → Hub; **no crash**, navigation stays usable.
|
|
||||||
- **No server request** on selection (no `/store/transaction`, no `/purchased/items`,
|
|
||||||
no 65534 reference); the verdict is client-side. (OBSERVED)
|
|
||||||
- **Zero economy/profile mutation:** coins/items/nextItemId/`unopenedPackIds`/
|
|
||||||
`last_pack` all unchanged; profile byte-identical (`39bb3e83…`); 65534 not
|
|
||||||
persisted. (OBSERVED)
|
|
||||||
|
|
||||||
**Active-placeholder verdict: MARGINALLY ACCEPTABLE** — crash-safe + economy-safe +
|
|
||||||
navigable, but with user-visible defects (empty fake tile; "no longer available" on
|
|
||||||
explicit click; Browse-Packs nav gate). It is a *strict improvement* over the current
|
|
||||||
inactive-sentinel baseline (which errors on store OPEN and bounces to Hub).
|
|
||||||
|
|
||||||
**Backend-side question is now fully answered.** The complete zero-unopened-packs
|
|
||||||
ladder:
|
|
||||||
```
|
|
||||||
no mypacks group -> CardsDLL null-deref CRASH (unsafe)
|
|
||||||
inactive placeholder -> "pack not available" on store open -> Hub (baseline)
|
|
||||||
active placeholder -> store loads; empty tile; "no longer available" only on
|
|
||||||
explicit click; recoverable; economy-safe (best backend option)
|
|
||||||
real active owned pack -> fully correct UI (but grants a real openable pack — economy risk)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Permanent-fix recommendation: P2.** The active non-openable placeholder is the best
|
|
||||||
*safe* backend-only option, but a fully *clean* zero-pack experience is **not**
|
|
||||||
achievable server-side (Candidate F CONTRADICTED — the My-Packs resolution is
|
|
||||||
Scaleform/movie-driven). Recommend: adopt the active placeholder as an optional
|
|
||||||
backend compatibility mode (safe, strictly better than baseline) AND pursue a
|
|
||||||
client-side fix (hide the fake tile / stop the forced My-Packs resolution) for the
|
|
||||||
fully clean result. **Not implemented.** Do NOT grant a real pack.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Client resolver-guard experiment (2026-08-13) — RESULT F3 (crash, confounded)
|
|
||||||
|
|
||||||
A client-side `autopatch.py` memory guard (CardsDLL `0x180014858` `JNZ`→`JG`, routing
|
|
||||||
category `<0` to list-all/Browse) was tested against the exact no-sentinel server condition
|
|
||||||
(sentinel 65534 suppressed; `GET /store/purchasegroup` ids `[1,5,6,7]`, no mypacks group).
|
|
||||||
The client **crashed at the identical resolver site `0x180014882`** (`[NULL+0x48]`), because
|
|
||||||
it presented a **positive** My-Packs ordinal (crash is in the `>0` resolve branch), not the
|
|
||||||
`-1` the guard diverts. **Confound:** FIFA was not relaunched after the backend flip, so it
|
|
||||||
reused stale (sentinel-present) tab state. So the negative-only guard is **insufficient for a
|
|
||||||
positive stale/invalid ordinal**, and the fresh-client case is **not yet decided** (needs a
|
|
||||||
clean re-test: fresh launch with backend already no-sentinel). Backend P2 sentinel was
|
|
||||||
restored immediately (mandatory rollback). Full record + candidate stronger guard:
|
|
||||||
`docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md` PART III.
|
|
||||||
|
|
||||||
## Fresh-process no-sentinel retest (2026-08-13) — RESULT R1 (SUCCESS)
|
|
||||||
|
|
||||||
Re-ran the above cleanly: backend entered no-sentinel mode **while FIFA was closed**, then a
|
|
||||||
**fresh** FIFA (pid 553220, new autopatch 552999, guard `85 ff 7f 0f` enforced) launched and
|
|
||||||
opened the Store. The genuine no-sentinel `/store/purchasegroup` (ids `[1,5,6,7]`, no 65534/
|
|
||||||
mypacks) is **byte-identical** to the F3 capture, so the only changed variable is client
|
|
||||||
process lifetime. Outcome: **no crash, no dialog, Store opens on Browse Packs, packs
|
|
||||||
navigable** (cosmetics only: no tabs / no cover art / "0 items" — pre-existing). A fresh
|
|
||||||
client publishes category `-1` for the absent group, which `JNZ→JG` routes to Browse/list-all
|
|
||||||
with no NULL deref. **This confirms F3 was stale-positive-ordinal contamination, and proves
|
|
||||||
Strategy A (resolver guard) on the tested build.** Backend P2 sentinel restored immediately
|
|
||||||
(`f416e71e…`, `state=active`) and remains production default. Full record:
|
|
||||||
`docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md` PART IV.
|
|
||||||
@@ -1,924 +0,0 @@
|
|||||||
# Store Tile Investigation (bug 6c)
|
|
||||||
|
|
||||||
Status: **ROOT CAUSE ESTABLISHED — P2 compatibility workaround IMPLEMENTED**
|
|
||||||
(2026-08-13). Full investigation complete (§1-§17); backend fix landed in
|
|
||||||
`fifa17-recon/tools/utas_server.py` `store_catalog` with regression tests in
|
|
||||||
`fifa17-recon/tools/test_empty_mypacks.py`. The store-tiles flags are unchanged
|
|
||||||
(`FUT_STORE_DISPLAYGROUP=ON`, `FUT_STORE_GROUPID=OFF`) and the profile is unchanged.
|
|
||||||
|
|
||||||
## RESOLUTION (2026-08-13)
|
|
||||||
|
|
||||||
**ROOT CAUSE (bug 6c):** FIFA 17's Store/Scaleform path RESOLVES the `mypacks`
|
|
||||||
category even when the account owns zero unopened packs (the category is chosen
|
|
||||||
client-side from the movie's `CATEGORY_ID` → `screen+0x290`; no server field gates
|
|
||||||
it — Candidate F CONTRADICTED). CardsDLL `FUN_1800147f0` then dereferences the
|
|
||||||
resolved group with NO null guard, so an absent `mypacks` group crashes the client
|
|
||||||
(`CardsDLL_Win64_retail.dll+0x14882`, `[NULL+0x48]` — minidump-confirmed, §8).
|
|
||||||
|
|
||||||
**BACKEND RESULT / DECISION — P2 (compatibility workaround):** emit a synthetic,
|
|
||||||
**active**, non-openable `mypacks` placeholder (id 65534, absent from `PACK_CATALOG`)
|
|
||||||
only when `unopenedPackIds == []`. This is crash-safe AND economy-safe (explicit
|
|
||||||
selection is rejected client-side with "This pack is no longer available", sends no
|
|
||||||
backend request, and mutates nothing — §17). It is a FIFA-17 client-compatibility
|
|
||||||
shim, **NOT** an EA-authentic empty-My-Packs representation, and is confined to the
|
|
||||||
FIFA-17 adapter/backend layer (NOT OpenFUT Core).
|
|
||||||
|
|
||||||
**KNOWN UX LIMITATIONS (unfixable server-side):** a fake empty "0 items" tile; an
|
|
||||||
explicit-selection dialog "This pack is no longer available"; and a Browse-Packs →
|
|
||||||
My-Packs navigation quirk. These are Scaleform/movie-driven.
|
|
||||||
|
|
||||||
**CLEAN CLIENT FIX:** still unresolved; belongs to client-side work —
|
|
||||||
`docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md`.
|
|
||||||
|
|
||||||
Evidence labels: **OBSERVED** (running code / `docker inspect` / live log / minidump /
|
|
||||||
table), **INFERRED**, **HYPOTHESIS**, **UNKNOWN**.
|
|
||||||
|
|
||||||
### Hypotheses
|
|
||||||
|
|
||||||
- **H1 (original — subtype/definition):** *Store tiles render "unknown" because the
|
|
||||||
server emits definitions whose type/subtype the client cannot map (prime suspects
|
|
||||||
5004xxx misc {231,232,233,236}, 8010xxx league logos, FCC↔wire subtype gaps).*
|
|
||||||
**Verdict: CONTRADICTED by static store-handler evidence.** The `/store/purchasegroup`
|
|
||||||
handler emits PACK definitions only — no `cardsubtypeid`/`carddbid`/`cardassetid`
|
|
||||||
anywhere in the response (§2). Those subtype families belong to the separate
|
|
||||||
**club-item / consumable / equippable** paths (`fut_clubitems.py`, `fut_consumables`,
|
|
||||||
`/club?type=`), which are OUT OF SCOPE for this store-tile task. History preserved
|
|
||||||
in §3.3 and §8; not investigated further here.
|
|
||||||
- **H2 (revised — displayGroup token):** **HYPOTHESIS (under runtime test).** *The
|
|
||||||
"unknown" FUT Store tile is caused by one or more pack entries whose
|
|
||||||
`displayGroup.value` token is not one of the six categories FIFA 17 can render:*
|
|
||||||
`mypacks, points, bronze, silver, gold, special`. Tested against a real capture in
|
|
||||||
§4-§8.
|
|
||||||
|
|
||||||
---
|
|
||||||
## Runtime capture plan (Phase 2) — OBSERVED
|
|
||||||
|
|
||||||
- **Backend container:** `openfut-fut-backend` (logs on stdout via `log()`,
|
|
||||||
`utas_server.py:59-62`; each request logs `"<VERB> <path>"` at `:3732`, and the
|
|
||||||
response line `" -> <code> <body[:200]>"` at `:3754` — **response body truncated
|
|
||||||
to 200 bytes**, so the full JSON body is NOT in the log).
|
|
||||||
- **Endpoint / path matcher:** `GET /ut/game/fifa17/store/purchasegroup/all?ppInfo=true`
|
|
||||||
(OBSERVED historically in the log; route regex `/store/purchasegroup`,
|
|
||||||
`utas_server.py:1215`).
|
|
||||||
- **Capture marker (UTC, set immediately before the manual test):**
|
|
||||||
`2026-08-12T23:54:01Z` (saved to `/tmp/store_capture_marker.txt`; 0 log lines
|
|
||||||
after it at set-time → clean boundary).
|
|
||||||
- **Log command to isolate the manual action:**
|
|
||||||
`docker logs --since 2026-08-12T23:54:01Z --timestamps openfut-fut-backend`
|
|
||||||
then locate the first `GET .../store/purchasegroup` line after the marker plus its
|
|
||||||
following headers and `-> 200 {"purchase"...` line.
|
|
||||||
- **Full-body recovery (because the log truncates at 200 bytes):** the response is a
|
|
||||||
deterministic pure function — `store_catalog()` (`:3408`) over static `PACK_CATALOG`
|
|
||||||
(`fut_store.py:820`) + live `STORE.unopened_packs()` (from `/state` profile) under
|
|
||||||
fixed flags (`STORE_DISPLAYGROUP=ON`, `STORE_GROUPID=OFF`, `FUT_PRICE_PROBE=OFF`).
|
|
||||||
Plan: reconstruct the exact body from the running `/app` code + live `/state`
|
|
||||||
profile, then **verify** its `json.dumps(...)[:200]` byte-for-byte equals the genuine
|
|
||||||
logged 200-byte prefix. Match ⇒ the reconstruction IS the sent body. No request is
|
|
||||||
synthesized, replayed, or curl'd; the genuine log line is the ground-truth anchor.
|
|
||||||
|
|
||||||
|
|
||||||
## 1. Method
|
|
||||||
|
|
||||||
### Environment (OBSERVED)
|
|
||||||
- Running on `10.10.0.120` (dev-lxc). Client is `10.10.0.105`.
|
|
||||||
- Backend under test: Docker container `openfut-fut-backend`
|
|
||||||
(image `openfut-fut-backend:dev`), `Up 7 hours`, entrypoint `/app/entrypoint.sh`.
|
|
||||||
- `docker inspect openfut-fut-backend`: only mount is
|
|
||||||
`/home/alex/OpenFUT/fifa17-recon/docker/state -> /state (rw)`; container ENV
|
|
||||||
contains **no `FUT_STORE_*`** vars.
|
|
||||||
- `entrypoint.sh` launches `python3 -u utas_server.py` from `/app/tools` with extra
|
|
||||||
env `FUT_TRADING=1 FUT_PILESIZES=1 FUT_TRADEABLE=1 FUT_DISCARD_TABLE=1
|
|
||||||
FUT_DISCARD_SEND=1` — again **no `FUT_STORE_*`**.
|
|
||||||
- The running store code is `/app/tools/utas_server.py`. Extracted read-only via
|
|
||||||
`docker cp openfut-fut-backend:/app/tools /tmp/app-tools` and confirmed
|
|
||||||
**byte-identical** (`sha256`) to the repo copy
|
|
||||||
`fifa17-recon/tools/utas_server.py` (both 3765 lines) and
|
|
||||||
`fifa17-recon/tools/fut_clubitems.py`. All line references below are to the repo
|
|
||||||
paths and equal the running code.
|
|
||||||
|
|
||||||
### Commands (exact)
|
|
||||||
```
|
|
||||||
docker ps --format '{{.Names}}\t{{.Image}}\t{{.Command}}\t{{.Status}}'
|
|
||||||
docker inspect openfut-fut-backend --format '...CMD/ENTRYPOINT/MOUNTS/ENV...'
|
|
||||||
docker exec openfut-fut-backend cat /app/entrypoint.sh
|
|
||||||
docker cp openfut-fut-backend:/app/tools /tmp/app-tools
|
|
||||||
docker cp openfut-fut-backend:/app/data /tmp/app-data
|
|
||||||
diff /tmp/app-tools/utas_server.py fifa17-recon/tools/utas_server.py # identical
|
|
||||||
diff /tmp/app-tools/fut_clubitems.py fifa17-recon/tools/fut_clubitems.py # identical
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manual test sequence
|
|
||||||
NOT executed. The manual FIFA-client store capture (2D/2E) was never reached
|
|
||||||
because the investigation blocked at 2C before any flag could be enabled. No
|
|
||||||
request was synthesized, replayed, or simulated.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Store handler code path (2A) — OBSERVED
|
|
||||||
|
|
||||||
Request → response for the store tile screen:
|
|
||||||
|
|
||||||
1. **Endpoint.** `GET ut/<sku>/store/purchasegroup/...` — `FutStoreGetPackTypes`
|
|
||||||
(client deser root `0x1801234e0`). (`utas_server.py:3408-3409` docstring.)
|
|
||||||
- Route table entry: `(re.compile(r"/store/purchasegroup"), lambda m,h:
|
|
||||||
store_catalog(h))` at `utas_server.py:1215`.
|
|
||||||
- Sibling store routes: `/store/transaction -> store_buy(h)` (`:1216`, the BUY);
|
|
||||||
bare `/store(\?|$) -> (200,{"result":"SUCCESS"})` eligibility gate (`:1221`).
|
|
||||||
2. **Handler.** `store_catalog(h)` (`utas_server.py:3408-3447`).
|
|
||||||
- Iterates `PACK_CATALOG` (non-`ownedOnly` packs) → `_pack_body(p, idx)`.
|
|
||||||
- Appends owned unopened packs from `visible_unopened_packs()` →
|
|
||||||
`STORE.unopened_packs()` + `_OPENED_PACK_GRACE` (`:51-52`, `:3423-3427`).
|
|
||||||
- If no owned packs, appends one inactive `mypacks` sentinel (id 65534) so
|
|
||||||
`GOTO_STORE_MYPACK` resolves (`:3428-3446`).
|
|
||||||
- Returns `200, {"purchase": [<pack bodies>], "timestamp": 1596326400}`
|
|
||||||
(`:3447`).
|
|
||||||
3. **Definition construction.** `_pack_body(p, idx, owned=False)`
|
|
||||||
(`utas_server.py:3268-3405`). Emitted keys (OBSERVED, `:3293-3328`):
|
|
||||||
`assetId`(=`p["id"]`), `id`(=`p["id"]`), `packType`, `description`(=`p["name"]`),
|
|
||||||
`state`, `saleType`, `limitType`, `quantity`, `purchaseLimit`, `purchaseCount`,
|
|
||||||
`isPremium`, `sortPriority`, `currencies`, `extPrice`, `packContentInfo`
|
|
||||||
(`bronze/silver/gold/rare/itemQuantity`), `unopened`, and one of:
|
|
||||||
- owned pack → `displayGroup = {"value":"mypacks","priority":idx}` (`:3336`);
|
|
||||||
- else if `STORE_DISPLAYGROUP` → `displayGroup = {"value": category}` where
|
|
||||||
`category ∈ {special, gold, silver, bronze}` chosen from
|
|
||||||
`p["specialChance"]`/`p["gold"]` (`:3337`,`:3373-3379`), and if `STORE_GROUPID`
|
|
||||||
also `displayGroupAssetId = p["id"]` (`:3403-3404`).
|
|
||||||
4. **Data source(s).**
|
|
||||||
- `PACK_CATALOG` — a hardcoded list of 3 pack dicts
|
|
||||||
(`{id,name,price,count,gold,tiers,specialChance}`) at `fut_store.py:820-841`.
|
|
||||||
There is NO card table read in this path.
|
|
||||||
- `STORE = Store()` (`fut_store.py:843`), backed by the profile JSON
|
|
||||||
(`unopened_packs()` reads `unopenedPackIds`, `fut_store.py:619-621`).
|
|
||||||
5. **Serialization → HTTP.** The dict is JSON-encoded by the server's response
|
|
||||||
writer and returned as the HTTP body.
|
|
||||||
|
|
||||||
### Fields 5 (`cardsubtypeid`/`carddbid`/`cardassetid`) — OBSERVED
|
|
||||||
**None of `cardsubtypeid`, `carddbid`, or `cardassetid` appear anywhere in the
|
|
||||||
store-tile (`purchasegroup`) response.** `_pack_body` (`:3293-3405`) emits only the
|
|
||||||
pack keys listed above; `assetId`/`id` are the PACK id `p["id"]` (e.g. 1, 5), not a
|
|
||||||
card asset id. The store catalog emits PACKS, never card definitions. (Grep of
|
|
||||||
`_pack_body` and `store_catalog` for those three field names returns zero hits.)
|
|
||||||
|
|
||||||
### Families the store handler can emit
|
|
||||||
Only **packs** (`PACK_CATALOG`: "Bronze Pack" id 1, "Gold Pack" id 5, and the third
|
|
||||||
catalog entry) plus owned reward packs and the `mypacks` sentinel. It cannot emit
|
|
||||||
any card family (players, staff, consumables, club items).
|
|
||||||
|
|
||||||
### Filtering / transformation
|
|
||||||
- `normal = [p for p in PACK_CATALOG if not p.get("ownedOnly")]` (`:3421`).
|
|
||||||
- `_pack_body` maps a pack to a category token via `specialChance>=1.0 -> special`,
|
|
||||||
else `gold -> gold`, `p.get("silver") -> silver`, else `bronze` (`:3373-3379`).
|
|
||||||
- The tile caption/category is `displayGroup.value`; `description` carries the
|
|
||||||
per-pack title.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Wire subtype coverage (2B)
|
|
||||||
|
|
||||||
### 3.1 Store path
|
|
||||||
The store-tile path emits **no `cardsubtypeid`** at all (see §2). So for the store
|
|
||||||
tile, "is `cardsubtypeid` the raw FCC subtype, the wire category, transformed, or
|
|
||||||
something else?" → **not present** (N/A). The store tile is selected by
|
|
||||||
`displayGroup.value` (a category-token STRING), not by any card subtype.
|
|
||||||
|
|
||||||
Per `_pack_body:3367-3372` (citing `FUN_180014580`/`FUN_180014df0`): FIFA 17's
|
|
||||||
StoreFront resolves exactly **six hard-coded category tokens** —
|
|
||||||
`mypacks, points, bronze, silver, gold, special`. Any other `displayGroup.value`
|
|
||||||
(e.g. a raw pack title) creates an "unsupported pseudo-category". The documented
|
|
||||||
cause of "unknown" store tiles is therefore a **`displayGroup` category-token**
|
|
||||||
issue on packs (absent group, or a non-canonical token), NOT a card type/subtype.
|
|
||||||
|
|
||||||
### 3.2 Club-item path (the only place wire subtypes live) — `fut_clubitems.py`
|
|
||||||
Club items are served on the **`/club?type=` route** (`utas_server.py:2250`,
|
|
||||||
`handle_club`), gated by `FUT_CLUBITEMS`, NOT by the store route. Current `FAMILIES`
|
|
||||||
(`fut_clubitems.py:61-67`), format `(family, table, art id, stat id, stat name,
|
|
||||||
UNVERIFIED cardsubtypeid)`:
|
|
||||||
|
|
||||||
| wire subtype | mapped family | table | art id | source | confidence |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| 9 | kits | fcc_kitcards.json | 35 | `fut_clubitems.py:65` | HYPOTHESIS (marked "UNVERIFIED", `:49`,`:30-32`) |
|
|
||||||
| 10 | stadia | fcc_stadium.json | 36 | `fut_clubitems.py:63` | HYPOTHESIS ("UNVERIFIED") |
|
|
||||||
| 11 | badges | fcc_badgecards.json| 39 | `fut_clubitems.py:64` | HYPOTHESIS ("UNVERIFIED") |
|
|
||||||
| 30 | balls | fcc_balls.json | 37 | `fut_clubitems.py:62` | HYPOTHESIS ("UNVERIFIED") |
|
|
||||||
| 31 | leaguelogos | fcc_leaguelogos.json| 40| `fut_clubitems.py:66` | HYPOTHESIS ("UNVERIFIED") |
|
|
||||||
|
|
||||||
- The prior recorded mapping (9=kits,10=stadia,11=badges,30=balls,31=logos) is
|
|
||||||
**confirmed present in current code** — but the code itself marks every one
|
|
||||||
"UNVERIFIED cardsubtypeid" and states the binary assigns family↔subtype **nowhere**
|
|
||||||
in the 149 dumped tables; cardtype-9 admits the set `{30,31,145,146,147,148,149,150}`
|
|
||||||
(`fut_clubitems.py:26-28`, `:73`). So these are server-chosen HYPOTHESIS values.
|
|
||||||
- In the club-item wire item (`_item:88-119`), `cardsubtypeid` (`:97`) is a
|
|
||||||
**server-assigned wire-category constant** (9/10/11/30/31), NOT the raw FCC
|
|
||||||
subtype: the club-item fcc tables carry **no `cardsubtype` column at all** (Task 1:
|
|
||||||
badges/stadium/kit/logos/balls have empty subtype sets). `resourceId`/`assetId`
|
|
||||||
= `carddbid`, and `cardassetid` = the family ART id (`:94-96`). So for club items:
|
|
||||||
**`cardsubtypeid` = wire category (server constant), `carddbid`/`cardassetid` =
|
|
||||||
real fcc columns.**
|
|
||||||
- By contrast, consumables (`fut_store._item` / `fut_consumables`) DO carry the raw
|
|
||||||
FCC subtype (51..341) as `cardsubtypeid`.
|
|
||||||
|
|
||||||
### 3.3 Task-1 families vs wire-subtype coverage
|
|
||||||
Families that the **store handler** can map to a wire subtype: **none** — the store
|
|
||||||
handler emits packs, which have no card subtype (by design).
|
|
||||||
|
|
||||||
Families for which a **club-item** wire subtype exists (HYPOTHESIS-grade):
|
|
||||||
kits(9), stadia(10), badges(11), balls(30), leaguelogos(31).
|
|
||||||
|
|
||||||
Task-1 card families with **no wire subtype mapping anywhere in the server**:
|
|
||||||
- **5001xxx contracts, 5002xxx fitness/healing, 5003xxx training** — served as
|
|
||||||
consumables carrying their raw FCC subtype; these are consumable overlays, not
|
|
||||||
store tiles, and not part of any store/club-item wire-category map.
|
|
||||||
- **5004xxx misc {231,232,233,236}** — **no wire subtype mapping found** in
|
|
||||||
`fut_clubitems.py` or the store path. (Grep: no `misc` family, no {231,232,233,236}
|
|
||||||
wire assignment.) They exist only as raw FCC subtypes in consumable data.
|
|
||||||
- **8010xxx league logos/stickers** — mapped in the **club-item** path as wire
|
|
||||||
subtype **31** (`fut_clubitems.py:66`), HYPOTHESIS-grade. `fcc_leaguelogostickers`
|
|
||||||
(39 rows) is NOT wired in `FAMILIES` (only `fcc_leaguelogos`, 44 rows).
|
|
||||||
- **6000xxx badges, 6200xxx stadiums, 6300/6400xxx kits, 8120xxx balls** — mapped in
|
|
||||||
the club-item path (11/10/9/30), HYPOTHESIS-grade.
|
|
||||||
- **staff (managers/coaches, subtypes 4-8)** — served by `fut_staff` on `/club?type=
|
|
||||||
manager`, not a store tile.
|
|
||||||
|
|
||||||
Note none of these belong to the **store-tile** (`purchasegroup`) response.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2C flag investigation — BLOCKED
|
|
||||||
|
|
||||||
### Store-tiles flags located (OBSERVED)
|
|
||||||
Two, both in `utas_server.py`, both module-level constants read **once at import**:
|
|
||||||
|
|
||||||
| flag | env var | line (read) | consumed | default | current running value |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| `STORE_DISPLAYGROUP` | `FUT_STORE_DISPLAYGROUP` | `:975` | `_pack_body:3337` | `"1"` → **ON** | **ON** (no env override) |
|
|
||||||
| `STORE_GROUPID` | `FUT_STORE_GROUPID` | `:980` | `_pack_body:3403` | `"0"` → **OFF** | **OFF** (no env override) |
|
|
||||||
|
|
||||||
- `:975` `STORE_DISPLAYGROUP = os.environ.get("FUT_STORE_DISPLAYGROUP", "1") == "1"`
|
|
||||||
- `:980` `STORE_GROUPID = os.environ.get("FUT_STORE_GROUPID", "0") == "1"`
|
|
||||||
|
|
||||||
**Current values (recorded before any change; nothing was changed):**
|
|
||||||
- `FUT_STORE_DISPLAYGROUP`: unset in container env → default `"1"` →
|
|
||||||
`STORE_DISPLAYGROUP = True` (**ON**). (INFERRED from OBSERVED env dump + OBSERVED
|
|
||||||
code default.)
|
|
||||||
- `FUT_STORE_GROUPID`: unset in container env → default `"0"` →
|
|
||||||
`STORE_GROUPID = False` (**OFF**). This is the flag "expected to be OFF".
|
|
||||||
|
|
||||||
The related club-item flag `FUT_CLUBITEMS` (`:1647-1648`) is likewise unset →
|
|
||||||
`CLUBITEMS = False` (club items not currently served), also a module-level import
|
|
||||||
constant.
|
|
||||||
|
|
||||||
### Dynamic evaluation? NO (OBSERVED)
|
|
||||||
- All three flags are top-level `os.environ.get(...)` assignments evaluated at
|
|
||||||
module import (`:975`, `:980`, `:1647`); `_pack_body` reads the resulting module
|
|
||||||
**constants** (`:3337`, `:3403`), never `os.environ` at request time.
|
|
||||||
- Repo-wide there is **no** `importlib.reload`, no `signal`/`SIGHUP` handler, and no
|
|
||||||
per-request environ re-read for these flags. (The only runtime-refreshable feature
|
|
||||||
is `FUT_ID_SWEEP`, which re-reads a *file* `SWEEP_FILE`, `:1965-1966` — unrelated.)
|
|
||||||
- `:3250` documents the intended workflow explicitly:
|
|
||||||
`# Enable for the test with: FUT_PRICE_PROBE=1 ./openfut-fut.sh restart`.
|
|
||||||
|
|
||||||
### Restart conflict → STOP
|
|
||||||
Changing either store flag requires either setting a container env var (→
|
|
||||||
`docker` recreate = restart) or editing the module (→ re-import = restart). Both
|
|
||||||
violate the no-restart / no-redeploy / no-recreate constraint. Therefore:
|
|
||||||
|
|
||||||
```
|
|
||||||
TASK 2 BLOCKED AT 2C:
|
|
||||||
Flag requires restart, conflicting with no-restart constraint.
|
|
||||||
```
|
|
||||||
|
|
||||||
No flag was enabled. 2D/2E (manual client capture + definition analysis) NOT
|
|
||||||
started. No client request generated, replayed, or simulated.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Captured request and response (Phase 3) — OBSERVED
|
|
||||||
|
|
||||||
Real client action (operator opened the FUT Store on `.105`, 2026-08-12). Only one
|
|
||||||
`/store/purchasegroup` request occurred after the capture marker
|
|
||||||
`2026-08-12T23:54:01Z`, so attribution is unambiguous (only the operator drives the
|
|
||||||
client). Log via `docker logs --since 2026-08-12T23:54:01Z --timestamps openfut-fut-backend`.
|
|
||||||
|
|
||||||
- **Request:** `23:54:43 GET /ut/game/fifa17/store/purchasegroup/all?ppInfo=true`
|
|
||||||
(Host `10.10.0.120:8099`, `User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)`,
|
|
||||||
`X-UT-SID` present). Preceded at 23:54:43 by `GET /ut/game/fifa17/user/credits`
|
|
||||||
→ `200 {"credits": 29876776, ...}`.
|
|
||||||
- **Response:** `200`. The server log truncates the body to 200 bytes
|
|
||||||
(`utas_server.py:3754`, `raw[:200]`), genuine prefix:
|
|
||||||
`{"purchase": [{"assetId": 1, "id": 1, "packType": "BRONZE", "description": "Bronze Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount`
|
|
||||||
- **Full body recovered** by running the exact running-container code
|
|
||||||
(`docker exec openfut-fut-backend python3 -c "import utas_server as u; u.store_catalog(None)"`)
|
|
||||||
over the live `/state` profile, under the live flags (confirmed in-process:
|
|
||||||
`STORE_DISPLAYGROUP=True`, `STORE_GROUPID=False`). Its `json.dumps(...)[:200]`
|
|
||||||
equals the genuine logged 200-byte prefix **byte-for-byte** (verified MATCH), so the
|
|
||||||
reconstruction IS the sent body (deterministic pure function + verified prefix). No
|
|
||||||
request was synthesized, replayed, or curl'd. Full body (2841 bytes) preserved
|
|
||||||
verbatim at `docs/evidence/store_purchasegroup_capture_2026-08-12.json`.
|
|
||||||
- **Operator-reported client behavior:** on opening the store, an error dialog
|
|
||||||
appeared — *"The pack you've selected is currently not available. Please select a
|
|
||||||
different pack or try again later."* — and clicking OK returned to the FUT hub. No
|
|
||||||
tiles were browsable; **no "unknown" tiles were reported**. There was **no**
|
|
||||||
`PUT /store/transaction` (no buy) and **no server error** (server answered `200`).
|
|
||||||
|
|
||||||
## 5. Definition analysis (Phase 4/5) — OBSERVED
|
|
||||||
|
|
||||||
`purchase[]` = 5 entries (`timestamp` 1596326400). No `cardsubtypeid`/`carddbid`/
|
|
||||||
`cardassetid` in any entry (packs, not cards).
|
|
||||||
|
|
||||||
| idx | id | assetId | packType | description | displayGroup.value | priority | state | dgAssetId | classification |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| 0 | 1 | 1 | BRONZE | Bronze Pack | `bronze` | — | active | absent | KNOWN TOKEN |
|
|
||||||
| 1 | 5 | 5 | GOLD | Gold Pack | `gold` | — | active | absent | KNOWN TOKEN |
|
|
||||||
| 2 | 6 | 6 | GOLD | Premium Gold | `gold` | — | active | absent | KNOWN TOKEN |
|
|
||||||
| 3 | 7 | 7 | GOLD | Special Players Pack | `special` | — | active | absent | KNOWN TOKEN |
|
|
||||||
| 4 | 65534 | 65534 | GOLD | "" (empty) | `mypacks` | 1 | **inactive** | absent | KNOWN TOKEN |
|
|
||||||
|
|
||||||
Per-pack fields (idx 0-3 identical shape): `saleType:"promo"`, `limitType:"NONE"`,
|
|
||||||
`quantity:0`, `purchaseLimit:0`, `purchaseCount:0`, `isPremium:false`,
|
|
||||||
`currencies:[{"name":"coins","funds":<price>,"finalFunds":<price>}]`,
|
|
||||||
`extPrice:{finalPrice/originalPrice:{amount:<price/100>,currency:"mtx"}}`,
|
|
||||||
`packContentInfo:{...tier quantities...}`, `unopened:false`. Prices: Bronze 400,
|
|
||||||
Gold 5000, Premium Gold 15000, Special Players 25000. The sentinel (idx 4) drops
|
|
||||||
`currencies`/`extPrice`, has empty description, `state:"inactive"`.
|
|
||||||
|
|
||||||
**Unique `displayGroup.value` set emitted: `{bronze, gold, special, mypacks}`.**
|
|
||||||
Compared to the six client renderer tokens `{mypacks, points, bronze, silver, gold,
|
|
||||||
special}` (`FUN_180014580`/`FUN_180014df0`, `plan-2026-08-05-store-subsystem.md:202-206`):
|
|
||||||
**every emitted token is a KNOWN token; the set outside the renderer categories is
|
|
||||||
EMPTY.**
|
|
||||||
|
|
||||||
## 6. Unmapped definitions / suspect tokens found — OBSERVED
|
|
||||||
|
|
||||||
None. Zero SUSPECT/UNKNOWN `displayGroup.value` tokens; zero card definitions;
|
|
||||||
zero `cardsubtypeid`/`carddbid`/`cardassetid`. The only anomalous element is the
|
|
||||||
**inactive, empty-description `mypacks` sentinel** (idx 4), emitted by
|
|
||||||
`store_catalog:3428-3446` **only when the profile owns zero unopened packs**
|
|
||||||
(OBSERVED: profile `unopenedPackIds == []`).
|
|
||||||
|
|
||||||
## 7. Client-side evidence (Phase 7) — existing RE only
|
|
||||||
|
|
||||||
No new Ghidra run. Existing decompiler evidence already bounds the answer and shows
|
|
||||||
new static analysis would be unproductive:
|
|
||||||
- **The parser is not the gate** (`OPENCODE_ENDPOINT_PROMPT.md:118-120`): per-pack
|
|
||||||
epilogue `0x18013badc` pushes every parsed pack unconditionally — no drop predicate
|
|
||||||
in the parse path. So a `200` with clean JSON cannot be rejected by the deserializer.
|
|
||||||
- **Store-level "not available"** (`FUT_CatalogNotAvailable`, msg `0x7550`) comes from
|
|
||||||
downstream client gates (`OPENCODE_ENDPOINT_PROMPT.md:120-131`): (1) resolution
|
|
||||||
`GetSystemMetrics` ≤1024×768, (2) store-data-model load status `0x180013cf0`,
|
|
||||||
(3) Blaze purchase-config flags `IS_STORE_ENABLED/IS_COIN_PURCHASABLE/...` (these
|
|
||||||
are already served, `blaze_responder_v3b.py:708-719`).
|
|
||||||
- **Per-pack tile-availability predicate is in the PACKED FIFA17.exe and UNREAD**
|
|
||||||
(`plan-2026-08-05-pack-opening.md:553`): fields `state/saleType/quantity/
|
|
||||||
purchaseLimit/purchaseCount/start/end` are parsed and copied to the tile, but the
|
|
||||||
predicate that greys/blocks a tile "is in the packed exe and unread." Whether
|
|
||||||
`purchaseLimit`+`purchaseCount` greys a tile is explicitly an OPEN question
|
|
||||||
(`:644-645`). Static Ghidra on the packed exe cannot read it (decrypts only in live
|
|
||||||
memory); resolving it requires a live-memory experiment on the running FIFA process,
|
|
||||||
which is out of scope (must not touch FIFA).
|
|
||||||
- The exact operator string *"The pack you've selected is currently not available"*
|
|
||||||
is **not present** anywhere in the recon corpus (docs/tools); the documented
|
|
||||||
store/pack error strings are `FUT_CatalogNotAvailable` and
|
|
||||||
`CARDS_CB_ERR_PACK_NOT_IN_DIME` (a server-returnable code). UNKNOWN loc key.
|
|
||||||
|
|
||||||
## 8. Assessment (Phase 8) — runtime verdict
|
|
||||||
|
|
||||||
- **H1 (subtype/definition): CONTRADICTED.** The captured response contains no card
|
|
||||||
definitions and no `cardsubtypeid`/`carddbid`/`cardassetid` (OBSERVED §4-§5).
|
|
||||||
- **H2 (unknown `displayGroup.value` token): CONTRADICTED.** Every emitted token is a
|
|
||||||
KNOWN renderer category (`bronze/gold/special/mypacks`); the unsupported-token set
|
|
||||||
is EMPTY (OBSERVED §5). The store-tile "unknown" mechanism is NOT reproduced under
|
|
||||||
the current config (`STORE_DISPLAYGROUP=ON`).
|
|
||||||
|
|
||||||
Answering the Phase-8 questions:
|
|
||||||
1. **Unsupported `displayGroup.value` in the response?** No — all four tokens
|
|
||||||
(`bronze/gold/special/mypacks`) are recognized. (OBSERVED)
|
|
||||||
2. **Which pack got it?** None. (OBSERVED)
|
|
||||||
3. **Correspond to an unknown tile in FIFA?** No unknown tile was reported; the
|
|
||||||
observed symptom was a *"pack not available"* dialog, not an unknown tile. (OBSERVED)
|
|
||||||
4. **Where does the backend assign the token?** `_pack_body:3373-3379` maps each pack
|
|
||||||
to `special/gold/silver/bronze` from `specialChance`/`gold`; owned/sentinel →
|
|
||||||
`mypacks` (`:3336`). All canonical. (OBSERVED)
|
|
||||||
5. **Is `displayGroup.value` sufficient to explain the bug?** No. The response is
|
|
||||||
clean; the failure is a downstream client/packed-exe gate, not a token. (INFERRED)
|
|
||||||
6. **Is the server emitting a value FIFA demonstrably cannot understand?** No.
|
|
||||||
(OBSERVED)
|
|
||||||
7. **Narrowest likely fix (REPORT ONLY — not implemented):** The single anomalous,
|
|
||||||
server-controllable element is the **inactive empty `mypacks` sentinel** emitted
|
|
||||||
when `unopenedPackIds == []` (`store_catalog:3428-3446`). Leading HYPOTHESIS: with
|
|
||||||
no owned packs, the store's My-Packs group contains only this inactive pack, and
|
|
||||||
the client's (packed-exe) selection/availability path lands on it →
|
|
||||||
*"the pack you've selected is currently not available"* → back to hub. Narrowest
|
|
||||||
candidate fixes to TEST (each needs a controlled change, hence a future
|
|
||||||
restart-gated experiment — do NOT implement now):
|
|
||||||
(a) suppress the sentinel when there are no owned packs and instead let the store
|
|
||||||
land on a real active category (bronze/gold/special); or
|
|
||||||
(b) if My-Packs must resolve, make the sentinel non-selectable rather than an
|
|
||||||
`inactive` pack in the group.
|
|
||||||
Cheaper-to-eliminate CLIENT-side cause to check first (existing RE, no server
|
|
||||||
change): FIFA display resolution must be **>1024×768** on `.105`
|
|
||||||
(`OPENCODE_ENDPOINT_PROMPT.md:122-124`).
|
|
||||||
|
|
||||||
**Overall runtime verdict: CONTRADICTED** — neither H1 nor H2 reproduces; the
|
|
||||||
"unknown store tile" hypothesis is not the live failure. The live failure is a
|
|
||||||
distinct *pack-availability* error whose trigger is in the packed FIFA17.exe and
|
|
||||||
cannot be pinned from the (clean) server response alone.
|
|
||||||
|
|
||||||
## 9. Open questions
|
|
||||||
1. What exactly raises *"The pack you've selected is currently not available"*? The
|
|
||||||
loc key is unknown and the predicate is in the packed exe (unread). Resolving it
|
|
||||||
needs a controlled field/flag experiment or live-memory RE (both currently gated).
|
|
||||||
2. Does the store, with `unopenedPackIds == []`, land on / auto-select the inactive
|
|
||||||
`mypacks` sentinel? (HYPOTHESIS §8.7; unproven without client-side observation.)
|
|
||||||
3. Did the store render tiles successfully in earlier sessions when the profile
|
|
||||||
owned unopened packs (e.g. the 21:54-21:57 pack-opening burst)? If so, the
|
|
||||||
presence/absence of owned packs (sentinel) is implicated. (UNKNOWN — earlier logs
|
|
||||||
truncate the body; not proven.)
|
|
||||||
4. Does `purchaseLimit:0`/`purchaseCount:0` grey a tile? Existing RE lists this as an
|
|
||||||
OPEN question; would need a controlled experiment. (UNKNOWN)
|
|
||||||
5. Is bug 6c ("unknown tile") a stale symptom from before `STORE_DISPLAYGROUP` became
|
|
||||||
the default `ON`? Under the current config no unknown tile reproduces.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## H3 — EMPTY MY-PACKS SENTINEL (HYPOTHESIS)
|
|
||||||
|
|
||||||
When the profile owns zero unopened packs (`unopenedPackIds == []`), OpenFUT emits
|
|
||||||
synthetic **inactive** pack id **65534** in the `mypacks` display group
|
|
||||||
(`store_catalog:3428-3446`). The FIFA 17 store may treat this object as a selectable
|
|
||||||
pack whose availability predicate fails, producing *"The pack you've selected is
|
|
||||||
currently not available"* and returning the user to the FUT Hub before any
|
|
||||||
`/store/transaction`. **Status: HYPOTHESIS (untested).**
|
|
||||||
|
|
||||||
## 10. Resolution check (Phase 1) — OBSERVED
|
|
||||||
|
|
||||||
Read-only inspection of `.105` (FIFA pid 529227, not touched):
|
|
||||||
- Desktop/monitor: **2560x1440** — DRM connectors `card1-DP-2` and `card1-HDMI-A-1`
|
|
||||||
both `connected`, native mode 2560x1440; compositor KDE `kwin_wayland` (no gamescope).
|
|
||||||
- FIFA render config: `…/Games/umu/fifa17/pfx/drive_c/users/steamuser/Documents/FIFA 17/settings/overrideAutodetect.lua`
|
|
||||||
→ `ResolutionWidth = 1280`, `ResolutionHeight = 720`, `FullscreenEnabled = 0` (windowed).
|
|
||||||
- Session: Wayland (`WAYLAND_DISPLAY=wayland-0`), FIFA via XWayland (`DISPLAY=:0`,
|
|
||||||
`XAUTHORITY=/run/pressure-vessel/Xauthority` inside the game namespace).
|
|
||||||
|
|
||||||
**Is FIFA rendering above 1024x768? YES (1280x720).**
|
|
||||||
**Resolution hypothesis eliminated for this reproduction.**
|
|
||||||
|
|
||||||
## 11. Sentinel 65534 provenance (Phase 3)
|
|
||||||
|
|
||||||
1. **Basis:** **OpenFUT INVENTION** (OBSERVED). `65534` (0xFFFE) appears nowhere in
|
|
||||||
any EA capture or recon note — repo-wide it exists only in `utas_server.py`
|
|
||||||
(`store_catalog:3435-3446`) and this evidence set. The author's comment
|
|
||||||
(`:3428-3434`) states it is a workaround: "Retain an inactive zero-item sentinel so
|
|
||||||
the [`mypacks`] destination resolves… Its id is deliberately absent from
|
|
||||||
PACK_CATALOG." It is a compatibility guess, not captured behavior.
|
|
||||||
2. **Known-good EA capture of EMPTY My Packs:** **UNKNOWN** — none found. All recon
|
|
||||||
My-Packs analysis is client-side RE (`FUN_1800150d0` filters
|
|
||||||
`displayGroup.value=="mypacks"`, `plan-2026-08-05-pack-opening.md:34-36,893-895`);
|
|
||||||
no EA server response for an empty My Packs state is on record.
|
|
||||||
3. **Known-good response with ≥1 unopened pack:** **UNKNOWN** for EA. OpenFUT's own
|
|
||||||
seed grants reward pack id 70 (`_new_profile` `unopenedPackIds:[70]`,
|
|
||||||
`fut_store.py:360`), but that is an OpenFUT synthetic grant, not an EA capture.
|
|
||||||
4. **Evidence FIFA EXPECTS a sentinel/placeholder:** **NO / UNKNOWN.** Recon shows My
|
|
||||||
Packs is a client-side filter over the ordinary catalogue; the "empty-category
|
|
||||||
dialog over the wrong tab" concern behind the sentinel is the author's HYPOTHESIS,
|
|
||||||
not decompiler-confirmed. No evidence FIFA requires a placeholder object.
|
|
||||||
5. **Evidence for the `inactive` representation:** **UNKNOWN / HYPOTHESIS.** The claim
|
|
||||||
"state != active keeps it out of the visible row list" (`:3432`) is an unverified
|
|
||||||
author assumption; no RE shows `state:"inactive"` hides a pack from selection. If
|
|
||||||
FIFA does NOT hide it, the sole `mypacks` entry is a selectable inactive pack —
|
|
||||||
exactly the H3 failure mode.
|
|
||||||
|
|
||||||
## 12. Empty vs non-empty My Packs behavior (Phase 2) — OBSERVED (code) / captured
|
|
||||||
|
|
||||||
`store_catalog(h)` (`utas_server.py:3421-3447`):
|
|
||||||
- Always emits the 4 non-`ownedOnly` catalogue packs (ids 1,5,6,7) via `_pack_body`.
|
|
||||||
- For each id in `visible_unopened_packs()` (= `STORE.unopened_packs()` +
|
|
||||||
`_OPENED_PACK_GRACE`) that resolves in `PACK_CATALOG`, appends `_pack_body(owned,
|
|
||||||
idx, owned=True)`.
|
|
||||||
- **Only when `unopened_packs()` is empty (`if not owned_ids`)** appends the inactive
|
|
||||||
sentinel (`:3428-3446`).
|
|
||||||
|
|
||||||
Sentinel 65534 vs a normal active pack (Bronze, idx 0), field-by-field (from the
|
|
||||||
captured body):
|
|
||||||
|
|
||||||
| field | sentinel 65534 | Bronze Pack (active) |
|
|
||||||
|---|---|---|
|
|
||||||
| id | 65534 | 1 |
|
|
||||||
| assetId | 65534 | 1 |
|
|
||||||
| packType | GOLD | BRONZE |
|
|
||||||
| description | "" (empty) | "Bronze Pack" |
|
|
||||||
| state | **inactive** | active |
|
|
||||||
| saleType | promo | promo |
|
|
||||||
| limitType | NONE | NONE |
|
|
||||||
| quantity | 0 | 0 |
|
|
||||||
| purchaseLimit | 0 | 0 |
|
|
||||||
| purchaseCount | 0 | 0 |
|
|
||||||
| isPremium | false | false |
|
|
||||||
| sortPriority | 1 | 1 |
|
|
||||||
| currencies | **ABSENT** (popped) | `[{coins,400,400}]` |
|
|
||||||
| extPrice | **ABSENT** (popped) | `{mtx 4/4}` |
|
|
||||||
| packContentInfo | all-zero quantities | bronze 5 / item 5 |
|
|
||||||
| unopened | false | false |
|
|
||||||
| displayGroup.value | **mypacks** | bronze |
|
|
||||||
| displayGroup.priority | 1 | ABSENT |
|
|
||||||
| displayGroupAssetId | ABSENT | ABSENT |
|
|
||||||
|
|
||||||
**What changes when `unopenedPackIds` is non-empty (e.g. `[70]`):** the sentinel is
|
|
||||||
NOT emitted; instead pack 70 (Reward Special Players Pack, `ownedOnly`) appears via
|
|
||||||
the owned branch of `_pack_body` (`:3335-3336`): `displayGroup={"value":"mypacks",
|
|
||||||
"priority":idx}`, `state:"active"` (default), `unopened:true`, `currencies`/`extPrice`
|
|
||||||
popped. i.e. the `mypacks` group would hold a genuine **active** owned pack instead of
|
|
||||||
the inactive sentinel.
|
|
||||||
|
|
||||||
## 13. Proposed controlled experiments (Phase 5) — DESIGN ONLY, NOT EXECUTED
|
|
||||||
|
|
||||||
### Existing grant mechanism (Phase 4)
|
|
||||||
- **Supported profile-only method: YES** — `Store.grant_unopened_pack(pack_id)`
|
|
||||||
(`fut_store.py:635-643`): validates the id is in `PACK_CATALOG`, appends to
|
|
||||||
`unopenedPackIds`, persists; reverts via `consume_unopened_pack` (`:623-633`).
|
|
||||||
Modifies **only** profile state; cleanly reversible.
|
|
||||||
- **BUT restart IS required to take effect.** `Store.load()` caches `self._p`
|
|
||||||
(`:370-372`); **no route calls `grant_unopened_pack` or `select_account`**, and
|
|
||||||
`select_account` only re-reads on a persona *change*. So an out-of-process grant or
|
|
||||||
a raw profile-file edit writes disk but the **running server keeps serving its
|
|
||||||
cached `unopenedPackIds`** until the process reloads. There is no SIGHUP/reload
|
|
||||||
endpoint. Therefore any profile change needs a container restart to be observed.
|
|
||||||
|
|
||||||
### Experiment A — Non-empty My Packs (PREFERRED; least invasive)
|
|
||||||
- **State change:** set the profile's `unopenedPackIds` to `[70]` (edit
|
|
||||||
`…/state/accounts/33068179/fifa17_profile.json`, or call
|
|
||||||
`STORE.grant_unopened_pack(70)`), **store code/config unchanged**.
|
|
||||||
- **Restart required?** **YES** — profile cache (above). Profile-only; no code edit.
|
|
||||||
- **Rollback:** set `unopenedPackIds` back to `[]` (or `consume_unopened_pack(70)`),
|
|
||||||
restart. (Also restore `nextItemId`/coins only if a pack is actually opened — the
|
|
||||||
grant alone touches only `unopenedPackIds`.)
|
|
||||||
- **Expected `purchasegroup` difference:** sentinel 65534 GONE; instead one active
|
|
||||||
pack id 70 in the `mypacks` group (`state:active`, `unopened:true`); hub/credits
|
|
||||||
report `recoveredPacks:1`.
|
|
||||||
- **Expected client observation:** if H3 is correct, the immediate *"pack not
|
|
||||||
available"* dialog should NOT fire (or behavior changes) and My Packs should show a
|
|
||||||
real pack. If the dialog still fires identically, H3 is weakened and the cause is
|
|
||||||
elsewhere (packed-exe predicate / another field).
|
|
||||||
|
|
||||||
### Experiment B — Suppress the empty sentinel (only if A is inconclusive)
|
|
||||||
- **Change:** in `store_catalog` (`:3428-3446`), when `unopened_packs()` is empty, do
|
|
||||||
NOT append pack 65534 (emit no `mypacks` entry). **Code change ⇒ restart. NOT
|
|
||||||
authorized.** Narrowest patch: guard/remove the `if not owned_ids:` sentinel block.
|
|
||||||
- **Purpose:** distinguishes "the inactive sentinel is selected and fails" (A already
|
|
||||||
tests the inverse) from "an absent `mypacks` group causes a different failure"
|
|
||||||
(the original author's stated fear at `:3429-3431`).
|
|
||||||
|
|
||||||
### Experiment C — Alternate sentinel representation (design only)
|
|
||||||
- If evidence later shows FIFA expects an empty `mypacks` group represented
|
|
||||||
differently (e.g. present-but-not-a-pack, or `state` other than `inactive`), adjust
|
|
||||||
the sentinel shape. **DESIGN ONLY; DO NOT IMPLEMENT.** No current evidence specifies
|
|
||||||
the correct empty-group representation (see §11.4-11.5).
|
|
||||||
|
|
||||||
Note: both A and B require a restart (A for the profile cache, B for the code). A is
|
|
||||||
strictly less invasive (profile-only, clean rollback, no code change) and is the
|
|
||||||
preferred next experiment. Neither is authorized yet.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Experiment A — Non-empty My Packs (EXECUTED 2026-08-13) — OBSERVED
|
|
||||||
|
|
||||||
Authorized controlled test of H3: change ONLY the profile's `unopenedPackIds`
|
|
||||||
(`[] → [70]`), restart the FUT backend once, capture a genuine FIFA store request,
|
|
||||||
then roll back. No store code/config/flags/PACK_CATALOG/pack-70/sentinel-code
|
|
||||||
changed; FIFA not modified/restarted; operator drove the client.
|
|
||||||
|
|
||||||
### 14.1 Baseline profile state
|
|
||||||
- Path: `fifa17-recon/docker/state/accounts/33068179/fifa17_profile.json` (persona
|
|
||||||
33068179/CAGE; proven live: `STORE.path` in-process = `/state/accounts/33068179/
|
|
||||||
fifa17_profile.json`, coins 29876776 matching the live `/user/credits`).
|
|
||||||
- `unopenedPackIds == []`. Original SHA-256
|
|
||||||
`39bb3e833fa55287d8516815ba3a717b41c0f0c7a7c41d20503f3a55c65cc6e7`. Backup:
|
|
||||||
`/tmp/fifa17_profile.33068179.ORIG.20260813T001228Z.json` (same hash).
|
|
||||||
|
|
||||||
### 14.2 State change
|
|
||||||
- Narrow anchored edit of line 97070 only: ` "unopenedPackIds": [],` →
|
|
||||||
` "unopenedPackIds": [70],`. Diff vs backup = exactly one line; semantic diff =
|
|
||||||
only key `unopenedPackIds` (`[] → [70]`), all other 24 keys identical. Modified
|
|
||||||
SHA-256 `2b5760baa265f320904de2d23fd6ab374733efe74cb0756c3be39c083bce4ac8`.
|
|
||||||
(Used the direct edit rather than `grant_unopened_pack(70)` to avoid whole-file
|
|
||||||
reserialization; net semantic effect is identical.)
|
|
||||||
|
|
||||||
### 14.3 Restart
|
|
||||||
- `docker restart openfut-fut-backend` (Pid 1398009→1548312, StartedAt
|
|
||||||
2026-08-12T16:40:52Z → 2026-08-13T00:13:54Z). Bridge/Core and Rust hosts untouched.
|
|
||||||
This container bundles blaze/roster/utas/pow (per `entrypoint.sh`); all rebound.
|
|
||||||
- Post-restart in-process check: `STORE.load()['unopenedPackIds'] == [70]`;
|
|
||||||
`store_catalog(None)` → pack 70 present, sentinel 65534 absent.
|
|
||||||
|
|
||||||
### 14.4 Genuine FIFA capture
|
|
||||||
- Marker `2026-08-13T00:14:24Z`. Single request after it (unambiguous):
|
|
||||||
`00:14:58 GET /ut/game/fifa17/store/purchasegroup/all?ppInfo=true → 200`.
|
|
||||||
**No `/store/transaction`, no `/purchased/items`** in the window (pack 70 not opened).
|
|
||||||
- Full body recovered from the running code over the live [70] profile; its
|
|
||||||
`[:200]` matches the genuine logged 200-byte prefix byte-for-byte (verified MATCH).
|
|
||||||
Preserved at `docs/evidence/store_purchasegroup_capture_mypacks70_2026-08-12.json`.
|
|
||||||
|
|
||||||
### 14.5 Client-observed behavior (operator report)
|
|
||||||
1. Store remains open: **YES**.
|
|
||||||
2. "The pack you've selected is currently not available": **NO (gone)**.
|
|
||||||
3. My Packs category / unopened reward pack visible: **YES**.
|
|
||||||
4. Returned to FUT Hub: yes (normal navigation; not forced by an error dialog).
|
|
||||||
|
|
||||||
### 14.6 Response diff (baseline 2026-08-12 vs experiment)
|
|
||||||
Only difference across all 5 entries:
|
|
||||||
- **Removed:** id `65534` (`state:inactive`, `displayGroup:mypacks`, `description:""`).
|
|
||||||
- **Added:** id `70` (`state:active`, `displayGroup:mypacks`,
|
|
||||||
`description:"Reward Special Players Pack"`, `unopened:true`).
|
|
||||||
- Packs 1/5/6/7 byte-identical. Classification: **all EXPECTED FROM UNOPENED PACK
|
|
||||||
STATE; nothing UNEXPECTED.**
|
|
||||||
|
|
||||||
### 14.7 H3 assessment — **SUPPORTED**
|
|
||||||
65534 disappeared AND pack 70 replaced it as a real My Packs entry AND the
|
|
||||||
"pack not available" / store-exit behavior disappeared → per the pre-registered
|
|
||||||
criterion, **H3 is strongly SUPPORTED**. The failure is tied to the My Packs group
|
|
||||||
content when the profile owns zero unopened packs.
|
|
||||||
|
|
||||||
Sub-hypothesis resolution:
|
|
||||||
- **H3c (failure unrelated to My Packs): RULED OUT.** A My-Packs-only profile change
|
|
||||||
(no store code/config change) eliminated the failure.
|
|
||||||
- **H3a (the inactive sentinel object itself is the trigger) vs H3b (empty My Packs
|
|
||||||
state generally is the trigger): NOT DISTINGUISHED by Experiment A.** The change
|
|
||||||
simultaneously (i) removed the inactive sentinel and (ii) supplied a real active
|
|
||||||
owned pack. Either "presence of the inactive/empty sentinel" or "absence of any
|
|
||||||
real owned pack" could be the cause. Distinguishing them requires Experiment B
|
|
||||||
(empty `unopenedPackIds` AND suppress the sentinel so `mypacks` has no entry): if
|
|
||||||
that also fixes it → H3a (sentinel object was the problem); if it re-breaks or
|
|
||||||
changes → H3b (empty My Packs itself is the problem). Experiment B is a code change
|
|
||||||
(restart-gated) and remains unauthorized.
|
|
||||||
|
|
||||||
### 14.8 Rollback verification
|
|
||||||
- Profile restored from backup → SHA-256 `39bb3e83…` == original (byte-identical);
|
|
||||||
`unopenedPackIds == []`. Disk was unmutated during the test (still `2b5760ba…`
|
|
||||||
before rollback → store reads don't persist; pack 70 never opened).
|
|
||||||
- `docker restart openfut-fut-backend` (Pid 1549503, StartedAt 2026-08-13T00:16:56Z).
|
|
||||||
Post-restart in-process: `unopenedPackIds == []`, sentinel 65534 present again,
|
|
||||||
pack 70 absent → runtime baseline restored. Bridge/Core untouched.
|
|
||||||
|
|
||||||
**H3 status: SUPPORTED (H3c ruled out; H3a vs H3b open).** No permanent fix
|
|
||||||
implemented.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 15. Experiment B — Empty My Packs Without Sentinel (EXECUTED 2026-08-13) — OBSERVED
|
|
||||||
|
|
||||||
### 15.1 Purpose
|
|
||||||
Distinguish **H3a** (the synthetic inactive sentinel 65534 itself is the trigger)
|
|
||||||
from **H3b** (FIFA cannot tolerate an empty My Packs state even without a sentinel).
|
|
||||||
Hold `unopenedPackIds == []` constant; change ONLY: sentinel 65534 emitted →
|
|
||||||
suppressed. Authorized TEMPORARY code change, reverted after test.
|
|
||||||
|
|
||||||
### 15.2 Baseline
|
|
||||||
Profile `unopenedPackIds == []` (SHA-256 `39bb3e83…`, unchanged throughout). Running
|
|
||||||
code before patch = `c89d43ea…` (host repo == container copy). Sentinel 65534 emitted.
|
|
||||||
|
|
||||||
### 15.3 Temporary patch — **TEMPORARY EXPERIMENT B PATCH, NOT A PERMANENT FIX**
|
|
||||||
Applied to the **container** copy `/app/tools/utas_server.py` only (the container
|
|
||||||
mounts `/state`, not `/app`; host repo `fifa17-recon/tools/utas_server.py` was NOT
|
|
||||||
edited — its git diff stayed empty). Single line, `store_catalog` (line 3428):
|
|
||||||
```
|
|
||||||
- if not owned_ids:
|
|
||||||
+ if False: # TEMP EXPERIMENT B PATCH -- suppress synthetic sentinel 65534 (NOT A PERMANENT FIX)
|
|
||||||
```
|
|
||||||
Semantic effect: when `unopened_packs()` is empty, append nothing (no sentinel, no
|
|
||||||
replacement object). Normal packs 1/5/6/7 (appended earlier) unchanged. Diff vs the
|
|
||||||
backed-up original = exactly this one line. Patched code SHA-256 `5bb8fca9…`.
|
|
||||||
|
|
||||||
### 15.4 Restart verification
|
|
||||||
`docker restart openfut-fut-backend` (Pid 1549503→1551026, StartedAt
|
|
||||||
2026-08-13T00:20:45Z). The writable-layer edit survived the restart; running
|
|
||||||
`/app/tools/utas_server.py` = `5bb8fca9…` (patched). In-process: `unopenedPackIds ==
|
|
||||||
[]`; `store_catalog` → 4 packs {1,5,6,7}, **no 65534, no 70, no `mypacks` entry**.
|
|
||||||
Flags unchanged (`DISPLAYGROUP=ON`, `GROUPID=OFF`). Bridge/Core/Rust untouched.
|
|
||||||
|
|
||||||
### 15.5 Genuine FIFA capture
|
|
||||||
Marker `2026-08-13T00:20:55Z`. Request sequence (operator opened the store):
|
|
||||||
`00:21:05 GET /hub` → `00:21:07 GET /user/credits` → `00:21:07 GET
|
|
||||||
/store/purchasegroup/all?ppInfo=true → 200`. **No `/store/transaction`; no further
|
|
||||||
requests** (client crashed after receiving the store body). Full body recovered from
|
|
||||||
the running patched code; `[:200]` matches the genuine logged prefix byte-for-byte
|
|
||||||
(verified MATCH). Preserved at
|
|
||||||
`docs/evidence/store_purchasegroup_capture_empty_no_sentinel_2026-08-12.json`
|
|
||||||
(4 packs {1,5,6,7}, no `mypacks` group).
|
|
||||||
|
|
||||||
### 15.6 Client behavior (operator report)
|
|
||||||
**The game CRASHED** on opening the store. Not the baseline dialog; a hard crash. No
|
|
||||||
`/store/transaction` was issued.
|
|
||||||
|
|
||||||
### 15.7 Three-way response comparison
|
|
||||||
| capture | ids present | `mypacks` group entry | client outcome |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Baseline (`…_2026-08-12.json`) | 1,5,6,7,**65534** | 65534 `inactive`, desc "" | "pack not available" dialog → Hub |
|
|
||||||
| Exp A (`…_mypacks70_…json`) | 1,5,6,7,**70** | 70 `active`, "Reward Special Players Pack" | **works** — store open, My Packs visible |
|
|
||||||
| Exp B (`…_empty_no_sentinel_…json`) | 1,5,6,7 | **none** | **CRASH** |
|
|
||||||
Normal packs {1,5,6,7} identical across all three.
|
|
||||||
|
|
||||||
### 15.8 H3a / H3b verdict
|
|
||||||
- **H3a (sentinel object itself is the trigger): CONTRADICTED.** Removing the
|
|
||||||
sentinel did NOT restore the store; it produced a *worse* outcome (crash). If the
|
|
||||||
sentinel object were the sole cause, its removal would yield a working store (it
|
|
||||||
did not).
|
|
||||||
- **H3b (FIFA cannot tolerate an empty My Packs state): SUPPORTED.** Only Exp A — a
|
|
||||||
real **active** owned pack in `mypacks` — worked. Both the inactive sentinel
|
|
||||||
(graceful "pack not available" dialog) and the total absence of any `mypacks` entry
|
|
||||||
(crash) fail. The sentinel is a **load-bearing workaround** that *downgrades* the
|
|
||||||
failure from a crash to a dialog but does not fix it.
|
|
||||||
- **Pre-registered-rule nuance:** Exp B produced a *distinct* failure (crash), which
|
|
||||||
the pre-registered rules classify as **B3 (different failure)** rather than the
|
|
||||||
exact B2 dialog. Documented as such: the crash is a THIRD failure mode. It still
|
|
||||||
resolves the question — it rules out H3a and supports H3b — but the specific
|
|
||||||
outcome (crash, not the same dialog) is stronger than B2 anticipated. Not forced
|
|
||||||
into a clean binary beyond what the evidence shows.
|
|
||||||
|
|
||||||
**Does FIFA tolerate an empty My Packs without the sentinel? NO — it crashes.**
|
|
||||||
|
|
||||||
### 15.9 Rollback verification
|
|
||||||
- Container `/app/tools/utas_server.py` restored from backup → SHA-256 `c89d43ea…`
|
|
||||||
== pre-experiment (byte-identical); the `if False:` patch fully removed.
|
|
||||||
- `docker restart openfut-fut-backend` (final Pid 1551901,
|
|
||||||
StartedAt 2026-08-13T00:22:04Z). In-process: `unopenedPackIds == []`, sentinel
|
|
||||||
65534 emitted again, pack 70 absent; `DISPLAYGROUP=ON`, `GROUPID=OFF`.
|
|
||||||
- Host repo `fifa17-recon/tools/utas_server.py` never edited (git diff empty, SHA-256
|
|
||||||
`c89d43ea…`). Profile unchanged (`39bb3e83…`). Bridge/Core/Rust untouched.
|
|
||||||
|
|
||||||
### 15.10 Likely permanent fix (REPORT ONLY — not implemented)
|
|
||||||
Evidence: the store's `mypacks` group must contain a **valid, active, openable owned
|
|
||||||
pack**; both an inactive sentinel and an absent group fail (dialog / crash). The only
|
|
||||||
working configuration observed is a genuine active owned pack (Exp A). Candidate
|
|
||||||
directions (report only, each needs design + authorization):
|
|
||||||
1. Ensure the profile always owns ≥1 legitimate active unopened pack while the store
|
|
||||||
is shown (e.g. keep a real reward pack such as id 70 granted), so `mypacks` is
|
|
||||||
never empty — this matches the only known-working state but changes economy state
|
|
||||||
and needs a lifecycle policy (what happens after the user opens it).
|
|
||||||
2. Change what `store_catalog` advertises so FIFA never lands on / requires a
|
|
||||||
`mypacks` group when there are zero owned packs (client-compatible empty-store
|
|
||||||
representation) — the correct representation is UNKNOWN; neither current option
|
|
||||||
(inactive sentinel / no group) is it, so this needs new client-side RE before
|
|
||||||
implementation.
|
|
||||||
Recommendation: do NOT simply delete the sentinel (Exp B proves that crashes). No fix
|
|
||||||
implemented.
|
|
||||||
|
|
||||||
**H3 status: SUPPORTED. H3a CONTRADICTED, H3b SUPPORTED (Exp B crash = third failure
|
|
||||||
mode; empty My Packs is the root problem). Sentinel is a load-bearing workaround.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 16. Experiment C′ — Active Non-Openable Placeholder (EXECUTED 2026-08-13) — OBSERVED
|
|
||||||
|
|
||||||
Question: can the required `mypacks` group be kept structurally valid with an
|
|
||||||
**active** placeholder that stays impossible to open/purchase? Change exactly one
|
|
||||||
field of sentinel 65534: `state "inactive" → "active"`. Profile untouched
|
|
||||||
(`unopenedPackIds==[]`); 65534 kept absent from `PACK_CATALOG`.
|
|
||||||
|
|
||||||
### 16.1 Server-side safety proof (OBSERVED, code)
|
|
||||||
65534 cannot grant value regardless of `state` — pack resolution is by
|
|
||||||
`pack_by_id(id)` over `PACK_CATALOG` (ids 1,5,6,7,70), independent of the display
|
|
||||||
`state`:
|
|
||||||
- `store_buy` (PUT `/store/transaction`, `:3460-3465`): `pack_by_id(65534)=None` →
|
|
||||||
`if not pack: return 200, {}` (no `open_pack`, no coin change).
|
|
||||||
- `purchased_items` (POST, `:3494-3496`): `pack_by_id(65534)=None` →
|
|
||||||
`return 200, {"itemData": STORE.last_pack()}` (stale prior items only; no new
|
|
||||||
grant, no `open_pack`, no `consume_unopened_pack`).
|
|
||||||
- `open_pack`/`consume_unopened_pack` are unreachable for 65534 (pack resolves to
|
|
||||||
None first). Precondition PASSED.
|
|
||||||
|
|
||||||
### 16.2 Baseline / patch
|
|
||||||
Baseline: profile `39bb3e83…`, code `c89d43ea…` (host==container), sentinel
|
|
||||||
`inactive`/`mypacks`, 65534∉catalog, flags `DISPLAYGROUP=ON`/`GROUPID=OFF`.
|
|
||||||
Temporary container-only patch (host repo untouched), line 3444:
|
|
||||||
`empty["state"] = "inactive"` → `empty["state"] = "active"`. Diff vs original = this
|
|
||||||
one line; patched code `e1a4e1dc…`. **TEMPORARY EXPERIMENT C′ PATCH — NOT A PERMANENT
|
|
||||||
FIX.**
|
|
||||||
|
|
||||||
### 16.3 Restart / runtime
|
|
||||||
`docker restart openfut-fut-backend` (Pid 1556305, StartedAt 00:38:51Z). Running code
|
|
||||||
`e1a4e1dc…`; `unopenedPackIds==[]`; sentinel `65534 state=active unopened=False
|
|
||||||
dg=mypacks desc=""`; 65534∉catalog; normal packs 1/5/6/7 active; 70 absent; flags
|
|
||||||
unchanged.
|
|
||||||
|
|
||||||
### 16.4 Genuine FIFA capture
|
|
||||||
Marker `2026-08-13T00:38:53Z`. FIFA relaunched (Exp-B crash had closed it) → booted to
|
|
||||||
hub. Three genuine store fetches: `00:39:44`, `00:40:26`, `00:41:23`
|
|
||||||
(`GET /store/purchasegroup/all?ppInfo=true → 200`), reconstructed body prefix-matches
|
|
||||||
the logged 200-byte prefix (verified). Saved
|
|
||||||
`docs/evidence/store_purchasegroup_capture_active_placeholder_2026-08-12.json`.
|
|
||||||
C′-vs-baseline full JSON diff = **only** `65534.state: "inactive" → "active"`.
|
|
||||||
|
|
||||||
### 16.5 UI observation (operator report)
|
|
||||||
1. **No crash** (game launched to hub).
|
|
||||||
2. **No "pack not available" dialog.**
|
|
||||||
3. Store remains open.
|
|
||||||
4. My Packs tab **not shown while inside the Store (Browse Packs)**.
|
|
||||||
5. Via the FUT-hub **My Packs** menu: **one pack tile with no cover, "0 items, 0
|
|
||||||
bronze, 0 rares"** (the placeholder renders as a visible empty pack).
|
|
||||||
6. Navigation: from the hub **My Packs** menu → Bronze/Gold/Special reachable; but
|
|
||||||
from the **Browse Packs** (Store) entry, Bronze/Gold/Special are **not reachable
|
|
||||||
until My Packs is opened first**.
|
|
||||||
|
|
||||||
### 16.6 Passive request sequence (OBSERVED)
|
|
||||||
Boot: `.../accountinfo → /ut/auth → settings → phishing → match/reset → userMassInfo
|
|
||||||
→ PUT store/transaction/0 → hub …`. The single `/store/transaction` is the routine
|
|
||||||
**boot** call with body `{"state":"TRANSACTIONCANCEL"}` → `200 {}` (no packId), fired
|
|
||||||
at 00:39:39 **before** any store fetch. Across all three store opens: **no
|
|
||||||
`/store/transaction`, no `/purchased`, and no request referencing 65534.** The active
|
|
||||||
placeholder did NOT cause FIFA to auto-submit any transaction/open.
|
|
||||||
|
|
||||||
### 16.7 Availability result / verdict
|
|
||||||
**Result C1 (strong positive) — ACTIVE PLACEHOLDER HYPOTHESIS SUPPORTED, with UX
|
|
||||||
caveats.** `state` participates materially: with `state:"active"` the group exists
|
|
||||||
(no crash, as in baseline) AND the availability path is satisfied (no
|
|
||||||
"pack not available" dialog, unlike baseline). So **C2 is refuted** — `state` is a
|
|
||||||
deciding field for the dialog. But it is **not a clean permanent fix**:
|
|
||||||
- the placeholder renders as a **visible empty pack tile** ("0 items"), i.e. a fake
|
|
||||||
pack a user could try to open (server-safe: opening → no-op `{}` / stale
|
|
||||||
`last_pack`, but confusing UX);
|
|
||||||
- **navigation caveat #6**: from Browse Packs the other categories are gated behind
|
|
||||||
opening My Packs first — an UNEXPECTED behavior not present with a genuine owned
|
|
||||||
pack (Exp A).
|
|
||||||
|
|
||||||
### 16.8 Rollback verification
|
|
||||||
Container code restored from backup → `c89d43ea…` (== host, == pre-experiment); the
|
|
||||||
`state` change removed. `docker restart` (Pid 1557831, StartedAt 00:43:04Z).
|
|
||||||
In-process: `unopenedPackIds==[]`, sentinel `state=inactive`, 65534∉catalog, 70
|
|
||||||
absent, flags `DISPLAYGROUP=ON`/`GROUPID=OFF`. Host repo `utas_server.py` never edited
|
|
||||||
(`c89d43ea…`, git diff empty). Profile `39bb3e83…` unchanged. Bridge/Core untouched.
|
|
||||||
|
|
||||||
### 16.9 Implications for the client contract
|
|
||||||
`state:"active"` satisfies the pack-availability predicate (no dialog) while the
|
|
||||||
group's existence prevents the crash — so an active non-openable placeholder is the
|
|
||||||
first representation that neither crashes nor shows the dialog. However it exposes a
|
|
||||||
**visible empty "pack"** and a **Browse-Packs navigation gate** (#5/#6), so it is NOT
|
|
||||||
adopted. **Permanent fix NOT established.** Open follow-ups: (a) what happens if the
|
|
||||||
user explicitly selects/opens the active placeholder (a later controlled test —
|
|
||||||
server-safe per §16.1 but UX-unknown); (b) whether a count-gated My-Packs default or a
|
|
||||||
representation that avoids rendering a fake tile can remove the empty-tile/navigation
|
|
||||||
artifacts. Do NOT adopt `state:"active"` as the fix on this evidence alone.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 17. Explicit Active-Placeholder Selection Test (EXECUTED 2026-08-13) — OBSERVED
|
|
||||||
|
|
||||||
**Purpose:** with the C′ active placeholder in place, characterize what happens when
|
|
||||||
the user *explicitly opens* the empty 65534 My-Packs tile (the last open backend-side
|
|
||||||
question).
|
|
||||||
|
|
||||||
**Server safety proof (re-confirmed, code `c89d43ea`):** `pack_by_id(65534)=None`;
|
|
||||||
65534∉PACK_CATALOG∉unopenedPackIds. `store_buy`→`200 {}`; `purchased_items`→
|
|
||||||
`200 {"itemData": last_pack}` (stale). No inventory/coin/profile mutation possible.
|
|
||||||
|
|
||||||
**Temporary C′ state:** container-only one-line patch `65534.state "inactive"→"active"`
|
|
||||||
(patched `e1a4e1dc…`), restart (Pid 1560774). `unopenedPackIds=[]`, 65534
|
|
||||||
active/mypacks/∉catalog, normal packs unchanged, flags ON/OFF, host code + profile
|
|
||||||
unchanged. Marker `2026-08-13T00:53:09Z`.
|
|
||||||
|
|
||||||
**Manual selection behavior (operator report):** opened FUT → My Packs → the empty
|
|
||||||
placeholder tile visible → selected/opened it ONCE:
|
|
||||||
1. Dialog: **YES**. 2. Exact text: **"This pack is no longer available"**.
|
|
||||||
3. Stays in My Packs: yes. 4. After closing the dialog → returns to My Packs.
|
|
||||||
5. Then navigates back to the FUT Hub successfully. 6. **No crash.** 7. No spinner.
|
|
||||||
8. **Navigation remains fully usable afterward.**
|
|
||||||
|
|
||||||
**Genuine request sequence (OBSERVED, marker `00:53:09Z`):** boot (`…/auth →
|
|
||||||
userMassInfo → PUT store/transaction/0 {"state":"TRANSACTIONCANCEL"}→200 {} → hub →
|
|
||||||
credits → purchasegroup`) then navigation (`hub→credits→purchasegroup` ×2 for the
|
|
||||||
store/My-Packs views). **The explicit tile selection generated NO server request** —
|
|
||||||
no `/store/transaction`, no `/purchased/items`, and NO reference to 65534 anywhere.
|
|
||||||
The "no longer available" verdict is rendered **client-side**.
|
|
||||||
|
|
||||||
**Result class: S1 — pure client-side rejection.**
|
|
||||||
|
|
||||||
**Post-test profile/economy integrity (OBSERVED):** coins 29876776, nextItemId
|
|
||||||
100004837, items 1995, purchased 0, `unopenedPackIds` `[]`, `last_pack` empty — ALL
|
|
||||||
unchanged vs pre-test; 65534 not persisted in items or unopenedPackIds; profile
|
|
||||||
SHA-256 `39bb3e83…` byte-identical. **Zero mutation.**
|
|
||||||
|
|
||||||
**UX assessment:** crash-safe ✓, economy-safe ✓ (no request even sent), navigation
|
|
||||||
recoverable ✓. Blemishes: a **visible empty "0 items" tile**, a **"This pack is no
|
|
||||||
longer available" dialog on explicit click**, and (from §16.5) the **Browse-Packs
|
|
||||||
navigation gate** (must open My Packs first). Notably this is a *strict improvement*
|
|
||||||
over the inactive-sentinel baseline, which throws "pack not available" immediately on
|
|
||||||
STORE OPEN and bounces to the Hub; the active placeholder only errors if the user
|
|
||||||
deliberately clicks the empty tile, and recovers cleanly.
|
|
||||||
|
|
||||||
**Active-placeholder verdict: MARGINALLY ACCEPTABLE.** Safe (crash + economy) and
|
|
||||||
usable, but visibly imperfect (fake tile + click-dialog + browse nav gate). Not
|
|
||||||
UNACCEPTABLE (no crash/economy risk, recoverable); not fully ACCEPTABLE (user-visible
|
|
||||||
defects).
|
|
||||||
|
|
||||||
**Permanent-fix decision: P2.** The active sentinel technically works and is safe, but
|
|
||||||
its UX is poor and — per Candidate F (CONTRADICTED) — **no backend-only *clean*
|
|
||||||
solution exists** (the store's My-Packs resolution is Scaleform/movie-driven, not
|
|
||||||
server-gated). Recommendation: keep the active placeholder as an optional/temporary
|
|
||||||
backend compatibility mode (strictly better than the current inactive-sentinel
|
|
||||||
baseline) and pursue a **client-side** fix for a fully clean zero-pack experience
|
|
||||||
(hiding the fake tile / suppressing the forced My-Packs resolution). NOT implemented.
|
|
||||||
|
|
||||||
**Rollback verification:** container code restored to `c89d43ea…` (== host, ==
|
|
||||||
pre-experiment), sentinel back to `inactive`; `docker restart` (Pid 1562665, StartedAt
|
|
||||||
00:59:04Z); `unopenedPackIds=[]`, 65534 inactive/∉catalog, 70 absent, flags ON/OFF;
|
|
||||||
host `utas_server.py` never edited; profile `39bb3e83…` unchanged. Bridge/Core
|
|
||||||
untouched.
|
|
||||||
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# FIFA 17 card-table provenance manifest
|
|
||||||
# Source (authoritative): 10.10.0.105:/home/alex/Documents/OpenFUT/fifa17-recon/data/tables/
|
|
||||||
# Dest (this repo): fifa17-recon/data/tables/
|
|
||||||
# Verified 2026-08-12: source and dest byte-identical (sha256), order-independent.
|
|
||||||
# Combined hash-of-hashes: 10f239add919089354d8dbff873fc9737b0a0f80f6ac41b1aa2a096c0ec8d331
|
|
||||||
# 31 fcc_*.json + 5 staff tables = 36 files. Files are DECODED tables:
|
|
||||||
# each carries {table, source, rowcount, schema[], rows[]}; card instances live in rows[].
|
|
||||||
#
|
|
||||||
0d1c9af7ae654c3e4363f18bb89bad03a0631056d36425c84b9a680fa989618c fcc_managerbonusvalues.json
|
|
||||||
0e5d5309cd1d9322476f8047fc6eaf4a88f4f19211b8ea01fe4d28ddd3733134 fcc_healingcards.json
|
|
||||||
1da80e390169ebb8ee8a6543e14b1191d9151f675797f01e23628f6c24d434c5 fcc_misccards.json
|
|
||||||
2212b0ee8d962f0fb6bd346bee35fff6566e22539e397cc2e42bb6efd4dc3ad3 fcc_textposvalues_hd.json
|
|
||||||
2a8e22ddb000b2c08f1a3e5eb47bc56ecf43f733ce6e7519498d332e4f439746 headcoachcards.json
|
|
||||||
3a323e1c0688a4068ccd21be0c9d8e88a875ab10ce2158d3aed79fc14e65f0fb fcc_chemlinkcalc.json
|
|
||||||
4a60bc4c0d8cb8d2b903e152a3dd5302348753568630818fde059b2de41f82ab fcc_leaguelogos.json
|
|
||||||
5304114078200da4564d33612c955598f12a44bdf52f18274184b98922229b8b fcc_GrandStandPlayers.json
|
|
||||||
5503291e381fee5008120615cd6d30a732b97636d4694a88f943eb14cb992741 fcc_leaguelogostickers.json
|
|
||||||
550739c124ca915fb294954afe3d9d04fb7d1faf2b0c96930b2dcdb1bfe7ac8f fcc_formationcardspositions_kc.json
|
|
||||||
5a5aabec1d40ffa21b8effb84f79e4b788cb42352aa592d71d95eba1db0d209e fcc_trainingcards.json
|
|
||||||
6476e396f166905857d2ada4f12cc37645ca42efdca121e8e74270fbf7422336 managercards.json
|
|
||||||
6546f602024973e20d04522a857c6c243473a177cab5b3be8400390651a42fab fcc_navcoords_hd.json
|
|
||||||
6b0209647383e4e940d2af2c3bbb2185a4aac7ac0e799fe6b50ae52e7625710d fcc_contractcards.json
|
|
||||||
6c52c83aafd9d9d3406e21c656762ac5cc0ba4522002f424e5593430bc5190f5 physiocards.json
|
|
||||||
765687d7f1e5c6c989adf45b174a0fdab0f65597c83132304b53b9f859c02586 fcc_stadium.json
|
|
||||||
79e50b07eecc47a0edf4d2a87782e904785e653937698cc712258a82fdf8b079 fcc_formationcardspositions_hd.json
|
|
||||||
7d36e0fbb9349eabd4267215bacbe29d78ff621deeb8cab3380dcac72c535eb9 fcc_preferredformationcalcmid.json
|
|
||||||
8122a4070901662fac97f675a3e4194dd5fd7e02194fdab204989af42676e268 fitnesscoachcards.json
|
|
||||||
828f8b90241672b9f62a9bbd3cb219a1d3bd8856bd160cc46284f2958e08f7d4 fcc_discardcoins.json
|
|
||||||
852bb82ed373881373d8610e6f4f2ca4406bac13da4bda9d6abba693d7cafc56 fcc_myclubscategories.json
|
|
||||||
974dcbbe6a46c02dc97c77df6c270c9a7f09ba23bee23005ecf95fd114ee66a7 gkcoachcards.json
|
|
||||||
9aa4b3b3f226202b21d2e9f96a1508ecce56abba64372099e090df101fce5eeb fcc_nationcalc.json
|
|
||||||
9b6797991520c05f7448b28e66d160f96afc73eefd661ed8272b0a093b6ba89f fcc_kitcards.json
|
|
||||||
9e8e6595fa8d3bcf963eb25bd9f9ea5d131aa92ed0e7e4ae3089adf5e1d55927 fcc_preferredformationcalcst.json
|
|
||||||
a4e8ac2ba0a6db45f1f59fe384fbd39a8cee8fb72a72f846e52daca44f1c7ff9 fcc_myclubs.json
|
|
||||||
bbf405e3a63b6fd03da1237b8b118764c57a40c797faf85d1e4691a1c95a840e fcc_balls.json
|
|
||||||
ca1184bd85cff3308af0104077feda4357ef483fae6335fa964922eea4e94330 fcc_navcoords_kc.json
|
|
||||||
ca3e4ab0f7892aac7473b774de4699c067c54647ca4a82cdd5fd1108c56ed894 fcc_badgecards.json
|
|
||||||
ccdda8ad0a15f73a056fa336abde8739b346d12b78cb8adbea0f0677487bb598 fcc_preferredpositioncalc.json
|
|
||||||
dbf95bddd456137e4b90a44bdd1f637458f4846ecd5c3ba6747d3f069c3f590f fcc_textposvalues_kc.json
|
|
||||||
dd8c2c860b18d999877c37e0f63dac64ef7bb57bff5a2173960cde71242f9a34 fcc_bonusvalues.json
|
|
||||||
df5b997b153941a5bb760ad5bb0fb23dc16cf8612b300559be23ac929b55eec6 fcc_leagues.json
|
|
||||||
e4619db324a7848639a8ba53f513cf3ea153eeaf698de05d71e56c319b3cc424 fcc_coinrewards.json
|
|
||||||
e6b1ca3ecb7c3923d77e73bda2e6c3f9794b9158354d566112f7979b33b4422c fcc_preferredformationcalcgk.json
|
|
||||||
f54814d61b72dd2b6186e9e414df4fbfbdbea1732da6a4622f001a1ff03bc12a fcc_preferredformationcalcback.json
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# Live UTAS wire captures — 2026-08-15
|
|
||||||
|
|
||||||
Fresh sanitised FIFA 17 UTAS wire, captured during the post-P1 staging A/B on a
|
|
||||||
**real FIFA 17 client** (`10.10.0.105`) driving the isolated post-P1 candidate
|
|
||||||
backend (`10.10.0.121`, host bin `fda40d12`, Core `fbb54ea`). Production untouched.
|
|
||||||
|
|
||||||
This rebuilds the primary-capture corpus that was lost to `.gitignore` (Known Issues
|
|
||||||
#200 / "take sanitised captures on the next live FIFA run and commit them").
|
|
||||||
|
|
||||||
## Files
|
|
||||||
- `utas-requests.sanitised.txt` — 10 real client requests (`---`-separated).
|
|
||||||
- `utas-responses.sanitised.txt` — 12 responses.
|
|
||||||
|
|
||||||
Captured with `tcpdump` on the container netns (UTAS is plain HTTP on `:8099`),
|
|
||||||
reassembled + split + **sanitised** by `openfut-staging/extract_http.py`. Redacted:
|
|
||||||
`X-UT-SID`/`sid` (`<SID>`), `authCode` (`<AUTH>`), `deviceId` (`<DEV>`), `macAddress`
|
|
||||||
(`<MAC>`), and any 32+char hex token (`<TOKEN>`). The raw `.pcap` is intentionally
|
|
||||||
NOT committed (it is unsanitised).
|
|
||||||
|
|
||||||
## Route contracts captured (verbatim shapes)
|
|
||||||
|
|
||||||
### POST /openfut/account/sync (launcher control-plane, Python-served)
|
|
||||||
Request (launcher → backend):
|
|
||||||
```json
|
|
||||||
{"personaId":33068179,"personaName":"CAGE","level":1,"experience":0,
|
|
||||||
"experienceMax":1000,"accountFunds":0,"accountFundsCap":100000}
|
|
||||||
```
|
|
||||||
Response:
|
|
||||||
```json
|
|
||||||
{"account":{"personaId":33068179,"personaName":"CAGE","clubName":"OpenFUT",
|
|
||||||
"clubAbbr":"OFC","level":1,"experience":0,"experienceMax":1000,"accountFunds":0,
|
|
||||||
"accountFundsCap":100000,"profilePath":"accounts/33068179/fifa17_profile.json",
|
|
||||||
"coins":29826776,"unopenedPacks":0},"status":"OK"}
|
|
||||||
```
|
|
||||||
NOTE: `coins` here is Python's STALE profile value (29,826,776) — Core's authoritative
|
|
||||||
balance at capture time was 29,859,876. The launcher's `AccountSummary` parser is
|
|
||||||
lenient and requires only `{personaId,personaName,level,experience,accountFunds,coins,
|
|
||||||
unopenedPacks}` (clubName/clubAbbr/etc. ignored). BLOCKER for a Rust migration is NOT
|
|
||||||
the shape — it is that Python `account/sync` also *selects the active profile* the
|
|
||||||
still-Python-served userMassInfo envelope depends on, so it is coupled to the
|
|
||||||
userMassInfo hybrid and cannot migrate standalone.
|
|
||||||
|
|
||||||
### GET /ut/game/fifa17/store/purchasegroup/all?ppInfo=true (empty My-Packs sentinel)
|
|
||||||
The `mypacks` group carries the synthetic sentinel pack when My Packs is empty:
|
|
||||||
```json
|
|
||||||
{"assetId":65534,"displayGroup":{"priority":1,"value":"mypacks"},"id":65534,
|
|
||||||
"packType":"GOLD","packContentInfo":{"goldQuantity":0,"itemQuantity":0,...},
|
|
||||||
"state":"active","unopened":false}
|
|
||||||
```
|
|
||||||
Client renders empty My Packs AS Browse Packs (no crash) — the bug-6c resolver guard.
|
|
||||||
|
|
||||||
### GET /ut/game/fifa17/userMassInfo (RUST_OVERLAY hybrid, full 8 KB envelope captured)
|
|
||||||
The complete envelope is in `utas-responses.sanitised.txt`: root `pileSizeClientData` +
|
|
||||||
`userInfo` (with `clubName`, `clubAbbr`, `established`, `accountCreatedPlatformName`,
|
|
||||||
`currencies` [Core coins overlaid by Rust], `won/draw/loss`, `clubNameChangeAllowed`,
|
|
||||||
`divisionOffline/Online`, `purchased`, `feature`, `reliability`, `bidTokens`, `trophies`,
|
|
||||||
`sessionCoinsBankBalance`, `actives`, `squadList`). Field spec is authoritative in
|
|
||||||
`fifa17-recon/tools/utas_server.py::user_info()`. This is the target shape for a future
|
|
||||||
full-Rust userMassInfo (needs the `clubAbbr`/`established` account triad, which Core lacks).
|
|
||||||
|
|
||||||
### Other captured request lines
|
|
||||||
`POST /ut/auth`, `GET user/accountinfo`, `GET settings`, `GET phishing/trusteddevice`,
|
|
||||||
`PUT match/reset`, `GET userMassInfo`, `PUT clientdata/userHubData`,
|
|
||||||
`PUT /ut/v2/game/fifa17/store/transaction/0`.
|
|
||||||
|
|
||||||
See `openfut-staging/p1p-live-2026-08-15/AB_VERDICT.md` for the full A/B result.
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
POST /openfut/account/sync HTTP/1.1
|
|
||||||
Host: 10.10.0.121:8099
|
|
||||||
Content-Type: application/json
|
|
||||||
Content-Length: 131
|
|
||||||
Connection: close
|
|
||||||
|
|
||||||
{"personaId":33068179,"personaName":"CAGE","level":1,"experience":0,"experienceMax":1000,"accountFunds":0,"accountFundsCap":100000}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
GET /ut/game/fifa17/user/accountinfo HTTP/1.1
|
|
||||||
Host: 10.10.0.121:8099
|
|
||||||
Connection: Close
|
|
||||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
|
||||||
Accept: application/json
|
|
||||||
Content-Type: application/json
|
|
||||||
Accept-Encoding: gzip
|
|
||||||
Easw-Session-Data-Nucleus-Id: 33068179
|
|
||||||
Accept-Encoding: gzip
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
POST /ut/auth HTTP/1.1
|
|
||||||
Host: 10.10.0.121:8099
|
|
||||||
Content-Length: 380
|
|
||||||
Connection: Close
|
|
||||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
|
||||||
Accept: application/json
|
|
||||||
Content-Type: application/json
|
|
||||||
Hash: <TOKEN>
|
|
||||||
|
|
||||||
{"isReadOnly":false,"priorityLevel":6,"sku":"FFA17PCC","nucleusPersonaPlatform":"pc","clientVersion":3,"nuc":33068179,"nucleusPersonaId":33068179,"nucleusPersonaDisplayName":"CAGE","locale":"en-US","regionCode":"US","deviceId":"<DEV>","macAddress":"<MAC>","method":"authcode","identification":{"authCode":"<AUTH>"}}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
GET /ut/game/fifa17/settings HTTP/1.1
|
|
||||||
Host: 10.10.0.121:8099
|
|
||||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
|
||||||
Accept: application/json
|
|
||||||
Content-Type: application/json
|
|
||||||
X-UT-SID: <SID>
|
|
||||||
Accept-Encoding: gzip
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
GET /ut/game/fifa17/phishing/trusteddevice?deviceId=<DEV> HTTP/1.1
|
|
||||||
Host: 10.10.0.121:8099
|
|
||||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
|
||||||
Accept: application/json
|
|
||||||
Content-Type: application/json
|
|
||||||
X-UT-SID: <SID>
|
|
||||||
Accept-Encoding: gzip
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
PUT /ut/game/fifa17/match/reset HTTP/1.1
|
|
||||||
Host: 10.10.0.121:8099
|
|
||||||
Content-Length: 0
|
|
||||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
|
||||||
Accept: application/json
|
|
||||||
Content-Type: application/json
|
|
||||||
X-UT-SID: <SID>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
GET /ut/game/fifa17/userMassInfo HTTP/1.1
|
|
||||||
Host: 10.10.0.121:8099
|
|
||||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
|
||||||
Accept: application/json
|
|
||||||
Content-Type: application/json
|
|
||||||
X-UT-SID: <SID>
|
|
||||||
Accept-Encoding: gzip
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
PUT /ut/v2/game/fifa17/store/transaction/0 HTTP/1.1
|
|
||||||
Host: 10.10.0.121:8099
|
|
||||||
Content-Length: 29
|
|
||||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
|
||||||
Accept: application/json
|
|
||||||
Content-Type: application/json
|
|
||||||
X-UT-SID: <SID>
|
|
||||||
|
|
||||||
{"state":"TRANSACTIONCANCEL"}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
PUT /ut/game/fifa17/clientdata/userHubData HTTP/1.1
|
|
||||||
Host: 10.10.0.121:8099
|
|
||||||
Content-Length: 53
|
|
||||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
|
||||||
Accept: application/json
|
|
||||||
Content-Type: application/json
|
|
||||||
X-UT-SID: <SID>
|
|
||||||
|
|
||||||
{"entries":[{"key":0,"value":0},{"key":1,"value":1}]}
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
GET /ut/game/fifa17/store/purchasegroup/all?ppInfo=true HTTP/1.1
|
|
||||||
Host: 10.10.0.121:8099
|
|
||||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
|
||||||
Accept: application/json
|
|
||||||
Content-Type: application/json
|
|
||||||
X-UT-SID: <SID>
|
|
||||||
Accept-Encoding: gzip
|
|
||||||
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,67 +0,0 @@
|
|||||||
{
|
|
||||||
"champion": {
|
|
||||||
"bytes": 2,
|
|
||||||
"path": "/ut/game/fifa17/champion",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
"clubUser": {
|
|
||||||
"bytes": 2,
|
|
||||||
"path": "/ut/game/fifa17/clubUser",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
"defid": {
|
|
||||||
"bytes": 600,
|
|
||||||
"path": "/ut/game/fifa17/defid?definitionId=200389",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
"defs_resource": {
|
|
||||||
"bytes": 600,
|
|
||||||
"path": "/ut/game/fifa17/item/resource?resourceId=200389",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
"draft_state": {
|
|
||||||
"bytes": 118,
|
|
||||||
"path": "/ut/game/fifa17/squad/mode/draft/state",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
"marketdata": {
|
|
||||||
"bytes": 110,
|
|
||||||
"path": "/ut/game/fifa17/marketdata/pricelimits?defId=200389,200104",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
"sbs_sets": {
|
|
||||||
"bytes": 537,
|
|
||||||
"path": "/ut/game/fifa17/sbs/sets",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
"season": {
|
|
||||||
"bytes": 2,
|
|
||||||
"path": "/ut/game/fifa17/season",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
"squad_0": {
|
|
||||||
"bytes": 7792,
|
|
||||||
"path": "/ut/game/fifa17/squad/0",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
"tournament": {
|
|
||||||
"bytes": 2,
|
|
||||||
"path": "/ut/game/fifa17/tournament",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
"user": {
|
|
||||||
"bytes": 751,
|
|
||||||
"path": "/ut/game/fifa17/user",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
"user_list": {
|
|
||||||
"bytes": 2,
|
|
||||||
"path": "/ut/game/fifa17/user/list",
|
|
||||||
"status": 200
|
|
||||||
},
|
|
||||||
"watchList": {
|
|
||||||
"bytes": 52,
|
|
||||||
"path": "/ut/game/fifa17/watchList",
|
|
||||||
"status": 200
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
{
|
|
||||||
"itemData": [
|
|
||||||
{
|
|
||||||
"assetId": 200389,
|
|
||||||
"attributeList": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"value": 70
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 1,
|
|
||||||
"value": 70
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 2,
|
|
||||||
"value": 70
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 3,
|
|
||||||
"value": 70
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 4,
|
|
||||||
"value": 70
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 5,
|
|
||||||
"value": 70
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cardType": 0,
|
|
||||||
"cardassetid": 200389,
|
|
||||||
"cardsubtypeid": 0,
|
|
||||||
"commodityId": 200389,
|
|
||||||
"commonName": "Player",
|
|
||||||
"definitionId": 200389,
|
|
||||||
"id": 200389,
|
|
||||||
"itemState": "free",
|
|
||||||
"itemType": "player",
|
|
||||||
"lastName": "Player",
|
|
||||||
"leagueId": 0,
|
|
||||||
"name": "Player",
|
|
||||||
"nation": 0,
|
|
||||||
"playStyle": 250,
|
|
||||||
"preferredPosition": "CM",
|
|
||||||
"rareflag": 1,
|
|
||||||
"rating": 75,
|
|
||||||
"resourceId": 200389,
|
|
||||||
"teamid": 0,
|
|
||||||
"untradeable": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
{
|
|
||||||
"itemData": [
|
|
||||||
{
|
|
||||||
"assetId": 200389,
|
|
||||||
"attributeList": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"value": 70
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 1,
|
|
||||||
"value": 70
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 2,
|
|
||||||
"value": 70
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 3,
|
|
||||||
"value": 70
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 4,
|
|
||||||
"value": 70
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 5,
|
|
||||||
"value": 70
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cardType": 0,
|
|
||||||
"cardassetid": 200389,
|
|
||||||
"cardsubtypeid": 0,
|
|
||||||
"commodityId": 200389,
|
|
||||||
"commonName": "Player",
|
|
||||||
"definitionId": 200389,
|
|
||||||
"id": 200389,
|
|
||||||
"itemState": "free",
|
|
||||||
"itemType": "player",
|
|
||||||
"lastName": "Player",
|
|
||||||
"leagueId": 0,
|
|
||||||
"name": "Player",
|
|
||||||
"nation": 0,
|
|
||||||
"playStyle": 250,
|
|
||||||
"preferredPosition": "CM",
|
|
||||||
"rareflag": 1,
|
|
||||||
"rating": 75,
|
|
||||||
"resourceId": 200389,
|
|
||||||
"teamid": 0,
|
|
||||||
"untradeable": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"gamesWonCurrentMatch": 0,
|
|
||||||
"roundsInfo": [],
|
|
||||||
"squadState": "INVALID",
|
|
||||||
"stateParam1": "INVALID",
|
|
||||||
"stateParam2": "0"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"defId": 200389,
|
|
||||||
"maxPrice": 15000,
|
|
||||||
"minPrice": 150
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"defId": 200104,
|
|
||||||
"maxPrice": 15000,
|
|
||||||
"minPrice": 150
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"categories": [
|
|
||||||
{
|
|
||||||
"categoryId": 1,
|
|
||||||
"name": "Foundations",
|
|
||||||
"priority": 1,
|
|
||||||
"sets": [
|
|
||||||
{
|
|
||||||
"awards": [],
|
|
||||||
"categoryId": 1,
|
|
||||||
"challengesCompletedCount": 0,
|
|
||||||
"challengesCount": 1,
|
|
||||||
"description": "Submit an 11-player squad.",
|
|
||||||
"endTime": 4102444800,
|
|
||||||
"hidden": false,
|
|
||||||
"name": "Bronze Challenge",
|
|
||||||
"priority": 1,
|
|
||||||
"setId": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"awards": [],
|
|
||||||
"categoryId": 1,
|
|
||||||
"challengesCompletedCount": 0,
|
|
||||||
"challengesCount": 1,
|
|
||||||
"description": "Get started with your first SBC.",
|
|
||||||
"endTime": 4102444800,
|
|
||||||
"hidden": false,
|
|
||||||
"name": "Simple Start",
|
|
||||||
"priority": 2,
|
|
||||||
"setId": 2
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1,707 +0,0 @@
|
|||||||
{
|
|
||||||
"actives": [],
|
|
||||||
"captain": 100000001,
|
|
||||||
"changed": 0,
|
|
||||||
"chemistry": 49,
|
|
||||||
"custom": "[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,50,50,0,50,40,65,0,65,50,50,1]",
|
|
||||||
"formation": "f433",
|
|
||||||
"id": 0,
|
|
||||||
"kicktakers": [
|
|
||||||
{
|
|
||||||
"dream": false,
|
|
||||||
"id": 100000001,
|
|
||||||
"index": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"dream": false,
|
|
||||||
"id": 100000001,
|
|
||||||
"index": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"dream": false,
|
|
||||||
"id": 100000001,
|
|
||||||
"index": 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"dream": false,
|
|
||||||
"id": 100000001,
|
|
||||||
"index": 3
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"dream": false,
|
|
||||||
"id": 100000001,
|
|
||||||
"index": 4
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"manager": [
|
|
||||||
{
|
|
||||||
"dream": false,
|
|
||||||
"id": 100000427
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"personaId": 33068179,
|
|
||||||
"players": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"itemData": {
|
|
||||||
"assetId": 200389,
|
|
||||||
"attributeList": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"value": 83
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 1,
|
|
||||||
"value": 90
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 2,
|
|
||||||
"value": 77
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 3,
|
|
||||||
"value": 82
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 4,
|
|
||||||
"value": 50
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 5,
|
|
||||||
"value": 87
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cardassetid": 200389,
|
|
||||||
"cardsubtypeid": 0,
|
|
||||||
"contract": 7,
|
|
||||||
"definitionId": 200389,
|
|
||||||
"fitness": 99,
|
|
||||||
"id": 100000003,
|
|
||||||
"itemState": "free",
|
|
||||||
"itemType": "player",
|
|
||||||
"leagueId": 53,
|
|
||||||
"nation": 44,
|
|
||||||
"owners": 1,
|
|
||||||
"playStyle": 250,
|
|
||||||
"preferredPosition": "GK",
|
|
||||||
"rareflag": 1,
|
|
||||||
"rating": 87,
|
|
||||||
"resourceId": 200389,
|
|
||||||
"teamid": 240,
|
|
||||||
"untradeable": true
|
|
||||||
},
|
|
||||||
"kitNumber": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 1,
|
|
||||||
"itemData": {
|
|
||||||
"assetId": 197445,
|
|
||||||
"attributeList": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"value": 86
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 1,
|
|
||||||
"value": 73
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 2,
|
|
||||||
"value": 81
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 3,
|
|
||||||
"value": 83
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 4,
|
|
||||||
"value": 83
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 5,
|
|
||||||
"value": 73
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cardassetid": 197445,
|
|
||||||
"cardsubtypeid": 0,
|
|
||||||
"contract": 7,
|
|
||||||
"definitionId": 197445,
|
|
||||||
"fitness": 99,
|
|
||||||
"id": 100000006,
|
|
||||||
"itemState": "free",
|
|
||||||
"itemType": "player",
|
|
||||||
"leagueId": 19,
|
|
||||||
"nation": 4,
|
|
||||||
"owners": 1,
|
|
||||||
"playStyle": 250,
|
|
||||||
"preferredPosition": "LB",
|
|
||||||
"rareflag": 1,
|
|
||||||
"rating": 87,
|
|
||||||
"resourceId": 197445,
|
|
||||||
"teamid": 21,
|
|
||||||
"untradeable": true
|
|
||||||
},
|
|
||||||
"kitNumber": 4
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 2,
|
|
||||||
"itemData": {
|
|
||||||
"assetId": 155862,
|
|
||||||
"attributeList": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"value": 78
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 1,
|
|
||||||
"value": 63
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 2,
|
|
||||||
"value": 70
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 3,
|
|
||||||
"value": 70
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 4,
|
|
||||||
"value": 87
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 5,
|
|
||||||
"value": 83
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cardassetid": 155862,
|
|
||||||
"cardsubtypeid": 0,
|
|
||||||
"contract": 7,
|
|
||||||
"definitionId": 155862,
|
|
||||||
"fitness": 99,
|
|
||||||
"id": 100000005,
|
|
||||||
"itemState": "free",
|
|
||||||
"itemType": "player",
|
|
||||||
"leagueId": 53,
|
|
||||||
"nation": 45,
|
|
||||||
"owners": 1,
|
|
||||||
"playStyle": 250,
|
|
||||||
"preferredPosition": "CB",
|
|
||||||
"rareflag": 1,
|
|
||||||
"rating": 89,
|
|
||||||
"resourceId": 155862,
|
|
||||||
"teamid": 243,
|
|
||||||
"untradeable": true
|
|
||||||
},
|
|
||||||
"kitNumber": 3
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 3,
|
|
||||||
"itemData": {
|
|
||||||
"assetId": 182521,
|
|
||||||
"attributeList": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"value": 45
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 1,
|
|
||||||
"value": 80
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 2,
|
|
||||||
"value": 88
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 3,
|
|
||||||
"value": 79
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 4,
|
|
||||||
"value": 69
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 5,
|
|
||||||
"value": 70
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cardassetid": 182521,
|
|
||||||
"cardsubtypeid": 0,
|
|
||||||
"contract": 7,
|
|
||||||
"definitionId": 182521,
|
|
||||||
"fitness": 99,
|
|
||||||
"id": 100000008,
|
|
||||||
"itemState": "free",
|
|
||||||
"itemType": "player",
|
|
||||||
"leagueId": 53,
|
|
||||||
"nation": 21,
|
|
||||||
"owners": 1,
|
|
||||||
"playStyle": 250,
|
|
||||||
"preferredPosition": "CM",
|
|
||||||
"rareflag": 1,
|
|
||||||
"rating": 88,
|
|
||||||
"resourceId": 182521,
|
|
||||||
"teamid": 243,
|
|
||||||
"untradeable": true
|
|
||||||
},
|
|
||||||
"kitNumber": 6
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 4,
|
|
||||||
"itemData": {
|
|
||||||
"assetId": 189332,
|
|
||||||
"attributeList": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"value": 93
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 1,
|
|
||||||
"value": 69
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 2,
|
|
||||||
"value": 75
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 3,
|
|
||||||
"value": 83
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 4,
|
|
||||||
"value": 81
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 5,
|
|
||||||
"value": 75
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cardassetid": 189332,
|
|
||||||
"cardsubtypeid": 0,
|
|
||||||
"contract": 7,
|
|
||||||
"definitionId": 189332,
|
|
||||||
"fitness": 99,
|
|
||||||
"id": 100000007,
|
|
||||||
"itemState": "free",
|
|
||||||
"itemType": "player",
|
|
||||||
"leagueId": 53,
|
|
||||||
"nation": 45,
|
|
||||||
"owners": 1,
|
|
||||||
"playStyle": 250,
|
|
||||||
"preferredPosition": "LB",
|
|
||||||
"rareflag": 1,
|
|
||||||
"rating": 86,
|
|
||||||
"resourceId": 189332,
|
|
||||||
"teamid": 241,
|
|
||||||
"untradeable": true
|
|
||||||
},
|
|
||||||
"kitNumber": 5
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 5,
|
|
||||||
"itemData": {
|
|
||||||
"assetId": 158023,
|
|
||||||
"attributeList": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"value": 89
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 1,
|
|
||||||
"value": 90
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 2,
|
|
||||||
"value": 86
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 3,
|
|
||||||
"value": 96
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 4,
|
|
||||||
"value": 26
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 5,
|
|
||||||
"value": 61
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cardassetid": 158023,
|
|
||||||
"cardsubtypeid": 0,
|
|
||||||
"contract": 7,
|
|
||||||
"definitionId": 158023,
|
|
||||||
"fitness": 99,
|
|
||||||
"id": 100000002,
|
|
||||||
"itemState": "free",
|
|
||||||
"itemType": "player",
|
|
||||||
"leagueId": 53,
|
|
||||||
"nation": 52,
|
|
||||||
"owners": 1,
|
|
||||||
"playStyle": 250,
|
|
||||||
"preferredPosition": "RW",
|
|
||||||
"rareflag": 1,
|
|
||||||
"rating": 93,
|
|
||||||
"resourceId": 158023,
|
|
||||||
"teamid": 241,
|
|
||||||
"untradeable": true
|
|
||||||
},
|
|
||||||
"kitNumber": 9
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 6,
|
|
||||||
"itemData": {
|
|
||||||
"assetId": 183907,
|
|
||||||
"attributeList": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"value": 79
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 1,
|
|
||||||
"value": 50
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 2,
|
|
||||||
"value": 72
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 3,
|
|
||||||
"value": 68
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 4,
|
|
||||||
"value": 90
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 5,
|
|
||||||
"value": 85
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cardassetid": 183907,
|
|
||||||
"cardsubtypeid": 0,
|
|
||||||
"contract": 7,
|
|
||||||
"definitionId": 183907,
|
|
||||||
"fitness": 99,
|
|
||||||
"id": 100000004,
|
|
||||||
"itemState": "free",
|
|
||||||
"itemType": "player",
|
|
||||||
"leagueId": 19,
|
|
||||||
"nation": 21,
|
|
||||||
"owners": 1,
|
|
||||||
"playStyle": 250,
|
|
||||||
"preferredPosition": "CB",
|
|
||||||
"rareflag": 1,
|
|
||||||
"rating": 90,
|
|
||||||
"resourceId": 183907,
|
|
||||||
"teamid": 21,
|
|
||||||
"untradeable": true
|
|
||||||
},
|
|
||||||
"kitNumber": 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 7,
|
|
||||||
"itemData": {
|
|
||||||
"assetId": 183277,
|
|
||||||
"attributeList": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"value": 90
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 1,
|
|
||||||
"value": 81
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 2,
|
|
||||||
"value": 82
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 3,
|
|
||||||
"value": 91
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 4,
|
|
||||||
"value": 32
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 5,
|
|
||||||
"value": 64
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cardassetid": 183277,
|
|
||||||
"cardsubtypeid": 0,
|
|
||||||
"contract": 7,
|
|
||||||
"definitionId": 183277,
|
|
||||||
"fitness": 99,
|
|
||||||
"id": 100000009,
|
|
||||||
"itemState": "free",
|
|
||||||
"itemType": "player",
|
|
||||||
"leagueId": 13,
|
|
||||||
"nation": 7,
|
|
||||||
"owners": 1,
|
|
||||||
"playStyle": 250,
|
|
||||||
"preferredPosition": "LM",
|
|
||||||
"rareflag": 1,
|
|
||||||
"rating": 88,
|
|
||||||
"resourceId": 183277,
|
|
||||||
"teamid": 5,
|
|
||||||
"untradeable": true
|
|
||||||
},
|
|
||||||
"kitNumber": 7
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 8,
|
|
||||||
"itemData": {
|
|
||||||
"assetId": 176580,
|
|
||||||
"attributeList": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"value": 82
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 1,
|
|
||||||
"value": 90
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 2,
|
|
||||||
"value": 79
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 3,
|
|
||||||
"value": 87
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 4,
|
|
||||||
"value": 42
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 5,
|
|
||||||
"value": 79
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cardassetid": 176580,
|
|
||||||
"cardsubtypeid": 0,
|
|
||||||
"contract": 7,
|
|
||||||
"definitionId": 176580,
|
|
||||||
"fitness": 99,
|
|
||||||
"id": 100000010,
|
|
||||||
"itemState": "free",
|
|
||||||
"itemType": "player",
|
|
||||||
"leagueId": 53,
|
|
||||||
"nation": 60,
|
|
||||||
"owners": 1,
|
|
||||||
"playStyle": 250,
|
|
||||||
"preferredPosition": "ST",
|
|
||||||
"rareflag": 1,
|
|
||||||
"rating": 92,
|
|
||||||
"resourceId": 176580,
|
|
||||||
"teamid": 241,
|
|
||||||
"untradeable": true
|
|
||||||
},
|
|
||||||
"kitNumber": 10
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 9,
|
|
||||||
"itemData": {
|
|
||||||
"assetId": 188545,
|
|
||||||
"attributeList": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"value": 81
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 1,
|
|
||||||
"value": 87
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 2,
|
|
||||||
"value": 74
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 3,
|
|
||||||
"value": 85
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 4,
|
|
||||||
"value": 38
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 5,
|
|
||||||
"value": 82
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cardassetid": 188545,
|
|
||||||
"cardsubtypeid": 0,
|
|
||||||
"contract": 7,
|
|
||||||
"definitionId": 188545,
|
|
||||||
"fitness": 99,
|
|
||||||
"id": 100000025,
|
|
||||||
"itemState": "free",
|
|
||||||
"itemType": "player",
|
|
||||||
"leagueId": 19,
|
|
||||||
"nation": 37,
|
|
||||||
"owners": 1,
|
|
||||||
"pile": "club",
|
|
||||||
"playStyle": 250,
|
|
||||||
"preferredPosition": "ST",
|
|
||||||
"rareflag": 1,
|
|
||||||
"rating": 90,
|
|
||||||
"resourceId": 188545,
|
|
||||||
"teamid": 21,
|
|
||||||
"untradeable": true
|
|
||||||
},
|
|
||||||
"kitNumber": 11
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 10,
|
|
||||||
"itemData": {
|
|
||||||
"assetId": 20801,
|
|
||||||
"attributeList": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"value": 92
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 1,
|
|
||||||
"value": 92
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 2,
|
|
||||||
"value": 81
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 3,
|
|
||||||
"value": 91
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 4,
|
|
||||||
"value": 33
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 5,
|
|
||||||
"value": 80
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"cardassetid": 20801,
|
|
||||||
"cardsubtypeid": 0,
|
|
||||||
"contract": 7,
|
|
||||||
"definitionId": 20801,
|
|
||||||
"fitness": 99,
|
|
||||||
"id": 100000001,
|
|
||||||
"itemState": "free",
|
|
||||||
"itemType": "player",
|
|
||||||
"leagueId": 53,
|
|
||||||
"nation": 38,
|
|
||||||
"owners": 1,
|
|
||||||
"playStyle": 250,
|
|
||||||
"preferredPosition": "LW",
|
|
||||||
"rareflag": 1,
|
|
||||||
"rating": 94,
|
|
||||||
"resourceId": 20801,
|
|
||||||
"teamid": 243,
|
|
||||||
"untradeable": true
|
|
||||||
},
|
|
||||||
"kitNumber": 8
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 11,
|
|
||||||
"itemData": {
|
|
||||||
"dream": false,
|
|
||||||
"id": 0
|
|
||||||
},
|
|
||||||
"kitNumber": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 12,
|
|
||||||
"itemData": {
|
|
||||||
"dream": false,
|
|
||||||
"id": 0
|
|
||||||
},
|
|
||||||
"kitNumber": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 13,
|
|
||||||
"itemData": {
|
|
||||||
"dream": false,
|
|
||||||
"id": 0
|
|
||||||
},
|
|
||||||
"kitNumber": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 14,
|
|
||||||
"itemData": {
|
|
||||||
"dream": false,
|
|
||||||
"id": 0
|
|
||||||
},
|
|
||||||
"kitNumber": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 15,
|
|
||||||
"itemData": {
|
|
||||||
"dream": false,
|
|
||||||
"id": 0
|
|
||||||
},
|
|
||||||
"kitNumber": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 16,
|
|
||||||
"itemData": {
|
|
||||||
"dream": false,
|
|
||||||
"id": 0
|
|
||||||
},
|
|
||||||
"kitNumber": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 17,
|
|
||||||
"itemData": {
|
|
||||||
"dream": false,
|
|
||||||
"id": 0
|
|
||||||
},
|
|
||||||
"kitNumber": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 18,
|
|
||||||
"itemData": {
|
|
||||||
"dream": false,
|
|
||||||
"id": 0
|
|
||||||
},
|
|
||||||
"kitNumber": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 19,
|
|
||||||
"itemData": {
|
|
||||||
"dream": false,
|
|
||||||
"id": 0
|
|
||||||
},
|
|
||||||
"kitNumber": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 20,
|
|
||||||
"itemData": {
|
|
||||||
"dream": false,
|
|
||||||
"id": 0
|
|
||||||
},
|
|
||||||
"kitNumber": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 21,
|
|
||||||
"itemData": {
|
|
||||||
"dream": false,
|
|
||||||
"id": 0
|
|
||||||
},
|
|
||||||
"kitNumber": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"index": 22,
|
|
||||||
"itemData": {
|
|
||||||
"dream": false,
|
|
||||||
"id": 0
|
|
||||||
},
|
|
||||||
"kitNumber": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"rating": 90,
|
|
||||||
"squadName": "OpenFUT",
|
|
||||||
"squadType": "REGULAR_SQUAD",
|
|
||||||
"starRating": 90
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
{
|
|
||||||
"userInfo": {
|
|
||||||
"accountCreatedPlatformName": "pc",
|
|
||||||
"actives": [],
|
|
||||||
"bidTokens": {
|
|
||||||
"count": 0,
|
|
||||||
"updateTime": 0
|
|
||||||
},
|
|
||||||
"clubAbbr": "OFC",
|
|
||||||
"clubName": "OpenFUT",
|
|
||||||
"clubNameChangeAllowed": false,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"active": true,
|
|
||||||
"finalFunds": 29876776,
|
|
||||||
"funds": 29876776,
|
|
||||||
"name": "coins"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"active": true,
|
|
||||||
"finalFunds": 0,
|
|
||||||
"funds": 0,
|
|
||||||
"name": "points"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"divisionOffline": 10,
|
|
||||||
"divisionOnline": 10,
|
|
||||||
"draw": 0,
|
|
||||||
"established": "2026",
|
|
||||||
"feature": {},
|
|
||||||
"loss": 0,
|
|
||||||
"personaId": 33068179,
|
|
||||||
"purchased": false,
|
|
||||||
"reliability": {
|
|
||||||
"matchUnfinishedTime": 0,
|
|
||||||
"reliability": 100
|
|
||||||
},
|
|
||||||
"sessionCoinsBankBalance": 0,
|
|
||||||
"squadList": {
|
|
||||||
"squad": [
|
|
||||||
{
|
|
||||||
"chemistry": 49,
|
|
||||||
"formation": "f433",
|
|
||||||
"id": 0,
|
|
||||||
"rating": 89,
|
|
||||||
"squadName": "OpenFUT",
|
|
||||||
"squadType": "REGULAR_SQUAD"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"trophies": 0,
|
|
||||||
"won": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"auctionInfo": [],
|
|
||||||
"credits": 29876776,
|
|
||||||
"total": 0
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
{
|
|
||||||
"purchase": [
|
|
||||||
{
|
|
||||||
"assetId": 1,
|
|
||||||
"id": 1,
|
|
||||||
"packType": "BRONZE",
|
|
||||||
"description": "Bronze Pack",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 1,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"name": "coins",
|
|
||||||
"funds": 400,
|
|
||||||
"finalFunds": 400
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"extPrice": {
|
|
||||||
"finalPrice": {
|
|
||||||
"amount": 4,
|
|
||||||
"currency": "mtx"
|
|
||||||
},
|
|
||||||
"originalPrice": {
|
|
||||||
"amount": 4,
|
|
||||||
"currency": "mtx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 5,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 0,
|
|
||||||
"rareQuantity": 0,
|
|
||||||
"itemQuantity": 5
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "bronze"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"assetId": 5,
|
|
||||||
"id": 5,
|
|
||||||
"packType": "GOLD",
|
|
||||||
"description": "Gold Pack",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 2,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"name": "coins",
|
|
||||||
"funds": 5000,
|
|
||||||
"finalFunds": 5000
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"extPrice": {
|
|
||||||
"finalPrice": {
|
|
||||||
"amount": 50,
|
|
||||||
"currency": "mtx"
|
|
||||||
},
|
|
||||||
"originalPrice": {
|
|
||||||
"amount": 50,
|
|
||||||
"currency": "mtx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 0,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 7,
|
|
||||||
"rareQuantity": 7,
|
|
||||||
"itemQuantity": 7
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "gold"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"assetId": 6,
|
|
||||||
"id": 6,
|
|
||||||
"packType": "GOLD",
|
|
||||||
"description": "Premium Gold",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 3,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"name": "coins",
|
|
||||||
"funds": 15000,
|
|
||||||
"finalFunds": 15000
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"extPrice": {
|
|
||||||
"finalPrice": {
|
|
||||||
"amount": 150,
|
|
||||||
"currency": "mtx"
|
|
||||||
},
|
|
||||||
"originalPrice": {
|
|
||||||
"amount": 150,
|
|
||||||
"currency": "mtx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 0,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 11,
|
|
||||||
"rareQuantity": 11,
|
|
||||||
"itemQuantity": 11
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "gold"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"assetId": 7,
|
|
||||||
"id": 7,
|
|
||||||
"packType": "GOLD",
|
|
||||||
"description": "Special Players Pack",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 4,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"name": "coins",
|
|
||||||
"funds": 25000,
|
|
||||||
"finalFunds": 25000
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"extPrice": {
|
|
||||||
"finalPrice": {
|
|
||||||
"amount": 250,
|
|
||||||
"currency": "mtx"
|
|
||||||
},
|
|
||||||
"originalPrice": {
|
|
||||||
"amount": 250,
|
|
||||||
"currency": "mtx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 0,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 11,
|
|
||||||
"rareQuantity": 11,
|
|
||||||
"itemQuantity": 11
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "special"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"assetId": 65534,
|
|
||||||
"id": 65534,
|
|
||||||
"packType": "GOLD",
|
|
||||||
"description": "",
|
|
||||||
"state": "inactive",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 1,
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 0,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 0,
|
|
||||||
"rareQuantity": 0,
|
|
||||||
"itemQuantity": 0
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "mypacks",
|
|
||||||
"priority": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timestamp": 1596326400
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
{
|
|
||||||
"purchase": [
|
|
||||||
{
|
|
||||||
"assetId": 1,
|
|
||||||
"id": 1,
|
|
||||||
"packType": "BRONZE",
|
|
||||||
"description": "Bronze Pack",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 1,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"name": "coins",
|
|
||||||
"funds": 400,
|
|
||||||
"finalFunds": 400
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"extPrice": {
|
|
||||||
"finalPrice": {
|
|
||||||
"amount": 4,
|
|
||||||
"currency": "mtx"
|
|
||||||
},
|
|
||||||
"originalPrice": {
|
|
||||||
"amount": 4,
|
|
||||||
"currency": "mtx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 5,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 0,
|
|
||||||
"rareQuantity": 0,
|
|
||||||
"itemQuantity": 5
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "bronze"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"assetId": 5,
|
|
||||||
"id": 5,
|
|
||||||
"packType": "GOLD",
|
|
||||||
"description": "Gold Pack",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 2,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"name": "coins",
|
|
||||||
"funds": 5000,
|
|
||||||
"finalFunds": 5000
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"extPrice": {
|
|
||||||
"finalPrice": {
|
|
||||||
"amount": 50,
|
|
||||||
"currency": "mtx"
|
|
||||||
},
|
|
||||||
"originalPrice": {
|
|
||||||
"amount": 50,
|
|
||||||
"currency": "mtx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 0,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 7,
|
|
||||||
"rareQuantity": 7,
|
|
||||||
"itemQuantity": 7
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "gold"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"assetId": 6,
|
|
||||||
"id": 6,
|
|
||||||
"packType": "GOLD",
|
|
||||||
"description": "Premium Gold",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 3,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"name": "coins",
|
|
||||||
"funds": 15000,
|
|
||||||
"finalFunds": 15000
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"extPrice": {
|
|
||||||
"finalPrice": {
|
|
||||||
"amount": 150,
|
|
||||||
"currency": "mtx"
|
|
||||||
},
|
|
||||||
"originalPrice": {
|
|
||||||
"amount": 150,
|
|
||||||
"currency": "mtx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 0,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 11,
|
|
||||||
"rareQuantity": 11,
|
|
||||||
"itemQuantity": 11
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "gold"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"assetId": 7,
|
|
||||||
"id": 7,
|
|
||||||
"packType": "GOLD",
|
|
||||||
"description": "Special Players Pack",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 4,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"name": "coins",
|
|
||||||
"funds": 25000,
|
|
||||||
"finalFunds": 25000
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"extPrice": {
|
|
||||||
"finalPrice": {
|
|
||||||
"amount": 250,
|
|
||||||
"currency": "mtx"
|
|
||||||
},
|
|
||||||
"originalPrice": {
|
|
||||||
"amount": 250,
|
|
||||||
"currency": "mtx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 0,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 11,
|
|
||||||
"rareQuantity": 11,
|
|
||||||
"itemQuantity": 11
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "special"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"assetId": 65534,
|
|
||||||
"id": 65534,
|
|
||||||
"packType": "GOLD",
|
|
||||||
"description": "",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 1,
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 0,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 0,
|
|
||||||
"rareQuantity": 0,
|
|
||||||
"itemQuantity": 0
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "mypacks",
|
|
||||||
"priority": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timestamp": 1596326400
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"purchase": [{"assetId": 1, "id": 1, "packType": "BRONZE", "description": "Bronze Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 1, "currencies": [{"name": "coins", "funds": 400, "finalFunds": 400}], "extPrice": {"finalPrice": {"amount": 4, "currency": "mtx"}, "originalPrice": {"amount": 4, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 5, "silverQuantity": 0, "goldQuantity": 0, "rareQuantity": 0, "itemQuantity": 5}, "unopened": false, "displayGroup": {"value": "bronze"}}, {"assetId": 5, "id": 5, "packType": "GOLD", "description": "Gold Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 2, "currencies": [{"name": "coins", "funds": 5000, "finalFunds": 5000}], "extPrice": {"finalPrice": {"amount": 50, "currency": "mtx"}, "originalPrice": {"amount": 50, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 0, "silverQuantity": 0, "goldQuantity": 7, "rareQuantity": 7, "itemQuantity": 7}, "unopened": false, "displayGroup": {"value": "gold"}}, {"assetId": 6, "id": 6, "packType": "GOLD", "description": "Premium Gold", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 3, "currencies": [{"name": "coins", "funds": 15000, "finalFunds": 15000}], "extPrice": {"finalPrice": {"amount": 150, "currency": "mtx"}, "originalPrice": {"amount": 150, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 0, "silverQuantity": 0, "goldQuantity": 11, "rareQuantity": 11, "itemQuantity": 11}, "unopened": false, "displayGroup": {"value": "gold"}}, {"assetId": 7, "id": 7, "packType": "GOLD", "description": "Special Players Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 4, "currencies": [{"name": "coins", "funds": 25000, "finalFunds": 25000}], "extPrice": {"finalPrice": {"amount": 250, "currency": "mtx"}, "originalPrice": {"amount": 250, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 0, "silverQuantity": 0, "goldQuantity": 11, "rareQuantity": 11, "itemQuantity": 11}, "unopened": false, "displayGroup": {"value": "special"}}], "timestamp": 1596326400}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
{
|
|
||||||
"purchase": [
|
|
||||||
{
|
|
||||||
"assetId": 1,
|
|
||||||
"id": 1,
|
|
||||||
"packType": "BRONZE",
|
|
||||||
"description": "Bronze Pack",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 1,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"name": "coins",
|
|
||||||
"funds": 400,
|
|
||||||
"finalFunds": 400
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"extPrice": {
|
|
||||||
"finalPrice": {
|
|
||||||
"amount": 4,
|
|
||||||
"currency": "mtx"
|
|
||||||
},
|
|
||||||
"originalPrice": {
|
|
||||||
"amount": 4,
|
|
||||||
"currency": "mtx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 5,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 0,
|
|
||||||
"rareQuantity": 0,
|
|
||||||
"itemQuantity": 5
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "bronze"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"assetId": 5,
|
|
||||||
"id": 5,
|
|
||||||
"packType": "GOLD",
|
|
||||||
"description": "Gold Pack",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 2,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"name": "coins",
|
|
||||||
"funds": 5000,
|
|
||||||
"finalFunds": 5000
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"extPrice": {
|
|
||||||
"finalPrice": {
|
|
||||||
"amount": 50,
|
|
||||||
"currency": "mtx"
|
|
||||||
},
|
|
||||||
"originalPrice": {
|
|
||||||
"amount": 50,
|
|
||||||
"currency": "mtx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 0,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 7,
|
|
||||||
"rareQuantity": 7,
|
|
||||||
"itemQuantity": 7
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "gold"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"assetId": 6,
|
|
||||||
"id": 6,
|
|
||||||
"packType": "GOLD",
|
|
||||||
"description": "Premium Gold",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 3,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"name": "coins",
|
|
||||||
"funds": 15000,
|
|
||||||
"finalFunds": 15000
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"extPrice": {
|
|
||||||
"finalPrice": {
|
|
||||||
"amount": 150,
|
|
||||||
"currency": "mtx"
|
|
||||||
},
|
|
||||||
"originalPrice": {
|
|
||||||
"amount": 150,
|
|
||||||
"currency": "mtx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 0,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 11,
|
|
||||||
"rareQuantity": 11,
|
|
||||||
"itemQuantity": 11
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "gold"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"assetId": 7,
|
|
||||||
"id": 7,
|
|
||||||
"packType": "GOLD",
|
|
||||||
"description": "Special Players Pack",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 4,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"name": "coins",
|
|
||||||
"funds": 25000,
|
|
||||||
"finalFunds": 25000
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"extPrice": {
|
|
||||||
"finalPrice": {
|
|
||||||
"amount": 250,
|
|
||||||
"currency": "mtx"
|
|
||||||
},
|
|
||||||
"originalPrice": {
|
|
||||||
"amount": 250,
|
|
||||||
"currency": "mtx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 0,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 11,
|
|
||||||
"rareQuantity": 11,
|
|
||||||
"itemQuantity": 11
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "special"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timestamp": 1596326400
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"purchase": [{"assetId": 1, "id": 1, "packType": "BRONZE", "description": "Bronze Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 1, "currencies": [{"name": "coins", "funds": 400, "finalFunds": 400}], "extPrice": {"finalPrice": {"amount": 4, "currency": "mtx"}, "originalPrice": {"amount": 4, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 5, "silverQuantity": 0, "goldQuantity": 0, "rareQuantity": 0, "itemQuantity": 5}, "unopened": false, "displayGroup": {"value": "bronze"}}, {"assetId": 5, "id": 5, "packType": "GOLD", "description": "Gold Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 2, "currencies": [{"name": "coins", "funds": 5000, "finalFunds": 5000}], "extPrice": {"finalPrice": {"amount": 50, "currency": "mtx"}, "originalPrice": {"amount": 50, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 0, "silverQuantity": 0, "goldQuantity": 7, "rareQuantity": 7, "itemQuantity": 7}, "unopened": false, "displayGroup": {"value": "gold"}}, {"assetId": 6, "id": 6, "packType": "GOLD", "description": "Premium Gold", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 3, "currencies": [{"name": "coins", "funds": 15000, "finalFunds": 15000}], "extPrice": {"finalPrice": {"amount": 150, "currency": "mtx"}, "originalPrice": {"amount": 150, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 0, "silverQuantity": 0, "goldQuantity": 11, "rareQuantity": 11, "itemQuantity": 11}, "unopened": false, "displayGroup": {"value": "gold"}}, {"assetId": 7, "id": 7, "packType": "GOLD", "description": "Special Players Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 4, "currencies": [{"name": "coins", "funds": 25000, "finalFunds": 25000}], "extPrice": {"finalPrice": {"amount": 250, "currency": "mtx"}, "originalPrice": {"amount": 250, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 0, "silverQuantity": 0, "goldQuantity": 11, "rareQuantity": 11, "itemQuantity": 11}, "unopened": false, "displayGroup": {"value": "special"}}], "timestamp": 1596326400}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
{
|
|
||||||
"purchase": [
|
|
||||||
{
|
|
||||||
"assetId": 1,
|
|
||||||
"id": 1,
|
|
||||||
"packType": "BRONZE",
|
|
||||||
"description": "Bronze Pack",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 1,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"name": "coins",
|
|
||||||
"funds": 400,
|
|
||||||
"finalFunds": 400
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"extPrice": {
|
|
||||||
"finalPrice": {
|
|
||||||
"amount": 4,
|
|
||||||
"currency": "mtx"
|
|
||||||
},
|
|
||||||
"originalPrice": {
|
|
||||||
"amount": 4,
|
|
||||||
"currency": "mtx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 5,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 0,
|
|
||||||
"rareQuantity": 0,
|
|
||||||
"itemQuantity": 5
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "bronze"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"assetId": 5,
|
|
||||||
"id": 5,
|
|
||||||
"packType": "GOLD",
|
|
||||||
"description": "Gold Pack",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 2,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"name": "coins",
|
|
||||||
"funds": 5000,
|
|
||||||
"finalFunds": 5000
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"extPrice": {
|
|
||||||
"finalPrice": {
|
|
||||||
"amount": 50,
|
|
||||||
"currency": "mtx"
|
|
||||||
},
|
|
||||||
"originalPrice": {
|
|
||||||
"amount": 50,
|
|
||||||
"currency": "mtx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 0,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 7,
|
|
||||||
"rareQuantity": 7,
|
|
||||||
"itemQuantity": 7
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "gold"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"assetId": 6,
|
|
||||||
"id": 6,
|
|
||||||
"packType": "GOLD",
|
|
||||||
"description": "Premium Gold",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 3,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"name": "coins",
|
|
||||||
"funds": 15000,
|
|
||||||
"finalFunds": 15000
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"extPrice": {
|
|
||||||
"finalPrice": {
|
|
||||||
"amount": 150,
|
|
||||||
"currency": "mtx"
|
|
||||||
},
|
|
||||||
"originalPrice": {
|
|
||||||
"amount": 150,
|
|
||||||
"currency": "mtx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 0,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 11,
|
|
||||||
"rareQuantity": 11,
|
|
||||||
"itemQuantity": 11
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "gold"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"assetId": 7,
|
|
||||||
"id": 7,
|
|
||||||
"packType": "GOLD",
|
|
||||||
"description": "Special Players Pack",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 4,
|
|
||||||
"currencies": [
|
|
||||||
{
|
|
||||||
"name": "coins",
|
|
||||||
"funds": 25000,
|
|
||||||
"finalFunds": 25000
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"extPrice": {
|
|
||||||
"finalPrice": {
|
|
||||||
"amount": 250,
|
|
||||||
"currency": "mtx"
|
|
||||||
},
|
|
||||||
"originalPrice": {
|
|
||||||
"amount": 250,
|
|
||||||
"currency": "mtx"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 0,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 11,
|
|
||||||
"rareQuantity": 11,
|
|
||||||
"itemQuantity": 11
|
|
||||||
},
|
|
||||||
"unopened": false,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "special"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"assetId": 70,
|
|
||||||
"id": 70,
|
|
||||||
"packType": "GOLD",
|
|
||||||
"description": "Reward Special Players Pack",
|
|
||||||
"state": "active",
|
|
||||||
"saleType": "promo",
|
|
||||||
"limitType": "NONE",
|
|
||||||
"quantity": 0,
|
|
||||||
"purchaseLimit": 0,
|
|
||||||
"purchaseCount": 0,
|
|
||||||
"isPremium": false,
|
|
||||||
"sortPriority": 1,
|
|
||||||
"packContentInfo": {
|
|
||||||
"bronzeQuantity": 0,
|
|
||||||
"silverQuantity": 0,
|
|
||||||
"goldQuantity": 11,
|
|
||||||
"rareQuantity": 11,
|
|
||||||
"itemQuantity": 11
|
|
||||||
},
|
|
||||||
"unopened": true,
|
|
||||||
"displayGroup": {
|
|
||||||
"value": "mypacks",
|
|
||||||
"priority": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"timestamp": 1596326400
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
# FIFA 23 PC Startup Flow (Offline / Proton)
|
|
||||||
|
|
||||||
Observed via FLE log, hook log, and file inspection on 2026-06-26.
|
|
||||||
|
|
||||||
## Launch chain
|
|
||||||
|
|
||||||
```
|
|
||||||
umu-run / Steam → FIFA23.exe (via Proton/Wine)
|
|
||||||
│
|
|
||||||
├─ DLL load order (before entry point)
|
|
||||||
│ ntdll.dll, kernel32.dll, ws2_32.dll …
|
|
||||||
│ version.dll ← our hook DLL slot (loads here)
|
|
||||||
│ FIFALiveEditor.DLL ← injected by FLE launcher after ~100 ms
|
|
||||||
│
|
|
||||||
├─ anadius / LSX emulator (anadius64.dll)
|
|
||||||
│ Fakes EA App / Origin session
|
|
||||||
│ Reads HKLM\SOFTWARE\Wow6432Node\Origin\ClientPath
|
|
||||||
│ Writes AppData\Local\anadius\LSX emu\achievement-*.xml
|
|
||||||
│ Provides fake PersonaId=1144668899 / UserId=1000200030000
|
|
||||||
│
|
|
||||||
├─ EA Anti-Cheat (EAAntiCheat.GameServiceLauncher.exe)
|
|
||||||
│ Spawns as child; checks EAAntiCheat.cfg
|
|
||||||
│ Not active in offline/cracked builds (FakeEAACLauncher present)
|
|
||||||
│
|
|
||||||
└─ FIFA23.exe entry point
|
|
||||||
Frostbite engine init (BuildDate 2023-07-05, changelist 5417699)
|
|
||||||
Reads Data\initfs_Win32 ← Frostbite package manifest
|
|
||||||
Reads Data\layout.toc ← file-system layout
|
|
||||||
Reads Patch\initfs_Win32 ← patches on top of base
|
|
||||||
Reads Documents\FIFA 23\fifasetup.ini ← display settings
|
|
||||||
Reads Data\locale.ini ← language table
|
|
||||||
Reads Data\db_meta.xml (via FLE) ← DB schema for all tables
|
|
||||||
```
|
|
||||||
|
|
||||||
## Phase timing (observed, single machine)
|
|
||||||
|
|
||||||
| Phase | Time after launch | Trigger |
|
|
||||||
|------------------------------|-------------------|----------------------------------|
|
|
||||||
| DLL load + FLE injection | 0 – 0.3 s | OS loader |
|
|
||||||
| Engine + DirectX init | 0.3 – 5 s | FIFA23 entry point |
|
|
||||||
| "Press any key" splash | ~5 s | First rendered frame |
|
|
||||||
| Main menu | ~25 s | After key press |
|
|
||||||
| FUT mode entry (attempted) | user-driven | User selects FUT tile |
|
|
||||||
| Network calls to EA services | at FUT entry | DirtySDK / EAWebKit |
|
|
||||||
|
|
||||||
## Files read at startup (observed)
|
|
||||||
|
|
||||||
| File | Format | Purpose |
|
|
||||||
|------|--------|---------|
|
|
||||||
| `Data/initfs_Win32` | Frostbite pkg | Base asset manifest |
|
|
||||||
| `Data/layout.toc` | Frostbite TOC | File layout index |
|
|
||||||
| `Patch/initfs_Win32` | Frostbite pkg | Patch layer |
|
|
||||||
| `Data/locale.ini` | INI | String localisation |
|
|
||||||
| `Data/db_meta.xml` | XML | DB schema (loaded by FLE) |
|
|
||||||
| `Data/id_map.json` | JSON | Player/team ID→name map |
|
|
||||||
| `Data/char_conv.json` | JSON | Character conversion table |
|
|
||||||
| `Documents/FIFA 23/fifasetup.ini` | INI | Display/audio settings |
|
|
||||||
| `AppData/Local/Temp/FIFA 23/_replay0.bin` | binary | Replay buffer |
|
|
||||||
| `anadius.cfg` | VDF | Fake EA persona config |
|
|
||||||
| `AppData/Local/anadius/LSX emu/achievement-*.xml` | XML | Achievement state |
|
|
||||||
|
|
||||||
## Files written during a session (observed)
|
|
||||||
|
|
||||||
| File | When written | Content |
|
|
||||||
|------|-------------|---------|
|
|
||||||
| `Documents/FIFA 23/settings/Settings*` | Main menu reached | FBCHUNKS — controller/display prefs |
|
|
||||||
| `Documents/FIFA 23/settings/ProfileOptions` | Profile load | FBCHUNKS — 1.5 MB profile blob |
|
|
||||||
| `Documents/FIFA 23/filesystemcache/survey.state` | Startup | Empty state file |
|
|
||||||
| `Documents/FIFA 23/filesystemcache/atlPlayTimeJson/playtime_*.json` | Ongoing | Playtime tracking |
|
|
||||||
| `FIFA 23 Live Editor/config.json` | FLE ready | FLE settings (rewritten each session) |
|
|
||||||
| `Logs/log_DD-MM-YYYY.txt` | Throughout | FLE debug log |
|
|
||||||
|
|
||||||
## Save file formats
|
|
||||||
|
|
||||||
### FBCHUNKS (Frostbite chunk container)
|
|
||||||
- Magic: `46 42 43 48 55 4E 4B 53` (`FBCHUNKS`)
|
|
||||||
- Byte 8: version (01 seen)
|
|
||||||
- Offset 0x12: null-terminated label string (e.g. "Personal Settings 1", "Career - Player Progress 1")
|
|
||||||
- Remainder: compressed/binary chunk data — no public spec; requires Frostbite tooling to fully parse
|
|
||||||
- Tools: [Frosty Tool Suite](https://github.com/CadeEvs/FrostyToolSuite) can read/write these
|
|
||||||
|
|
||||||
### fifasetup.ini
|
|
||||||
- Plain `KEY = VALUE` ini, fully human-readable
|
|
||||||
- Safe to edit (display resolution, locale, vsync)
|
|
||||||
|
|
||||||
## Network calls at FUT entry (observed with iptables redirect)
|
|
||||||
|
|
||||||
Traffic pattern captured before changing strategy:
|
|
||||||
- Multiple TLS connections to port 443 (destination: EA servers, resolved as various EA IPs)
|
|
||||||
- TLS 1.3, AES-256-GCM (DirtySDK's copy of ProtoSSL, inline in FIFA23.exe)
|
|
||||||
- No SNI sent (DirtySDK does not set `server_name` extension)
|
|
||||||
- Connections originate from Wine/Proton network stack via Linux kernel TCP
|
|
||||||
|
|
||||||
Specific EA hostnames used (from openfut-bridge captures, not decoded from TLS):
|
|
||||||
- `fut.ea.com` (FUT API)
|
|
||||||
- `accounts.ea.com` (auth)
|
|
||||||
- `gateway.ea.com` (entitlements)
|
|
||||||
- `pin-river.data.ea.com` (telemetry)
|
|
||||||
|
|
||||||
## Key FLE Lua API hooks
|
|
||||||
|
|
||||||
FLE injects `FIFALiveEditor.DLL` and exposes a Lua engine that can:
|
|
||||||
- Read any in-memory DB table via `GetDBTableRows(tableName)`
|
|
||||||
- Write any cell via `EditDBTableField`
|
|
||||||
- Query career mode state via `IsInCM()`
|
|
||||||
- Get player/team names via `GetPlayerName`, `GetTeamName`
|
|
||||||
|
|
||||||
This is the primary safe integration path (see `fut-integration-options.md`).
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
# Foundational test — live custom XI via Freeze Lineup
|
|
||||||
|
|
||||||
**Status: PENDING — test has not yet been run.**
|
|
||||||
|
|
||||||
This is build-order step 1 from `docs/direction.md`: the test everything else
|
|
||||||
in the direction pivot depends on.
|
|
||||||
|
|
||||||
## What changed since the first draft of this doc
|
|
||||||
|
|
||||||
The first version of this test guessed at a "selection bias" DB field and a
|
|
||||||
candidate squad/lineup table name, based on general FIFA-modding precedent
|
|
||||||
that turned out not to hold for FLE's documented API — no such field appears
|
|
||||||
anywhere in FLE's actual Lua API docs or its own example scripts. While
|
|
||||||
researching an unrelated hotkey issue, a **confirmed, FLE-documented**
|
|
||||||
mechanism for forcing a starting XI turned up instead: the **Formation
|
|
||||||
Editor's "Freeze Lineup" feature** (FLE wiki, `Formation-Editor.md`):
|
|
||||||
|
|
||||||
> This feature can be used in player career mode if you want to manage the
|
|
||||||
> starting lineup of your team. Can be also used in manager career mode to
|
|
||||||
> manually manage your next opponent's starting lineup.
|
|
||||||
|
|
||||||
Steps (GUI, no scripting): open Formation Editor for a team → arrange players
|
|
||||||
on the pitch → tick **Freeze Lineup** → `Data → Save`.
|
|
||||||
|
|
||||||
This is real and documented, but it's GUI-only — there is no Lua function for
|
|
||||||
it, and what DB write it actually performs under the hood is undocumented.
|
|
||||||
This test is now two phases: confirm the GUI feature works at all, then
|
|
||||||
reverse the DB write it makes so it can be replicated programmatically
|
|
||||||
(required for the app→game bridge in build-order step 2, which needs this
|
|
||||||
driven from outside the game, not from a person clicking checkboxes).
|
|
||||||
|
|
||||||
Also fixed in this pass: `EditDBTableField`'s real signature, confirmed from
|
|
||||||
FLE's own docs and `lua/scripts/99ovr_99pot.lua`, is
|
|
||||||
`EditDBTableField(cell)` where `cell` is `row["fieldname"]` with `.value`
|
|
||||||
mutated in place — **not** `EditDBTableField(table, row_index, field, value)`
|
|
||||||
as originally (incorrectly) written into the first draft of the injector
|
|
||||||
script.
|
|
||||||
|
|
||||||
## What this test settles
|
|
||||||
|
|
||||||
Whether a *specific, externally-chosen* 11 players can be forced into a
|
|
||||||
career (or Kick-Off) match's starting lineup, live, with no restart — and
|
|
||||||
whether the mechanism that does it (Freeze Lineup's underlying DB write) can
|
|
||||||
be driven by a script instead of a person clicking through the Formation
|
|
||||||
Editor UI.
|
|
||||||
|
|
||||||
If Freeze Lineup itself doesn't actually hold under match start (the wiki
|
|
||||||
doesn't show it being tested against a live match, only "you should be able
|
|
||||||
to see... when you play against them"), the whole bridge architecture in
|
|
||||||
`docs/direction.md` §3 needs rethinking — there is no other documented
|
|
||||||
mechanism for forcing a lineup.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- FIFA 23 launched normally (FLE injected, EAAC neutralized — same baseline
|
|
||||||
as `track-c-fut-table-test.md`)
|
|
||||||
- A career save loaded (Freeze Lineup is documented for career mode
|
|
||||||
specifically — confirm separately whether it does anything in Kick-Off,
|
|
||||||
don't assume it does)
|
|
||||||
- Note 11 player IDs from your club (`tools/squad-exporter/export_squad.lua`
|
|
||||||
output, `playerid` field) that are NOT currently your starting XI
|
|
||||||
|
|
||||||
## Phase 1 — confirm Freeze Lineup actually holds into a match
|
|
||||||
|
|
||||||
This has zero scripting and should be done first since everything else is
|
|
||||||
wasted effort if it fails.
|
|
||||||
|
|
||||||
1. Open the Live Editor overlay (F9, or `Windows → Settings` from the
|
|
||||||
overlay's own menu bar if the hotkey isn't registering — see the umu/Wine
|
|
||||||
hotkey note below).
|
|
||||||
2. `Features → Teams` → find your team → `Edit`.
|
|
||||||
3. `Team → Formation` to open the Formation Editor.
|
|
||||||
4. Swap players around on the pitch so the XI differs from your current
|
|
||||||
actual starting XI in some checkable way (e.g. swap two outfield players'
|
|
||||||
positions, or bench/start a specific player).
|
|
||||||
5. Tick **Freeze Lineup**.
|
|
||||||
6. `Data → Save`.
|
|
||||||
7. Hide Live Editor (F9), save your career **on a new slot** (don't overwrite
|
|
||||||
your main save in case this corrupts something), exit to main menu, reload
|
|
||||||
that save, and check the team's lineup screen / play a match and watch who
|
|
||||||
starts.
|
|
||||||
|
|
||||||
**Record in the Results table below whether the frozen lineup actually took
|
|
||||||
the pitch.** If not, stop here — Phase 2 is moot.
|
|
||||||
|
|
||||||
## Phase 2 — find the underlying DB write
|
|
||||||
|
|
||||||
Only proceed if Phase 1 confirmed Freeze Lineup works.
|
|
||||||
|
|
||||||
1. In FLE's Lua Engine, run `tools/squad-injector/snapshot_lineup_tables.lua`.
|
|
||||||
This dumps every DB table whose name contains `squad`, `lineup`,
|
|
||||||
`formation`, `tactic`, `teamsheet`, `selection`, `players`, or `teams` to
|
|
||||||
`C:\FIFA 23 Live Editor\openfut_snapshot_<timestamp>.json`. Note this
|
|
||||||
filename — this is your **before** snapshot.
|
|
||||||
2. Without restarting or reloading, repeat the Formation Editor steps from
|
|
||||||
Phase 1 (steps 2–6 only — open Formation Editor, change the lineup, tick
|
|
||||||
Freeze Lineup, `Data → Save`). Don't save/reload the career between
|
|
||||||
snapshot and this step — keep it to a single live session so the diff
|
|
||||||
isn't polluted by other state changes.
|
|
||||||
3. Run `snapshot_lineup_tables.lua` again. This is your **after** snapshot.
|
|
||||||
4. Copy both JSON files out of the Wine prefix (same path pattern as
|
|
||||||
`track-c-fut-table-test.md`: `~/Games/umu/.../drive_c/FIFA 23 Live
|
|
||||||
Editor/`) and run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 tools/squad-injector/diff_snapshots.py before.json after.json
|
|
||||||
```
|
|
||||||
|
|
||||||
5. The output shows exactly which table(s) and field(s) changed. This is the
|
|
||||||
real, confirmed write Freeze Lineup performs — record it in the Results
|
|
||||||
table below.
|
|
||||||
|
|
||||||
## Phase 3 — replicate the write via script
|
|
||||||
|
|
||||||
1. Open `tools/squad-injector/apply_lineup_write.lua` and fill in
|
|
||||||
`TARGET_TABLE` and `TARGET_FIELDS` using Phase 2's diff output.
|
|
||||||
2. Edit `C:\FIFA 23 Live Editor\openfut_test_xi.json`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"team_id": 12345,
|
|
||||||
"xi": [
|
|
||||||
{ "player_id": 111111, "position": 0 },
|
|
||||||
{ "player_id": 222222, "position": 5 }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Use 11 entries. Position codes are **confirmed numeric 0–27**
|
|
||||||
(`GK=0, SW=1, RWB=2, RB=3, RCB=4, CB=5, LCB=6, LB=7, LWB=8, RDM=9, CDM=10,
|
|
||||||
LDM=11, RM=12, RCM=13, CM=14, LCM=15, LM=16, RAM=17, CAM=18, LAM=19,
|
|
||||||
RF=20, CF=21, LF=22, RW=23, RS=24, ST=25, LS=26, LW=27`) — from
|
|
||||||
`lua/scripts/export_season_stats.lua`'s `get_pos_name` table in FLE's own
|
|
||||||
repo, not a guess.
|
|
||||||
3. Run `apply_lineup_write.lua` from FLE's Lua Engine.
|
|
||||||
4. Repeat the save-to-new-slot / reload / check-lineup verification from
|
|
||||||
Phase 1, but this time without ever opening the Formation Editor — the
|
|
||||||
write was made entirely from the script.
|
|
||||||
|
|
||||||
## Classification criteria
|
|
||||||
|
|
||||||
### "Confirmed — full mechanism works"
|
|
||||||
|
|
||||||
Phase 1 holds, Phase 2 finds a clean diff, Phase 3's scripted write produces
|
|
||||||
the same in-match result as the manual GUI path.
|
|
||||||
|
|
||||||
**Verdict:** Build-order step 1 done. Proceed to step 2 (bridge transport) in
|
|
||||||
`docs/direction.md`.
|
|
||||||
|
|
||||||
### "GUI works, script doesn't"
|
|
||||||
|
|
||||||
Phase 1 holds but Phase 3's replicated write doesn't stick, even though the
|
|
||||||
diffed fields matched what changed in Phase 2.
|
|
||||||
|
|
||||||
**Verdict:** Freeze Lineup likely does more than a single DB field write
|
|
||||||
(e.g. an internal engine call beyond `EditDBTableField`'s reach, or a second
|
|
||||||
write the diff missed because it happened in a table outside the `KEYWORDS`
|
|
||||||
filter in `snapshot_lineup_tables.lua` — widen the filter and redo Phase 2).
|
|
||||||
|
|
||||||
### "Freeze Lineup doesn't hold at all"
|
|
||||||
|
|
||||||
Phase 1 fails — the lineup reverts to the game's own AI-picked XI regardless.
|
|
||||||
|
|
||||||
**Verdict:** No confirmed mechanism exists for forcing a lineup. This kills
|
|
||||||
the bridge architecture as designed in `direction.md` §3 and needs a return
|
|
||||||
to first principles — there is no fallback documented anywhere in FLE's wiki
|
|
||||||
for this specific case.
|
|
||||||
|
|
||||||
## A note on the umu/Wine F9/F11 hotkey issue
|
|
||||||
|
|
||||||
If FLE's F9 (hide/show) hotkey isn't registering under umu, this is plausibly
|
|
||||||
a Wine keyboard-hook limitation (FLE's global hotkey detection likely uses a
|
|
||||||
low-level hook that doesn't translate cleanly through Wine's input layer) —
|
|
||||||
not something documented anywhere in FLE's own troubleshooting docs, which
|
|
||||||
don't mention Linux/Wine at all. F11 specifically has **no documented FLE
|
|
||||||
function** — F9 is the only documented toggle. Workaround: click directly
|
|
||||||
into the FLE overlay window (it should still be visible/clickable even if the
|
|
||||||
hotkey doesn't fire) and use its own menu bar instead of relying on the
|
|
||||||
hotkey.
|
|
||||||
|
|
||||||
## Results
|
|
||||||
|
|
||||||
*(To be filled in after the test is run.)*
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|---|---|
|
|
||||||
| Date run | — |
|
|
||||||
| Phase 1: Freeze Lineup holds into a match? | — |
|
|
||||||
| Phase 2: table(s)/field(s) changed | — |
|
|
||||||
| Phase 3: scripted write reproduces Phase 1 result? | — |
|
|
||||||
| **Classification** | **PENDING** |
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
# FUT Integration Options
|
|
||||||
|
|
||||||
How to connect FIFA 23 to the OpenFUT local simulator, ranked by safety and feasibility.
|
|
||||||
|
|
||||||
## Option A — FLE Lua scripting (RECOMMENDED)
|
|
||||||
|
|
||||||
**What it does:** Use FIFA Live Editor's in-memory Lua API to read and write the game's
|
|
||||||
database tables at runtime. FLE is already injected; no additional hooking needed.
|
|
||||||
|
|
||||||
**Why it's the right path:**
|
|
||||||
- Fully offline, no EA servers touched
|
|
||||||
- FLE is already trusted by the user (it's the launch mechanism)
|
|
||||||
- `GetDBTableRows` / `EditDBTableField` expose the full Frostbite DB in memory
|
|
||||||
- Scripts run inside the game process; no IPC complexity
|
|
||||||
- Same mechanism used by modders for career mode edits today
|
|
||||||
|
|
||||||
**Integration design:**
|
|
||||||
|
|
||||||
```
|
|
||||||
openfut-core (SQLite)
|
|
||||||
│
|
|
||||||
│ HTTP REST (localhost)
|
|
||||||
▼
|
|
||||||
openfut-bridge (port 8080, plain HTTP, no TLS)
|
|
||||||
│ pulls club/squad/player data as JSON
|
|
||||||
▼
|
|
||||||
FLE Lua bridge script
|
|
||||||
│ calls GetDBTableRows, EditDBTableField
|
|
||||||
▼
|
|
||||||
FIFA 23 in-memory DB (Frostbite)
|
|
||||||
```
|
|
||||||
|
|
||||||
The Lua script polls openfut-core's REST API at intervals (or on FUT menu entry)
|
|
||||||
and writes simulator data (coins, items, squad) into the appropriate DB tables.
|
|
||||||
|
|
||||||
**Tables likely involved (to verify with export_squad.lua):**
|
|
||||||
|
|
||||||
| Table | Expected FUT content |
|
|
||||||
|-------|---------------------|
|
|
||||||
| `players` | Player attributes (OVR, potential, stats) |
|
|
||||||
| `teams` | Club identity, stadium, colors |
|
|
||||||
| `fut_clubs` | FUT club record (if in memory when FUT loads) |
|
|
||||||
| `fut_items` | Card inventory (if in memory) |
|
|
||||||
| `fut_squads` | Active squad (if in memory) |
|
|
||||||
|
|
||||||
**Steps to implement:**
|
|
||||||
1. Run `tools/squad-exporter/export_squad.lua` from FLE Lua Engine while in FUT to discover which tables are live
|
|
||||||
2. Map openfut-core's data model to the discovered table fields
|
|
||||||
3. Write a Lua polling script that fetches `/api/v1/club`, `/api/v1/squad`, etc. from openfut-core and calls `EditDBTableField` to populate them
|
|
||||||
4. Optionally add a small HTTP client to the Lua script using LuaSocket (FLE ships with Lua 5.4)
|
|
||||||
|
|
||||||
**Limitations:**
|
|
||||||
- Changes are in-memory only; they reset on game restart (acceptable for a simulator)
|
|
||||||
- Only works while FLE is running (always true in our setup)
|
|
||||||
- FUT tables may only be populated when the FUT hub is loaded; test with the exporter
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Option B — Local save file injection (career mode proxy)
|
|
||||||
|
|
||||||
**What it does:** Generate or modify offline career mode save files that contain FUT-like
|
|
||||||
squad/player data, using Frostbite's FBCHUNKS format.
|
|
||||||
|
|
||||||
**Feasibility:** Medium
|
|
||||||
- FBCHUNKS format is not publicly documented but has been partially reverse-engineered by the Frosty Tool Suite project
|
|
||||||
- Career saves are 16 MB — large and complex
|
|
||||||
- Changes take effect only after a game restart
|
|
||||||
|
|
||||||
**Best use:** Pre-populating a career club with the same players as the FUT simulator squad, so offline Squad Battles use "your" players.
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. Use Frosty Tool Suite to open a career save and map the schema
|
|
||||||
2. Build a Python exporter that writes a valid FBCHUNKS save with simulator squad data
|
|
||||||
3. Test: replace the career save, launch FIFA, verify squad is correct
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Option C — Local companion web UI
|
|
||||||
|
|
||||||
**What it does:** The user manages their FUT simulator entirely in a web browser (openfut-core already has this). A button exports the current squad/club state to a format that a Lua script or file injector can consume.
|
|
||||||
|
|
||||||
**This is already implemented** — openfut-core serves the FUT simulator REST API. The missing piece is the Lua bridge script (Option A) that reads from it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Option D — Local proxy for non-secured local calls only
|
|
||||||
|
|
||||||
**What it does:** Intercept FIFA 23's calls to `localhost:*` or a known local endpoint (not EA servers) and respond with simulator data.
|
|
||||||
|
|
||||||
**Feasibility:** Low value in isolation
|
|
||||||
- FIFA 23 does not make calls to localhost in normal operation (except EA App on port 10853)
|
|
||||||
- All FUT API calls go to EA's servers over TLS
|
|
||||||
- Intercepting those would require the approach we explicitly ruled out
|
|
||||||
|
|
||||||
**Not recommended as a primary path.** Could be combined with Option A if the Lua script exposes a local socket that a coordinator process writes to.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Option E — Memory bridge (Cheat Engine / FLE offsets)
|
|
||||||
|
|
||||||
**What it does:** Use known memory offsets (FLE's `offset_cache.json`) to read/write FUT state directly in FIFA23.exe's heap.
|
|
||||||
|
|
||||||
**Feasibility:** Medium — FLE already does this for career mode
|
|
||||||
- FLE's `offset_cache.json` contains addresses for many game structures
|
|
||||||
- FUT in-memory structs are separate from career structs and may not be mapped yet
|
|
||||||
- This is fragile (offsets change with game updates)
|
|
||||||
|
|
||||||
**Not recommended** unless Options A and B both fail — too brittle.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Recommendation
|
|
||||||
|
|
||||||
**Start with Option A (FLE Lua scripting).**
|
|
||||||
|
|
||||||
1. Run `tools/squad-exporter/export_squad.lua` in-game to discover which DB tables exist in FUT mode
|
|
||||||
2. Use `tools/file-watch-diff/watch.sh` to snapshot file state entering FUT and identify any new local files
|
|
||||||
3. Use `tools/network-metadata-logger/netlog.sh` to log which EA hosts FIFA contacts at FUT entry (metadata only, no decryption)
|
|
||||||
4. Map findings back to openfut-core's data model
|
|
||||||
5. Implement the Lua bridge script that calls openfut-core's REST API and writes to discovered tables
|
|
||||||
|
|
||||||
If FUT tables are not exposed by FLE's DB API (they may not be — FUT data lives server-side in online mode), fall back to **Option B** (career save injection) to provide a squad that mirrors the simulator's club.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Safety boundary
|
|
||||||
|
|
||||||
The following are out of scope and must not be implemented:
|
|
||||||
|
|
||||||
- Decrypting or inspecting EA's TLS traffic
|
|
||||||
- Spoofing EA domain names or impersonating EA servers
|
|
||||||
- Sending modified clients to EA's production services
|
|
||||||
- Bypassing EA App login or account verification
|
|
||||||
- Anything that could constitute online cheating or violate EA's ToS for online play
|
|
||||||
|
|
||||||
All integration must remain local/offline/single-player.
|
|
||||||
@@ -1,523 +0,0 @@
|
|||||||
# FIFA 17 — Clean Empty-My-Packs client fix (DESIGN / RESEARCH ONLY)
|
|
||||||
|
|
||||||
Status: **design only — no client binary/movie changes made.** This is the client-side
|
|
||||||
follow-up to bug 6c. The backend already ships a compatibility workaround (P2, active
|
|
||||||
non-openable sentinel 65534; see `docs/evidence/STORE_TILE_6C.md` §17 and
|
|
||||||
`FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md`). This plan describes what a *client-side*
|
|
||||||
fix would need to change so the backend shim can eventually become unnecessary for
|
|
||||||
patched clients.
|
|
||||||
|
|
||||||
Do NOT patch the executable, DLLs, or Scaleform movies in this task.
|
|
||||||
|
|
||||||
## 1. Established client-side evidence
|
|
||||||
|
|
||||||
Binary: `CardsDLL_Win64_retail.dll`
|
|
||||||
SHA-256 `4706a881ae1fc7b5769fd810b25a868d29d2b16a8e65a7513436327ef645573c`
|
|
||||||
(dump load base `0x00006FFFFC120000`; RE-space base `0x180000000`). `FIFA17.exe`
|
|
||||||
(`29c31cef…`) is Denuvo-packed (decrypts only in live memory).
|
|
||||||
|
|
||||||
Store category pipeline (all decompiled; see `docs/plan-2026-08-05-store-subsystem.md`):
|
|
||||||
- `FUN_1800150d0` — builds display groups from `purchase[]`; a `mypacks` group exists
|
|
||||||
iff some pack has `displayGroup.value=="mypacks"`. `group+0x104=(value=="mypacks")`,
|
|
||||||
tiles in `group+0x40`, ordinal in `group+0x00` (1-based creation order).
|
|
||||||
- `FUN_18007dab0` → `FUN_1800147f0(model, screen+0x290, …)` — renders/resolves a
|
|
||||||
category. `screen+0x290==0` lists group tiles (`FUN_180014610`); otherwise
|
|
||||||
`FUN_180014420` exact-matches the ordinal and **returns NULL on a miss**, after
|
|
||||||
which `FUN_1800147f0` dereferences `[RAX+0x48]` with **no null guard** →
|
|
||||||
**crash at `0x180014882`** (`ACCESS_VIOLATION` read of `0x48`, minidump-confirmed).
|
|
||||||
- `screen+0x290` is written in exactly two CardsDLL sites: ctor `FUN_18007d1a0`
|
|
||||||
writes `0`; **`FUN_18007e7f0` case `0x7551` copies the Flash movie message field
|
|
||||||
`CATEGORY_ID` verbatim** into it. So the category is chosen by the Scaleform movie.
|
|
||||||
- `FUN_18007e5e0` binds the six store tabs (`FUN_180014580`: `mypacks, points, bronze,
|
|
||||||
silver, gold, special`) to `PANEL_ID` = matching group ordinal, or hides the panel.
|
|
||||||
- Unopened-pack count signals (server, already correct at 0 when empty):
|
|
||||||
`userInfo.unopenedPacks.recoveredPacks` and `/user/credits .unopenedPacks`. The hub
|
|
||||||
`CentralUnclaimedPack` tile (destination `GOTO_STORE_MYPACK`) is gated by this count
|
|
||||||
in the hub model (`model+0x20950`). **Candidate F (a server count gating the STORE's
|
|
||||||
My-Packs resolution) was CONTRADICTED**: the count is correct at 0 yet the store
|
|
||||||
still resolves My Packs, because the decision is movie-side.
|
|
||||||
|
|
||||||
## 2. Desired clean client behavior
|
|
||||||
|
|
||||||
```
|
|
||||||
unopened-pack count == 0:
|
|
||||||
Store defaults to Browse Packs (e.g. a real category such as bronze/gold)
|
|
||||||
My Packs is NOT selected/resolved
|
|
||||||
no synthetic placeholder tile is required from the server
|
|
||||||
unopened-pack count > 0:
|
|
||||||
existing My Packs behavior unchanged
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Candidate insertion points (ranked)
|
|
||||||
|
|
||||||
Ranking favors fixing the UX (not merely preventing the crash) and the smallest,
|
|
||||||
lowest-risk change that achieves it.
|
|
||||||
|
|
||||||
### Rank 1 (preferred, best UX) — Scaleform / category-selection layer
|
|
||||||
Prevent the movie from emitting `CATEGORY_ID == mypacks` (and from defaulting the
|
|
||||||
store into My Packs) when the unopened-pack count is 0; default to Browse Packs
|
|
||||||
instead.
|
|
||||||
- **Where:** the FUT Store Scaleform movie / ActionScript (`StoreFront`,
|
|
||||||
`CATEGORY_ID`/`ACTION_GET_PACKLIST`, `GOTO_STORE_MYPACK`), which the packed exe hosts
|
|
||||||
and which reads the hub model (it already knows the count for the
|
|
||||||
`CentralUnclaimedPack` tile).
|
|
||||||
- **Behavior changed:** the store's initial/selected category when empty.
|
|
||||||
- **Scope:** movie asset edit (client-side), no native-code patch.
|
|
||||||
- **Risk:** medium — Scaleform RE/editing is fiddly; must find where the default
|
|
||||||
`CATEGORY_ID` is chosen and gate it on the count without breaking the count>0 path.
|
|
||||||
- **Compatibility:** per-client asset change; does not touch protocol or other clients.
|
|
||||||
- **Fixes UX or just crash?** **UX** — no fake tile, correct default; the crash also
|
|
||||||
disappears because `mypacks` is never resolved when absent.
|
|
||||||
- **Evidence:** `screen+0x290 ← CATEGORY_ID` (`FUN_18007e7f0` case `0x7551`); count
|
|
||||||
already available client-side (hub model / `unopenedPacks`).
|
|
||||||
|
|
||||||
### Rank 2 — Native Store resolver fallback (CardsDLL)
|
|
||||||
Make `FUN_1800147f0`/`FUN_180014420` fall back to a safe category (e.g. list-tiles
|
|
||||||
`N==0`, or the first existing group) when the requested ordinal misses, instead of
|
|
||||||
dereferencing NULL.
|
|
||||||
- **Behavior changed:** category-miss handling for ALL categories, not just mypacks.
|
|
||||||
- **Scope:** small, localized CardsDLL binary patch near `0x180014420`/`0x180014882`.
|
|
||||||
- **Risk:** medium — alters native store behavior globally; could mask other
|
|
||||||
legitimate misses; the movie may still believe it is in My Packs (empty/odd view).
|
|
||||||
- **Compatibility:** binary patch to the shipped DLL (client-side).
|
|
||||||
- **Fixes UX or just crash?** Crash + partial UX (no crash, but the empty-My-Packs
|
|
||||||
view may still be awkward).
|
|
||||||
- **Evidence:** the no-guard deref at `0x180014882`; `FUN_180014420` returns NULL on
|
|
||||||
miss.
|
|
||||||
|
|
||||||
### Rank 3 (cheapest, crash-only) — CardsDLL null guard
|
|
||||||
Insert a null check before the `[RAX+0x48]` dereference in `FUN_1800147f0` (a single
|
|
||||||
`TEST/JZ` around the deref) so a NULL group is skipped/returned safely.
|
|
||||||
- **Behavior changed:** only the crash path.
|
|
||||||
- **Scope:** minimal (a few bytes) binary patch at `~0x180014882`.
|
|
||||||
- **Risk:** low — smallest change; but purely crash-prevention. With no `mypacks`
|
|
||||||
group the resulting empty view is unverified (could be a blank/empty-category state).
|
|
||||||
- **Compatibility:** binary patch (client-side).
|
|
||||||
- **Fixes UX or just crash?** Crash only.
|
|
||||||
- **Evidence:** minidump faulting instruction `CardsDLL+0x14882`, `[NULL+0x48]`.
|
|
||||||
|
|
||||||
## 4. Recommended long-term outcome
|
|
||||||
|
|
||||||
Rank 1 (Scaleform default-category gating) is the clean fix: with count 0 the store
|
|
||||||
opens on Browse Packs, no `mypacks` resolution, no fake tile — and the **backend
|
|
||||||
sentinel 65534 can be dropped for patched clients** (the server would simply omit the
|
|
||||||
`mypacks` group when empty, which is safe once the client no longer resolves it).
|
|
||||||
Rank 3 (null guard) is a cheap universal crash-safety net that could ship alongside.
|
|
||||||
Until a client-side fix exists, the backend P2 sentinel remains the required
|
|
||||||
compatibility behavior for unpatched retail clients.
|
|
||||||
|
|
||||||
## 5. Open questions / next research (no execution here)
|
|
||||||
- Locate the Store movie's default/initial `CATEGORY_ID` selection and confirm it can
|
|
||||||
read the unopened count (Rank 1 feasibility).
|
|
||||||
- Confirm, via a guarded-resolver experiment, what the empty-My-Packs view degrades to
|
|
||||||
if the `mypacks` group is simply absent + a null guard is present (Rank 2/3).
|
|
||||||
- Determine whether the Browse-Packs→My-Packs navigation gate (observed with the
|
|
||||||
active sentinel) also resolves under Rank 1.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# PART II — Native client-fix design (RE-backed, 2026-08-13)
|
|
||||||
|
|
||||||
Investigation-and-design phase (no client binary/movie changed, no backend changed,
|
|
||||||
no new live Store experiment). CardsDLL was re-analysed in Ghidra on `.105`; the
|
|
||||||
in-repo decompiled addresses were reconfirmed against a freshly-built project. Every
|
|
||||||
claim below is labelled **ESTABLISHED** (read from this build's binary / crash dump),
|
|
||||||
**PROPOSED** (design, not yet implemented), or **UNKNOWN**.
|
|
||||||
|
|
||||||
## 6. Binary + environment verification (ESTABLISHED)
|
|
||||||
|
|
||||||
Hashes re-verified on `.105` (`/mnt/games/FIFA 17/`) — identical to the recorded RE:
|
|
||||||
- `CardsDLL_Win64_retail.dll` SHA-256 `4706a881ae1fc7b5769fd810b25a868d29d2b16a8e65a7513436327ef645573c`,
|
|
||||||
size 3179952, PE `TimeDateStamp` 1497050156 (2017-06-09T23:15:56Z), `SizeOfImage`
|
|
||||||
`0x31d000`, image base `0x180000000` (RE-space). **Unpacked → statically analysable.**
|
|
||||||
- `FIFA17.exe` SHA-256 `29c31cef12b0c3c2a7305220617c7b4fa139ab76b8c857851bdbe88987962899`,
|
|
||||||
size 224639408, **Denuvo-packed** → the Scaleform/StoreFront ActionScript that
|
|
||||||
*decides* to emit `CATEGORY_ID` is NOT statically readable. This is why a
|
|
||||||
pure-Scaleform edit (old "Rank 1") is not the practical vehicle; the fix is taken
|
|
||||||
at the readable native boundary in CardsDLL instead.
|
|
||||||
- Ghidra project rebuilt at `.105:/tmp/ghidra_fut/cardsdll` (headless import+analysis
|
|
||||||
succeeded). Tooling: `fifa17-recon/tools/ghidra_env.py` run under `~/.venv`
|
|
||||||
(`PYTHONPATH=/opt/ghidra/Ghidra/Features/PyGhidra/pypkg/src:/usr/lib/python3.14/site-packages`;
|
|
||||||
`jpype1` reinstalled offline from pip cache). RVAs below = static VA − `0x180000000`.
|
|
||||||
|
|
||||||
## 7. Category-selection path (ESTABLISHED — decompiled this build)
|
|
||||||
|
|
||||||
Store message dispatch `FUN_18007d880` (RVA `0x7d880`) routes Flash message ids:
|
|
||||||
`0x753f → FUN_18007dab0` (render), `0x278a → FUN_18007df60` (publish category ids),
|
|
||||||
and the input handler `FUN_18007e7f0` (RVA `0x7e7f0`) case **`0x7551`** copies the
|
|
||||||
movie field `CATEGORY_ID` verbatim into `screen+0x290` (the only non-ctor writer;
|
|
||||||
ctor `FUN_18007d1a0` writes 0).
|
|
||||||
|
|
||||||
**Store render `FUN_18007dab0` (RVA `0x7dab0`), decompiled verbatim, is the decision
|
|
||||||
point:**
|
|
||||||
```c
|
|
||||||
iVar1 = *(int *)(param_1 + 0x290); // requested CATEGORY_ID (screen+0x290)
|
|
||||||
iVar6 = FUN_180014580(store, 1); // the *points* category id (see tab map)
|
|
||||||
if (iVar1 == iVar6) { // requested category is POINTS (real-money)
|
|
||||||
if (region_check() == 0) { post "REGION_MISMATCH"; return; }
|
|
||||||
if (FUN_180014de0(store) != 0) return; // points group present → handled
|
|
||||||
FUN_180014b60(store, dp); // else points render
|
|
||||||
} else {
|
|
||||||
FUN_1800147f0(store, iVar1, dp, 0, 0); // EVERY other category, incl. My Packs
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- `param_1` (RCX) = the store-screen object; `+0x290` is the requested category.
|
|
||||||
- **Tab→id map `FUN_180014580(store, n)` (RVA `0x14580`): `0=mypacks, 1=points,
|
|
||||||
2=bronze, 3=silver, 4=gold, 5=special`.** Each returns the group's **1-based
|
|
||||||
ordinal** (via caption compare `FUN_180014380`) or **`-1`** if that group is absent.
|
|
||||||
So category ids are DYNAMIC ordinals, not fixed constants. The tab publisher
|
|
||||||
`FUN_18007df60` pushes `MYPACK_/BRONZE_/…_CATEGORY_ID` to the movie from these
|
|
||||||
lookups; the movie echoes one back as `CATEGORY_ID`.
|
|
||||||
- The **points** tab is the only one special-cased (commerce/region gate). **My Packs
|
|
||||||
is NOT special-cased — it falls into the `else` and is resolved by
|
|
||||||
`FUN_1800147f0`.**
|
|
||||||
|
|
||||||
**Resolver `FUN_1800147f0` (RVA `0x147f0`) — the crash (ESTABLISHED, instruction
|
|
||||||
level):**
|
|
||||||
```
|
|
||||||
0x14856: 85 ff TEST EDI,EDI ; EDI = category ordinal (param_2)
|
|
||||||
0x14858: 75 0f JNZ 0x14869 ; ==0 → list-all (Browse), else resolve
|
|
||||||
0x1485a: … CALL 0x14610 ; FUN_180014610 list ALL group tiles
|
|
||||||
0x14867: eb 29 JMP 0x14892
|
|
||||||
0x14869: 8b d7 MOV EDX,EDI
|
|
||||||
0x1486b: e8 … CALL 0x14420 ; FUN_180014420(store, ordinal) → RAX (group|NULL)
|
|
||||||
0x14870: 48 8d 50 40 LEA RDX,[RAX + 0x40] ; RDX = group+0x40 (=0x40 when RAX=NULL)
|
|
||||||
0x14878: 48 3b c2 CMP RAX,RDX
|
|
||||||
0x1487b: 74 15 JZ 0x14892
|
|
||||||
0x14882: 4c 8b 42 08 MOV R8,[RDX + 0x8] ; <-- FAULT: read [0x40+0x8]=0x48 when NULL
|
|
||||||
0x14886: 48 8b 12 MOV RDX,[RDX] ; [0x40]
|
|
||||||
```
|
|
||||||
`FUN_180014420` (RVA `0x14420`) exact-matches `group+0x00` (ordinal), stride `0x108`,
|
|
||||||
**returns NULL on a miss, with no guard in the caller** → faulting read of VA `0x48`
|
|
||||||
at `0x180014882`. This is byte-for-byte the Experiment-B minidump
|
|
||||||
(`0xC0000005` READ `0x48` at `CardsDLL+0x14882`).
|
|
||||||
- `param_2 == 0` → `FUN_180014610` lists **all** group tiles = the safe "Browse Packs"
|
|
||||||
view. `param_2 == existing ordinal` → resolves. `param_2 == a non-existent ordinal`
|
|
||||||
(e.g. `-1`, which `MYPACK_CATEGORY_ID` becomes when the group is absent) → NULL → crash.
|
|
||||||
|
|
||||||
**Why it crashes with zero packs (ESTABLISHED):** with `unopenedPackIds==[]` and no
|
|
||||||
sentinel, no `mypacks` group exists, so `FUN_180014580(store,0) = -1`,
|
|
||||||
`MYPACK_CATEGORY_ID = -1`, the movie still selects My Packs and echoes `CATEGORY_ID =
|
|
||||||
-1`, and `FUN_1800147f0(store, -1, …)` → `FUN_180014420(-1)=NULL` → crash. The active
|
|
||||||
sentinel (65534) works only because it makes a real `mypacks` ordinal exist to resolve.
|
|
||||||
|
|
||||||
## 8. Zero-pack state client-side (ESTABLISHED)
|
|
||||||
|
|
||||||
The client already holds the correct unopened-pack count in a **data-manager
|
|
||||||
singleton** (the same one the store resolver uses):
|
|
||||||
- Obtain: `seed = FUN_1800d7170()` then `FUN_180009c80(&p, seed)` → `p` (release with
|
|
||||||
`p->vtbl[0x08](p)`). This exact accessor already runs inside `FUN_180014420` and
|
|
||||||
`FUN_1800147f0`, so any store-category hook can reach it.
|
|
||||||
- **Read count: `p->vtbl[0x4d8](p)` → int. Write: `p->vtbl[0x4e0](p, n)`.** Confirmed
|
|
||||||
in `FUN_180019780`, which reads slot `0x4d8`, adds the number of set booleans in a
|
|
||||||
pack response, and writes slot `0x4e0` (it also fetches `FutGetPurchasedItems`).
|
|
||||||
- Representation: plain `int`; **0 = no unopened packs**, `>0` = count. Lifetime: the
|
|
||||||
singleton persists for the session; updated on pack acquire/open.
|
|
||||||
- No dedicated "hasUnopenedPacks" boolean helper was found; `count != 0` is the
|
|
||||||
predicate. (The hub `CentralUnclaimedPack` tile is gated by this same count via
|
|
||||||
`model+0x20950`, written by `FUN_18010cdc0`/`FUN_18011e120` — the hub mirror, not the
|
|
||||||
store gate.)
|
|
||||||
|
|
||||||
## 9. Implementation vehicle (ESTABLISHED — reuse, do not build a new loader)
|
|
||||||
|
|
||||||
OpenFUT **already ships a client hook framework**: `openfut-launcher/openfut-hook`
|
|
||||||
(`crate-type=["cdylib"]`) builds **`version.dll`**, a proxy DLL placed in the game dir
|
|
||||||
(`/mnt/games/FIFA 17/version.dll`, present & active; log `~/.wine/drive_c/openfut_hook.log`).
|
|
||||||
- Load path: Wine/Windows loads `version.dll` from the app dir at process start →
|
|
||||||
`DllMain(DLL_PROCESS_ATTACH)` → `install_hooks()`.
|
|
||||||
- Existing hooks (`lib.rs`): `getaddrinfo` (IAT via `iat::resolve`), `connect`
|
|
||||||
(inline detour), `WSAConnect`, `WSAIoctl`/ConnectEx, origin_spy registry/mutex,
|
|
||||||
crypt32 `CertVerifyCertificateChainPolicy`, **and in-memory byte-patching of the
|
|
||||||
loaded (packed) main exe + EAWebKit** (`ssl_patch`: `GetModuleHandleA` → scan for a
|
|
||||||
unique prologue → `VirtualProtect`+`copy_nonoverlapping`).
|
|
||||||
- Inline-hook primitive (`connect_hook`): `write_hook(target, dest)` lays a 14-byte
|
|
||||||
`FF 25 00000000 <abs64>` JMP; `restore_original` restores saved bytes
|
|
||||||
(unhook → call real → rehook, avoiding trampoline relocation).
|
|
||||||
- Config: `openfut.cfg` beside the DLL (`host`/ports today; a `store_mypacks_fix`
|
|
||||||
flag would be added there).
|
|
||||||
- **Suitability for the Store fix: direct.** The DLL is in-process with full access
|
|
||||||
to the loaded `CardsDLL_Win64_retail.dll`; the store fix is a NEW module
|
|
||||||
(`store_hook.rs`) installed from `install_hooks`, reusing the `ssl_patch`
|
|
||||||
signature-scan and the `connect_hook` inline-detour patterns. No new loader, no ASI,
|
|
||||||
no separate injector.
|
|
||||||
|
|
||||||
## 10. Three strategies re-evaluated against the RE (Task 4)
|
|
||||||
|
|
||||||
### A. Category-selection redirect — **PREFERRED** (best UX, native, targeted)
|
|
||||||
Hook `FUN_18007dab0` (RVA `0x7dab0`) at entry; before the original runs, redirect a
|
|
||||||
zero-pack My-Packs request to Browse Packs:
|
|
||||||
```
|
|
||||||
cat = *(int*)(store + 0x290)
|
|
||||||
mypacks_id = FUN_180014580(store, 0) // -1 when the group is absent
|
|
||||||
if (cat == mypacks_id) { // movie asked for My Packs (incl. cat==-1==id)
|
|
||||||
if (unopened_count() == 0) // singleton vtbl[0x4d8]
|
|
||||||
*(int*)(store + 0x290) = 0; // 0 = FUN_180014610 list-all = Browse Packs
|
|
||||||
}
|
|
||||||
// then call the original FUN_18007dab0(store)
|
|
||||||
```
|
|
||||||
- Uses the real count? **Yes** (singleton `vtbl[0x4d8]`). Removes the fake 65534 tile?
|
|
||||||
**Yes** (server can omit the group). Removes the click-dialog? **Yes** (no placeholder
|
|
||||||
to click). Removes the Browse→My-Packs nav gate? **Yes** (store lands on Browse, not
|
|
||||||
an empty My-Packs). Preserves count>0? **Yes** (`cat==mypacks_id` with count>0 is left
|
|
||||||
untouched → normal My Packs). Affects other categories? **No** (`cat!=mypacks_id`
|
|
||||||
path is unmodified; points/bronze/… unchanged).
|
|
||||||
- Prevents the crash as a side effect (My Packs is never resolved when its group is
|
|
||||||
absent). This is the old "Rank 1" INTENT, implemented at the readable native boundary
|
|
||||||
instead of in packed Scaleform.
|
|
||||||
|
|
||||||
### B. Resolver fallback — acceptable safety net, less targeted
|
|
||||||
In `FUN_1800147f0` (or right after the `CALL 0x14420` at RVA `0x1486b`): if the
|
|
||||||
resolved group is NULL, fall back to list-all (`param_2=0`) instead of dereferencing.
|
|
||||||
- Prevents crash? **Yes.** Fixes default nav / removes fake tile? **Partially** — the
|
|
||||||
movie still believes it is in My Packs, so the view may be an empty/odd My-Packs
|
|
||||||
rather than a clean Browse. Leaves other lookups unchanged? **It changes miss-handling
|
|
||||||
for ALL categories** — a generic NULL fallback that could mask a genuine
|
|
||||||
missing-category protocol bug. Higher risk than A for that reason; keep as a
|
|
||||||
belt-and-braces guard, not the primary UX fix. The resolver does NOT know *why*
|
|
||||||
`mypacks` is missing, which is exactly the concern the task flags.
|
|
||||||
|
|
||||||
### C. Null-guard only — weakest (crash-only)
|
|
||||||
Insert `TEST RAX,RAX; JZ 0x14892` immediately after `CALL 0x14420` (RVA `0x1486b`),
|
|
||||||
before `LEA RDX,[RAX+0x40]`. Needs a trampoline (no inline slack).
|
|
||||||
- Converts the crash into whatever an empty tile-vector renders (unverified; likely a
|
|
||||||
blank/empty category). Does **not** remove the fake tile or fix the default category;
|
|
||||||
the sentinel would still be needed for acceptable UX. Verified as expected-weakest.
|
|
||||||
|
|
||||||
## 11. Concrete hook target for strategy A (Task 6, PROPOSED)
|
|
||||||
```
|
|
||||||
module: CardsDLL_Win64_retail.dll (GetModuleHandleA)
|
|
||||||
function: FUN_18007dab0 (store render / message 0x753f)
|
|
||||||
RVA: 0x7dab0 (static VA 0x18007dab0)
|
|
||||||
calling conv: Microsoft x64 fastcall; single arg store-screen ptr in RCX
|
|
||||||
screen offset: store+0x290 = requested CATEGORY_ID (int)
|
|
||||||
helpers to call: FUN_180014580 (RVA 0x14580) tab→ordinal, arg0=RCX store, arg1=EDX index(0=mypacks)
|
|
||||||
count singleton: FUN_1800d7170 (0xd7370-seed) + FUN_180009c80 (0x9c80), read vtbl[0x4d8]
|
|
||||||
redirect target: set store+0x290 = 0 (FUN_180014610 list-all → Browse Packs)
|
|
||||||
original behavior: zero packs → resolves absent mypacks ordinal → FUN_180014420 NULL → crash at 0x14882
|
|
||||||
desired behavior: zero packs + mypacks requested → store+0x290 forced to 0 → Browse Packs; no crash/dialog/tile
|
|
||||||
```
|
|
||||||
Hook mechanics (reuse `connect_hook`): lay a 14-byte `FF 25` JMP at `base+0x7dab0` to a
|
|
||||||
Rust `hooked_store_render(store)`; inside: apply the redirect, unhook, call real
|
|
||||||
`FUN_18007dab0(store)`, rehook, return its value. Intercepting only the entry means the
|
|
||||||
minimum interception is the 14 JMP bytes; the first instructions of `FUN_18007dab0`
|
|
||||||
(`MOV RAX,RSP; MOV [RAX+8],RCX; PUSH …`) are a standard prologue safe to save/restore.
|
|
||||||
Alt insertion point (earlier): `FUN_18007e7f0` case `0x7551`, where `CATEGORY_ID` is
|
|
||||||
written to `screen+0x290` — redirect there instead of at render. Entry-hook of
|
|
||||||
`FUN_18007dab0` is preferred (single, well-typed arg; runs once per store render).
|
|
||||||
|
|
||||||
Thread/context: the store screen runs on the client's UI/update thread; the hook reads
|
|
||||||
one int and (rarely) writes one int on the same object the callee immediately reads —
|
|
||||||
no new synchronization needed. Called for categories other than My Packs? The FUNCTION
|
|
||||||
is, but the redirect body only fires when `cat==mypacks_id`, so other tabs are
|
|
||||||
untouched.
|
|
||||||
|
|
||||||
## 12. Version / build safety (Task 7, PROPOSED)
|
|
||||||
FIFA17-specific compat code MUST validate the client before hooking, and MUST no-op on
|
|
||||||
any other build (the same `version.dll` is also used for FIFA23):
|
|
||||||
1. **Module gate:** only proceed if `GetModuleHandleA("CardsDLL_Win64_retail.dll")`
|
|
||||||
resolves (FIFA23 has no such module → auto-skip).
|
|
||||||
2. **Build gate (both, belt-and-braces):**
|
|
||||||
- Exact hash/PE gate: on-disk SHA-256 == `4706a881…`, or PE `SizeOfImage==0x31d000`
|
|
||||||
&& `TimeDateStamp==1497050156` (cheap in-memory check).
|
|
||||||
- Signature scan + validation: locate `FUN_18007dab0` by a unique prologue/byte
|
|
||||||
window rather than trusting the RVA, and assert the known bytes at the branch
|
|
||||||
(`85 ff 75 0f` region) and at the resolver `CALL 0x14420` site match before
|
|
||||||
installing. Recommend **both**: hash to reject the wrong game fast, signature to
|
|
||||||
confirm the exact patch site.
|
|
||||||
3. **Failure behavior:** any check fails (unknown/updated build) → **do NOT patch**,
|
|
||||||
log, and leave the **backend P2 active-sentinel (65534) as the fallback**. Never
|
|
||||||
patch or crash an unrecognised build.
|
|
||||||
|
|
||||||
## 13. First controlled client experiment (Task 8, PROPOSED — not executed here)
|
|
||||||
Goal: prove a patched client sends zero-pack Store entry to Browse Packs with **no**
|
|
||||||
active placeholder.
|
|
||||||
- Build `openfut-hook` with strategy-A `store_hook`, gated behind `openfut.cfg`
|
|
||||||
`store_mypacks_fix=1` (opt-in; default off preserves today's behavior).
|
|
||||||
- Test profile: `unopenedPackIds == []`.
|
|
||||||
- Sequence (each variable changed alone; operator drives FIFA; read-only capture):
|
|
||||||
1. Deploy patched `version.dll`; confirm `openfut_hook.log` shows the store hook
|
|
||||||
installed + build gate PASSED.
|
|
||||||
2. **Backend test mode (LATER, separately authorized — NOT in this task):** switch the
|
|
||||||
backend to *empty-no-sentinel* (the Exp-B config that crashed the UNPATCHED client)
|
|
||||||
so the patched client must handle a genuinely-absent `mypacks` group.
|
|
||||||
3. Operator opens Store. **Predicted (patched + zero packs + no sentinel):** Store
|
|
||||||
opens, defaults to Browse Packs, no `mypacks` resolve, **no crash, no dialog, no
|
|
||||||
fake tile**.
|
|
||||||
4. Set `unopenedPackIds=[70]`; reopen. **Predicted:** My Packs works normally
|
|
||||||
(hook body skipped because count>0).
|
|
||||||
5. Revert backend to the active sentinel.
|
|
||||||
- **Backend change eventually required for this experiment: YES** — a controlled
|
|
||||||
empty-no-sentinel test mode to force the absent group. It is NOT performed in this
|
|
||||||
phase and MUST be separately authorized (same experiment discipline: patch the
|
|
||||||
container copy, capture, revert, restart; never synthesize a client request).
|
|
||||||
- **Client rollback:** flip `store_mypacks_fix=0` (hook not installed) or restore the
|
|
||||||
original `version.dll`; the game reverts to depending on the backend sentinel. No FIFA
|
|
||||||
binaries/movies/config are modified on disk — the hook is in-memory only, so rollback
|
|
||||||
is a file/flag swap.
|
|
||||||
|
|
||||||
## 14. Interaction with the backend 65534 fallback (ESTABLISHED + PROPOSED)
|
|
||||||
- **Keep the backend sentinel deployed** until strategy A is implemented AND verified.
|
|
||||||
It remains the required behavior for unpatched retail clients and for any client whose
|
|
||||||
build gate fails.
|
|
||||||
- Once strategy A is verified, the server MAY, **for patched clients only**, omit the
|
|
||||||
`mypacks` group when empty (the safe representation the client will then handle) —
|
|
||||||
but only behind explicit detection/opt-in; do NOT drop the sentinel globally, since
|
|
||||||
unpatched clients still crash without it.
|
|
||||||
|
|
||||||
## 15. ESTABLISHED / PROPOSED / UNKNOWN summary
|
|
||||||
- **ESTABLISHED:** binary hashes/build; the full native category path and addresses
|
|
||||||
(`FUN_18007d880/18007dab0/18007e7f0/1800147f0/180014420/180014580/180014610`); the
|
|
||||||
instruction-level crash (`0x14882`, `[NULL+0x48]`); tab→ordinal map; that My Packs is
|
|
||||||
not special-cased and funnels through `FUN_1800147f0`; the unopened-count singleton
|
|
||||||
and its `vtbl[0x4d8]/[0x4e0]` accessors, reachable from store code; the
|
|
||||||
`openfut-hook`/`version.dll` vehicle and its hook/patch primitives.
|
|
||||||
- **PROPOSED (not implemented):** the strategy-A entry hook and its redirect logic; the
|
|
||||||
build-guard scheme; the opt-in config flag; the first experiment and its backend
|
|
||||||
test-mode requirement; the per-patched-client server relaxation.
|
|
||||||
- **UNKNOWN:** exactly why the packed Scaleform movie selects My Packs on store open
|
|
||||||
(Denuvo-packed, unread) — not needed for strategy A, which intercepts the native
|
|
||||||
result; the precise rendered appearance of `category==0` list-all in this empty
|
|
||||||
configuration (to be observed in the experiment); whether any non-store path also
|
|
||||||
drives `screen+0x290` to a My-Packs ordinal (none found; `FUN_18007e7f0` case `0x7551`
|
|
||||||
and the ctor are the only writers).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# PART III — Final no-sentinel resolver experiment (2026-08-13) — RESULT F3 (CRASH), CONFOUNDED
|
|
||||||
|
|
||||||
Vehicle change: the resolver guard was implemented as an **`autopatch.py` memory patch**
|
|
||||||
(the live FIFA-17 client-patch mechanism), NOT the `version.dll` proxy — Proton loads its
|
|
||||||
builtin `version.dll`, so the earlier `store_hook`/`version.dll` prototype was inert and
|
|
||||||
has been rolled back. Guard: at CardsDLL `0x180014858`, `JNZ 0x14869` (`75 0f`) →
|
|
||||||
`JG 0x14869` (`7f 0f`), orig-verified; routes category `< 0` (and `== 0`) to the safe
|
|
||||||
list-all/Browse path (`FUN_180014610`), category `> 0` to the existing resolver.
|
|
||||||
`TEST EDI,EDI` at `0x180014856` is the flag source (OF cleared ⇒ `JG` = signed `> 0`).
|
|
||||||
|
|
||||||
## Setup (verified)
|
|
||||||
- CLIENT: guard active/enforced — live bytes `85 ff 7f 0f` at `0x180014856` (FIFA pid 547843,
|
|
||||||
autopatch pid 547621; log `ENFORCED guarded store patch @ … (JNZ->JG)`, orig `75 0f` matched).
|
|
||||||
- BACKEND: sentinel 65534 suppressed by a one-line `if not owned_ids:` → `if False:` in the
|
|
||||||
container copy only (committed source `f42279f` untouched; backup `/tmp/utas_server.EXP_ORIG.py`).
|
|
||||||
Genuine `GET /store/purchasegroup` (02:44:39Z) → ids `[1,5,6,7]`, **no 65534, no mypacks group**,
|
|
||||||
normal packs unchanged (evidence: `docs/evidence/store_purchasegroup_capture_client_guard_no_sentinel_2026-08-13.json`).
|
|
||||||
- PROFILE: `unopenedPackIds=[]`, coins 29,876,776, sha `39bb3e83…` — unchanged throughout.
|
|
||||||
|
|
||||||
## Result — F3 (CRASH)
|
|
||||||
Minidump `CrashDump_2026.08.12_20.44.40.302.dmp` (preserved `/tmp/expF_crash.dmp`, sha `4dcb0cb7…`):
|
|
||||||
`0xC0000005` READ of VA `0x48` at `ExceptionAddress 0x6ffffc224882` → **RE `0x180014882`** —
|
|
||||||
the **identical** resolver crash instruction as Experiment B (`FUN_1800147f0`,
|
|
||||||
`MOV R8,[RDX+0x8]` with the group ptr NULL).
|
|
||||||
|
|
||||||
**Mechanism (decisive):** `0x14882` lives in the *resolve* branch, which the guard's `JG`
|
|
||||||
reaches **only when category `> 0`**. Since the guard was verified in place, the client
|
|
||||||
presented a **positive** My-Packs ordinal that no longer resolves (no mypacks group) →
|
|
||||||
`FUN_180014420` returned NULL → crash. The guard's design assumption — *absent mypacks ⇒
|
|
||||||
category `-1`* — did NOT hold on this path.
|
|
||||||
|
|
||||||
## Confound (uncontrolled variable)
|
|
||||||
FIFA was **not relaunched** after the backend flipped to no-sentinel; the client carried
|
|
||||||
**stale store/tab state** from the sentinel-present safe stage, where the mypacks group
|
|
||||||
existed at a *positive* ordinal `N` (`MYPACK_CATEGORY_ID = N`). Reopening the Store reused
|
|
||||||
that stale positive ordinal rather than the `-1` a **fresh** launch publishes
|
|
||||||
(`FUN_18007df60 → FUN_180014580(store,0) = -1` when absent). So the intended clean A/B (client
|
|
||||||
only ever sees the no-sentinel response) was not achieved — the category that reached the
|
|
||||||
resolver was a stale `>0`, exactly the case the negative-only guard does not divert.
|
|
||||||
|
|
||||||
## Conclusion / strategy status
|
|
||||||
- **The guard as-written does NOT handle a positive, now-invalid My-Packs ordinal** — proven
|
|
||||||
by this crash. Diverting only `category < 0` is insufficient when the client presents a
|
|
||||||
stale/positive ordinal for an absent group.
|
|
||||||
- **Not falsified for the fresh-client case.** Whether a fresh no-sentinel launch presents
|
|
||||||
`-1` (guard diverts → Browse, no crash) or still a positive ordinal is **UNKNOWN** and needs
|
|
||||||
a **clean re-test**: launch FIFA fresh with the backend already in no-sentinel mode so the
|
|
||||||
client never sees a mypacks group. That is the proper equivalent of Experiment B.
|
|
||||||
- **Candidate stronger guard** (design only, not implemented): divert to list-all when the
|
|
||||||
resolved group is NULL for *any* category (guard `FUN_180014420`'s NULL return at the
|
|
||||||
`0x14870`/`0x14882` site), not merely when `category < 0`. This covers the positive-invalid
|
|
||||||
ordinal too, at the cost of being a generic miss-fallback (the higher-risk Rank-2 behavior).
|
|
||||||
Do NOT implement without authorization and a clean re-test first.
|
|
||||||
|
|
||||||
**Strategy A / resolver guard status: NOT PROVEN.** Crash-guard installs and is build-validated
|
|
||||||
and dormant-safe with the sentinel present, but the first no-sentinel test CRASHED at the
|
|
||||||
resolver via a positive stale ordinal (confounded by no relaunch). Backend P2 active-sentinel
|
|
||||||
was restored immediately (mandatory rollback; source `f416e71e…`, sentinel `state=active`),
|
|
||||||
and remains the production safety net. Guard left in `autopatch.py` (dormant) pending the
|
|
||||||
clean re-test decision; `autopatch.py.pre-storeguard.bak` available to remove it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# PART IV — Fresh-process no-sentinel retest (2026-08-13) — RESULT R1 (SUCCESS)
|
|
||||||
|
|
||||||
Corrects PART III's confound. This time the mandatory ordering was enforced: the backend
|
|
||||||
entered no-sentinel mode **while FIFA was closed**, then FIFA launched **fresh** (new pid,
|
|
||||||
new autopatch) so the process never saw a sentinel-present Store response.
|
|
||||||
|
|
||||||
## Setup (verified, clean A/B)
|
|
||||||
- BACKEND set no-sentinel at 02:55:09Z with FIFA down; genuine `GET /store/purchasegroup`
|
|
||||||
(02:58:45Z) served to the fresh client = ids `[1,5,6,7]`, **no 65534, no mypacks group**,
|
|
||||||
packs 1/5/6/7 present. This body is **byte-identical** to the PART III (F3) no-sentinel
|
|
||||||
capture — the ONLY changed variable vs F3 is the client process lifetime.
|
|
||||||
Evidence: `docs/evidence/store_purchasegroup_capture_freshretest_no_sentinel_2026-08-13.json`.
|
|
||||||
- CLIENT: NEW FIFA pid 553220, NEW autopatch pid 552999; guard ENFORCED (orig `75 0f`
|
|
||||||
matched → `85 ff 7f 0f` = `TEST EDI,EDI; JG`). Process never saw a sentinel response
|
|
||||||
(0 purchasegroup responses containing 65534 after the no-sentinel restart).
|
|
||||||
- PROFILE unchanged throughout (`39bb3e83…`, `[]`, coins 29,876,776).
|
|
||||||
|
|
||||||
## Result — R1 (operator-observed)
|
|
||||||
- **No crash** (FIFA 553220 alive after the test; no new minidump), **no dialog**, **Store
|
|
||||||
stays open**, opens on **Browse Packs**, Bronze/Gold/Special packs visible and navigable.
|
|
||||||
- Cosmetic-only imperfections (pre-existing, NOT caused by the guard): the six-tab bar is
|
|
||||||
unbound (no tabs), packs render without cover art, and tiles show "0 items". These match
|
|
||||||
the known store tab-bind / list-all rendering quirks (`plan-2026-08-05-store-subsystem.md`
|
|
||||||
§2.1) and are independent of the resolver guard.
|
|
||||||
|
|
||||||
## Causal conclusion (decisive A/B)
|
|
||||||
```
|
|
||||||
server response (no sentinel, no mypacks group) == byte-identical across F3 and R1
|
|
||||||
client original JNZ + this response -> CRASH 0x180014882 (Experiment B)
|
|
||||||
client JG (stale positive ordinal) -> CRASH 0x180014882 (PART III F3, contaminated)
|
|
||||||
client JG (FRESH, category = -1) -> NO CRASH, Browse Packs (PART IV R1) ✅
|
|
||||||
```
|
|
||||||
A **fresh** client publishes `MYPACK_CATEGORY_ID = FUN_180014580(store,0) = -1` for the absent
|
|
||||||
group; the movie echoes `-1`; `TEST EDI,EDI; JG` does **not** take the resolve branch, so the
|
|
||||||
client runs the list-all/Browse path (`FUN_180014610`) — no `FUN_180014420(NULL)` deref, no
|
|
||||||
crash. **PART III's F3 is confirmed as stale-positive-ordinal contamination** (FIFA not
|
|
||||||
relaunched across the sentinel→no-sentinel flip), not a guard failure.
|
|
||||||
|
|
||||||
## Strategy status
|
|
||||||
**Strategy A / resolver guard: PROVEN ON THE TESTED FIFA 17 BUILD** (CardsDLL
|
|
||||||
`4706a881…`) for the clean process-lifetime case — it safely routes the absent My-Packs
|
|
||||||
category to Browse Packs with no crash and no dialog, needing **no** backend sentinel. Scope
|
|
||||||
caveats: (1) tested build only; (2) the negative-only guard does NOT cover a stale/positive
|
|
||||||
invalid ordinal (PART III) — only arises if the client's Store state predates a sentinel→
|
|
||||||
no-sentinel change within one process, which does not happen on a normal launch; a NULL-return
|
|
||||||
guard at `FUN_180014420` would additionally cover that, deferred/not implemented; (3) UX still
|
|
||||||
has the pre-existing no-tabs/no-art/"0 items" cosmetics.
|
|
||||||
|
|
||||||
Backend P2 active-sentinel was restored immediately after capture (mandatory rollback; source
|
|
||||||
`f416e71e…`, sentinel `state=active`) and **remains production default**. The clean UX is only
|
|
||||||
safe to serve when the server knows the client is patched — see PART II §12 rollout options
|
|
||||||
(recommend B: suppress the sentinel only when client patch-capability is known; keep the
|
|
||||||
sentinel universal by default). Guard retained in `autopatch.py` (dormant with the sentinel).
|
|
||||||
|
|
||||||
## INVARIANT — empty-My-Packs capability MUST be session-stable
|
|
||||||
|
|
||||||
F3 vs R1 establish a hard operational invariant for any deployment (sentinel or client
|
|
||||||
guard): **the server MUST NOT switch a running FIFA client between sentinel-present and
|
|
||||||
sentinel-absent for the My Packs group within a single FIFA process lifetime.**
|
|
||||||
|
|
||||||
Rationale: the client resolves and caches the My-Packs group **ordinal** (positive when a
|
|
||||||
group — real or sentinel — is present; `-1` when absent) from the `purchasegroup` response
|
|
||||||
seen at Store-subsystem init. The resolver guard only reclassifies the ordinal *sign*
|
|
||||||
(`≤0` → Browse). If a client that already cached a **positive** ordinal later receives a
|
|
||||||
no-sentinel topology, the stale positive ordinal still takes the resolve branch and
|
|
||||||
`FUN_180014420` returns NULL → crash at `0x180014882` (exactly F3). A **fresh** process that
|
|
||||||
only ever sees the no-sentinel topology caches `-1` and is routed to Browse safely (R1).
|
|
||||||
|
|
||||||
Practical rules:
|
|
||||||
- Choose the My-Packs representation (sentinel-present vs sentinel-absent) **before** a client
|
|
||||||
starts its session, and hold it for that session.
|
|
||||||
- The future patch-capability handshake (PART II §12) MUST therefore be decided at
|
|
||||||
login/session start, not toggled mid-session.
|
|
||||||
- A NULL-return guard at `FUN_180014420` (deferred) is the only thing that would make a
|
|
||||||
mid-session flip crash-safe; until then, session stability is mandatory.
|
|
||||||
@@ -1,358 +0,0 @@
|
|||||||
# FIFA 17 — verified patched-client capability negotiation
|
|
||||||
|
|
||||||
Goal: let the FIFA 17 backend suppress the synthetic My-Packs sentinel (id 65534)
|
|
||||||
**only when the current FIFA process has positively verified that the CardsDLL
|
|
||||||
resolver guard is active** (JNZ→JG at RVA `0x14858`). Unpatched / unsupported /
|
|
||||||
unknown / failed-patch clients keep receiving the existing P2 active sentinel.
|
|
||||||
|
|
||||||
Core principle: **the capability is not "this launcher supports the patch"; it is
|
|
||||||
"the resolver guard was verified in *this particular FIFA process*."**
|
|
||||||
|
|
||||||
This document is the design + the cross-component contract. It is deliberately
|
|
||||||
additive: the P2 active-sentinel path (`docs/evidence/FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md`)
|
|
||||||
remains the default and the universal fallback.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Architecture inventory (as-built, verified by reading the code)
|
|
||||||
|
|
||||||
Data flow today (launch of one FIFA process):
|
|
||||||
|
|
||||||
```
|
|
||||||
LauncherApp::launch_game (openfut-launcher/src/app.rs:450)
|
|
||||||
-> account_sync::sync POST /openfut/account/sync (:8099) [REQUIRED; launch is gated on it]
|
|
||||||
-> ensure_local_services() spawn LSX, then autopatch.py --launcher-pid <launcher_pid>
|
|
||||||
-> game_launch::launch umu-run FIFA17.exe (grandchild; launcher never learns FIFA PID)
|
|
||||||
FIFA process
|
|
||||||
-> autopatch.py self-discovers FIFA by comm=='FIFA17.exe'; patches /proc/<pid>/mem each tick
|
|
||||||
-> FIFA -> backend POST /ut/auth (login) ; GET /store/purchasegroup ; ... (:8099)
|
|
||||||
```
|
|
||||||
|
|
||||||
Facts that shape the design:
|
|
||||||
|
|
||||||
- **Launcher ↔ autopatch IPC = one-way stdout only.** `local_services::spawn`
|
|
||||||
(openfut-launcher/src/local_services.rs:279-296) pipes autopatch stdout/stderr
|
|
||||||
into the launcher `LogBuffer` line-by-line as `[autopatch] <line>`. There is no
|
|
||||||
socket / named pipe / status-file readback. `--launcher-pid` is the *launcher's*
|
|
||||||
own pid (local_services.rs:66), used for liveness, not to identify FIFA.
|
|
||||||
- **Launcher ↔ backend = exactly one control call:** `account_sync::sync`
|
|
||||||
(openfut-launcher/src/account_sync.rs:43) — a tiny stdlib-HTTP `POST
|
|
||||||
/openfut/account/sync` on `openfut_account_sync_port` (default 8099), sent once
|
|
||||||
per launch, *before* FIFA starts, and **launch is blocked unless it succeeds**
|
|
||||||
(utas_server.py:1204). This is the reliable per-FIFA-process session boundary.
|
|
||||||
- **Backend is single-account, stateless-per-request, threaded.** `SID` is a fixed
|
|
||||||
module constant shared by all clients (utas_server.py:32); account identity is one
|
|
||||||
global `ACCOUNT` singleton. There is **no per-session identity** in requests. The
|
|
||||||
only per-connection discriminator available at every handler is
|
|
||||||
`self.client_address[0]` (peer IP), currently unused. Server is
|
|
||||||
`ThreadingHTTPServer` (utas_server.py:3784); module is import-safe (server under
|
|
||||||
`if __name__ == "__main__"`).
|
|
||||||
- **No bridge/proxy in the FIFA-17 path.** FIFA reaches the Python backend's
|
|
||||||
published `:8099` directly (client-side DNAT/hosts redirect); the openfut-bridge is
|
|
||||||
legacy FIFA-23. Docker's iptables DNAT preserves the source IP for external LAN
|
|
||||||
clients. The launcher and FIFA run on the **same** client machine, so the backend
|
|
||||||
observes them under the **same** peer IP regardless of NAT.
|
|
||||||
|
|
||||||
## 2. Capability transport — options and choice
|
|
||||||
|
|
||||||
Ranked against the as-built architecture:
|
|
||||||
|
|
||||||
**autopatch → launcher (chosen: structured stdout line).**
|
|
||||||
1. **Structured stdout line (CHOSEN).** Reuses the existing one-way pipe the
|
|
||||||
launcher already reads. It is *live* (only the current autopatch child's stdout),
|
|
||||||
inherently child-bound, and carries **zero stale-file risk** — a previous
|
|
||||||
launch's capability cannot leak because nothing is persisted. Smallest possible
|
|
||||||
change. Format is a machine-readable token (§4).
|
|
||||||
2. Status file in `$XDG_RUNTIME_DIR` keyed by launcher-pid+FIFA-pid+version+timestamp
|
|
||||||
— works but needs explicit staleness handling and cleanup; more moving parts.
|
|
||||||
3. Unix-domain socket — most capable but overkill; there is no bidirectional need.
|
|
||||||
|
|
||||||
**launcher → backend (chosen: sibling HTTP endpoint on the account-sync port).**
|
|
||||||
- A. **Existing session-init channel (CHOSEN).** Add `POST /openfut/fifa17/capability`
|
|
||||||
next to the existing `/openfut/account/sync` (same port 8099, same tiny stdlib-HTTP
|
|
||||||
client). It cannot ride *inside* account_sync because the capability is only known
|
|
||||||
*after* autopatch verifies (which happens after account_sync + FIFA start), so it is
|
|
||||||
a separate, later call — but on the same proven transport.
|
|
||||||
- B. Blaze/login metadata — rejected: no OpenFUT-owned field is available without
|
|
||||||
risking a field FIFA depends on, and Blaze runs in a separate responder.
|
|
||||||
- C. New local IPC + backend side-channel — unnecessary; A already exists.
|
|
||||||
- D. Server-wide "assume patched" config — dev/testing fallback only; cannot
|
|
||||||
distinguish patched vs unpatched clients, so never the production mechanism.
|
|
||||||
|
|
||||||
## 3. The capability (name + version + VERIFIED semantics)
|
|
||||||
|
|
||||||
- Name: **`fifa17.empty_mypacks_resolver`**, integer version, current **`1`**.
|
|
||||||
- **VERIFIED (v1) means, for THIS FIFA process:** the CardsDLL tested build was
|
|
||||||
recognised AND the live bytes at RVA `0x14858` are `7f 0f` (`JG`) **after
|
|
||||||
autopatch enforcement** — i.e. `guarded_action` returned `"patch"` (was `75 0f`,
|
|
||||||
written, re-read as `7f 0f`) **or** `"noop"` (already `7f 0f`).
|
|
||||||
- It explicitly does **NOT** mean any of: "autopatch.py contains the guard code",
|
|
||||||
"the launcher build is new enough", or "a config flag is set". The signal
|
|
||||||
represents **observed runtime enforcement on the specific process**, nothing less.
|
|
||||||
|
|
||||||
## 4. autopatch verification state + emitted line
|
|
||||||
|
|
||||||
Per-FIFA-pid guard status (fail-closed; never loosens the existing byte guard):
|
|
||||||
|
|
||||||
| state | meaning |
|
|
||||||
|---|---|
|
|
||||||
| `NOT_ATTEMPTED` | CardsDLL not yet mapped / guard not evaluated for this pid |
|
|
||||||
| `VERIFIED` | live bytes == `7f 0f` after enforcement (from `patch` or `noop`) |
|
|
||||||
| `UNSUPPORTED_BUILD` | live bytes are neither the known original nor patched (`guarded_action` → `skip`) |
|
|
||||||
| `WRITE_FAILED` | `/proc/<pid>/mem` write raised |
|
|
||||||
| `VERIFY_FAILED` | post-write re-read != `7f 0f` |
|
|
||||||
|
|
||||||
Only `VERIFIED` advertises capability. On transition to `VERIFIED`, autopatch emits
|
|
||||||
**once per FIFA pid** on stdout:
|
|
||||||
|
|
||||||
```
|
|
||||||
[store-guard] verified capability fifa17.empty_mypacks_resolver=1 fifa_pid=<pid>
|
|
||||||
```
|
|
||||||
|
|
||||||
Any non-verified terminal state emits an explicit, non-advertising status line, e.g.:
|
|
||||||
|
|
||||||
```
|
|
||||||
[store-guard] guard status=UNSUPPORTED_BUILD fifa_pid=<pid> (no capability advertised)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. Launcher per-process capability state
|
|
||||||
|
|
||||||
```rust
|
|
||||||
pub struct Fifa17ClientCapabilities { pub empty_mypacks_resolver: Option<u32> }
|
|
||||||
```
|
|
||||||
|
|
||||||
- Starts **UNKNOWN** (`None`) at each launch.
|
|
||||||
- Becomes `Some(1)` when the launcher parses a valid capability line from the
|
|
||||||
**current** autopatch child's stdout (`parse_capability_line`).
|
|
||||||
- **Discarded** when autopatch stops / FIFA exits / launcher exits / next launch. It
|
|
||||||
is never persisted and never reused for a later FIFA process — staleness is
|
|
||||||
structurally impossible.
|
|
||||||
|
|
||||||
On first `Some(v)`, the launcher registers the capability with the backend (§6) once.
|
|
||||||
|
|
||||||
## 6. Launcher → backend registration + binding
|
|
||||||
|
|
||||||
`POST /openfut/fifa17/capability` (port = `openfut_account_sync_port`, 8099), body:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"capability":"empty_mypacks_resolver","version":1,"personaId":<id>,"fifaPid":<pid>}
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Binding key = source IP** (`self.client_address[0]`). The registration arrives
|
|
||||||
from the client machine's IP; FIFA's `/store/purchasegroup` requests arrive from
|
|
||||||
the **same** IP (same machine). `personaId`/`fifaPid` are for logging only (the
|
|
||||||
backend is single-account, so persona cannot discriminate clients).
|
|
||||||
- Concurrency: distinct client machines → distinct peer IPs → independent decisions
|
|
||||||
(no global state). Two FIFA processes on **one** machine share an IP — an accepted
|
|
||||||
limitation (the backend is single-account anyway); documented in §Trust.
|
|
||||||
|
|
||||||
## 7. Backend session-stable decision
|
|
||||||
|
|
||||||
Per-IP record (guarded by a lock; threaded server):
|
|
||||||
|
|
||||||
```
|
|
||||||
_FIFA17_STORE[ip] = {"resolver": Option[int], "mode": Option[str]} # mode: None|"sentinel"|"clean-v1"
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Reset (session boundary):** `/openfut/account/sync` from `ip` sets
|
|
||||||
`{resolver: None, mode: None}`. This is the launcher's required per-launch call, so
|
|
||||||
every new FIFA process starts from a clean, unfrozen record — no cross-process leak.
|
|
||||||
- **Register:** `/openfut/fifa17/capability` from `ip` sets `resolver = version`. If
|
|
||||||
`mode` is already frozen, it is logged as late and **ignored for this session**.
|
|
||||||
- **Freeze point = first `/store/purchasegroup`** from `ip` (§9): if `mode is None`,
|
|
||||||
set `mode = "clean-v1"` iff `resolver == 1` else `"sentinel"`, and log once.
|
|
||||||
Thereafter `mode` is immutable for the session.
|
|
||||||
- **Default / fail-closed:** an IP with no record (no account-sync, no capability),
|
|
||||||
an unknown resolver version, a late capability, or a disappeared capability all
|
|
||||||
resolve to (or remain) `"sentinel"`.
|
|
||||||
|
|
||||||
## 8. Freeze point rationale
|
|
||||||
|
|
||||||
Freeze at **first `/store/purchasegroup`**, not at login/account-sync. account-sync
|
|
||||||
fires *before* FIFA starts and *before* autopatch can verify, so freezing there would
|
|
||||||
always be `sentinel`. First Store request is the earliest moment at which a genuine
|
|
||||||
capability can already be registered (autopatch verifies at process start; the user
|
|
||||||
opens the Store later), while still being a single, well-defined topology commit for
|
|
||||||
the session. Once Store topology is served, it must not change (the F3 experiment
|
|
||||||
proved a mid-session flip can leave a stale positive ordinal that crashes even the
|
|
||||||
sign-only guard — see `FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md` PART III/IV and the
|
|
||||||
SESSION-STABLE invariant).
|
|
||||||
|
|
||||||
## 9. Store behaviour (additive switch)
|
|
||||||
|
|
||||||
At `store_catalog`, only the zero-owned-packs branch changes:
|
|
||||||
|
|
||||||
```
|
|
||||||
if not owned_ids:
|
|
||||||
if fifa17_empty_mypacks_mode(client_ip) == "clean-v1":
|
|
||||||
pass # patched client: emit NO mypacks group; guard routes -1 to Browse
|
|
||||||
else:
|
|
||||||
<append active 65534 sentinel exactly as today> # P2 fallback (unchanged)
|
|
||||||
```
|
|
||||||
|
|
||||||
Untouched: real owned-pack rendering, `PACK_CATALOG`, pack 70, normal packs 1/5/6/7,
|
|
||||||
profile state, all store env flags. Default remains sentinel. This lives in the FIFA-17
|
|
||||||
Python backend only — **never** in game-independent OpenFUT Core.
|
|
||||||
|
|
||||||
| client | zero packs | real unopened pack |
|
|
||||||
|---|---|---|
|
|
||||||
| verified v1 | **no sentinel** (clean) | genuine My Packs, no sentinel |
|
|
||||||
| no / unknown capability | **active 65534 sentinel** | genuine My Packs, no sentinel |
|
|
||||||
|
|
||||||
## 10. Trust model (Task 14)
|
|
||||||
|
|
||||||
This is **not** anti-cheat / attestation. OpenFUT assumes the user controls the
|
|
||||||
launcher/client machine and the server is a private preservation environment. The
|
|
||||||
verification exists to prevent *accidents*: a stale capability, an unsupported
|
|
||||||
CardsDLL build, a failed autopatch, the wrong process, or an unpatched client
|
|
||||||
receiving no sentinel and crashing. No signatures / PKI / remote attestation.
|
|
||||||
|
|
||||||
Isolation across *distinct client machines* relies on the backend observing distinct
|
|
||||||
peer IPs (source-IP-preserving publish; Docker's default for external LAN via iptables
|
|
||||||
DNAT). Two FIFA processes on one machine cannot be distinguished by IP — accepted,
|
|
||||||
since the backend is single-account. The single-client production case is unaffected
|
|
||||||
by NAT because launcher and FIFA share one IP.
|
|
||||||
|
|
||||||
## 11. Fail-closed matrix (Task 15) — every failure ⇒ sentinel
|
|
||||||
|
|
||||||
autopatch missing / not run · guard `UNSUPPORTED_BUILD` / `WRITE_FAILED` /
|
|
||||||
`VERIFY_FAILED` · launcher cannot parse the line · registration POST fails ·
|
|
||||||
account-sync never called · unknown capability version · capability arrives after
|
|
||||||
freeze · capability disappears after a sentinel freeze — **all resolve to the active
|
|
||||||
65534 sentinel.** Asserted by tests (matrix 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.
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
# OpenFUT Status Review
|
|
||||||
*Generated 2026-06-30 — read-only stocktake, no code changed.*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Executive Summary
|
|
||||||
|
|
||||||
OpenFUT has a mature offline FUT economy backend (Core, 25 phases, fully functional in
|
|
||||||
isolation) and a sophisticated hook DLL that loads into FIFA 23, redirects EA hostnames
|
|
||||||
to loopback, and bypasses TLS certificate verification. The Blaze/ProtoSSL layer is
|
|
||||||
structurally ready: framing code exists, a TLS listener runs, cert-verify is patched.
|
|
||||||
However the project is currently blocked before any Blaze traffic is ever seen.
|
|
||||||
The fundamental problem is that FIFA 23 submits `GoOnline` to EbisuSDK and then
|
|
||||||
**waits for an asynchronous ONLINE_STATUS_EVENT push** from the EA-app LSX server —
|
|
||||||
a push that current code never sends. Every approach tried so far (flipping poll
|
|
||||||
return values, forcing the state flags, read-only probes) confirms the gate is
|
|
||||||
event-driven, not poll-driven. The Blaze captures directory contains six empty files.
|
|
||||||
No Fire2 frame from FIFA 23 has ever been decoded. Until the ONLINE_STATUS_EVENT push
|
|
||||||
is synthesized and delivered correctly, Milestones 2–7 are all waiting on the same
|
|
||||||
single wall.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Proven vs Assumed
|
|
||||||
|
|
||||||
| Claim | Status | Evidence |
|
|
||||||
|---|---|---|
|
|
||||||
| FIFA 23 uses DirtySDK / ProtoSSL | **Proven** | String scan hit `ProtoSSLSend`, `ProtoSSLRecv`, `gosredirector` in FIFA23.exe memory (Task 1) |
|
|
||||||
| `version.dll` loads and runs hook code | **Proven** | `hook.log` written at DLL_PROCESS_ATTACH |
|
|
||||||
| `getaddrinfo` IAT hook redirects EA domains to loopback | **Proven** | Hook log records every EA `getaddrinfo` call; connect_hook log confirms port redirects |
|
|
||||||
| ProtoSSL cert-verify prologue found and patched (FIFA23.exe) | **Proven** | ssl_patch.rs prologue confirmed at file offset 0xf0c850; hook log "ssl: main exe cert-verify patched" |
|
|
||||||
| ProtoSSL cert-verify patched in EAWebKit.dll | **Proven** (if loaded) | Lazy patch fires on first EA getaddrinfo call; hook log message confirms |
|
|
||||||
| Gate is upstream of DirtySDK — no DNS/connect fires on FUT entry | **Proven** | getaddrinfo, connect, WSASend/Recv hooks all show zero external traffic during "connecting to EA Servers" |
|
|
||||||
| `GoOnline` is called by the game | **Proven** | Read-only detour on `anadius64.dll+0x2BB90` confirmed hit |
|
|
||||||
| anadius returns GoOnline success | **Proven** | Handler observed returning successfully; game still retries every ~7 s |
|
|
||||||
| Gate is downstream of GoOnline | **Proven** | GoOnline called + returns success; no Blaze connect follows |
|
|
||||||
| Connection-state function: `GetInternetConnectedState @ anadius64.dll+0x27790` | **Proven** | Located via anadius LSX command-registration table; two-flag branch decoded (`+0xCAB1A`, `+0xCAB1B`) |
|
|
||||||
| Gate is event-driven (game waits for async push, not a poll return) | **Proven** | Forced both state flags AND GoOnline return to "1"; game kept retrying; worker-thread stack scan confirms handler runs on anadius IOCP thread, not FIFA's thread |
|
|
||||||
| GoOnline runs on anadius worker thread, not FIFA's call thread | **Proven** | Stack scan from inside detour found zero FIFA23.exe frames, sp ~2.4 KB from thread stack top |
|
|
||||||
| `protossl-scan` live toolkit is exhausted for finding GoOnline in FIFA23.exe | **Proven** | No `"GoOnline"` string in image; worker-thread call stack has no FIFA frames; jmpscan yields ~3875 hits (overwhelmingly data false positives) |
|
|
||||||
| FIFA 23 redirector config references `Authorization:` header (Nucleus token) | **Proven** | Found in FIFA23.exe .rdata pointer table @ `+0x83FC858` |
|
|
||||||
| openfut-core REST API complete and tested | **Proven** | 25 phases, 15 migrations, passing integration tests |
|
|
||||||
| Bridge LSX server starts and handles request-response | **Proven** (code) | `openfut-bridge/src/lsx.rs` + `main.rs` — server starts on 127.0.0.1:3216 |
|
|
||||||
| Bridge LSX server ACTUALLY receives FIFA's LSX connections | **UNCONFIRMED** | anadius may intercept the same calls in-process before the TCP connection reaches the bridge |
|
|
||||||
| Bridge LSX server `GetInternetConnectedState → connected="1"` unblocks the gate | **UNCONFIRMED (known to fail in-process)** | Flipping the value via anadius in-process failed; bridge path not yet confirmed working |
|
|
||||||
| ONLINE_STATUS_EVENT push XML format | **UNKNOWN** | No capture; format not derived |
|
|
||||||
| Fire2 framing is correct for FIFA 23 | **UNCONFIRMED** | Implemented based on post-2012 EA convention; all blaze captures are empty (0 bytes) |
|
|
||||||
| Blaze component / command IDs for FIFA 23 | **UNKNOWN** | Zero captures; dispatch table entirely empty placeholders |
|
|
||||||
| ProtoSSL recv-injection convention (non-blocking return values etc.) | **UNCONFIRMED** | Never reached M4; recv_hook module removed from active install path |
|
|
||||||
| FUT REST endpoint paths in mapper.rs | **SPECULATIVE** | Based on community knowledge of older FIFA titles; the one actual capture in `captures/` is an early GET from before the Blaze strategy |
|
|
||||||
| FLE Lua API exposes FUT DB tables in memory | **UNKNOWN** | `export_squad.lua` has never been run; FUT data may only exist server-side in online mode |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Milestone Status
|
|
||||||
|
|
||||||
| Milestone | Status | Blocker | Depends on unconfirmed assumption? |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **M1** — Locate connection-state decision point | ✅ Done | — | No |
|
|
||||||
| **M2** — Flip gate, force "connected" | ⛔ Blocked | Game waits for async ONLINE_STATUS_EVENT push; no current code sends it | Yes — unknown event XML format |
|
|
||||||
| **M3** — First ProtoSSL plaintext on Blaze connection | 🔲 Not started | Depends on M2 | Yes — Fire2 framing unconfirmed |
|
|
||||||
| **M4** — Answer redirector + decode first Fire2 frame | 🔲 Not started | Hard wall: Fire2 framing, recv-injection convention, component/command IDs all unconfirmed | Yes — all three unknown |
|
|
||||||
| **M5** — Blaze preauth / login / postauth | 🔲 Not started | Depends on M4 | Yes — Blaze auth TDF body layout unknown |
|
|
||||||
| **M6** — FUT entry + hub load | 🔲 Not started | Depends on M5; also requires FUT REST response shapes confirmed | Yes — endpoint paths speculative |
|
|
||||||
| **M7** — Squad Battles (AI FUT) | 🔲 Not started | Depends on M6 | Yes |
|
|
||||||
|
|
||||||
**Note on roadmap.md wording:** Under M2–M4, roadmap.md uses `**Done (observable):**` bullets. These describe the *success criterion* for each milestone, not an achieved state. The authoritative status is in `connection-gate-findings.md` (M2 attempts failed; M3/M4 never started). The roadmap has not been updated to reflect M2 failure.
|
|
||||||
|
|
||||||
### M4 is the first hard wall in detail
|
|
||||||
|
|
||||||
Even assuming M2 is solved, M4 requires three unconfirmed things simultaneously:
|
|
||||||
1. **Fire2 framing** — the 12-byte header layout is assumed; if FIFA 23 uses an older Fire variant or a custom delta, the codec will misparse every packet.
|
|
||||||
2. **ProtoSSL recv-injection** — delivering responses to the game via recv hook requires knowing what return values and buffer conventions ProtoSSL expects; recv_hook.rs exists but is not installed.
|
|
||||||
3. **Blaze component/command IDs** — the dispatch table is entirely empty; we cannot answer any request until IDs are known from captures.
|
|
||||||
|
|
||||||
All three are resolved by getting one real captured frame. M4 is primarily a capture problem, not a decoding problem — once bytes exist, the framing and IDs are immediately readable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Blockers, Risks, Unknowns
|
|
||||||
|
|
||||||
### Blockers (stop progress now)
|
|
||||||
|
|
||||||
1. **ONLINE_STATUS_EVENT push not synthesized** *(M2 wall)*
|
|
||||||
The game calls GoOnline, gets success, then waits indefinitely for a push event on the LSX socket that never arrives. This is the single gate blocking all Blaze work. Options: (a) trace the event format via Ghidra on FIFA23.exe (xref `ONLINE_STATUS_EVENT` string + the game's EbisuSDK listener), (b) RE anadius's LSX event-send path (find what it would push in an "online" scenario), (c) brute-force push candidate event XMLs and observe whether the game advances.
|
|
||||||
|
|
||||||
2. **Bridge LSX server delivery unconfirmed** *(architectural risk converted to blocker)*
|
|
||||||
The hook passes port 3216 connections through, assuming the bridge LSX server on the Linux host receives them. If anadius's in-process hooks intercept the winsock calls before they reach the TCP stack, the bridge server is never reached. This must be confirmed by checking `openfut_hook.log` for a getaddrinfo on the LSX host, or by observing the bridge server's accept logs.
|
|
||||||
|
|
||||||
### Risks (could derail later)
|
|
||||||
|
|
||||||
3. **Fire2 framing wrong** *(M4 risk)*
|
|
||||||
If FIFA 23 uses Fire (pre-2012) or a modified frame layout, the codec misparses. Mitigation: the server has a `Raw` fallback mode for capturing raw bytes when framing fails.
|
|
||||||
|
|
||||||
4. **Secondary auth-token gate** *(M5 risk)*
|
|
||||||
`connection-gate-findings.md` noted the redirector request carries an `Authorization:` header. M1's final conclusion said `GetAuthCode` returns a fake token that appears accepted — but this was inferred, not confirmed by seeing the redirector request actually constructed with that token.
|
|
||||||
|
|
||||||
5. **EAAC not fully neutralized** *(persistent risk)*
|
|
||||||
`FakeEAACLauncher` bypasses the anticheat launcher. The hook DLL is unsigned. If EAAC is ever active (e.g., after a game update re-enables it), all hooks fail silently. Marked as "not active in offline/cracked builds" — assumed, not confirmed on every launch.
|
|
||||||
|
|
||||||
6. **FUT REST response shapes wrong** *(M6 risk)*
|
|
||||||
The 61 endpoint mappings in mapper.rs and the shaper stubs in shaper.rs are based on community guesses about older FIFA FUT APIs, not FIFA 23 captures. Response JSON shapes may differ enough to cause the client to fail silently or crash.
|
|
||||||
|
|
||||||
### Unknowns (open questions)
|
|
||||||
|
|
||||||
7. **ONLINE_STATUS_EVENT XML format** — exact tag names, field order, sender attribute, and any nonces/tokens required.
|
|
||||||
8. **GoOnline event sequence** — whether ONLINE_STATUS_EVENT alone is sufficient or a sequence of events (e.g., PROFILE_EVENT, LOGIN_EVENT, COMMERCE_EVENT) is expected.
|
|
||||||
9. **Whether FLE exposes FUT DB tables** — FUT card inventory and squad data likely live server-side in online mode; FLE may not surface them for in-process editing.
|
|
||||||
10. **Blaze component/command IDs for FIFA 23** — entirely unknown; no captures.
|
|
||||||
11. **openfut_hook.log current content** — we have the code but no log output in any document. Whether the current hook (with connect, ssl_patch, tls_bypass, WSAIoctl, origin_spy all installed) fires correctly and what it observes is unverified in this review.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Track Comparison
|
|
||||||
|
|
||||||
### Track A — Full EA-backend fake (M1–M7, playable FUT vs AI)
|
|
||||||
|
|
||||||
**What it delivers:** The FIFA 23 FUT hub loads from OpenFUT Core; Squad Battles matches play and reward economy items.
|
|
||||||
|
|
||||||
**Effort:** Research-grade. Minimum path: synthesize ONLINE_STATUS_EVENT (unknown format, 1–2 weeks of RE), then capture Fire2 frames (days once M2 is solved), then implement Blaze auth handlers (weeks), then implement FUT entry (weeks), then Squad Battles (weeks). Realistic minimum: 3–6 months of expert RE work.
|
|
||||||
|
|
||||||
**Proven support:** Hook loads and redirects correctly. TLS bypass patched. Core economy backend complete. Blaze framing code and TLS listener exist.
|
|
||||||
|
|
||||||
**Assumed:** Fire2 framing correct; component/command IDs discoverable from captures; FUT REST shapes close enough to community guesses; no additional undiscovered gates.
|
|
||||||
|
|
||||||
**Evidence for:** Architecture is coherent. The M1 finding (gate precisely named and decoded) was achieved cleanly. The in-process hook approach is validated.
|
|
||||||
|
|
||||||
**Evidence against:** M2 was attempted and failed with the in-process approach. The event-driven architecture adds a full EbisuSDK emulation layer before even one Blaze byte is seen. The live toolkit is exhausted (Path A verdict); Ghidra-level work on a 505 MB binary is required. Six capture files with zero bytes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Track B — Clean-room spec deliverable (M1–M5 documented)
|
|
||||||
|
|
||||||
**What it delivers:** A documented map of the connection gate, LSX event sequence, Blaze auth surface (transport, framing, gate conditions, component IDs, TDF schemas). Valuable as an archival/community artifact even if Track A stalls.
|
|
||||||
|
|
||||||
**Effort:** Medium. M1 is done. M2–M5 documentation emerges as a by-product of engineering work. The spec itself (writing) is lightweight; the engineering to produce the captures is the cost.
|
|
||||||
|
|
||||||
**Proven support:** M1 complete and documented. connection-gate-findings.md is already a high-quality spec artifact.
|
|
||||||
|
|
||||||
**Assumed:** Same as Track A for the unconfirmed values, but the spec can mark them `TODO/CONFIRM` rather than needing to implement them.
|
|
||||||
|
|
||||||
**Evidence for:** The clean-room constraint means a spec is the only artifact that can be safely published. connection-gate-findings.md shows this approach produces real value. B finishes even if A is never fully playable.
|
|
||||||
|
|
||||||
**Evidence against:** Track B alone doesn't produce a playable FUT; it is a foundation, not an end-user product.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Track C — FLE Lua bridge (local-match path, skip the backend gate)
|
|
||||||
|
|
||||||
**What it delivers:** FIFA 23 career mode or Kick-Off with an OpenFUT club's players and squad loaded via FLE's in-memory DB API. No online gate, no Blaze, no TLS. Fully offline from day one.
|
|
||||||
|
|
||||||
**Effort:** Low-to-medium. FLE is already loaded in the normal launch path. Tools exist (`tools/squad-exporter/`, `tools/profile-exporter/`). Primary unknown is whether FUT-relevant DB tables are accessible.
|
|
||||||
|
|
||||||
**Proven support:** FLE Lua API exposes `GetDBTableRows` / `EditDBTableField` for career mode. `fifa23-startup-flow.md` confirms FLE injects at load. `fut-integration-options.md` documents the integration path in detail and rates this as the recommended option.
|
|
||||||
|
|
||||||
**Assumed:** FUT card/club/squad data has in-memory DB table representations that FLE can write. If FUT data is purely server-side (loaded from EA servers, not from the Frostbite DB layer), Track C produces no FUT simulation at all — only career mode player stats.
|
|
||||||
|
|
||||||
**Evidence for:** Career mode already works with FLE edits (community precedent). Tools are present and designed for this path. No infrastructure work needed.
|
|
||||||
|
|
||||||
**Evidence against:** FUT in FIFA 23 uses server-side data. The cards in a player's FUT club, the coins, the squad — these are fetched from `fut.ea.com` REST APIs, not from the Frostbite embedded DB. FLE's `GetDBTableRows` likely exposes base player stats tables but not FUT item tables. The crucial test (run `export_squad.lua` while in FUT mode) has never been done.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Recommendation
|
|
||||||
|
|
||||||
**Start Track C immediately as a parallel, low-cost validation.**
|
|
||||||
|
|
||||||
Run `export_squad.lua` in FLE while inside the FUT hub (or attempting to enter it). If FUT tables appear in the export, Track C is viable and is the fastest path to something a user can interact with. This test takes one session and costs nothing.
|
|
||||||
|
|
||||||
Simultaneously, **continue Track A/B with the next concrete RE step:** synthesize the ONLINE_STATUS_EVENT push. The most actionable option is to run `origin_spy` logs from the current hook to see what LSX events fire during a session, then attempt to push candidate event XMLs via the bridge LSX server and watch whether the game advances. This is bounded, testable work that either unblocks M2 or produces the spec value for Track B.
|
|
||||||
|
|
||||||
**Do not abandon Track A/B for Track C** — they are complementary. Core is already built; the bridge is mostly built. The gap is purely the RE wall at M2.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Architecture and Provenance Sanity-Check
|
|
||||||
|
|
||||||
### Hook + Brain coherence
|
|
||||||
|
|
||||||
The CLAUDE.md bridge architecture diagram (hook intercepts ProtoSSL → plain localhost TCP → blaze_brain → Core) remains coherent. The M1/M2 findings revealed one additional layer (EbisuSDK LSX event) that must precede the Blaze connection. The bridge has been updated to handle LSX directly. The overall design is sound; the M2 blocker is an implementation gap (event synthesis), not an architectural flaw.
|
|
||||||
|
|
||||||
**One inconsistency to flag:** The hook's `lsx.rs` contains a complete in-process LSX emulator (AES-128-ECB, CRandom, all response builders), but the recv/send hooks that activate it are explicitly removed (`lib.rs`: "recv/send hooks removed — LSX is now handled by the native openfut-bridge LSX server"). This is dead code. The bridge's LSX server is the current path. The in-process lsx.rs should either be deleted or documented as a fallback; its presence is confusing.
|
|
||||||
|
|
||||||
### Clean-room status
|
|
||||||
|
|
||||||
No evidence of EA leaked source anywhere in the tree. All RE work is derived from:
|
|
||||||
- Running the shipping binary and observing behavior (function return values, network traffic patterns)
|
|
||||||
- Memory scanning of the live process (string search, xref, disasm of observed addresses)
|
|
||||||
- Reading anadius's own compiled output (its exported symbols, its LSX XML format — which is anadius's own implementation, not EA's)
|
|
||||||
- Community FUT API knowledge (mapper.rs endpoint paths — plausible but speculative)
|
|
||||||
|
|
||||||
The Blaze framing in `fifa-blaze/crates/blaze-proto/src/frame.rs` cites "Fire2 used by ME3, BF3, and most post-2012 titles" — this is sourced from public community documentation of those older titles, not from any leaked EA source. **Clean-room intact.**
|
|
||||||
|
|
||||||
The `AES_KEY` in the hook's lsx.rs (`[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]`) is a placeholder key used for the LSX session encryption. The real session key is derived from the challenge seed via CRandom — this algorithm was RE'd from anadius's own binary. No EA source required.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. If You Read Only This
|
|
||||||
|
|
||||||
- **The project is blocked at M2.** FIFA 23 submits `GoOnline`, gets success, then waits for an async `ONLINE_STATUS_EVENT` push on the LSX socket that no current code ever sends. All six Blaze capture files are empty (0 bytes). No Fire2 frame has ever been decoded.
|
|
||||||
|
|
||||||
- **M1 is the only completed milestone.** The gate function (`GetInternetConnectedState @ anadius64.dll+0x27790`) is precisely named and its two-flag branch decoded. Everything after M1 is either blocked or not started.
|
|
||||||
|
|
||||||
- **The next concrete action** is synthesizing the ONLINE_STATUS_EVENT push XML and testing whether the bridge's LSX server can deliver it to the game. This is the single thing that unblocks all Blaze work.
|
|
||||||
|
|
||||||
- **Track C (FLE Lua) is untested but cheap to validate.** Run `export_squad.lua` while in FUT to find out if FUT DB tables are accessible. If yes, it is the fastest path to user-visible results. If no, it is ruled out with one session.
|
|
||||||
|
|
||||||
- **openfut-core is complete and ready** — 25 phases, 15 migrations, full economy REST API, passing tests. It is not blocking anything; it is waiting for the bridge to connect to it.
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
# Track C — FUT DB table viability test
|
|
||||||
|
|
||||||
**Status: PENDING — test has not yet been run.**
|
|
||||||
|
|
||||||
## What this test settles
|
|
||||||
|
|
||||||
Track C ("FLE Lua bridge") would inject OpenFUT club data directly into FIFA 23's
|
|
||||||
in-memory Frostbite DB tables at runtime, bypassing the entire backend/Blaze stack.
|
|
||||||
It is only viable for FUT (not just career mode) if FUT-specific tables — card
|
|
||||||
inventory, squad composition with FUT fields, coins — are accessible in memory when
|
|
||||||
the game is in the FUT area.
|
|
||||||
|
|
||||||
FUT data in online mode is fetched server-side from `fut.ea.com`. It is not known
|
|
||||||
whether FIFA 23 mirrors any of this into the Frostbite in-memory DB that FLE
|
|
||||||
can read/write. This test settles that question directly.
|
|
||||||
|
|
||||||
## Test procedure
|
|
||||||
|
|
||||||
**Prerequisites:**
|
|
||||||
- FIFA 23 launched normally via umu-run/Steam
|
|
||||||
- FLE (FIFA Live Editor) injected and active (normal launch path)
|
|
||||||
- EAAC in offline/neutralized state
|
|
||||||
- Game navigated as deep into FUT as possible (FUT hub if reachable; otherwise the
|
|
||||||
furthest FUT screen before the gate blocks it)
|
|
||||||
|
|
||||||
**Run the exporter:**
|
|
||||||
1. In FLE's Lua Engine, open and run `tools/squad-exporter/export_squad.lua`
|
|
||||||
(full path on the Windows side: `C:\<game>\openfut_squad_export.json`)
|
|
||||||
2. Wait for the MessageBox "Done! N players, M teams." or "ERROR writing..."
|
|
||||||
3. Retrieve the output file from the Wine prefix:
|
|
||||||
`~/Games/umu/fifa23-tools/drive_c/FIFA 23 Live Editor/openfut_squad_export.json`
|
|
||||||
(or wherever `C:\FIFA 23 Live Editor\` maps in the active prefix)
|
|
||||||
|
|
||||||
**What to inspect in the output:**
|
|
||||||
- `all_db_tables` array — the complete list of table names visible to FLE right now
|
|
||||||
- `fut_tables` object — any table whose name contains `fut`, `club`, `pack`, `item`, or
|
|
||||||
`market` (the script auto-extracts these)
|
|
||||||
- `is_career_mode` — confirms whether FUT or career mode was active
|
|
||||||
|
|
||||||
## Classification criteria
|
|
||||||
|
|
||||||
### "FUT tables present"
|
|
||||||
|
|
||||||
`fut_tables` is non-empty AND contains FUT-specific fields beyond base player stats:
|
|
||||||
- e.g., `fut_items` with card-type / rating / chemistry fields
|
|
||||||
- e.g., a squad table with FUT formation / chemistry / loan-flag fields
|
|
||||||
- e.g., a coins or points balance field
|
|
||||||
|
|
||||||
**Verdict:** Track C is viable for FUT. Fastest path to user-visible results.
|
|
||||||
|
|
||||||
### "only base player tables"
|
|
||||||
|
|
||||||
`fut_tables` is empty (no `fut_*` / `club_*` / `item_*` / `market_*` table names found
|
|
||||||
in `all_db_tables`), OR those tables exist but contain only base player attributes
|
|
||||||
(OVR, potential, position, pace, …) — the same fields visible in career mode.
|
|
||||||
|
|
||||||
**Verdict:** Track C cannot produce FUT. It could at most provide a custom Kick-Off or
|
|
||||||
career-mode match with players sourced from OpenFUT Core. FUT items and coins exist
|
|
||||||
only on EA's servers (not in the in-memory DB in offline mode).
|
|
||||||
|
|
||||||
### "FUT area unreachable to test"
|
|
||||||
|
|
||||||
The connection gate blocked entering FUT deeply enough for FUT tables to be populated.
|
|
||||||
Record which tables were visible and at what screen the test was run.
|
|
||||||
|
|
||||||
**Verdict:** Retest after M2 is unblocked, OR test with `TLS_ENABLED=false` bridge
|
|
||||||
handling the entry check stub.
|
|
||||||
|
|
||||||
## Results
|
|
||||||
|
|
||||||
*(To be filled in after the test is run.)*
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
|---|---|
|
|
||||||
| Date run | — |
|
|
||||||
| FIFA screen at test time | — |
|
|
||||||
| `is_career_mode` | — |
|
|
||||||
| Total tables in `all_db_tables` | — |
|
|
||||||
| FUT-specific table names found | — |
|
|
||||||
| Key FUT fields present | — |
|
|
||||||
| **Classification** | **PENDING** |
|
|
||||||
|
|
||||||
## Honest prior
|
|
||||||
|
|
||||||
`fut-integration-options.md` rates this as the recommended path and lists `fut_clubs`,
|
|
||||||
`fut_items`, `fut_squads` as "expected" tables. However those expectations are based on
|
|
||||||
analogy with career mode (which does store club/squad in the DB). FUT's data model is
|
|
||||||
architecturally different — it is account-bound server-side. The expectation may be
|
|
||||||
wrong. This test is the oracle.
|
|
||||||
|
|
||||||
The `export_squad.lua` script checks `GetDBTablesNames()` exhaustively (not just
|
|
||||||
assumed names), so it will surface any FUT tables that actually exist, regardless of
|
|
||||||
what name they use.
|
|
||||||
@@ -37,17 +37,25 @@ RUN set -eu; \
|
|||||||
|
|
||||||
COPY data/ /app/data/
|
COPY data/ /app/data/
|
||||||
|
|
||||||
# Redirector TLS cert (CN/SAN = winter15.gosredirector.ea.com). ProtoSSL
|
# Redirector/roster TLS cert (CN/SAN = winter15.gosredirector.ea.com). ProtoSSL
|
||||||
# cert-verify is patched client-side, so a self-signed cert is fine. The pair is
|
# cert-verify is patched client-side, so a self-signed cert is fine — but the
|
||||||
# git-ignored (*.pem/*.key); regenerate if absent so a fresh checkout builds
|
# client dials the roster and redirector BY IP, and that path still checks the
|
||||||
# without extra steps.
|
# SAN against the dialed address (it is NOT covered by the two patched gates), so
|
||||||
|
# a cert without a matching IP SAN is rejected with fatal certificate_unknown
|
||||||
|
# (docs/FIFA17_FUT_SQUAD_UPDATE_TLS.md). The advertised LAN IP is a RUNTIME value,
|
||||||
|
# unknown here, so this bakes only a loopback-IP baseline and the entrypoint
|
||||||
|
# reissues with IP:$OPENFUT_ADVERTISE at start.
|
||||||
|
#
|
||||||
|
# openssl therefore has to remain in the image for the entrypoint, not be dropped
|
||||||
|
# with the apt lists. The pair is git-ignored (*.pem/*.key); regenerate if absent
|
||||||
|
# so a fresh checkout builds without extra steps.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends openssl && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
RUN if [ ! -s tools/redir_cert.pem ] || [ ! -s tools/redir_key.pem ]; then \
|
RUN if [ ! -s tools/redir_cert.pem ] || [ ! -s tools/redir_key.pem ]; then \
|
||||||
apt-get update && apt-get install -y --no-install-recommends openssl && \
|
|
||||||
openssl req -x509 -newkey rsa:2048 -nodes \
|
openssl req -x509 -newkey rsa:2048 -nodes \
|
||||||
-keyout tools/redir_key.pem -out tools/redir_cert.pem \
|
-keyout tools/redir_key.pem -out tools/redir_cert.pem \
|
||||||
-days 3650 -subj "/CN=winter15.gosredirector.ea.com" \
|
-days 3650 -subj "/CN=winter15.gosredirector.ea.com" \
|
||||||
-addext "subjectAltName=DNS:winter15.gosredirector.ea.com,DNS:*.gosredirector.ea.com,DNS:*.ea.com" && \
|
-addext "subjectAltName=DNS:winter15.gosredirector.ea.com,DNS:*.gosredirector.ea.com,DNS:*.ea.com,IP:127.0.0.1"; \
|
||||||
rm -rf /var/lib/apt/lists/*; \
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Bake a dataset manifest so every image is self-identifying.
|
# Bake a dataset manifest so every image is self-identifying.
|
||||||
|
|||||||
@@ -28,6 +28,26 @@ export POW_CONTENT_ADDR="${POW_CONTENT_ADDR:-$BIND:8080}"
|
|||||||
|
|
||||||
echo "[openfut] bind=$BIND advertise=$ADV"
|
echo "[openfut] bind=$BIND advertise=$ADV"
|
||||||
|
|
||||||
|
# The TLS cert every responder serves must carry the ADVERTISED IP in its SAN.
|
||||||
|
# The client dials the roster (:8081) and redirector by that IP, and that path
|
||||||
|
# validates the cert's SAN against the dialed address — it is NOT covered by the
|
||||||
|
# two client-side ProtoSSL gates autopatch patches, so a cert lacking IP:$ADV is
|
||||||
|
# rejected with fatal certificate_unknown and the FUT hub fails with "An error
|
||||||
|
# occurred downloading the FUT Squad Update" (docs/FIFA17_FUT_SQUAD_UPDATE_TLS.md).
|
||||||
|
# The advertised IP is unknown at image-build time, so reconcile it here: reissue
|
||||||
|
# only when the current cert does not already carry it, so a restart reuses the
|
||||||
|
# same cert (no per-start fingerprint churn) and this self-heals if $ADV changes.
|
||||||
|
CERT=redir_cert.pem KEY=redir_key.pem
|
||||||
|
if ! openssl x509 -in "$CERT" -noout -ext subjectAltName 2>/dev/null | grep -qF "IP Address:$ADV"; then
|
||||||
|
echo "[openfut] reissuing TLS cert with SAN IP:$ADV (was missing it)"
|
||||||
|
openssl req -x509 -newkey rsa:2048 -nodes -keyout "$KEY" -out "$CERT" -days 3650 \
|
||||||
|
-subj "/CN=winter15.gosredirector.ea.com" \
|
||||||
|
-addext "subjectAltName=DNS:winter15.gosredirector.ea.com,DNS:*.gosredirector.ea.com,DNS:*.ea.com,IP:$ADV,IP:127.0.0.1" \
|
||||||
|
>/dev/null 2>&1 \
|
||||||
|
&& echo "[openfut] cert SAN now: $(openssl x509 -in "$CERT" -noout -ext subjectAltName 2>/dev/null | tail -1 | tr -s ' ')" \
|
||||||
|
|| { echo "[openfut] FATAL: could not reissue TLS cert" >&2; exit 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
# name script extra-env
|
# name script extra-env
|
||||||
declare -a SERVERS=(
|
declare -a SERVERS=(
|
||||||
"lsx|lsx_responder_v2.py|OPENFUT_LSX_EVENT_COUNT=100000"
|
"lsx|lsx_responder_v2.py|OPENFUT_LSX_EVENT_COUNT=100000"
|
||||||
|
|||||||
@@ -32,11 +32,26 @@ c() { printf ' %s\n' "$*"; }
|
|||||||
up() { ss -tlnp 2>/dev/null | grep -q ":$1 "; }
|
up() { ss -tlnp 2>/dev/null | grep -q ":$1 "; }
|
||||||
|
|
||||||
ensure_cert() {
|
ensure_cert() {
|
||||||
[ -s "$CERT" ] && [ -s "$KEY" ] && return 0
|
# The cert MUST carry the address the client dials in its SAN, or the roster
|
||||||
echo "[*] generating self-signed TLS cert (redirector MITM; ProtoSSL verify is patched)"
|
# HTTPS handshake is rejected with fatal certificate_unknown and the FUT hub
|
||||||
|
# fails to load (docs/FIFA17_FUT_SQUAD_UPDATE_TLS.md): the client dials the
|
||||||
|
# roster/redirector by IP and that path validates the SAN against it. Default to
|
||||||
|
# this host's primary LAN IP so a client on another machine works;
|
||||||
|
# OPENFUT_ADVERTISE overrides. Reissue when absent OR when the current cert lacks
|
||||||
|
# that IP, so this self-heals rather than serving a stale DNS-only cert.
|
||||||
|
local adv ip_sans regen=0
|
||||||
|
adv="${OPENFUT_ADVERTISE:-$(ip route get 1.1.1.1 2>/dev/null | awk '{print $7; exit}')}"
|
||||||
|
ip_sans="IP:127.0.0.1"; [ -n "$adv" ] && ip_sans="IP:$adv,IP:127.0.0.1"
|
||||||
|
if [ ! -s "$CERT" ] || [ ! -s "$KEY" ]; then
|
||||||
|
regen=1
|
||||||
|
elif [ -n "$adv" ] && ! openssl x509 -in "$CERT" -noout -ext subjectAltName 2>/dev/null | grep -qF "IP Address:$adv"; then
|
||||||
|
regen=1
|
||||||
|
fi
|
||||||
|
[ "$regen" = 0 ] && return 0
|
||||||
|
echo "[*] issuing self-signed TLS cert (SAN includes $ip_sans; redirector MITM; ProtoSSL verify is patched)"
|
||||||
openssl req -x509 -newkey rsa:2048 -nodes -keyout "$KEY" -out "$CERT" -days 3650 \
|
openssl req -x509 -newkey rsa:2048 -nodes -keyout "$KEY" -out "$CERT" -days 3650 \
|
||||||
-subj "/CN=winter15.gosredirector.ea.com" \
|
-subj "/CN=winter15.gosredirector.ea.com" \
|
||||||
-addext "subjectAltName=DNS:winter15.gosredirector.ea.com,DNS:*.gosredirector.ea.com,DNS:*.ea.com,IP:127.0.0.1" \
|
-addext "subjectAltName=DNS:winter15.gosredirector.ea.com,DNS:*.gosredirector.ea.com,DNS:*.ea.com,$ip_sans" \
|
||||||
>/dev/null 2>&1
|
>/dev/null 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Dump CardsDLL's 45-row route table from the ON-DISK PE. READ-ONLY, static.
|
||||||
|
|
||||||
|
The transfer-market analysis locates the table at .rdata 0x18021df80 as
|
||||||
|
{char*, char*} rows. This resolves VA->file offset properly through the PE section
|
||||||
|
table rather than assuming a single .text mapping, then prints every row so we can
|
||||||
|
see whether any route other than `tradePile` could own a trade-pile ITEM list.
|
||||||
|
"""
|
||||||
|
import struct, sys
|
||||||
|
|
||||||
|
DLL = "/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll"
|
||||||
|
TABLE_VA = 0x18021DF80
|
||||||
|
MAX_ROWS = 64
|
||||||
|
|
||||||
|
pe = open(DLL, "rb").read()
|
||||||
|
e_lfanew = struct.unpack_from("<I", pe, 0x3C)[0]
|
||||||
|
assert pe[e_lfanew:e_lfanew + 4] == b"PE\0\0", "not a PE"
|
||||||
|
coff = e_lfanew + 4
|
||||||
|
nsec, opt_size = struct.unpack_from("<HH", pe, coff + 2), None
|
||||||
|
num_sections = struct.unpack_from("<H", pe, coff + 2)[0]
|
||||||
|
opt_size = struct.unpack_from("<H", pe, coff + 16)[0]
|
||||||
|
opt = coff + 20
|
||||||
|
magic = struct.unpack_from("<H", pe, opt)[0]
|
||||||
|
assert magic == 0x20B, "expected PE32+"
|
||||||
|
image_base = struct.unpack_from("<Q", pe, opt + 24)[0]
|
||||||
|
sec_off = opt + opt_size
|
||||||
|
|
||||||
|
sections = []
|
||||||
|
for i in range(num_sections):
|
||||||
|
b = sec_off + i * 40
|
||||||
|
name = pe[b:b + 8].rstrip(b"\0").decode("ascii", "replace")
|
||||||
|
vsize, vaddr, rawsize, rawptr = struct.unpack_from("<IIII", pe, b + 8)
|
||||||
|
sections.append((name, vaddr, vsize, rawptr, rawsize))
|
||||||
|
|
||||||
|
print("image_base=%#x sections=%d" % (image_base, num_sections))
|
||||||
|
for s in sections:
|
||||||
|
print(" %-8s rva=%#010x vsize=%#x rawptr=%#010x rawsize=%#x" % s)
|
||||||
|
|
||||||
|
|
||||||
|
def va2off(va):
|
||||||
|
rva = va - image_base
|
||||||
|
for name, vaddr, vsize, rawptr, rawsize in sections:
|
||||||
|
if vaddr <= rva < vaddr + max(vsize, rawsize):
|
||||||
|
off = rva - vaddr + rawptr
|
||||||
|
if off < len(pe):
|
||||||
|
return off
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cstr(va, limit=96):
|
||||||
|
off = va2off(va)
|
||||||
|
if off is None:
|
||||||
|
return None
|
||||||
|
end = pe.find(b"\0", off, off + limit)
|
||||||
|
if end < 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return pe[off:end].decode("ascii")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
base = va2off(TABLE_VA)
|
||||||
|
print("\nroute table VA %#x -> file offset %s" % (TABLE_VA, hex(base) if base else None))
|
||||||
|
assert base, "table VA did not resolve"
|
||||||
|
|
||||||
|
print("\n%-4s %-34s %s" % ("#", "field A", "field B"))
|
||||||
|
rows = 0
|
||||||
|
for i in range(MAX_ROWS):
|
||||||
|
a_va, b_va = struct.unpack_from("<QQ", pe, base + i * 16)
|
||||||
|
a, b = cstr(a_va), cstr(b_va)
|
||||||
|
if a is None and b is None:
|
||||||
|
print("-- table ends after %d rows --" % rows)
|
||||||
|
break
|
||||||
|
print("%-4d %-34s %s" % (i, repr(a), repr(b)))
|
||||||
|
rows += 1
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Dump CardsDLL's NULL-terminated {const char*, int} vocabulary tables from the
|
||||||
|
ON-DISK PE. READ-ONLY, static.
|
||||||
|
|
||||||
|
The transfer-market analysis records tradeState as decoding through a table walk at
|
||||||
|
0x180229e40 and lists sibling vocabularies (type/zone/lev/pos) as tables of the same
|
||||||
|
shape. This prints the exact token spellings and their integer codes, so the accepted
|
||||||
|
strings come from the client rather than from inference.
|
||||||
|
"""
|
||||||
|
import struct
|
||||||
|
|
||||||
|
DLL = "/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll"
|
||||||
|
TABLES = [
|
||||||
|
(0x180229E40, "tradeState (table walk)"),
|
||||||
|
(0x180229C30, "type"),
|
||||||
|
(0x1802296E0, "zone"),
|
||||||
|
(0x180229A60, "lev"),
|
||||||
|
(0x1802295C0, "pos"),
|
||||||
|
(0x180229AB0, "cat"),
|
||||||
|
(0x180229880, "form"),
|
||||||
|
]
|
||||||
|
MAX_ROWS = 64
|
||||||
|
|
||||||
|
pe = open(DLL, "rb").read()
|
||||||
|
e_lfanew = struct.unpack_from("<I", pe, 0x3C)[0]
|
||||||
|
coff = e_lfanew + 4
|
||||||
|
num_sections = struct.unpack_from("<H", pe, coff + 2)[0]
|
||||||
|
opt_size = struct.unpack_from("<H", pe, coff + 16)[0]
|
||||||
|
opt = coff + 20
|
||||||
|
image_base = struct.unpack_from("<Q", pe, opt + 24)[0]
|
||||||
|
sec_off = opt + opt_size
|
||||||
|
sections = []
|
||||||
|
for i in range(num_sections):
|
||||||
|
b = sec_off + i * 40
|
||||||
|
vsize, vaddr, rawsize, rawptr = struct.unpack_from("<IIII", pe, b + 8)
|
||||||
|
sections.append((vaddr, vsize, rawptr, rawsize))
|
||||||
|
|
||||||
|
|
||||||
|
def va2off(va):
|
||||||
|
rva = va - image_base
|
||||||
|
for vaddr, vsize, rawptr, rawsize in sections:
|
||||||
|
if vaddr <= rva < vaddr + max(vsize, rawsize):
|
||||||
|
off = rva - vaddr + rawptr
|
||||||
|
if 0 <= off < len(pe):
|
||||||
|
return off
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cstr(va, limit=64):
|
||||||
|
off = va2off(va)
|
||||||
|
if off is None:
|
||||||
|
return None
|
||||||
|
end = pe.find(b"\0", off, off + limit)
|
||||||
|
if end < 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
s = pe[off:end].decode("ascii")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return None
|
||||||
|
return s if s.isprintable() else None
|
||||||
|
|
||||||
|
|
||||||
|
for table_va, name in TABLES:
|
||||||
|
base = va2off(table_va)
|
||||||
|
print("\n=== %s VA %#x -> off %s ===" % (name, table_va, hex(base) if base else None))
|
||||||
|
if base is None:
|
||||||
|
print(" (VA did not resolve)")
|
||||||
|
continue
|
||||||
|
for i in range(MAX_ROWS):
|
||||||
|
ptr, code = struct.unpack_from("<Qi", pe, base + i * 16)
|
||||||
|
if ptr == 0:
|
||||||
|
print(" -- NULL terminator after %d rows --" % i)
|
||||||
|
break
|
||||||
|
s = cstr(ptr)
|
||||||
|
if s is None:
|
||||||
|
print(" row %d: ptr %#x does not resolve to a string; stopping" % (i, ptr))
|
||||||
|
break
|
||||||
|
print(" %-28s = %d" % (repr(s), code))
|
||||||
@@ -56,6 +56,46 @@ pub fn pack_price(pack_id: u64) -> Option<u64> {
|
|||||||
.map(|p| p.price)
|
.map(|p| p.price)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// FIFA 17 transfer-market fee, in PERCENT of the gross sale price.
|
||||||
|
///
|
||||||
|
/// FIFA17-HISTORICAL: 5% is the well-documented FUT transfer tax of the era. It
|
||||||
|
/// was not recovered from our client binary — no `tax`/`netPrice`/`sellerProceeds`
|
||||||
|
/// wire field exists (`docs/FIFA17_TRANSFER_MARKET_WIRE.md`), because the client
|
||||||
|
/// is told only the GROSS price and the deduction is server-side.
|
||||||
|
pub const TRANSFER_MARKET_FEE_PERCENT: i64 = 5;
|
||||||
|
|
||||||
|
/// Fee withheld from a completed sale of `gross` coins.
|
||||||
|
///
|
||||||
|
/// Integer arithmetic only — coin settlement never touches floating point, where
|
||||||
|
/// `0.05` is not representable and a large price could round a coin into or out of
|
||||||
|
/// existence. Widening to `i128` for the multiply makes overflow unreachable for
|
||||||
|
/// any `i64` price, so no ceiling has to be assumed.
|
||||||
|
///
|
||||||
|
/// ROUNDING, and it is a CHOICE that needs live confirmation: the fee is FLOORED,
|
||||||
|
/// so the seller keeps the fractional coin. That is deliberate — it makes
|
||||||
|
/// `fee + proceeds == gross` hold exactly for every input, which is the property
|
||||||
|
/// the accounting invariant depends on. The discriminating case against the
|
||||||
|
/// alternative (flooring the seller's 95% instead) is a gross of 150: this rule
|
||||||
|
/// pays 143, the alternative 142. Nothing in the corpus settles which the real
|
||||||
|
/// server did, so this MUST NOT be treated as confirmed FIFA behaviour.
|
||||||
|
///
|
||||||
|
/// A negative gross is not a sale; it yields a zero fee rather than inventing a
|
||||||
|
/// negative one, and `settle_sale` rejects the price itself.
|
||||||
|
pub fn transfer_market_fee(gross: i64) -> i64 {
|
||||||
|
if gross <= 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
((gross as i128 * TRANSFER_MARKET_FEE_PERCENT as i128) / 100) as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the seller is credited for a completed sale of `gross` coins.
|
||||||
|
///
|
||||||
|
/// Defined as `gross - fee` rather than as its own percentage, so the pair can
|
||||||
|
/// never disagree about where a rounded coin went.
|
||||||
|
pub fn seller_proceeds(gross: i64) -> i64 {
|
||||||
|
gross.max(0) - transfer_market_fee(gross)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -84,4 +124,63 @@ mod tests {
|
|||||||
assert_eq!(pack_price(65534), None); // sentinel absent from catalogue
|
assert_eq!(pack_price(65534), None); // sentinel absent from catalogue
|
||||||
assert_eq!(pack_price(70), None); // owned-only reward pack, not purchasable
|
assert_eq!(pack_price(70), None); // owned-only reward pack, not purchasable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pins the rounding rule at every boundary the fee can turn over. If one of
|
||||||
|
/// these ever changes, monetary behaviour changed — that must be deliberate.
|
||||||
|
#[test]
|
||||||
|
fn transfer_market_fee_is_floored_five_percent() {
|
||||||
|
// (gross, expected fee, expected proceeds)
|
||||||
|
let cases = [
|
||||||
|
(0i64, 0i64, 0i64),
|
||||||
|
(1, 0, 1), // 0.05 -> 0
|
||||||
|
(19, 0, 19), // 0.95 -> 0, the last fee-free price
|
||||||
|
(20, 1, 19), // exactly 1.0, the first price that pays
|
||||||
|
(21, 1, 20), // 1.05 -> 1
|
||||||
|
(39, 1, 38), // 1.95 -> 1
|
||||||
|
(40, 2, 38), // exactly 2.0
|
||||||
|
(100, 5, 95),
|
||||||
|
(101, 5, 96), // 5.05 -> 5
|
||||||
|
(119, 5, 114), // 5.95 -> 5, last price paying 5
|
||||||
|
(120, 6, 114), // exactly 6.0
|
||||||
|
(149, 7, 142), // 7.45 -> 7
|
||||||
|
(150, 7, 143), // 7.5 -> 7: THE discriminating case (alternative: 8/142)
|
||||||
|
(151, 7, 144), // 7.55 -> 7 (a HALF-UP rule would pay 8 here too)
|
||||||
|
(199, 9, 190), // 9.95 -> 9
|
||||||
|
(200, 10, 190), // exactly 10.0
|
||||||
|
(1_000, 50, 950),
|
||||||
|
(15_000, 750, 14_250), // the canonical fixture
|
||||||
|
(15_000_000, 750_000, 14_250_000), // FUT's practical price ceiling
|
||||||
|
(i64::MAX, i64::MAX / 20, i64::MAX - i64::MAX / 20), // no overflow
|
||||||
|
];
|
||||||
|
for (gross, fee, proceeds) in cases {
|
||||||
|
assert_eq!(transfer_market_fee(gross), fee, "fee for gross {gross}");
|
||||||
|
assert_eq!(
|
||||||
|
seller_proceeds(gross),
|
||||||
|
proceeds,
|
||||||
|
"proceeds for gross {gross}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The property the whole settlement's accounting rests on: the fee and the
|
||||||
|
/// seller's proceeds account for the gross EXACTLY, with no coin created or
|
||||||
|
/// destroyed by rounding, at every price.
|
||||||
|
#[test]
|
||||||
|
fn fee_plus_proceeds_is_exactly_gross() {
|
||||||
|
for gross in (0i64..2_000).chain([15_000, 999_999, 15_000_000, i64::MAX]) {
|
||||||
|
assert_eq!(
|
||||||
|
transfer_market_fee(gross) + seller_proceeds(gross),
|
||||||
|
gross,
|
||||||
|
"fee + proceeds != gross at {gross}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A non-sale must not invent a negative fee.
|
||||||
|
#[test]
|
||||||
|
fn negative_gross_yields_no_fee() {
|
||||||
|
assert_eq!(transfer_market_fee(-1), 0);
|
||||||
|
assert_eq!(transfer_market_fee(i64::MIN), 0);
|
||||||
|
assert_eq!(seller_proceeds(-100), 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,13 +267,19 @@ pub fn parse_account_sync(body: &[u8], default_persona: i64) -> AccountSyncReque
|
|||||||
/// `POST /openfut/account/sync` — the launcher control-plane account summary.
|
/// `POST /openfut/account/sync` — the launcher control-plane account summary.
|
||||||
/// `coins`/`unopened_packs` are the AUTHORITATIVE Core values (balance +
|
/// `coins`/`unopened_packs` are the AUTHORITATIVE Core values (balance +
|
||||||
/// entitlement count), never Python's stale profile funds.
|
/// entitlement count), never Python's stale profile funds.
|
||||||
pub fn account_sync_body(req: &AccountSyncRequest, coins: i64, unopened_packs: usize) -> Value {
|
pub fn account_sync_body(
|
||||||
|
req: &AccountSyncRequest,
|
||||||
|
coins: i64,
|
||||||
|
unopened_packs: usize,
|
||||||
|
club_name: &str,
|
||||||
|
club_abbr: &str,
|
||||||
|
) -> Value {
|
||||||
json!({
|
json!({
|
||||||
"account": {
|
"account": {
|
||||||
"personaId": req.persona_id,
|
"personaId": req.persona_id,
|
||||||
"personaName": req.persona_name,
|
"personaName": req.persona_name,
|
||||||
"clubName": "OpenFUT",
|
"clubName": club_name,
|
||||||
"clubAbbr": "OFC",
|
"clubAbbr": club_abbr,
|
||||||
"level": req.level,
|
"level": req.level,
|
||||||
"experience": req.experience,
|
"experience": req.experience,
|
||||||
"experienceMax": req.experience_max,
|
"experienceMax": req.experience_max,
|
||||||
@@ -301,6 +307,9 @@ pub fn user_mass_info_body(
|
|||||||
coins: i64,
|
coins: i64,
|
||||||
unopened_packs: usize,
|
unopened_packs: usize,
|
||||||
persona_id: i64,
|
persona_id: i64,
|
||||||
|
club_name: &str,
|
||||||
|
club_abbr: &str,
|
||||||
|
established: &str,
|
||||||
) -> Value {
|
) -> Value {
|
||||||
let actives: Vec<Value> = squad
|
let actives: Vec<Value> = squad
|
||||||
.get("actives")
|
.get("actives")
|
||||||
@@ -310,9 +319,9 @@ pub fn user_mass_info_body(
|
|||||||
let squad_list = crate::fut::squad_projection::squad_list(&squad);
|
let squad_list = crate::fut::squad_projection::squad_list(&squad);
|
||||||
let mut user_info = json!({
|
let mut user_info = json!({
|
||||||
"personaId": persona_id,
|
"personaId": persona_id,
|
||||||
"clubName": "OpenFUT",
|
"clubName": club_name,
|
||||||
"clubAbbr": "OFC",
|
"clubAbbr": club_abbr,
|
||||||
"established": "2026",
|
"established": established,
|
||||||
"accountCreatedPlatformName": "pc",
|
"accountCreatedPlatformName": "pc",
|
||||||
"currencies": [
|
"currencies": [
|
||||||
{"name": "coins", "funds": coins, "finalFunds": coins, "active": true},
|
{"name": "coins", "funds": coins, "finalFunds": coins, "active": true},
|
||||||
@@ -583,10 +592,10 @@ mod tests {
|
|||||||
assert_eq!(req.level, 1);
|
assert_eq!(req.level, 1);
|
||||||
assert_eq!(req.experience_max, 1000);
|
assert_eq!(req.experience_max, 1000);
|
||||||
assert_eq!(req.account_funds_cap, 100_000);
|
assert_eq!(req.account_funds_cap, 100_000);
|
||||||
let body = account_sync_body(&req, 29_859_876, 2);
|
let body = account_sync_body(&req, 29_859_876, 2, "Real FUT", "RF");
|
||||||
let acc = &body["account"];
|
let acc = &body["account"];
|
||||||
assert_eq!(acc["clubName"], "OpenFUT");
|
assert_eq!(acc["clubName"], "Real FUT");
|
||||||
assert_eq!(acc["clubAbbr"], "OFC");
|
assert_eq!(acc["clubAbbr"], "RF");
|
||||||
assert_eq!(acc["profilePath"], "accounts/33068179/fifa17_profile.json");
|
assert_eq!(acc["profilePath"], "accounts/33068179/fifa17_profile.json");
|
||||||
assert_eq!(acc["coins"], 29_859_876);
|
assert_eq!(acc["coins"], 29_859_876);
|
||||||
assert_eq!(acc["unopenedPacks"], 2);
|
assert_eq!(acc["unopenedPacks"], 2);
|
||||||
@@ -617,7 +626,7 @@ mod tests {
|
|||||||
"actives": [],
|
"actives": [],
|
||||||
"players": [],
|
"players": [],
|
||||||
});
|
});
|
||||||
let body = user_mass_info_body(squad, 29_859_876, 0, 33_068_179);
|
let body = user_mass_info_body(squad, 29_859_876, 0, 33_068_179, "Real FUT", "RF", "2016");
|
||||||
// Flat top-level envelope.
|
// Flat top-level envelope.
|
||||||
assert_eq!(body["pileSizeClientData"]["entries"][0], json!({"key": 2, "value": 100}));
|
assert_eq!(body["pileSizeClientData"]["entries"][0], json!({"key": 2, "value": 100}));
|
||||||
assert_eq!(body["pileSizeClientData"]["entries"][1], json!({"key": 4, "value": 50}));
|
assert_eq!(body["pileSizeClientData"]["entries"][1], json!({"key": 4, "value": 50}));
|
||||||
@@ -626,9 +635,9 @@ mod tests {
|
|||||||
// userInfo economy + club identity.
|
// userInfo economy + club identity.
|
||||||
let ui = &body["userInfo"];
|
let ui = &body["userInfo"];
|
||||||
assert_eq!(ui["personaId"], 33_068_179);
|
assert_eq!(ui["personaId"], 33_068_179);
|
||||||
assert_eq!(ui["clubName"], "OpenFUT");
|
assert_eq!(ui["clubName"], "Real FUT");
|
||||||
assert_eq!(ui["clubAbbr"], "OFC");
|
assert_eq!(ui["clubAbbr"], "RF");
|
||||||
assert_eq!(ui["established"], "2026"); // string, not number
|
assert_eq!(ui["established"], "2016"); // string, not number
|
||||||
assert_eq!(ui["accountCreatedPlatformName"], "pc");
|
assert_eq!(ui["accountCreatedPlatformName"], "pc");
|
||||||
assert_eq!(ui["currencies"][0]["name"], "coins");
|
assert_eq!(ui["currencies"][0]["name"], "coins");
|
||||||
assert_eq!(ui["currencies"][0]["funds"], 29_859_876);
|
assert_eq!(ui["currencies"][0]["funds"], 29_859_876);
|
||||||
@@ -645,7 +654,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn user_mass_info_includes_unopened_packs_when_present() {
|
fn user_mass_info_includes_unopened_packs_when_present() {
|
||||||
let squad = json!({"id": 0, "actives": [], "players": []});
|
let squad = json!({"id": 0, "actives": [], "players": []});
|
||||||
let body = user_mass_info_body(squad, 100, 3, 33_068_179);
|
let body = user_mass_info_body(squad, 100, 3, 33_068_179, "OpenFUT", "OFC", "2026");
|
||||||
assert_eq!(body["userInfo"]["unopenedPacks"]["recoveredPacks"], 3);
|
assert_eq!(body["userInfo"]["unopenedPacks"]["recoveredPacks"], 3);
|
||||||
assert_eq!(body["userInfo"]["unopenedPacks"]["preOrderPacks"], 0);
|
assert_eq!(body["userInfo"]["unopenedPacks"]["preOrderPacks"], 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "openfut-autopatch"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "MIT"
|
||||||
|
description = "Applies the FIFA 17 ProtoSSL cert and CardsDLL store patches to a running client"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
# Deliberately dependency-free. Everything this needs is in std: /proc scanning for
|
||||||
|
# the client pid, and positioned reads/writes against /proc/<pid>/mem via
|
||||||
|
# std::os::unix::fs::FileExt. A patcher that edits another process's memory should be
|
||||||
|
# auditable end to end without pulling in a dependency tree.
|
||||||
|
[dependencies]
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
//! FIFA 17 client patcher — the ProtoSSL cert gates and the CardsDLL FUT store
|
||||||
|
//! patches, applied to a running `FIFA17.exe` through `/proc/<pid>/mem`.
|
||||||
|
//!
|
||||||
|
//! Port of `fifa17-recon/tools/autopatch.py`; that file is the specification and
|
||||||
|
//! every address, byte pattern and log line here is reproduced from it verbatim.
|
||||||
|
//! The store patches are re-applied on every tick because the game rewrites
|
||||||
|
//! those sites; the cert gates are applied once per pid.
|
||||||
|
//!
|
||||||
|
//! This module holds the constants, the two pure decision functions and the
|
||||||
|
//! parsers. The enforcement passes live in [`patch`], process access in
|
||||||
|
//! [`procmem`], timestamping in [`localtime`], and the watch loop in `main.rs`.
|
||||||
|
|
||||||
|
pub mod localtime;
|
||||||
|
pub mod logging;
|
||||||
|
pub mod patch;
|
||||||
|
pub mod procmem;
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
pub use logging::Logger;
|
||||||
|
|
||||||
|
/// `/proc/<pid>/comm` of the client we patch (exact match after trimming).
|
||||||
|
pub const CLIENT_COMM: &str = "FIFA17.exe";
|
||||||
|
|
||||||
|
/// Substring identifying the CardsDLL mapping in `/proc/<pid>/maps`.
|
||||||
|
pub const CARDSDLL_MARKER: &str = "CardsDLL";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ProtoSSL certificate gates (absolute VAs in the unpacked FIFA17.exe image;
|
||||||
|
// present only once the packer has mapped the real code, hence the watch loop).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub const GATE2: u64 = 0x1461361b0;
|
||||||
|
pub const GATE2_ORIG: [u8; 3] = [0x48, 0x89, 0x5c];
|
||||||
|
pub const GATE2_PATCH: [u8; 3] = [0x31, 0xc0, 0xc3];
|
||||||
|
|
||||||
|
pub const GATE1: u64 = 0x146132548;
|
||||||
|
pub const GATE1_ORIG: [u8; 6] = [0x0f, 0x85, 0x76, 0x01, 0x00, 0x00];
|
||||||
|
pub const GATE1_PATCH: [u8; 6] = [0x90; 6];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// FUT store patches, expressed against the CardsDLL preferred image base and
|
||||||
|
// relocated to the live mapping base at runtime.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// `mov eax, 1; ret` — force a predicate true.
|
||||||
|
pub const RET_TRUE: [u8; 6] = [0xb8, 0x01, 0x00, 0x00, 0x00, 0xc3];
|
||||||
|
/// Two `nop`s.
|
||||||
|
pub const NOP2: [u8; 2] = [0x90, 0x90];
|
||||||
|
/// `jmp +0x3f`, the one non-uniform store patch (site `0x180017543`).
|
||||||
|
pub const SHORT_JMP_3F: [u8; 2] = [0xeb, 0x3f];
|
||||||
|
/// CardsDLL's preferred image base; live address = `cbase + (va - IMG_BASE)`.
|
||||||
|
pub const IMG_BASE: u64 = 0x180000000;
|
||||||
|
|
||||||
|
/// Unconditional store patches, in the Python's insertion order — the order
|
||||||
|
/// decides the order of the `ENFORCED store patch` log lines.
|
||||||
|
///
|
||||||
|
/// The Python carries no per-site rationale for these eight sites, so none is
|
||||||
|
/// invented here; the addresses and bytes are reproduced verbatim.
|
||||||
|
pub const STORE_PATCHES: [(u64, &[u8]); 8] = [
|
||||||
|
(0x1800f7fb0, &RET_TRUE),
|
||||||
|
(0x1800fb850, &RET_TRUE),
|
||||||
|
(0x180100500, &RET_TRUE),
|
||||||
|
(0x180013cf0, &RET_TRUE),
|
||||||
|
(0x180017543, &SHORT_JMP_3F),
|
||||||
|
(0x180017487, &NOP2),
|
||||||
|
(0x180017490, &NOP2),
|
||||||
|
(0x1800175aa, &NOP2),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Store resolver crash-guard for the empty "My Packs" case (bug 6c; PROVEN R1 on the
|
||||||
|
/// tested FIFA 17 build -- see docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md PART IV and
|
||||||
|
/// docs/evidence/FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md).
|
||||||
|
///
|
||||||
|
/// When no `mypacks` group exists, FIFA's Store resolver receives category id -1. CardsDLL
|
||||||
|
/// FUN_1800147f0 @ 0x180014858 is `JNZ 0x14869` (75 0f): the original treats every non-zero
|
||||||
|
/// category (including -1) as resolvable, calls FUN_180014420, gets NULL, and crashes at the
|
||||||
|
/// \[NULL+0x48\] deref in FUN_1800147f0 (0x180014882). Changing JNZ->JG (7f 0f) preserves
|
||||||
|
/// positive-category resolution (EDI>0 branch) while routing zero/negative categories through
|
||||||
|
/// the existing Browse/list-all path -> no NULL lookup, no crash, Store opens on Browse Packs.
|
||||||
|
///
|
||||||
|
/// CAVEAT: this guards the category SIGN only. It does NOT protect a stale *positive* invalid
|
||||||
|
/// ordinal produced by changing the Store group topology (sentinel-present <-> sentinel-absent)
|
||||||
|
/// DURING one running FIFA process -- that reproduced the same crash in the confounded run F3.
|
||||||
|
/// The empty-My-Packs representation MUST stay stable for a FIFA session (see the SESSION-STABLE
|
||||||
|
/// invariant in the client-fix plan).
|
||||||
|
///
|
||||||
|
/// Orig-verified / fail-closed: applied only when the live bytes are the known original (75 0f);
|
||||||
|
/// already-patched (7f 0f) is a no-op; anything else is logged and SKIPPED (never blindly
|
||||||
|
/// overwritten), so an unrecognised CardsDLL build is not patched.
|
||||||
|
///
|
||||||
|
/// Tuple layout: `(va, orig, patch)`. `JNZ 0x14869` -> `JG 0x14869`.
|
||||||
|
pub const STORE_PATCHES_GUARDED: [(u64, &[u8], &[u8]); 1] =
|
||||||
|
[(0x180014858, &[0x75, 0x0f], &[0x7f, 0x0f])];
|
||||||
|
|
||||||
|
/// Capability advertised to the launcher/backend once the resolver guard is VERIFIED
|
||||||
|
/// live in a specific FIFA process (docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md #3/#4).
|
||||||
|
pub const EMPTY_MYPACKS_RESOLVER_CAPABILITY: &str = "fifa17.empty_mypacks_resolver";
|
||||||
|
pub const EMPTY_MYPACKS_RESOLVER_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// The guarded site whose verified enforcement backs the capability above.
|
||||||
|
pub const RESOLVER_GUARD_VA: u64 = 0x180014858;
|
||||||
|
|
||||||
|
/// Longest patch payload, so the watch loop can compare live bytes on the stack.
|
||||||
|
pub const MAX_PATCH_LEN: usize = RET_TRUE.len();
|
||||||
|
|
||||||
|
// Compile-time proof that the watch loop's stack buffers are large enough, so no
|
||||||
|
// slicing panic is reachable from the patch tables.
|
||||||
|
const _: () = {
|
||||||
|
let mut i = 0;
|
||||||
|
while i < STORE_PATCHES.len() {
|
||||||
|
assert!(STORE_PATCHES[i].1.len() <= MAX_PATCH_LEN);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
let mut i = 0;
|
||||||
|
while i < STORE_PATCHES_GUARDED.len() {
|
||||||
|
assert!(STORE_PATCHES_GUARDED[i].1.len() <= MAX_PATCH_LEN);
|
||||||
|
assert!(STORE_PATCHES_GUARDED[i].2.len() <= MAX_PATCH_LEN);
|
||||||
|
assert!(STORE_PATCHES_GUARDED[i].1.len() == STORE_PATCHES_GUARDED[i].2.len());
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Fail-closed decision for a guarded byte patch (see [`STORE_PATCHES_GUARDED`]).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum GuardedAction {
|
||||||
|
/// Live bytes are already the patch; nothing to write.
|
||||||
|
Noop,
|
||||||
|
/// Live bytes are the known original; safe to apply.
|
||||||
|
Patch,
|
||||||
|
/// Unrecognised CardsDLL build — never blindly overwritten.
|
||||||
|
Skip,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-FIFA-pid guard status (fail-closed; FIFA17_PATCHED_CLIENT_CAPABILITY.md #4).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum GuardState {
|
||||||
|
/// CardsDLL not mapped / guard not yet evaluated.
|
||||||
|
NotAttempted,
|
||||||
|
/// Live bytes == patch after enforcement (patch or noop).
|
||||||
|
Verified,
|
||||||
|
/// Neither original nor patched ([`GuardedAction::Skip`]).
|
||||||
|
UnsupportedBuild,
|
||||||
|
/// The `/proc/<pid>/mem` write failed.
|
||||||
|
WriteFailed,
|
||||||
|
/// Post-write re-read != patch.
|
||||||
|
VerifyFailed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GuardState {
|
||||||
|
/// Wire spelling used in the `[store-guard] guard status=…` line the launcher reads.
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
GuardState::NotAttempted => "NOT_ATTEMPTED",
|
||||||
|
GuardState::Verified => "VERIFIED",
|
||||||
|
GuardState::UnsupportedBuild => "UNSUPPORTED_BUILD",
|
||||||
|
GuardState::WriteFailed => "WRITE_FAILED",
|
||||||
|
GuardState::VerifyFailed => "VERIFY_FAILED",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for GuardState {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(self.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fail-closed decision for a guarded byte patch.
|
||||||
|
///
|
||||||
|
/// [`GuardedAction::Noop`] when the live bytes are already patched,
|
||||||
|
/// [`GuardedAction::Patch`] when they are the known original (safe to apply), or
|
||||||
|
/// [`GuardedAction::Skip`] for anything else — an unrecognised CardsDLL build
|
||||||
|
/// that must never be blindly overwritten.
|
||||||
|
pub fn guarded_action(cur: &[u8], orig: &[u8], patch: &[u8]) -> GuardedAction {
|
||||||
|
if cur == patch {
|
||||||
|
return GuardedAction::Noop;
|
||||||
|
}
|
||||||
|
if cur == orig {
|
||||||
|
return GuardedAction::Patch;
|
||||||
|
}
|
||||||
|
GuardedAction::Skip
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a guarded-patch enforcement outcome to a per-pid guard STATE (pure).
|
||||||
|
///
|
||||||
|
/// Mirrors [`guarded_action`]'s decision, extended with post-write verification so the
|
||||||
|
/// caller advertises the capability only on VERIFIED. No `/proc` access — unit-testable.
|
||||||
|
///
|
||||||
|
/// - `cur_before == patch` -> VERIFIED (already patched; [`GuardedAction::Noop`])
|
||||||
|
/// - `cur_before == orig` -> WRITE_FAILED if the write raised, else VERIFIED when the
|
||||||
|
/// re-read is patch, else VERIFY_FAILED ([`GuardedAction::Patch`])
|
||||||
|
/// - otherwise -> UNSUPPORTED_BUILD ([`GuardedAction::Skip`])
|
||||||
|
///
|
||||||
|
/// [`GuardState::NotAttempted`] is never returned: it is the state a pid carries before
|
||||||
|
/// the guard is evaluated at all (CardsDLL not mapped yet), and this function is only
|
||||||
|
/// reached once live bytes have been read.
|
||||||
|
pub fn guard_state_after(
|
||||||
|
cur_before: &[u8],
|
||||||
|
orig: &[u8],
|
||||||
|
patch: &[u8],
|
||||||
|
wrote_ok: bool,
|
||||||
|
cur_after: &[u8],
|
||||||
|
) -> GuardState {
|
||||||
|
if cur_before == patch {
|
||||||
|
return GuardState::Verified;
|
||||||
|
}
|
||||||
|
if cur_before == orig {
|
||||||
|
if !wrote_ok {
|
||||||
|
return GuardState::WriteFailed;
|
||||||
|
}
|
||||||
|
if cur_after == patch {
|
||||||
|
return GuardState::Verified;
|
||||||
|
}
|
||||||
|
return GuardState::VerifyFailed;
|
||||||
|
}
|
||||||
|
GuardState::UnsupportedBuild
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Live address of an image-relative patch site inside the mapped CardsDLL.
|
||||||
|
pub fn live_addr(cbase: u64, va: u64) -> u64 {
|
||||||
|
cbase + (va - IMG_BASE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lowercase, unseparated hex — the spelling of `bytes.hex()` in the SKIP log line.
|
||||||
|
pub fn hex(bytes: &[u8]) -> String {
|
||||||
|
let mut out = String::with_capacity(bytes.len() * 2);
|
||||||
|
for b in bytes {
|
||||||
|
// Two nibbles, no separator, no allocation per byte.
|
||||||
|
const DIGITS: &[u8; 16] = b"0123456789abcdef";
|
||||||
|
out.push(DIGITS[(b >> 4) as usize] as char);
|
||||||
|
out.push(DIGITS[(b & 0x0f) as usize] as char);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when a `/proc/<pid>/comm` body names the FIFA 17 client.
|
||||||
|
pub fn is_client_comm(comm: &str) -> bool {
|
||||||
|
comm.trim() == CLIENT_COMM
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pid from a `/proc` directory entry name.
|
||||||
|
///
|
||||||
|
/// The Python globs `/proc/[0-9]*` and then `int()`s the name inside a bare
|
||||||
|
/// `except`, so a name has to start with a digit *and* be entirely numeric.
|
||||||
|
pub fn pid_from_proc_entry(name: &str) -> Option<u32> {
|
||||||
|
if !name.as_bytes().first().is_some_and(u8::is_ascii_digit) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
name.parse::<u32>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Base address of the first `CardsDLL` mapping in a `/proc/<pid>/maps` body.
|
||||||
|
///
|
||||||
|
/// Only the first matching line is considered, and a line whose base does not
|
||||||
|
/// parse yields `None` rather than falling through to the next mapping — the
|
||||||
|
/// Python's `int(...)` raises inside the `try` that returns `None`.
|
||||||
|
pub fn parse_cardsdll_base(maps: &str) -> Option<u64> {
|
||||||
|
let line = maps.lines().find(|line| line.contains(CARDSDLL_MARKER))?;
|
||||||
|
u64::from_str_radix(line.split('-').next()?, 16).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `--launcher-pid <pid>` out of the argument list.
|
||||||
|
///
|
||||||
|
/// `Ok(None)` when the flag is absent, `Err(())` when it is present with a
|
||||||
|
/// missing or non-numeric value (the Python raises `SystemExit`). The value is
|
||||||
|
/// kept signed and un-clamped so the liveness check behaves exactly like the
|
||||||
|
/// Python's `os.path.exists(f"/proc/{launcher_pid}")` for odd inputs.
|
||||||
|
#[allow(clippy::result_unit_err)]
|
||||||
|
pub fn parse_launcher_pid<I, S>(args: I) -> Result<Option<i64>, ()>
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = S>,
|
||||||
|
S: AsRef<str>,
|
||||||
|
{
|
||||||
|
let args: Vec<S> = args.into_iter().collect();
|
||||||
|
let Some(idx) = args.iter().position(|a| a.as_ref() == "--launcher-pid") else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let value = args.get(idx + 1).ok_or(())?;
|
||||||
|
value.as_ref().trim().parse::<i64>().map(Some).map_err(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const ORIG: &[u8] = &[0x75, 0x0f];
|
||||||
|
const PATCH: &[u8] = &[0x7f, 0x0f];
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constants_match_the_python_spec() {
|
||||||
|
assert_eq!(GATE2, 0x1461361b0);
|
||||||
|
assert_eq!(GATE2_ORIG, [0x48, 0x89, 0x5c]);
|
||||||
|
assert_eq!(GATE2_PATCH, [0x31, 0xc0, 0xc3]);
|
||||||
|
assert_eq!(GATE1, 0x146132548);
|
||||||
|
assert_eq!(GATE1_ORIG, [0x0f, 0x85, 0x76, 0x01, 0x00, 0x00]);
|
||||||
|
assert_eq!(GATE1_PATCH, [0x90, 0x90, 0x90, 0x90, 0x90, 0x90]);
|
||||||
|
assert_eq!(IMG_BASE, 0x180000000);
|
||||||
|
assert_eq!(hex(&RET_TRUE), "b801000000c3");
|
||||||
|
assert_eq!(hex(&NOP2), "9090");
|
||||||
|
|
||||||
|
// Site order is part of the log contract, so assert the whole table.
|
||||||
|
let sites: Vec<(u64, String)> = STORE_PATCHES
|
||||||
|
.iter()
|
||||||
|
.map(|(va, data)| (*va, hex(data)))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
sites,
|
||||||
|
vec![
|
||||||
|
(0x1800f7fb0, "b801000000c3".to_string()),
|
||||||
|
(0x1800fb850, "b801000000c3".to_string()),
|
||||||
|
(0x180100500, "b801000000c3".to_string()),
|
||||||
|
(0x180013cf0, "b801000000c3".to_string()),
|
||||||
|
(0x180017543, "eb3f".to_string()),
|
||||||
|
(0x180017487, "9090".to_string()),
|
||||||
|
(0x180017490, "9090".to_string()),
|
||||||
|
(0x1800175aa, "9090".to_string()),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(STORE_PATCHES_GUARDED.len(), 1);
|
||||||
|
let (va, orig, patch) = STORE_PATCHES_GUARDED[0];
|
||||||
|
assert_eq!(va, 0x180014858);
|
||||||
|
assert_eq!(hex(orig), "750f");
|
||||||
|
assert_eq!(hex(patch), "7f0f");
|
||||||
|
assert_eq!(RESOLVER_GUARD_VA, va);
|
||||||
|
assert_eq!(EMPTY_MYPACKS_RESOLVER_CAPABILITY, "fifa17.empty_mypacks_resolver");
|
||||||
|
assert_eq!(EMPTY_MYPACKS_RESOLVER_VERSION, 1);
|
||||||
|
assert_eq!(MAX_PATCH_LEN, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn guarded_action_noops_when_already_patched() {
|
||||||
|
assert_eq!(guarded_action(PATCH, ORIG, PATCH), GuardedAction::Noop);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn guarded_action_patches_the_known_original() {
|
||||||
|
assert_eq!(guarded_action(ORIG, ORIG, PATCH), GuardedAction::Patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn guarded_action_skips_an_unrecognised_build() {
|
||||||
|
// Fail-closed: an unknown CardsDLL build is never overwritten.
|
||||||
|
assert_eq!(guarded_action(&[0x74, 0x0f], ORIG, PATCH), GuardedAction::Skip);
|
||||||
|
assert_eq!(guarded_action(&[], ORIG, PATCH), GuardedAction::Skip);
|
||||||
|
assert_eq!(guarded_action(&[0x75], ORIG, PATCH), GuardedAction::Skip);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn guard_state_verified_when_already_patched() {
|
||||||
|
// wrote_ok / cur_after are irrelevant on this branch.
|
||||||
|
assert_eq!(
|
||||||
|
guard_state_after(PATCH, ORIG, PATCH, false, &[]),
|
||||||
|
GuardState::Verified
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn guard_state_verified_after_a_successful_write() {
|
||||||
|
assert_eq!(
|
||||||
|
guard_state_after(ORIG, ORIG, PATCH, true, PATCH),
|
||||||
|
GuardState::Verified
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn guard_state_write_failed() {
|
||||||
|
assert_eq!(
|
||||||
|
guard_state_after(ORIG, ORIG, PATCH, false, ORIG),
|
||||||
|
GuardState::WriteFailed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn guard_state_verify_failed() {
|
||||||
|
// Write reported success but the re-read still shows the original …
|
||||||
|
assert_eq!(
|
||||||
|
guard_state_after(ORIG, ORIG, PATCH, true, ORIG),
|
||||||
|
GuardState::VerifyFailed
|
||||||
|
);
|
||||||
|
// … or could not be re-read at all (the Python's `cur_after = b""`).
|
||||||
|
assert_eq!(
|
||||||
|
guard_state_after(ORIG, ORIG, PATCH, true, &[]),
|
||||||
|
GuardState::VerifyFailed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn guard_state_unsupported_build() {
|
||||||
|
assert_eq!(
|
||||||
|
guard_state_after(&[0x74, 0x0f], ORIG, PATCH, true, PATCH),
|
||||||
|
GuardState::UnsupportedBuild
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn guard_state_spellings_are_the_launcher_contract() {
|
||||||
|
assert_eq!(GuardState::NotAttempted.to_string(), "NOT_ATTEMPTED");
|
||||||
|
assert_eq!(GuardState::Verified.to_string(), "VERIFIED");
|
||||||
|
assert_eq!(GuardState::UnsupportedBuild.to_string(), "UNSUPPORTED_BUILD");
|
||||||
|
assert_eq!(GuardState::WriteFailed.to_string(), "WRITE_FAILED");
|
||||||
|
assert_eq!(GuardState::VerifyFailed.to_string(), "VERIFY_FAILED");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn live_addr_relocates_against_the_image_base() {
|
||||||
|
assert_eq!(live_addr(0x7f0000000000, 0x180014858), 0x7f0000014858);
|
||||||
|
assert_eq!(live_addr(IMG_BASE, RESOLVER_GUARD_VA), RESOLVER_GUARD_VA);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hex_is_lowercase_and_unseparated() {
|
||||||
|
assert_eq!(hex(&[0x00, 0x0f, 0xa5, 0xff]), "000fa5ff");
|
||||||
|
assert_eq!(hex(&[]), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn comm_matches_only_the_client() {
|
||||||
|
assert!(is_client_comm("FIFA17.exe\n"));
|
||||||
|
assert!(is_client_comm("FIFA17.exe"));
|
||||||
|
assert!(!is_client_comm("fifa17.exe\n"));
|
||||||
|
assert!(!is_client_comm("FIFA17.exe.bak\n"));
|
||||||
|
assert!(!is_client_comm("wineserver\n"));
|
||||||
|
assert!(!is_client_comm(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn proc_entry_names_yield_only_numeric_pids() {
|
||||||
|
assert_eq!(pid_from_proc_entry("1"), Some(1));
|
||||||
|
assert_eq!(pid_from_proc_entry("41234"), Some(41234));
|
||||||
|
assert_eq!(pid_from_proc_entry("self"), None);
|
||||||
|
assert_eq!(pid_from_proc_entry("1abc"), None);
|
||||||
|
assert_eq!(pid_from_proc_entry("+7"), None);
|
||||||
|
assert_eq!(pid_from_proc_entry(""), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cardsdll_base_is_the_first_matching_mapping() {
|
||||||
|
let maps = concat!(
|
||||||
|
"140000000-140001000 r--p 00000000 08:02 12 /home/u/FIFA17.exe\n",
|
||||||
|
"7f2a11c00000-7f2a11c9c000 r-xp 00000000 08:02 44 /home/u/CardsDLL_Win64_retail.dll\n",
|
||||||
|
"7f2a12000000-7f2a12001000 r--p 00000000 08:02 45 /home/u/CardsDLL_second.dll\n",
|
||||||
|
);
|
||||||
|
assert_eq!(parse_cardsdll_base(maps), Some(0x7f2a11c00000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cardsdll_base_absent_or_unparsable() {
|
||||||
|
assert_eq!(parse_cardsdll_base(""), None);
|
||||||
|
let no_dll = "140000000-140001000 r--p 00000000 08:02 12 /home/u/FIFA17.exe\n";
|
||||||
|
assert_eq!(parse_cardsdll_base(no_dll), None);
|
||||||
|
// A CardsDLL line whose base is not hex: fail, do not fall through.
|
||||||
|
let broken = concat!(
|
||||||
|
"zzzz-140001000 r--p 00000000 08:02 12 /home/u/CardsDLL.dll\n",
|
||||||
|
"7f2a11c00000-7f2a11c9c000 r-xp 0 08:02 44 /home/u/CardsDLL.dll\n",
|
||||||
|
);
|
||||||
|
assert_eq!(parse_cardsdll_base(broken), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn launcher_pid_parsing() {
|
||||||
|
assert_eq!(parse_launcher_pid(Vec::<String>::new()), Ok(None));
|
||||||
|
assert_eq!(parse_launcher_pid(["--other", "3"]), Ok(None));
|
||||||
|
assert_eq!(parse_launcher_pid(["--launcher-pid", "4242"]), Ok(Some(4242)));
|
||||||
|
assert_eq!(
|
||||||
|
parse_launcher_pid(["-x", "--launcher-pid", "7", "--launcher-pid", "9"]),
|
||||||
|
Ok(Some(7))
|
||||||
|
);
|
||||||
|
// Falsy in the Python (`if launcher_pid and ...`): parsed, never watched.
|
||||||
|
assert_eq!(parse_launcher_pid(["--launcher-pid", "0"]), Ok(Some(0)));
|
||||||
|
assert_eq!(parse_launcher_pid(["--launcher-pid", "-5"]), Ok(Some(-5)));
|
||||||
|
assert_eq!(parse_launcher_pid(["--launcher-pid"]), Err(()));
|
||||||
|
assert_eq!(parse_launcher_pid(["--launcher-pid", "abc"]), Err(()));
|
||||||
|
assert_eq!(parse_launcher_pid(["--launcher-pid", ""]), Err(()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
//! Wall-clock `HH:MM:SS` in local time, from std alone.
|
||||||
|
//!
|
||||||
|
//! The Python logs `time.strftime('%H:%M:%S')`, i.e. *local* time, and the
|
||||||
|
//! launcher interleaves these lines with its own log, so UTC would misreport the
|
||||||
|
//! timestamps by the machine's offset. std has no local-time support, so the
|
||||||
|
//! UTC offset is taken from the system's TZif database (`TZ` or `/etc/localtime`),
|
||||||
|
//! parsed here — a bounded, well-specified format (RFC 8536).
|
||||||
|
//!
|
||||||
|
//! LIMITATION, stated rather than hidden: only the TZif transition table is
|
||||||
|
//! evaluated, not the trailing POSIX-TZ footer string. With the "fat" tzdata
|
||||||
|
//! that Debian-family systems ship, transitions run to 2037, so the offset —
|
||||||
|
//! including DST — is exact. Two cases fall back to the last known transition's
|
||||||
|
//! offset (so a DST-observing zone could read one hour off) and one falls back
|
||||||
|
//! to UTC:
|
||||||
|
//! * "slim" tzdata, or dates past the last transition -> last transition;
|
||||||
|
//! * `TZ` holding a bare POSIX rule (`EST5EDT`) with no such zone file, or an
|
||||||
|
//! unreadable/corrupt zone file -> UTC.
|
||||||
|
//! The timestamp is diagnostic; no line the launcher parses carries a time.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
/// Resolved UTC offsets over time: an offset before the first transition, then
|
||||||
|
/// `(transition instant, offset from that instant on)` in ascending order.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub struct TzData {
|
||||||
|
initial: i32,
|
||||||
|
transitions: Vec<(i64, i32)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TzData {
|
||||||
|
/// UTC offset in seconds applying at `unix_secs`.
|
||||||
|
pub fn offset_at(&self, unix_secs: i64) -> i32 {
|
||||||
|
let idx = self
|
||||||
|
.transitions
|
||||||
|
.partition_point(|(start, _)| *start <= unix_secs);
|
||||||
|
if idx == 0 {
|
||||||
|
self.initial
|
||||||
|
} else {
|
||||||
|
self.transitions[idx - 1].1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `[HH:MM:SS]`-worthy time-of-day for a Unix timestamp at a given UTC offset.
|
||||||
|
pub fn hms(unix_secs: i64, utc_offset_secs: i32) -> (u32, u32, u32) {
|
||||||
|
let local = unix_secs + i64::from(utc_offset_secs);
|
||||||
|
// rem_euclid keeps pre-epoch and negative-offset instants on a sane clock.
|
||||||
|
let day = local.rem_euclid(86_400);
|
||||||
|
((day / 3600) as u32, (day % 3600 / 60) as u32, (day % 60) as u32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current local time of day, `(hour, minute, second)`.
|
||||||
|
pub fn now_hms() -> (u32, u32, u32) {
|
||||||
|
let unix = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
// Before 1970 the clock is broken anyway; keep logging rather than panic.
|
||||||
|
.unwrap_or(0);
|
||||||
|
hms(unix, local_offset_at(unix))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UTC offset in seconds for `unix_secs`, or 0 when no zone data is usable.
|
||||||
|
///
|
||||||
|
/// The zone file is read and parsed once; the offset is then recomputed per call
|
||||||
|
/// so a DST transition during a long-running session is picked up.
|
||||||
|
pub fn local_offset_at(unix_secs: i64) -> i32 {
|
||||||
|
static TZ: LazyLock<Option<TzData>> = LazyLock::new(load_system_tz);
|
||||||
|
TZ.as_ref().map_or(0, |tz| tz.offset_at(unix_secs))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read and parse the zone file named by `TZ`, else `/etc/localtime`.
|
||||||
|
fn load_system_tz() -> Option<TzData> {
|
||||||
|
let path = match std::env::var("TZ") {
|
||||||
|
Ok(tz) if !tz.is_empty() => {
|
||||||
|
// glibc accepts a leading ':' and either an absolute path or a name
|
||||||
|
// relative to the zoneinfo directory.
|
||||||
|
let name = tz.strip_prefix(':').unwrap_or(&tz);
|
||||||
|
if name.starts_with('/') {
|
||||||
|
name.to_string()
|
||||||
|
} else {
|
||||||
|
format!("/usr/share/zoneinfo/{name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => "/etc/localtime".to_string(),
|
||||||
|
};
|
||||||
|
parse_tzif(&fs::read(path).ok()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a TZif (RFC 8536) file into resolved offsets.
|
||||||
|
///
|
||||||
|
/// For version 2+ files the 64-bit data block is used; the legacy 32-bit block
|
||||||
|
/// is skipped, because modern tzdata leaves it minimal.
|
||||||
|
pub fn parse_tzif(bytes: &[u8]) -> Option<TzData> {
|
||||||
|
let (version, counts) = parse_header(bytes, 0)?;
|
||||||
|
if version >= b'2' {
|
||||||
|
// Skip the v1 header + v1 data block, then re-read the 64-bit header.
|
||||||
|
let v1_end = 44 + data_block_len(&counts, 4)?;
|
||||||
|
let (_, counts64) = parse_header(bytes, v1_end)?;
|
||||||
|
parse_data(bytes, v1_end + 44, &counts64, 8)
|
||||||
|
} else {
|
||||||
|
parse_data(bytes, 44, &counts, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `(isutcnt, isstdcnt, leapcnt, timecnt, typecnt, charcnt)`.
|
||||||
|
type Counts = [u32; 6];
|
||||||
|
|
||||||
|
fn parse_header(bytes: &[u8], off: usize) -> Option<(u8, Counts)> {
|
||||||
|
let head = bytes.get(off..off + 44)?;
|
||||||
|
if &head[0..4] != b"TZif" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let version = head[4];
|
||||||
|
let mut counts = [0u32; 6];
|
||||||
|
for (i, slot) in counts.iter_mut().enumerate() {
|
||||||
|
let at = 20 + i * 4;
|
||||||
|
*slot = u32::from_be_bytes(head[at..at + 4].try_into().ok()?);
|
||||||
|
}
|
||||||
|
Some((version, counts))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Byte length of a data block with `time_len`-wide transition times.
|
||||||
|
fn data_block_len(counts: &Counts, time_len: usize) -> Option<usize> {
|
||||||
|
let [isutcnt, isstdcnt, leapcnt, timecnt, typecnt, charcnt] = counts.map(|c| c as usize);
|
||||||
|
Some(
|
||||||
|
timecnt * time_len
|
||||||
|
+ timecnt
|
||||||
|
+ typecnt * 6
|
||||||
|
+ charcnt
|
||||||
|
+ leapcnt * (time_len + 4)
|
||||||
|
+ isstdcnt
|
||||||
|
+ isutcnt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_data(bytes: &[u8], off: usize, counts: &Counts, time_len: usize) -> Option<TzData> {
|
||||||
|
let [_, _, _, timecnt, typecnt, _] = counts.map(|c| c as usize);
|
||||||
|
if typecnt == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let block = bytes.get(off..off + data_block_len(counts, time_len)?)?;
|
||||||
|
|
||||||
|
let times = block.get(..timecnt * time_len)?;
|
||||||
|
let type_idx = block.get(timecnt * time_len..timecnt * time_len + timecnt)?;
|
||||||
|
let ttinfo_at = timecnt * time_len + timecnt;
|
||||||
|
let ttinfo = block.get(ttinfo_at..ttinfo_at + typecnt * 6)?;
|
||||||
|
|
||||||
|
// utoff + isdst per local-time type.
|
||||||
|
let mut offsets = Vec::with_capacity(typecnt);
|
||||||
|
for i in 0..typecnt {
|
||||||
|
let rec = &ttinfo[i * 6..i * 6 + 6];
|
||||||
|
let utoff = i32::from_be_bytes(rec[0..4].try_into().ok()?);
|
||||||
|
offsets.push((utoff, rec[4] != 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Before the first transition, RFC 8536 says to use the first non-DST type,
|
||||||
|
// falling back to the first type. This is also the whole answer for a
|
||||||
|
// fixed-offset zone (typecnt 1, timecnt 0), e.g. Etc/UTC.
|
||||||
|
let initial = offsets
|
||||||
|
.iter()
|
||||||
|
.find(|(_, isdst)| !*isdst)
|
||||||
|
.unwrap_or(&offsets[0])
|
||||||
|
.0;
|
||||||
|
|
||||||
|
let mut transitions = Vec::with_capacity(timecnt);
|
||||||
|
for i in 0..timecnt {
|
||||||
|
let raw = ×[i * time_len..(i + 1) * time_len];
|
||||||
|
let at = if time_len == 8 {
|
||||||
|
i64::from_be_bytes(raw.try_into().ok()?)
|
||||||
|
} else {
|
||||||
|
i64::from(i32::from_be_bytes(raw.try_into().ok()?))
|
||||||
|
};
|
||||||
|
let (utoff, _) = *offsets.get(*type_idx.get(i)? as usize)?;
|
||||||
|
transitions.push((at, utoff));
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(TzData {
|
||||||
|
initial,
|
||||||
|
transitions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Minimal TZif builder: `transitions` are `(instant, type index)`.
|
||||||
|
fn tzif(version: u8, types: &[(i32, bool)], transitions: &[(i64, u8)]) -> Vec<u8> {
|
||||||
|
fn block(types: &[(i32, bool)], transitions: &[(i64, u8)], time_len: usize) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (at, _) in transitions {
|
||||||
|
if time_len == 8 {
|
||||||
|
out.extend_from_slice(&at.to_be_bytes());
|
||||||
|
} else {
|
||||||
|
out.extend_from_slice(&(*at as i32).to_be_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (_, idx) in transitions {
|
||||||
|
out.push(*idx);
|
||||||
|
}
|
||||||
|
for (utoff, isdst) in types {
|
||||||
|
out.extend_from_slice(&utoff.to_be_bytes());
|
||||||
|
out.push(u8::from(*isdst));
|
||||||
|
out.push(0); // abbreviation index
|
||||||
|
}
|
||||||
|
out.push(0); // one NUL abbreviation byte
|
||||||
|
out
|
||||||
|
}
|
||||||
|
fn header(version: u8, types: usize, transitions: usize) -> Vec<u8> {
|
||||||
|
let mut out = Vec::from(*b"TZif");
|
||||||
|
out.push(version);
|
||||||
|
out.extend_from_slice(&[0u8; 15]);
|
||||||
|
for count in [0u32, 0, 0, transitions as u32, types as u32, 1] {
|
||||||
|
out.extend_from_slice(&count.to_be_bytes());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
let mut out = header(version, types.len(), transitions.len());
|
||||||
|
if version >= b'2' {
|
||||||
|
// Modern "slim-ish" shape: an empty v1 block, then the 64-bit block.
|
||||||
|
out.truncate(0);
|
||||||
|
out.extend(header(version, types.len(), 0));
|
||||||
|
out.extend(block(types, &[], 4));
|
||||||
|
out.extend(header(version, types.len(), transitions.len()));
|
||||||
|
out.extend(block(types, transitions, 8));
|
||||||
|
} else {
|
||||||
|
out.extend(block(types, transitions, 4));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixed_offset_zone_has_no_transitions() {
|
||||||
|
let tz = parse_tzif(&tzif(b'2', &[(0, false)], &[])).unwrap();
|
||||||
|
assert_eq!(tz.offset_at(0), 0);
|
||||||
|
assert_eq!(tz.offset_at(1_800_000_000), 0);
|
||||||
|
|
||||||
|
let kolkata = parse_tzif(&tzif(b'2', &[(19_800, false)], &[])).unwrap();
|
||||||
|
assert_eq!(kolkata.offset_at(1_800_000_000), 19_800);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dst_transitions_select_the_right_offset() {
|
||||||
|
// CET/CEST with two transitions.
|
||||||
|
let tz = parse_tzif(&tzif(
|
||||||
|
b'2',
|
||||||
|
&[(3600, false), (7200, true)],
|
||||||
|
&[(1_000_000_000, 1), (1_100_000_000, 0)],
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(tz.offset_at(999_999_999), 3600); // before the first transition
|
||||||
|
assert_eq!(tz.offset_at(1_000_000_000), 7200); // exactly at it
|
||||||
|
assert_eq!(tz.offset_at(1_050_000_000), 7200);
|
||||||
|
assert_eq!(tz.offset_at(1_100_000_000), 3600);
|
||||||
|
assert_eq!(tz.offset_at(i64::MAX), 3600); // past the table: last known
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn version_1_files_parse_from_the_32_bit_block() {
|
||||||
|
let tz = parse_tzif(&tzif(b'\0', &[(-18_000, false)], &[(100, 0)])).unwrap();
|
||||||
|
assert_eq!(tz.offset_at(0), -18_000);
|
||||||
|
assert_eq!(tz.offset_at(1_000), -18_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn initial_offset_skips_a_leading_dst_type() {
|
||||||
|
let tz = parse_tzif(&tzif(b'2', &[(7200, true), (3600, false)], &[])).unwrap();
|
||||||
|
assert_eq!(tz.offset_at(0), 3600);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn garbage_is_rejected_rather_than_guessed() {
|
||||||
|
assert_eq!(parse_tzif(b""), None);
|
||||||
|
assert_eq!(parse_tzif(b"not a tzif file at all, truncated"), None);
|
||||||
|
let mut truncated = tzif(b'2', &[(3600, false)], &[(1, 0)]);
|
||||||
|
truncated.truncate(truncated.len() - 5);
|
||||||
|
assert_eq!(parse_tzif(&truncated), None);
|
||||||
|
// Well-formed header claiming zero local-time types is unusable.
|
||||||
|
assert_eq!(parse_tzif(&tzif(b'2', &[], &[])), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hms_matches_known_instants() {
|
||||||
|
assert_eq!(hms(0, 0), (0, 0, 0));
|
||||||
|
// 2026-08-18T04:52:08Z
|
||||||
|
assert_eq!(hms(1_787_028_728, 0), (4, 52, 8));
|
||||||
|
// …the same instant at +02:00 and at -05:00 (the latter is the day before).
|
||||||
|
assert_eq!(hms(1_787_028_728, 7200), (6, 52, 8));
|
||||||
|
assert_eq!(hms(1_787_028_728, -18_000), (23, 52, 8));
|
||||||
|
// Offsets that cross midnight in either direction stay on the clock.
|
||||||
|
assert_eq!(hms(86_399, 1), (0, 0, 0));
|
||||||
|
assert_eq!(hms(0, -1), (23, 59, 59));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_system_zone_resolves_to_a_plausible_offset() {
|
||||||
|
// Whatever this machine's zone is, the offset must be a real one.
|
||||||
|
let offset = local_offset_at(1_786_697_528);
|
||||||
|
assert!((-50_400..=50_400).contains(&offset), "implausible {offset}");
|
||||||
|
assert_eq!(offset % 60, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
//! `[HH:MM:SS] <msg>` to stdout (flushed per line) and appended to the log file.
|
||||||
|
//!
|
||||||
|
//! The launcher pipes this process's stdout into its own log buffer and *parses*
|
||||||
|
//! some of these lines, so the per-line flush and the line shapes are a contract,
|
||||||
|
//! not cosmetics.
|
||||||
|
|
||||||
|
use std::fs::OpenOptions;
|
||||||
|
use std::io::{self, Write};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::localtime;
|
||||||
|
use crate::procmem;
|
||||||
|
|
||||||
|
/// Environment override for the log file path.
|
||||||
|
pub const LOG_PATH_ENV: &str = "OPENFUT_AUTOPATCH_LOG";
|
||||||
|
|
||||||
|
pub struct Logger {
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Logger {
|
||||||
|
/// `$OPENFUT_AUTOPATCH_LOG`, defaulting to `/tmp/openfut-autopatch-<uid>.log`.
|
||||||
|
///
|
||||||
|
/// Resolved once at startup, exactly like the Python's module-level `LOG`, so
|
||||||
|
/// a later environment change cannot move the file mid-run.
|
||||||
|
pub fn from_env() -> io::Result<Self> {
|
||||||
|
let path = match std::env::var_os(LOG_PATH_ENV) {
|
||||||
|
Some(path) if !path.is_empty() => PathBuf::from(path),
|
||||||
|
_ => PathBuf::from(format!(
|
||||||
|
"/tmp/openfut-autopatch-{}.log",
|
||||||
|
procmem::current_uid()?
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
Ok(Self { path })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(&self) -> &std::path::Path {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit one line. Timestamped in local time.
|
||||||
|
pub fn log(&self, msg: &str) {
|
||||||
|
let (h, m, s) = localtime::now_hms();
|
||||||
|
let line = format!("[{h:02}:{m:02}:{s:02}] {msg}");
|
||||||
|
|
||||||
|
let mut stdout = io::stdout().lock();
|
||||||
|
// Ignore a broken pipe: the launcher may have stopped reading, and dying
|
||||||
|
// here would leave FIFA's store patches unenforced.
|
||||||
|
let _ = writeln!(stdout, "{line}");
|
||||||
|
let _ = stdout.flush();
|
||||||
|
drop(stdout);
|
||||||
|
|
||||||
|
if let Err(e) = self.append(&line) {
|
||||||
|
// The Python lets a failing log write kill the process. Patching the
|
||||||
|
// running client matters more than the transcript, so report once to
|
||||||
|
// stderr (the launcher captures it too) and carry on.
|
||||||
|
let _ = writeln!(io::stderr(), "autopatch: cannot append to {}: {e}", self.path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append(&self, line: &str) -> io::Result<()> {
|
||||||
|
let mut file = OpenOptions::new().create(true).append(true).open(&self.path)?;
|
||||||
|
file.write_all(line.as_bytes())?;
|
||||||
|
file.write_all(b"\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn appends_a_timestamped_line_to_the_configured_path() {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"openfut-autopatch-test-{}.log",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let logger = Logger {
|
||||||
|
path: path.clone(),
|
||||||
|
};
|
||||||
|
logger.log("pid 4242: PATCHED cert gates");
|
||||||
|
logger.log("second line");
|
||||||
|
|
||||||
|
let body = std::fs::read_to_string(&path).unwrap();
|
||||||
|
let lines: Vec<&str> = body.lines().collect();
|
||||||
|
assert_eq!(lines.len(), 2);
|
||||||
|
assert_eq!(&lines[0][..1], "[");
|
||||||
|
assert_eq!(&lines[0][3..4], ":");
|
||||||
|
assert_eq!(&lines[0][6..7], ":");
|
||||||
|
assert_eq!(&lines[0][9..], "] pid 4242: PATCHED cert gates");
|
||||||
|
assert!(lines[1].ends_with("] second line"));
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bad_log_path_does_not_kill_the_patcher() {
|
||||||
|
let logger = Logger {
|
||||||
|
path: PathBuf::from("/proc/definitely/not/writable.log"),
|
||||||
|
};
|
||||||
|
logger.log("still running");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
//! Watch for a (re)launched FIFA17.exe and auto-apply the ProtoSSL cert patches
|
||||||
|
//! the moment its unpacked code is mapped, plus the CardsDLL FUT store patches.
|
||||||
|
//! Idempotent; keeps watching across relaunches.
|
||||||
|
//!
|
||||||
|
//! Port of `fifa17-recon/tools/autopatch.py`, tick for tick: cert gates once per
|
||||||
|
//! pid, store patches re-enforced every second (the game rewrites those sites),
|
||||||
|
//! then the resolver-guard capability reported once per pid. The passes
|
||||||
|
//! themselves live in `openfut_autopatch::patch`; this is the loop and the CLI.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::process::ExitCode;
|
||||||
|
use std::thread::sleep;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use openfut_autopatch::patch::{cert_pass, enforce_store_patches};
|
||||||
|
use openfut_autopatch::procmem::{self, ProcMem};
|
||||||
|
use openfut_autopatch::{parse_launcher_pid, Logger};
|
||||||
|
|
||||||
|
/// One tick per second, as in the Python.
|
||||||
|
const TICK: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
|
/// Per-pid bookkeeping so each of these lines is logged exactly once per client
|
||||||
|
/// process (the Python's three module-level sets).
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Seen {
|
||||||
|
patched: HashSet<u32>,
|
||||||
|
store_patched: HashSet<u32>,
|
||||||
|
guard_reported: HashSet<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
let launcher_pid = match parse_launcher_pid(std::env::args().skip(1)) {
|
||||||
|
Ok(pid) => pid,
|
||||||
|
Err(()) => {
|
||||||
|
eprintln!("invalid --launcher-pid");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let logger = match Logger::from_env() {
|
||||||
|
Ok(logger) => logger,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("cannot resolve the autopatch log path: {e}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.log("=== AUTOPATCH watching for FIFA17.exe ===");
|
||||||
|
|
||||||
|
let mut seen = Seen::default();
|
||||||
|
loop {
|
||||||
|
// `if launcher_pid and not os.path.exists(...)`: pid 0 is falsy in the
|
||||||
|
// Python, so `--launcher-pid 0` parses but is never watched.
|
||||||
|
if let Some(pid) = launcher_pid {
|
||||||
|
if pid != 0 && !procmem::pid_alive(pid) {
|
||||||
|
logger.log(&format!("launcher pid {pid} exited; stopping autopatch"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for pid in procmem::find_pids() {
|
||||||
|
let mem = ProcMem::new(pid);
|
||||||
|
|
||||||
|
if !seen.patched.contains(&pid)
|
||||||
|
&& !cert_pass(&mem, &mut |line| logger.log(line), &mut seen.patched)
|
||||||
|
{
|
||||||
|
// Code not mapped yet: nothing else to do for this pid this tick.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store patches are enforced on EVERY tick, not once: the game
|
||||||
|
// rewrites these sites, so a single pass at startup does not hold.
|
||||||
|
let Some(cbase) = procmem::cardsdll_base(pid) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match enforce_store_patches(
|
||||||
|
&mem,
|
||||||
|
cbase,
|
||||||
|
&mut |line| logger.log(line),
|
||||||
|
&mut seen.guard_reported,
|
||||||
|
) {
|
||||||
|
Ok(()) => {
|
||||||
|
if seen.store_patched.insert(pid) {
|
||||||
|
logger.log(&format!(
|
||||||
|
"pid {pid}: PATCHED store gates in CardsDLL @ {cbase:#x}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => logger.log(&format!("pid {pid}: store patch write failed: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep(TICK);
|
||||||
|
}
|
||||||
|
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
}
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
//! The two enforcement passes: ProtoSSL cert gates (once per pid) and the
|
||||||
|
//! CardsDLL store patches (every tick).
|
||||||
|
//!
|
||||||
|
//! Both are written against the [`Memory`] trait rather than `/proc` directly, so
|
||||||
|
//! the log lines and their order — which the launcher reads — are unit-testable
|
||||||
|
//! without a live FIFA client.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
guard_state_after, guarded_action, hex, live_addr, GuardState, GuardedAction,
|
||||||
|
EMPTY_MYPACKS_RESOLVER_CAPABILITY, EMPTY_MYPACKS_RESOLVER_VERSION, GATE1, GATE1_ORIG,
|
||||||
|
GATE1_PATCH, GATE2, GATE2_ORIG, GATE2_PATCH, MAX_PATCH_LEN, RESOLVER_GUARD_VA, STORE_PATCHES,
|
||||||
|
STORE_PATCHES_GUARDED,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Byte-level access to one client process's address space.
|
||||||
|
pub trait Memory {
|
||||||
|
/// The pid being patched; it appears in every log line.
|
||||||
|
fn pid(&self) -> u32;
|
||||||
|
/// Fill `buf` from virtual address `va`. An error means "not mapped (yet)".
|
||||||
|
fn read(&self, va: u64, buf: &mut [u8]) -> io::Result<()>;
|
||||||
|
/// Write `data` at virtual address `va`.
|
||||||
|
fn write(&self, va: u64, data: &[u8]) -> io::Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the two ProtoSSL cert gates, once per pid.
|
||||||
|
///
|
||||||
|
/// Returns `false` when the gates could not be read — the packer has not mapped
|
||||||
|
/// that code yet, which is the Python's `continue`, not an error to report.
|
||||||
|
pub fn cert_pass<M: Memory>(
|
||||||
|
mem: &M,
|
||||||
|
log: &mut impl FnMut(&str),
|
||||||
|
patched: &mut HashSet<u32>,
|
||||||
|
) -> bool {
|
||||||
|
let pid = mem.pid();
|
||||||
|
// Sized from the patterns themselves; the initial contents are overwritten by
|
||||||
|
// the reads and are never compared unless both reads succeed.
|
||||||
|
let mut g2 = GATE2_ORIG;
|
||||||
|
let mut g1 = GATE1_ORIG;
|
||||||
|
if mem.read(GATE2, &mut g2).is_err() || mem.read(GATE1, &mut g1).is_err() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if g2 == GATE2_PATCH && g1 == GATE1_PATCH {
|
||||||
|
log(&format!("pid {pid}: cert gates already patched"));
|
||||||
|
patched.insert(pid);
|
||||||
|
} else if g2 == GATE2_ORIG && g1 == GATE1_ORIG {
|
||||||
|
match mem
|
||||||
|
.write(GATE2, &GATE2_PATCH)
|
||||||
|
.and_then(|()| mem.write(GATE1, &GATE1_PATCH))
|
||||||
|
{
|
||||||
|
Ok(()) => {
|
||||||
|
log(&format!("pid {pid}: PATCHED cert gates"));
|
||||||
|
patched.insert(pid);
|
||||||
|
}
|
||||||
|
Err(e) => log(&format!("pid {pid}: cert patch write failed: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Anything else is a build we do not recognise: left alone, as in the Python.
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-apply every store patch whose live bytes have drifted, then the guarded
|
||||||
|
/// patch, then report the resolver-guard capability once per pid.
|
||||||
|
///
|
||||||
|
/// An `Err` is a read or write that failed outside the guarded site's own
|
||||||
|
/// handling; it aborts the rest of this pid's pass for this tick, exactly like
|
||||||
|
/// the Python's enclosing `try`.
|
||||||
|
pub fn enforce_store_patches<M: Memory>(
|
||||||
|
mem: &M,
|
||||||
|
cbase: u64,
|
||||||
|
log: &mut impl FnMut(&str),
|
||||||
|
guard_reported: &mut HashSet<u32>,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
let pid = mem.pid();
|
||||||
|
|
||||||
|
for (va, data) in STORE_PATCHES {
|
||||||
|
let live = live_addr(cbase, va);
|
||||||
|
let mut buf = [0u8; MAX_PATCH_LEN];
|
||||||
|
let cur = &mut buf[..data.len()];
|
||||||
|
mem.read(live, cur)?;
|
||||||
|
if cur != data {
|
||||||
|
mem.write(live, data)?;
|
||||||
|
log(&format!("pid {pid}: ENFORCED store patch @ {live:#x}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (va, orig, patch) in STORE_PATCHES_GUARDED {
|
||||||
|
let live = live_addr(cbase, va);
|
||||||
|
let mut before = [0u8; MAX_PATCH_LEN];
|
||||||
|
mem.read(live, &mut before[..patch.len()])?;
|
||||||
|
let cur = &before[..patch.len()];
|
||||||
|
|
||||||
|
let mut wrote_ok = true;
|
||||||
|
// The Python starts with `cur_after = cur`, which only matters on the
|
||||||
|
// branches that never re-read.
|
||||||
|
let mut after = [0u8; MAX_PATCH_LEN];
|
||||||
|
after[..patch.len()].copy_from_slice(cur);
|
||||||
|
let mut after_len = patch.len();
|
||||||
|
|
||||||
|
match guarded_action(cur, orig, patch) {
|
||||||
|
GuardedAction::Patch => {
|
||||||
|
match mem.write(live, patch) {
|
||||||
|
Ok(()) => log(&format!(
|
||||||
|
"pid {pid}: ENFORCED guarded store patch @ {live:#x} (JNZ->JG, empty My Packs)"
|
||||||
|
)),
|
||||||
|
Err(e) => {
|
||||||
|
wrote_ok = false;
|
||||||
|
log(&format!(
|
||||||
|
"pid {pid}: guarded patch write failed @ {live:#x}: {e}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if wrote_ok && mem.read(live, &mut after[..patch.len()]).is_err() {
|
||||||
|
// The Python's `cur_after = b""`: unverifiable, so not verified.
|
||||||
|
after_len = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GuardedAction::Skip => log(&format!(
|
||||||
|
"pid {pid}: SKIP guarded patch @ {live:#x}: unexpected {} (build mismatch)",
|
||||||
|
hex(cur)
|
||||||
|
)),
|
||||||
|
// Already patched; nothing to write.
|
||||||
|
GuardedAction::Noop => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if va == RESOLVER_GUARD_VA && !guard_reported.contains(&pid) {
|
||||||
|
let state = guard_state_after(cur, orig, patch, wrote_ok, &after[..after_len]);
|
||||||
|
if state == GuardState::Verified {
|
||||||
|
// PARSED BY THE LAUNCHER (fifa17_capability::parse_capability_line):
|
||||||
|
// this line must keep both `verified capability` and the
|
||||||
|
// `fifa17.empty_mypacks_resolver=<version>` token verbatim.
|
||||||
|
log(&format!(
|
||||||
|
"[store-guard] verified capability {EMPTY_MYPACKS_RESOLVER_CAPABILITY}={EMPTY_MYPACKS_RESOLVER_VERSION} fifa_pid={pid}"
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
log(&format!(
|
||||||
|
"[store-guard] guard status={state} fifa_pid={pid} (no capability advertised)"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
guard_reported.insert(pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
const CBASE: u64 = 0x7f2a11c00000;
|
||||||
|
const GUARD_LIVE: u64 = CBASE + 0x14858;
|
||||||
|
|
||||||
|
/// Sparse fake address space: an unmapped byte reads as `NotFound`, mirroring
|
||||||
|
/// `/proc/<pid>/mem` refusing an address the packer has not produced yet.
|
||||||
|
struct FakeMemory {
|
||||||
|
bytes: RefCell<BTreeMap<u64, u8>>,
|
||||||
|
/// Writes to these addresses fail.
|
||||||
|
fail_writes: Vec<u64>,
|
||||||
|
/// Writes to these addresses report success but change nothing (the
|
||||||
|
/// VERIFY_FAILED shape).
|
||||||
|
swallow_writes: Vec<u64>,
|
||||||
|
/// Reads of these addresses fail even when mapped.
|
||||||
|
fail_reads: Vec<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeMemory {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
bytes: RefCell::new(BTreeMap::new()),
|
||||||
|
fail_writes: Vec::new(),
|
||||||
|
swallow_writes: Vec::new(),
|
||||||
|
fail_reads: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map(self, va: u64, bytes: &[u8]) -> Self {
|
||||||
|
{
|
||||||
|
let mut mem = self.bytes.borrow_mut();
|
||||||
|
for (i, b) in bytes.iter().enumerate() {
|
||||||
|
mem.insert(va + i as u64, *b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every store-patch site mapped with filler that is neither the patch
|
||||||
|
/// nor (for the guarded site) the original.
|
||||||
|
fn with_store_sites(mut self, filler: u8) -> Self {
|
||||||
|
for (va, data) in STORE_PATCHES {
|
||||||
|
self = self.map(live_addr(CBASE, va), &vec![filler; data.len()]);
|
||||||
|
}
|
||||||
|
for (va, _, patch) in STORE_PATCHES_GUARDED {
|
||||||
|
self = self.map(live_addr(CBASE, va), &vec![filler; patch.len()]);
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn at(&self, va: u64, len: usize) -> Vec<u8> {
|
||||||
|
let mem = self.bytes.borrow();
|
||||||
|
(0..len as u64).map(|i| mem[&(va + i)]).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Memory for FakeMemory {
|
||||||
|
fn pid(&self) -> u32 {
|
||||||
|
4242
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(&self, va: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||||
|
if self.fail_reads.contains(&va) {
|
||||||
|
return Err(io::Error::from(io::ErrorKind::PermissionDenied));
|
||||||
|
}
|
||||||
|
let mem = self.bytes.borrow();
|
||||||
|
for (i, slot) in buf.iter_mut().enumerate() {
|
||||||
|
*slot = *mem
|
||||||
|
.get(&(va + i as u64))
|
||||||
|
.ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(&self, va: u64, data: &[u8]) -> io::Result<()> {
|
||||||
|
if self.fail_writes.contains(&va) {
|
||||||
|
return Err(io::Error::from(io::ErrorKind::PermissionDenied));
|
||||||
|
}
|
||||||
|
if self.swallow_writes.contains(&va) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let mut mem = self.bytes.borrow_mut();
|
||||||
|
for (i, b) in data.iter().enumerate() {
|
||||||
|
mem.insert(va + i as u64, *b);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collects log lines so the contract strings can be asserted verbatim.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Lines(Vec<String>);
|
||||||
|
|
||||||
|
impl Lines {
|
||||||
|
fn sink(&mut self) -> impl FnMut(&str) + '_ {
|
||||||
|
|line: &str| self.0.push(line.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cert_pass_defers_while_the_code_is_not_mapped() {
|
||||||
|
let mem = FakeMemory::new();
|
||||||
|
let mut lines = Lines::default();
|
||||||
|
let mut patched = HashSet::new();
|
||||||
|
assert!(!cert_pass(&mem, &mut lines.sink(), &mut patched));
|
||||||
|
assert!(lines.0.is_empty(), "{:?}", lines.0);
|
||||||
|
assert!(patched.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cert_pass_defers_when_only_the_first_gate_is_mapped() {
|
||||||
|
let mem = FakeMemory::new().map(GATE2, &GATE2_ORIG);
|
||||||
|
let mut lines = Lines::default();
|
||||||
|
let mut patched = HashSet::new();
|
||||||
|
assert!(!cert_pass(&mem, &mut lines.sink(), &mut patched));
|
||||||
|
assert!(lines.0.is_empty());
|
||||||
|
assert!(patched.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cert_pass_writes_both_gates_once() {
|
||||||
|
let mem = FakeMemory::new()
|
||||||
|
.map(GATE2, &GATE2_ORIG)
|
||||||
|
.map(GATE1, &GATE1_ORIG);
|
||||||
|
let mut lines = Lines::default();
|
||||||
|
let mut patched = HashSet::new();
|
||||||
|
assert!(cert_pass(&mem, &mut lines.sink(), &mut patched));
|
||||||
|
assert_eq!(lines.0, vec!["pid 4242: PATCHED cert gates"]);
|
||||||
|
assert_eq!(mem.at(GATE2, 3), GATE2_PATCH);
|
||||||
|
assert_eq!(mem.at(GATE1, 6), GATE1_PATCH);
|
||||||
|
assert!(patched.contains(&4242));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cert_pass_recognises_an_already_patched_client() {
|
||||||
|
let mem = FakeMemory::new()
|
||||||
|
.map(GATE2, &GATE2_PATCH)
|
||||||
|
.map(GATE1, &GATE1_PATCH);
|
||||||
|
let mut lines = Lines::default();
|
||||||
|
let mut patched = HashSet::new();
|
||||||
|
assert!(cert_pass(&mem, &mut lines.sink(), &mut patched));
|
||||||
|
assert_eq!(lines.0, vec!["pid 4242: cert gates already patched"]);
|
||||||
|
assert!(patched.contains(&4242));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cert_pass_reports_a_write_failure_and_stays_unpatched() {
|
||||||
|
let mut mem = FakeMemory::new()
|
||||||
|
.map(GATE2, &GATE2_ORIG)
|
||||||
|
.map(GATE1, &GATE1_ORIG);
|
||||||
|
mem.fail_writes.push(GATE2);
|
||||||
|
let mut lines = Lines::default();
|
||||||
|
let mut patched = HashSet::new();
|
||||||
|
assert!(cert_pass(&mem, &mut lines.sink(), &mut patched));
|
||||||
|
assert_eq!(lines.0.len(), 1);
|
||||||
|
assert!(
|
||||||
|
lines.0[0].starts_with("pid 4242: cert patch write failed: "),
|
||||||
|
"{}",
|
||||||
|
lines.0[0]
|
||||||
|
);
|
||||||
|
// Not recorded as patched, so the next tick tries again.
|
||||||
|
assert!(patched.is_empty());
|
||||||
|
assert_eq!(mem.at(GATE2, 3), GATE2_ORIG);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cert_pass_leaves_an_unrecognised_build_alone() {
|
||||||
|
let mem = FakeMemory::new()
|
||||||
|
.map(GATE2, &[0x55, 0x48, 0x89])
|
||||||
|
.map(GATE1, &[0x0f, 0x84, 0x76, 0x01, 0x00, 0x00]);
|
||||||
|
let mut lines = Lines::default();
|
||||||
|
let mut patched = HashSet::new();
|
||||||
|
assert!(cert_pass(&mem, &mut lines.sink(), &mut patched));
|
||||||
|
assert!(lines.0.is_empty(), "{:?}", lines.0);
|
||||||
|
assert!(patched.is_empty());
|
||||||
|
assert_eq!(mem.at(GATE2, 3), [0x55, 0x48, 0x89]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_pass_enforces_every_site_in_table_order_then_advertises() {
|
||||||
|
let mem = FakeMemory::new().with_store_sites(0xcc);
|
||||||
|
let mut lines = Lines::default();
|
||||||
|
let mut reported = HashSet::new();
|
||||||
|
enforce_store_patches(&mem, CBASE, &mut lines.sink(), &mut reported).unwrap();
|
||||||
|
|
||||||
|
let mut expected: Vec<String> = STORE_PATCHES
|
||||||
|
.iter()
|
||||||
|
.map(|(va, _)| {
|
||||||
|
let live = live_addr(CBASE, *va);
|
||||||
|
format!("pid 4242: ENFORCED store patch @ {live:#x}")
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// 0xcc is neither the original nor the patch: fail-closed SKIP, and the
|
||||||
|
// capability is withheld.
|
||||||
|
expected.push(format!(
|
||||||
|
"pid 4242: SKIP guarded patch @ {GUARD_LIVE:#x}: unexpected cccc (build mismatch)"
|
||||||
|
));
|
||||||
|
expected.push(
|
||||||
|
"[store-guard] guard status=UNSUPPORTED_BUILD fifa_pid=4242 (no capability advertised)"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
assert_eq!(lines.0, expected);
|
||||||
|
assert!(reported.contains(&4242));
|
||||||
|
|
||||||
|
for (va, data) in STORE_PATCHES {
|
||||||
|
assert_eq!(mem.at(live_addr(CBASE, va), data.len()), data);
|
||||||
|
}
|
||||||
|
// The guarded site was NOT overwritten.
|
||||||
|
assert_eq!(mem.at(GUARD_LIVE, 2), [0xcc, 0xcc]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_pass_patches_the_guard_and_advertises_the_capability() {
|
||||||
|
let mem = FakeMemory::new()
|
||||||
|
.with_store_sites(0xcc)
|
||||||
|
.map(GUARD_LIVE, STORE_PATCHES_GUARDED[0].1);
|
||||||
|
let mut lines = Lines::default();
|
||||||
|
let mut reported = HashSet::new();
|
||||||
|
enforce_store_patches(&mem, CBASE, &mut lines.sink(), &mut reported).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
lines.0[lines.0.len() - 2],
|
||||||
|
format!(
|
||||||
|
"pid 4242: ENFORCED guarded store patch @ {GUARD_LIVE:#x} (JNZ->JG, empty My Packs)"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lines.0[lines.0.len() - 1],
|
||||||
|
"[store-guard] verified capability fifa17.empty_mypacks_resolver=1 fifa_pid=4242"
|
||||||
|
);
|
||||||
|
assert_eq!(mem.at(GUARD_LIVE, 2), [0x7f, 0x0f]);
|
||||||
|
|
||||||
|
// Second tick: everything already enforced, and the capability is not
|
||||||
|
// re-advertised.
|
||||||
|
let mut lines = Lines::default();
|
||||||
|
enforce_store_patches(&mem, CBASE, &mut lines.sink(), &mut reported).unwrap();
|
||||||
|
assert!(lines.0.is_empty(), "{:?}", lines.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_pass_verifies_an_already_patched_guard() {
|
||||||
|
let mem = FakeMemory::new()
|
||||||
|
.with_store_sites(0xcc)
|
||||||
|
.map(GUARD_LIVE, STORE_PATCHES_GUARDED[0].2);
|
||||||
|
let mut lines = Lines::default();
|
||||||
|
let mut reported = HashSet::new();
|
||||||
|
enforce_store_patches(&mem, CBASE, &mut lines.sink(), &mut reported).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
lines.0.last().unwrap(),
|
||||||
|
"[store-guard] verified capability fifa17.empty_mypacks_resolver=1 fifa_pid=4242"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_pass_reports_a_guarded_write_failure_without_aborting_the_tick() {
|
||||||
|
let mut mem = FakeMemory::new()
|
||||||
|
.with_store_sites(0xcc)
|
||||||
|
.map(GUARD_LIVE, STORE_PATCHES_GUARDED[0].1);
|
||||||
|
mem.fail_writes.push(GUARD_LIVE);
|
||||||
|
let mut lines = Lines::default();
|
||||||
|
let mut reported = HashSet::new();
|
||||||
|
// The guarded write failure is handled inline, so the pass still succeeds.
|
||||||
|
enforce_store_patches(&mem, CBASE, &mut lines.sink(), &mut reported).unwrap();
|
||||||
|
assert!(
|
||||||
|
lines.0[lines.0.len() - 2]
|
||||||
|
.starts_with(&format!("pid 4242: guarded patch write failed @ {GUARD_LIVE:#x}: ")),
|
||||||
|
"{}",
|
||||||
|
lines.0[lines.0.len() - 2]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lines.0[lines.0.len() - 1],
|
||||||
|
"[store-guard] guard status=WRITE_FAILED fifa_pid=4242 (no capability advertised)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_pass_withholds_the_capability_when_verification_fails() {
|
||||||
|
let mut mem = FakeMemory::new()
|
||||||
|
.with_store_sites(0xcc)
|
||||||
|
.map(GUARD_LIVE, STORE_PATCHES_GUARDED[0].1);
|
||||||
|
// Write reported OK, memory unchanged: the re-read still shows the original.
|
||||||
|
mem.swallow_writes.push(GUARD_LIVE);
|
||||||
|
let mut lines = Lines::default();
|
||||||
|
let mut reported = HashSet::new();
|
||||||
|
enforce_store_patches(&mem, CBASE, &mut lines.sink(), &mut reported).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
lines.0[lines.0.len() - 1],
|
||||||
|
"[store-guard] guard status=VERIFY_FAILED fifa_pid=4242 (no capability advertised)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_pass_aborts_the_tick_when_a_site_is_not_mapped() {
|
||||||
|
// CardsDLL is mapped but this tick catches a site mid-unpack.
|
||||||
|
let mem = FakeMemory::new();
|
||||||
|
let mut lines = Lines::default();
|
||||||
|
let mut reported = HashSet::new();
|
||||||
|
let err = enforce_store_patches(&mem, CBASE, &mut lines.sink(), &mut reported).unwrap_err();
|
||||||
|
assert_eq!(err.kind(), io::ErrorKind::NotFound);
|
||||||
|
assert!(lines.0.is_empty());
|
||||||
|
// Nothing advertised, so the next tick re-evaluates the guard.
|
||||||
|
assert!(reported.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn store_pass_skips_writing_sites_that_already_hold_the_patch() {
|
||||||
|
let mut mem = FakeMemory::new().with_store_sites(0xcc);
|
||||||
|
for (va, data) in STORE_PATCHES {
|
||||||
|
mem = mem.map(live_addr(CBASE, va), data);
|
||||||
|
}
|
||||||
|
mem = mem.map(GUARD_LIVE, STORE_PATCHES_GUARDED[0].2);
|
||||||
|
// Any write at all would fail these sites, proving none is attempted.
|
||||||
|
mem.fail_writes
|
||||||
|
.extend(STORE_PATCHES.iter().map(|(va, _)| live_addr(CBASE, *va)));
|
||||||
|
mem.fail_writes.push(GUARD_LIVE);
|
||||||
|
|
||||||
|
let mut lines = Lines::default();
|
||||||
|
let mut reported = HashSet::new();
|
||||||
|
enforce_store_patches(&mem, CBASE, &mut lines.sink(), &mut reported).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
lines.0,
|
||||||
|
vec!["[store-guard] verified capability fifa17.empty_mypacks_resolver=1 fifa_pid=4242"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
//! `/proc` access: finding the client, locating CardsDLL, and positioned reads
|
||||||
|
//! and writes against `/proc/<pid>/mem`.
|
||||||
|
//!
|
||||||
|
//! Positioned I/O (`pread`/`pwrite`) is used rather than seek+read: a 64-bit
|
||||||
|
//! virtual address is passed straight through as the file offset, so nothing
|
||||||
|
//! depends on a shared file cursor.
|
||||||
|
|
||||||
|
use std::fs::{self, File, OpenOptions};
|
||||||
|
use std::io;
|
||||||
|
use std::os::unix::fs::FileExt;
|
||||||
|
|
||||||
|
use crate::patch::Memory;
|
||||||
|
use crate::{is_client_comm, parse_cardsdll_base, pid_from_proc_entry};
|
||||||
|
|
||||||
|
/// Pids whose `comm` is exactly `FIFA17.exe`.
|
||||||
|
///
|
||||||
|
/// Sorted ascending so that, when more than one client is somehow running, the
|
||||||
|
/// per-pid log lines come out in a stable order (the Python inherits readdir
|
||||||
|
/// order, which is arbitrary).
|
||||||
|
pub fn find_pids() -> Vec<u32> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let Ok(entries) = fs::read_dir("/proc") else {
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name();
|
||||||
|
let Some(pid) = name.to_str().and_then(pid_from_proc_entry) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// A pid can exit between readdir and this read; that is not an error.
|
||||||
|
if let Ok(comm) = fs::read_to_string(format!("/proc/{pid}/comm")) {
|
||||||
|
if is_client_comm(&comm) {
|
||||||
|
out.push(pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort_unstable();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Base address of the mapped CardsDLL, or `None` while it is not mapped.
|
||||||
|
pub fn cardsdll_base(pid: u32) -> Option<u64> {
|
||||||
|
let maps = fs::read_to_string(format!("/proc/{pid}/maps")).ok()?;
|
||||||
|
parse_cardsdll_base(&maps)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Memory`] over one live client process.
|
||||||
|
pub struct ProcMem {
|
||||||
|
pid: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProcMem {
|
||||||
|
pub fn new(pid: u32) -> Self {
|
||||||
|
Self { pid }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Memory for ProcMem {
|
||||||
|
fn pid(&self) -> u32 {
|
||||||
|
self.pid
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A failure here normally means the address is not mapped yet — the packer
|
||||||
|
/// has not unpacked that code — which the watch loop treats as "come back
|
||||||
|
/// next tick", not as a failure worth reporting. Read-only handle.
|
||||||
|
fn read(&self, va: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||||
|
File::open(format!("/proc/{}/mem", self.pid))?.read_exact_at(buf, va)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opened read+write like the Python's `r+b`; write-only is not universally
|
||||||
|
/// accepted for `/proc/<pid>/mem` across kernels.
|
||||||
|
fn write(&self, va: u64, data: &[u8]) -> io::Result<()> {
|
||||||
|
OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.open(format!("/proc/{}/mem", self.pid))?
|
||||||
|
.write_all_at(data, va)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `/proc/<pid>` still exists — the launcher-liveness check.
|
||||||
|
pub fn pid_alive(pid: i64) -> bool {
|
||||||
|
// Formatted exactly like the Python so a negative or zero pid behaves the
|
||||||
|
// same way (the path simply does not exist).
|
||||||
|
fs::metadata(format!("/proc/{pid}")).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Real uid of this process, from the ownership of `/proc/self`.
|
||||||
|
///
|
||||||
|
/// std exposes no `getuid`, and this crate takes no dependencies; `/proc` is
|
||||||
|
/// mandatory for the patcher anyway, so reading it back is not a new assumption.
|
||||||
|
pub fn current_uid() -> io::Result<u32> {
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
Ok(fs::metadata("/proc/self")?.uid())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn our_own_pid_is_alive_and_pid_zero_is_not() {
|
||||||
|
let me: i64 = fs::read_to_string("/proc/self/stat")
|
||||||
|
.unwrap()
|
||||||
|
.split(' ')
|
||||||
|
.next()
|
||||||
|
.unwrap()
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
assert!(pid_alive(me));
|
||||||
|
// /proc/0 and /proc/-1 never exist, matching the Python's path check.
|
||||||
|
assert!(!pid_alive(0));
|
||||||
|
assert!(!pid_alive(-1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uid_is_readable() {
|
||||||
|
// Only that it resolves; the value is environment-dependent.
|
||||||
|
assert!(current_uid().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_pids_scan_is_safe_without_a_client() {
|
||||||
|
// Deterministic without a client: the scan must not panic and must only
|
||||||
|
// ever return numeric pids.
|
||||||
|
for pid in find_pids() {
|
||||||
|
assert!(pid > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn positioned_io_round_trips_against_our_own_address_space() {
|
||||||
|
// Patching FIFA is not testable here, but the /proc/<pid>/mem mechanism
|
||||||
|
// is: read and write this process's own heap through the same code path.
|
||||||
|
let me: u32 = fs::read_to_string("/proc/self/stat")
|
||||||
|
.unwrap()
|
||||||
|
.split(' ')
|
||||||
|
.next()
|
||||||
|
.unwrap()
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
let mem = ProcMem::new(me);
|
||||||
|
// black_box throughout: this buffer is mutated by the kernel on our
|
||||||
|
// behalf, never by Rust code, so the compiler must not assume it is
|
||||||
|
// unchanged across the write.
|
||||||
|
let target = std::hint::black_box(vec![0x75u8, 0x0f, 0x11, 0x22]);
|
||||||
|
let va = target.as_ptr() as u64;
|
||||||
|
|
||||||
|
let mut seen = [0u8; 4];
|
||||||
|
mem.read(va, &mut seen).unwrap();
|
||||||
|
assert_eq!(seen, *target);
|
||||||
|
|
||||||
|
mem.write(va, &[0x7f, 0x0f]).unwrap();
|
||||||
|
assert_eq!(*std::hint::black_box(&target), [0x7f, 0x0f, 0x11, 0x22]);
|
||||||
|
|
||||||
|
// An address that is certainly not mapped reads as an error, which the
|
||||||
|
// watch loop treats as "not unpacked yet".
|
||||||
|
assert!(mem.read(0x1000, &mut seen).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
Submodule openfut-core updated: 68d10658c7...637a21eac1
+1
-1
Submodule openfut-launcher updated: c2772132c1...1cd4f18e92
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "openfut-lsx"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "MIT"
|
||||||
|
description = "EA Origin LSX emulator for the FIFA 17 client (loopback 4216)"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# AES-128-ECB only. The LSX session cipher is a single fixed-key ECB block operation,
|
||||||
|
# so the block cipher alone is the whole requirement -- no AEAD, no TLS stack. Using
|
||||||
|
# the `aes` crate rather than hand-rolling it keeps the one security-shaped primitive
|
||||||
|
# in reviewed code, while the surrounding framing (PKCS7 pad, lowercase hex,
|
||||||
|
# NUL-terminated) stays explicit here because it is protocol, not cryptography.
|
||||||
|
aes = "0.8"
|
||||||
|
|
||||||
|
# Project rule rs-parking-lot: locks that are immediately unwrapped use parking_lot.
|
||||||
|
parking_lot = "0.12"
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
//! LSX session crypto.
|
||||||
|
//!
|
||||||
|
//! Transcription of the crypto block of `fifa17-recon/tools/lsx_responder_v2.py`,
|
||||||
|
//! which carries the note:
|
||||||
|
//!
|
||||||
|
//! > (verbatim from v1 -- verified end-to-end by decrypting captured
|
||||||
|
//! > captures/lsx/lsx_raw/C1_ENC-IN_*.bin. DO NOT TOUCH.)
|
||||||
|
//!
|
||||||
|
//! So the Python is the specification and this file is a byte-for-byte port. The
|
||||||
|
//! block cipher itself comes from the `aes` crate; the framing around it
|
||||||
|
//! (PKCS7 pad to 16, lowercase hex, NUL terminator) is *protocol*, not
|
||||||
|
//! cryptography, and is therefore spelled out here rather than delegated to a
|
||||||
|
//! mode/padding helper.
|
||||||
|
//!
|
||||||
|
//! Wire recap (reversed from stp-origin_emu.dll @ 0x6ffffc930000):
|
||||||
|
//! handshake: server sends `<Challenge key="...">` in PLAINTEXT; the client
|
||||||
|
//! answers plaintext with `response=`/`key=`; the server answers
|
||||||
|
//! `<ChallengeAccepted response="H">` where
|
||||||
|
//! `H = hex(AES128_ECB(K_FIXED, clientKeyAscii))` + a fixed 3rd block.
|
||||||
|
//! session: every later frame, both directions, Responses AND Events, is
|
||||||
|
//! `hex_lower(AES128_ECB(SESSION_KEY, pkcs7pad16(xml))) + b"\0"`,
|
||||||
|
//! with SESSION_KEY derived from `H` through the MSVCR srand/rand LCG.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
use aes::cipher::generic_array::GenericArray;
|
||||||
|
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit};
|
||||||
|
use aes::Aes128;
|
||||||
|
|
||||||
|
/// A 128-bit LSX key (the fixed handshake key or a derived session key).
|
||||||
|
pub type Key = [u8; 16];
|
||||||
|
|
||||||
|
/// Recovered from stp-origin_emu.dll .rdata @ VA 0x6ffffc935038.
|
||||||
|
/// `bytes(range(16))` == `000102030405060708090a0b0c0d0e0f`.
|
||||||
|
pub const K_FIXED: Key = [
|
||||||
|
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Failure modes of the LSX codec. The variants mirror the exceptions the Python
|
||||||
|
/// raises at the same points, because they surface through the same log lines
|
||||||
|
/// (`decrypt fail: ...`, `connection error: ...`).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum CryptoError {
|
||||||
|
/// ECB has no padding of its own: pycryptodome raises ValueError here.
|
||||||
|
NotBlockAligned(usize),
|
||||||
|
/// `bytes.fromhex` rejected the payload.
|
||||||
|
BadHex,
|
||||||
|
/// Nothing before the first NUL, so there is no block to decrypt (the Python
|
||||||
|
/// dies on `raw[-1]` with IndexError).
|
||||||
|
Empty,
|
||||||
|
/// The client's own `response=` carried a 3rd block that is not the emu's
|
||||||
|
/// constant. The Python asserts here on purpose: a future client that
|
||||||
|
/// randomises block 3 must fail LOUDLY, not silently.
|
||||||
|
UnexpectedTail(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for CryptoError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
// Wording kept close to pycryptodome's so existing log greps still hit.
|
||||||
|
CryptoError::NotBlockAligned(n) => write!(
|
||||||
|
f,
|
||||||
|
"Data must be padded to 16 byte boundary in ECB mode (got {n} bytes)"
|
||||||
|
),
|
||||||
|
CryptoError::BadHex => f.write_str("Non-hexadecimal digit found"),
|
||||||
|
CryptoError::Empty => f.write_str("empty ciphertext"),
|
||||||
|
CryptoError::UnexpectedTail(t) => {
|
||||||
|
write!(f, "unexpected ChallengeResponse tail {t:?}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for CryptoError {}
|
||||||
|
|
||||||
|
/// MSVCR120 `srand`/`rand` LCG (verified: `srand(7); rand() == 61`).
|
||||||
|
pub struct MsvcrRand {
|
||||||
|
state: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MsvcrRand {
|
||||||
|
/// `srand(seed)`.
|
||||||
|
pub fn new(seed: u32) -> Self {
|
||||||
|
Self { state: seed }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `rand()` -- 15 bits, exactly as MSVCR120 returns them.
|
||||||
|
pub fn next_u15(&mut self) -> u16 {
|
||||||
|
self.state = self.state.wrapping_mul(214013).wrapping_add(2531011);
|
||||||
|
((self.state >> 16) & 0x7FFF) as u16
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reimplementation of emu `sub_0x6ffffc931f10` tail (0x9320bf-0x932101).
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// srand(7); r0 = rand() -> r0 == 61
|
||||||
|
/// bx = (u16)((resp[0] << 8) + resp[1]) (16-bit wrap)
|
||||||
|
/// srand(bx + r0)
|
||||||
|
/// key[i] = (uint8_t)rand() for i in 0..15
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// `resp[0]`/`resp[1]` are the first two ASCII *characters* of the hex response,
|
||||||
|
/// not the first decoded byte.
|
||||||
|
pub fn derive_session_key(resp_hex: &str) -> Key {
|
||||||
|
let r0 = u32::from(MsvcrRand::new(7).next_u15()); // == 61
|
||||||
|
let b = resp_hex.as_bytes();
|
||||||
|
assert!(
|
||||||
|
b.len() >= 2,
|
||||||
|
"derive_session_key needs the first two response chars, got {:?}",
|
||||||
|
resp_hex
|
||||||
|
);
|
||||||
|
let bx = ((u32::from(b[0]) << 8) + u32::from(b[1])) & 0xFFFF;
|
||||||
|
let mut g = MsvcrRand::new(bx.wrapping_add(r0));
|
||||||
|
let mut key = [0u8; 16];
|
||||||
|
for byte in key.iter_mut() {
|
||||||
|
*byte = (g.next_u15() & 0xFF) as u8;
|
||||||
|
}
|
||||||
|
key
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `AES128-ECB(K_FIXED, 0x10 * 16)` -- the constant the emu appends as the 3rd
|
||||||
|
/// hex block (== the PKCS7 pad block of an aligned 32-byte key). See
|
||||||
|
/// REPACK_INTEL.md sec.0-B.
|
||||||
|
pub fn tail_const() -> &'static str {
|
||||||
|
static TAIL: LazyLock<String> = LazyLock::new(|| {
|
||||||
|
let mut block = [0x10u8; 16];
|
||||||
|
ecb_encrypt_blocks(&K_FIXED, &mut block);
|
||||||
|
hex_lower(&block)
|
||||||
|
});
|
||||||
|
TAIL.as_str()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emu-exact `ChallengeAccepted.response` (stp-origin_emu.dll 0x180001f10).
|
||||||
|
///
|
||||||
|
/// The emu computes only TWO AES blocks from the 32-ASCII client key, then
|
||||||
|
/// `strcat_s`'s the client's OWN `response[64:]` verbatim (@0x1800020a9) -> 96
|
||||||
|
/// hex. The older 3-block PKCS7 form is numerically identical *while the client
|
||||||
|
/// PKCS7-pads its 3rd block* (REPACK_INTEL.md sec.0-A/0-B, workflow-confirmed
|
||||||
|
/// byte-exact). We reproduce the emu exactly and, when the client's `response=`
|
||||||
|
/// is available, echo its tail and assert the constant so a future client that
|
||||||
|
/// randomises block 3 fails LOUDLY instead of silently.
|
||||||
|
pub fn challenge_response(
|
||||||
|
client_key_ascii: &str,
|
||||||
|
client_response_attr: &str,
|
||||||
|
) -> Result<String, CryptoError> {
|
||||||
|
let mut buf = client_key_ascii.as_bytes().to_vec();
|
||||||
|
if buf.is_empty() || buf.len() % 16 != 0 {
|
||||||
|
// ECB cannot pad: the Python's AES.encrypt raises here too.
|
||||||
|
return Err(CryptoError::NotBlockAligned(buf.len()));
|
||||||
|
}
|
||||||
|
ecb_encrypt_blocks(&K_FIXED, &mut buf);
|
||||||
|
let two = hex_lower(&buf);
|
||||||
|
|
||||||
|
// Python slices `client_response_attr[64:]`, i.e. by characters.
|
||||||
|
if let Some((idx, _)) = client_response_attr.char_indices().nth(64) {
|
||||||
|
let tail = &client_response_attr[idx..];
|
||||||
|
if tail != tail_const() {
|
||||||
|
return Err(CryptoError::UnexpectedTail(tail.to_string()));
|
||||||
|
}
|
||||||
|
return Ok(two + tail);
|
||||||
|
}
|
||||||
|
if client_response_attr.chars().count() == 64 {
|
||||||
|
// len(attr) >= 64 with an empty tail: the assert compares "" against the
|
||||||
|
// constant and fails, exactly as it would here.
|
||||||
|
return Err(CryptoError::UnexpectedTail(String::new()));
|
||||||
|
}
|
||||||
|
Ok(two + tail_const())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// pkcs7-pad to 16, AES-128-ECB, lowercase hex, NUL-terminated.
|
||||||
|
pub fn lsx_encrypt(xml: &str, key: &Key) -> Vec<u8> {
|
||||||
|
let body = xml.as_bytes();
|
||||||
|
let pad = 16 - (body.len() % 16); // emu always pads (pad==16 when aligned)
|
||||||
|
let mut buf = Vec::with_capacity(body.len() + pad);
|
||||||
|
buf.extend_from_slice(body);
|
||||||
|
buf.resize(body.len() + pad, pad as u8);
|
||||||
|
ecb_encrypt_blocks(key, &mut buf);
|
||||||
|
|
||||||
|
// hex + NUL, sized up front: this runs on every frame we ever send.
|
||||||
|
let mut out = Vec::with_capacity(buf.len() * 2 + 1);
|
||||||
|
for b in &buf {
|
||||||
|
out.push(HEX_DIGITS[usize::from(*b >> 4)]);
|
||||||
|
out.push(HEX_DIGITS[usize::from(*b & 0x0f)]);
|
||||||
|
}
|
||||||
|
out.push(0);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hex up to the first NUL, decrypt, then take up to the first NUL of the
|
||||||
|
/// plaintext (the PKCS7 tail is stripped when it is well-formed, matching the
|
||||||
|
/// Python's tolerant check).
|
||||||
|
pub fn lsx_decrypt(data: &[u8], key: &Key) -> Result<String, CryptoError> {
|
||||||
|
let head = match data.iter().position(|b| *b == 0) {
|
||||||
|
Some(i) => &data[..i],
|
||||||
|
None => data,
|
||||||
|
};
|
||||||
|
let mut raw = hex_decode(trim_ascii_ws(head))?;
|
||||||
|
if raw.is_empty() {
|
||||||
|
return Err(CryptoError::Empty);
|
||||||
|
}
|
||||||
|
if raw.len() % 16 != 0 {
|
||||||
|
return Err(CryptoError::NotBlockAligned(raw.len()));
|
||||||
|
}
|
||||||
|
ecb_decrypt_blocks(key, &mut raw);
|
||||||
|
|
||||||
|
let pad = usize::from(raw[raw.len() - 1]);
|
||||||
|
if pad > 0 && pad <= 16 && raw[raw.len() - pad..].iter().all(|c| usize::from(*c) == pad) {
|
||||||
|
raw.truncate(raw.len() - pad);
|
||||||
|
}
|
||||||
|
if let Some(i) = raw.iter().position(|b| *b == 0) {
|
||||||
|
raw.truncate(i);
|
||||||
|
}
|
||||||
|
// Python decodes with errors="replace".
|
||||||
|
Ok(String::from_utf8_lossy(&raw).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEX_DIGITS: [u8; 16] = *b"0123456789abcdef";
|
||||||
|
|
||||||
|
/// Lowercase hex, like `bytes.hex()`.
|
||||||
|
pub fn hex_lower(bytes: &[u8]) -> String {
|
||||||
|
let mut s = String::with_capacity(bytes.len() * 2);
|
||||||
|
for b in bytes {
|
||||||
|
s.push(char::from(HEX_DIGITS[usize::from(*b >> 4)]));
|
||||||
|
s.push(char::from(HEX_DIGITS[usize::from(*b & 0x0f)]));
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `bytes.fromhex`: ASCII whitespace between bytes is skipped, everything else
|
||||||
|
/// must be a hex digit pair.
|
||||||
|
fn hex_decode(s: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||||
|
let mut out = Vec::with_capacity(s.len() / 2);
|
||||||
|
let mut hi: Option<u8> = None;
|
||||||
|
for c in s {
|
||||||
|
if c.is_ascii_whitespace() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let nib = match c {
|
||||||
|
b'0'..=b'9' => c - b'0',
|
||||||
|
b'a'..=b'f' => c - b'a' + 10,
|
||||||
|
b'A'..=b'F' => c - b'A' + 10,
|
||||||
|
_ => return Err(CryptoError::BadHex),
|
||||||
|
};
|
||||||
|
match hi.take() {
|
||||||
|
None => hi = Some(nib),
|
||||||
|
Some(h) => out.push((h << 4) | nib),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hi.is_some() {
|
||||||
|
return Err(CryptoError::BadHex);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `bytes.strip()` -- ASCII whitespace at both ends.
|
||||||
|
fn trim_ascii_ws(mut b: &[u8]) -> &[u8] {
|
||||||
|
while let Some((first, rest)) = b.split_first() {
|
||||||
|
if first.is_ascii_whitespace() {
|
||||||
|
b = rest;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while let Some((last, rest)) = b.split_last() {
|
||||||
|
if last.is_ascii_whitespace() {
|
||||||
|
b = rest;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ecb_encrypt_blocks(key: &Key, buf: &mut [u8]) {
|
||||||
|
debug_assert_eq!(buf.len() % 16, 0);
|
||||||
|
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||||
|
for block in buf.chunks_exact_mut(16) {
|
||||||
|
cipher.encrypt_block(GenericArray::from_mut_slice(block));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ecb_decrypt_blocks(key: &Key, buf: &mut [u8]) {
|
||||||
|
debug_assert_eq!(buf.len() % 16, 0);
|
||||||
|
let cipher = Aes128::new(GenericArray::from_slice(key));
|
||||||
|
for block in buf.chunks_exact_mut(16) {
|
||||||
|
cipher.decrypt_block(GenericArray::from_mut_slice(block));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The captured client key from the 2026-07-30 session the Python's own
|
||||||
|
/// selftest pins.
|
||||||
|
const CAPTURED_CLIENT_KEY: &str = "18a70055a3541fb27ab8e0f47afad18c";
|
||||||
|
const CAPTURED_H: &str = concat!(
|
||||||
|
"e4f5166209929e156a2ca47b81cdd6bf",
|
||||||
|
"6f2a20371532ed4f968c5a9274899dbf",
|
||||||
|
"954f64f2e4e86e9eee82d20216684899"
|
||||||
|
);
|
||||||
|
const CAPTURED_SESSION_KEY: &str = "6a9da3e78615153cc2f10eec25ae6382";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn msvcr_srand7_first_rand_is_61() {
|
||||||
|
// The one vector the Python names in its docstring.
|
||||||
|
assert_eq!(MsvcrRand::new(7).next_u15(), 61);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tail_const_is_the_emu_constant() {
|
||||||
|
// AES128-ECB(K_FIXED, 0x10*16), pinned so a cipher/keying regression is
|
||||||
|
// caught without a live client.
|
||||||
|
assert_eq!(tail_const(), "954f64f2e4e86e9eee82d20216684899");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn k_fixed_is_000102_to_0f() {
|
||||||
|
assert_eq!(hex_lower(&K_FIXED), "000102030405060708090a0b0c0d0e0f");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn challenge_response_matches_captured_session() {
|
||||||
|
let h = challenge_response(CAPTURED_CLIENT_KEY, "").unwrap();
|
||||||
|
assert!(h.starts_with("e4f5166209929e15"), "{h}");
|
||||||
|
assert_eq!(h, CAPTURED_H);
|
||||||
|
assert_eq!(h.len(), 96);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn challenge_response_echoes_the_clients_own_tail() {
|
||||||
|
// The emu strcat_s's response[64:] verbatim; identical result while the
|
||||||
|
// client pads block 3 with the constant.
|
||||||
|
let h = challenge_response(CAPTURED_CLIENT_KEY, CAPTURED_H).unwrap();
|
||||||
|
assert_eq!(h, CAPTURED_H);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn challenge_response_rejects_a_randomised_third_block() {
|
||||||
|
let bogus = format!("{}{}", &CAPTURED_H[..64], "00".repeat(16));
|
||||||
|
let err = challenge_response(CAPTURED_CLIENT_KEY, &bogus).unwrap_err();
|
||||||
|
assert_eq!(err, CryptoError::UnexpectedTail("00".repeat(16)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn challenge_response_rejects_unaligned_client_key() {
|
||||||
|
let err = challenge_response("short", "").unwrap_err();
|
||||||
|
assert_eq!(err, CryptoError::NotBlockAligned(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_key_derives_from_the_response() {
|
||||||
|
let k = derive_session_key(CAPTURED_H);
|
||||||
|
assert_eq!(hex_lower(&k), CAPTURED_SESSION_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encrypt_pins_the_first_login_frame() {
|
||||||
|
let k = derive_session_key(CAPTURED_H);
|
||||||
|
let frame = r#"<LSX><Event sender=""><Login IsLoggedIn="true"/></Event></LSX>"#;
|
||||||
|
let out = lsx_encrypt(frame, &k);
|
||||||
|
assert_eq!(*out.last().unwrap(), 0, "frames are NUL-terminated");
|
||||||
|
// Pinned against `openssl enc -aes-128-ecb -nopad` over the PKCS7-padded
|
||||||
|
// frame under the captured session key: 62 bytes of XML -> pad 2 -> 4 blocks.
|
||||||
|
assert_eq!(
|
||||||
|
std::str::from_utf8(&out[..out.len() - 1]).unwrap(),
|
||||||
|
concat!(
|
||||||
|
"ded1180ab8a2ab85b7408cc009eb0191",
|
||||||
|
"00b7b4c827fe0b9b4de2ca7834b3ed51",
|
||||||
|
"31a0714a8eb66ba3e38d1855724b0a48",
|
||||||
|
"09ad8616e71b3a78880c570c9af7ff80"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trip() {
|
||||||
|
let k = derive_session_key(CAPTURED_H);
|
||||||
|
for xml in [
|
||||||
|
r#"<LSX><Response id="1" sender=""><ErrorSuccess Code="0" Description=""/></Response></LSX>"#,
|
||||||
|
r#"<LSX><Event sender="LOGIN_EVENT"><Login IsLoggedIn="true"/></Event></LSX>"#,
|
||||||
|
"",
|
||||||
|
] {
|
||||||
|
assert_eq!(lsx_decrypt(&lsx_encrypt(xml, &k), &k).unwrap(), xml);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trip_of_block_aligned_plaintext_pads_a_whole_block() {
|
||||||
|
let k = derive_session_key(CAPTURED_H);
|
||||||
|
let xml = "0123456789abcdef"; // exactly 16 bytes
|
||||||
|
let enc = lsx_encrypt(xml, &k);
|
||||||
|
// 2 blocks (16 data + 16 pad) -> 64 hex chars + NUL.
|
||||||
|
assert_eq!(enc.len(), 65);
|
||||||
|
assert_eq!(lsx_decrypt(&enc, &k).unwrap(), xml);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decrypt_ignores_everything_past_the_first_nul() {
|
||||||
|
let k = derive_session_key(CAPTURED_H);
|
||||||
|
let mut enc = lsx_encrypt("<LSX/>", &k);
|
||||||
|
enc.extend_from_slice(b"deadbeef\0trailing");
|
||||||
|
assert_eq!(lsx_decrypt(&enc, &k).unwrap(), "<LSX/>");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decrypt_strips_surrounding_whitespace() {
|
||||||
|
let k = derive_session_key(CAPTURED_H);
|
||||||
|
let enc = lsx_encrypt("<LSX/>", &k);
|
||||||
|
let mut padded = b" ".to_vec();
|
||||||
|
padded.extend_from_slice(&enc[..enc.len() - 1]);
|
||||||
|
padded.extend_from_slice(b"\r\n\0");
|
||||||
|
assert_eq!(lsx_decrypt(&padded, &k).unwrap(), "<LSX/>");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decrypt_rejects_garbage() {
|
||||||
|
let k = derive_session_key(CAPTURED_H);
|
||||||
|
assert_eq!(lsx_decrypt(b"zz\0", &k).unwrap_err(), CryptoError::BadHex);
|
||||||
|
assert_eq!(lsx_decrypt(b"abc\0", &k).unwrap_err(), CryptoError::BadHex);
|
||||||
|
assert_eq!(lsx_decrypt(b"\0", &k).unwrap_err(), CryptoError::Empty);
|
||||||
|
assert_eq!(
|
||||||
|
lsx_decrypt(b"00112233\0", &k).unwrap_err(),
|
||||||
|
CryptoError::NotBlockAligned(4)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
//! PUSHED EVENTS -- the whole reason v2 exists, plus the per-connection state
|
||||||
|
//! they need.
|
||||||
|
//!
|
||||||
|
//! Frame shape is identical to the server-initiated `<Challenge>` that already
|
||||||
|
//! works, i.e. `<LSX><Event sender="..."><Element .../></Event></LSX>`. No `id`
|
||||||
|
//! attribute (the Challenge has none; the matcher never reads one).
|
||||||
|
|
||||||
|
use std::io::{self, Write};
|
||||||
|
use std::net::{SocketAddr, TcpStream};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use parking_lot::{Mutex, RwLock};
|
||||||
|
|
||||||
|
use crate::crypto::{self, Key};
|
||||||
|
use crate::{env_flag, log, ConfigError, Env};
|
||||||
|
|
||||||
|
/// `sender` is strcmp'd against the handler's registered service name. A mismatch
|
||||||
|
/// is SILENTLY DROPPED -- it costs us nothing -- so we emit every candidate.
|
||||||
|
///
|
||||||
|
/// Event handlers are keyed on `serviceNames[facility]` (registrar 0x14710df80);
|
||||||
|
/// with our empty GetConfigResponse those names are "", so the handlers expect
|
||||||
|
/// `sender=""`. "" first; the named variants are harmless no-ops (dropped
|
||||||
|
/// silently) and become correct once GetConfigResponse populates the table.
|
||||||
|
/// "LOGIN_EVENT" is table index 14 (the one the event-handler factory uses) and
|
||||||
|
/// "LOGIN" is index 8 (the plain service name); exactly one of the three will
|
||||||
|
/// match.
|
||||||
|
pub const LOGIN_EVENT_SENDERS: [&str; 3] = ["", "LOGIN_EVENT", "LOGIN"];
|
||||||
|
pub const ONLINE_EVENT_SENDERS: [&str; 2] = ["", "ONLINE_STATUS_EVENT"];
|
||||||
|
|
||||||
|
pub fn event(sender: &str, element: &str) -> String {
|
||||||
|
format!(r#"<LSX><Event sender="{sender}"><{element}/></Event></LSX>"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The frames that flip `OriginMgr.m_isLoggedIn` ([OriginMgr+0x13]) to 1.
|
||||||
|
///
|
||||||
|
/// `IsLoggedIn` is parsed as `strcmp(v,"false") != 0`, so "true" -> TRUE. Keep the
|
||||||
|
/// value literally "true" anyway: it is what a real Origin client sends and it
|
||||||
|
/// keeps the log readable.
|
||||||
|
pub fn login_event_frames() -> Vec<String> {
|
||||||
|
let mut out = Vec::with_capacity(LOGIN_EVENT_SENDERS.len() + ONLINE_EVENT_SENDERS.len());
|
||||||
|
out.extend(LOGIN_EVENT_SENDERS.iter().map(|s| event(s, r#"Login IsLoggedIn="true""#)));
|
||||||
|
out.extend(
|
||||||
|
ONLINE_EVENT_SENDERS
|
||||||
|
.iter()
|
||||||
|
.map(|s| event(s, r#"OnlineStatusEvent isOnline="true""#)),
|
||||||
|
);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Event tuning.
|
||||||
|
///
|
||||||
|
/// Pushes are idempotent state notifications, so re-sending is harmless and is
|
||||||
|
/// cheap insurance against FIFA registering its `<Login>` handler later than our
|
||||||
|
/// first push. Set `OPENFUT_LSX_EVENTS=0` to fall back to v1 behaviour (useful as
|
||||||
|
/// an A/B control if you want to prove the events are what moved the needle).
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct EventConfig {
|
||||||
|
/// `OPENFUT_LSX_EVENTS != "0"` (default on).
|
||||||
|
pub enabled: bool,
|
||||||
|
/// `OPENFUT_LSX_EVENT_PERIOD` seconds (default 5).
|
||||||
|
pub period_secs: f64,
|
||||||
|
/// `OPENFUT_LSX_EVENT_COUNT` heartbeat repeats (default 24).
|
||||||
|
pub count: i64,
|
||||||
|
/// EXPERIMENT (`OPENFUT_LSX_LOGIN_PLAINTEXT`, default off): push the Login
|
||||||
|
/// Event in PLAINTEXT right after ChallengeAccepted (before the stream goes
|
||||||
|
/// encrypted) instead of via the encrypted heartbeat. Tests the workflow's
|
||||||
|
/// strongest remaining hypothesis -- that FIFA drops encrypted mid-session
|
||||||
|
/// Events (the emu's only Event, the Challenge, is plaintext and pre-key).
|
||||||
|
/// See REPACK_INTEL.md sec.4 step 2.
|
||||||
|
pub login_plaintext: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EventConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
enabled: true,
|
||||||
|
period_secs: 5.0,
|
||||||
|
count: 24,
|
||||||
|
login_plaintext: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventConfig {
|
||||||
|
pub fn from_env() -> Result<Self, ConfigError> {
|
||||||
|
Self::from_vars(&crate::os_env)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_vars(env: Env<'_>) -> Result<Self, ConfigError> {
|
||||||
|
let period_secs = match env("OPENFUT_LSX_EVENT_PERIOD") {
|
||||||
|
Some(v) => v.trim().parse::<f64>().map_err(|_| ConfigError {
|
||||||
|
var: "OPENFUT_LSX_EVENT_PERIOD",
|
||||||
|
value: v.clone(),
|
||||||
|
expected: "a number of seconds",
|
||||||
|
})?,
|
||||||
|
None => 5.0,
|
||||||
|
};
|
||||||
|
let count = match env("OPENFUT_LSX_EVENT_COUNT") {
|
||||||
|
Some(v) => v.trim().parse::<i64>().map_err(|_| ConfigError {
|
||||||
|
var: "OPENFUT_LSX_EVENT_COUNT",
|
||||||
|
value: v.clone(),
|
||||||
|
expected: "an integer",
|
||||||
|
})?,
|
||||||
|
None => 24,
|
||||||
|
};
|
||||||
|
Ok(Self {
|
||||||
|
enabled: env_flag(env, "OPENFUT_LSX_EVENTS", true),
|
||||||
|
period_secs,
|
||||||
|
count,
|
||||||
|
login_plaintext: env_flag(env, "OPENFUT_LSX_LOGIN_PLAINTEXT", false),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a period the way Python's `float` repr does, so the startup line reads
|
||||||
|
/// `period=5.0s` and not `period=5s`.
|
||||||
|
pub fn fmt_secs(secs: f64) -> String {
|
||||||
|
if secs.is_finite() && secs.fract() == 0.0 && secs.abs() < 1e16 {
|
||||||
|
format!("{secs:.1}")
|
||||||
|
} else {
|
||||||
|
format!("{secs}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Socket + session key + a send lock.
|
||||||
|
///
|
||||||
|
/// The lock matters: pushes come from a heartbeat thread while the request loop
|
||||||
|
/// may be writing a Response. LSX frames are NUL-delimited, so two interleaved
|
||||||
|
/// writes would corrupt the stream and the client would drop the connection
|
||||||
|
/// (which would look exactly like a protocol bug).
|
||||||
|
pub struct Conn {
|
||||||
|
pub peer: SocketAddr,
|
||||||
|
/// A `try_clone` of the accepted socket: the request loop reads from the
|
||||||
|
/// original while this half is serialised behind the send lock.
|
||||||
|
writer: Mutex<TcpStream>,
|
||||||
|
key: RwLock<Option<Key>>,
|
||||||
|
alive: AtomicBool,
|
||||||
|
pushed_login: AtomicBool,
|
||||||
|
/// Set once GetAuthCode has been issued, so the heartbeat stops re-pushing
|
||||||
|
/// Login/OnlineStatus events. Re-pushing after the auth code is granted
|
||||||
|
/// re-enters FIFA's state-mutating Origin event dispatcher (case 2
|
||||||
|
/// @0x146f1e0ab sets m_isLoggedIn + clears loginError + rebroadcasts on the FE
|
||||||
|
/// bus) ~24 more times DURING Blaze login, which we do not want.
|
||||||
|
stop_events: AtomicBool,
|
||||||
|
events: EventConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Conn {
|
||||||
|
pub fn new(sock: &TcpStream, peer: SocketAddr, events: EventConfig) -> io::Result<Arc<Self>> {
|
||||||
|
Ok(Arc::new(Self {
|
||||||
|
peer,
|
||||||
|
writer: Mutex::new(sock.try_clone()?),
|
||||||
|
key: RwLock::new(None),
|
||||||
|
alive: AtomicBool::new(true),
|
||||||
|
pushed_login: AtomicBool::new(false),
|
||||||
|
stop_events: AtomicBool::new(false),
|
||||||
|
events,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn events(&self) -> &EventConfig {
|
||||||
|
&self.events
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_key(&self, key: Key) {
|
||||||
|
*self.key.write() = Some(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn key(&self) -> Option<Key> {
|
||||||
|
*self.key.read()
|
||||||
|
}
|
||||||
|
|
||||||
|
// These flags are advisory hand-offs between the request loop and the
|
||||||
|
// heartbeat thread; no data travels with them, so Relaxed is enough.
|
||||||
|
pub fn is_alive(&self) -> bool {
|
||||||
|
self.alive.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn mark_dead(&self) {
|
||||||
|
self.alive.store(false, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop_events(&self) {
|
||||||
|
self.stop_events.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn events_stopped(&self) -> bool {
|
||||||
|
self.stop_events.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send_plain(&self, xml: &str) -> io::Result<()> {
|
||||||
|
let mut buf = Vec::with_capacity(xml.len() + 1);
|
||||||
|
buf.extend_from_slice(xml.as_bytes());
|
||||||
|
buf.push(0);
|
||||||
|
self.writer.lock().write_all(&buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send_enc(&self, xml: &str) -> io::Result<()> {
|
||||||
|
let key = self
|
||||||
|
.key()
|
||||||
|
.ok_or_else(|| io::Error::other("session key not initialised"))?;
|
||||||
|
let frame = crypto::lsx_encrypt(xml, &key);
|
||||||
|
self.writer.lock().write_all(&frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_login_state(&self, why: &str) {
|
||||||
|
if !self.events.enabled {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for frame in login_event_frames() {
|
||||||
|
if let Err(e) = self.send_enc(&frame) {
|
||||||
|
self.mark_dead();
|
||||||
|
log!("push failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log!("PUSH ({why}) >> {frame}");
|
||||||
|
}
|
||||||
|
if !self.pushed_login.swap(true, Ordering::Relaxed) {
|
||||||
|
log!(
|
||||||
|
"*** first <Login IsLoggedIn=\"true\"> pushed. Watch for \
|
||||||
|
GetAuthCode next. ***"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-push the login state a bounded number of times.
|
||||||
|
///
|
||||||
|
/// FIFA builds its Origin event handlers lazily; if our first push lands
|
||||||
|
/// before the `<Login>` handler is registered the matcher simply finds no
|
||||||
|
/// handler and drops it. Re-pushing removes that race without needing to
|
||||||
|
/// guess the exact registration moment.
|
||||||
|
pub fn heartbeat(&self) {
|
||||||
|
let period = match Duration::try_from_secs_f64(self.events.period_secs) {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
// Python's time.sleep() would raise here and kill just this
|
||||||
|
// thread; the connection keeps serving requests either way.
|
||||||
|
log!(
|
||||||
|
"heartbeat disabled: OPENFUT_LSX_EVENT_PERIOD={} is not a usable delay ({e})",
|
||||||
|
self.events.period_secs
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for _ in 0..self.events.count.max(0) {
|
||||||
|
std::thread::sleep(period);
|
||||||
|
if !self.is_alive() || self.events_stopped() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.push_login_state("heartbeat");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::crypto::{derive_session_key, lsx_decrypt, lsx_encrypt};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
|
||||||
|
let map: HashMap<String, String> = pairs
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
|
||||||
|
.collect();
|
||||||
|
move |k: &str| map.get(k).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frames_are_the_five_candidate_pushes_with_the_empty_sender_first() {
|
||||||
|
let frames = login_event_frames();
|
||||||
|
assert_eq!(
|
||||||
|
frames.len(),
|
||||||
|
LOGIN_EVENT_SENDERS.len() + ONLINE_EVENT_SENDERS.len()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
frames,
|
||||||
|
vec![
|
||||||
|
r#"<LSX><Event sender=""><Login IsLoggedIn="true"/></Event></LSX>"#,
|
||||||
|
r#"<LSX><Event sender="LOGIN_EVENT"><Login IsLoggedIn="true"/></Event></LSX>"#,
|
||||||
|
r#"<LSX><Event sender="LOGIN"><Login IsLoggedIn="true"/></Event></LSX>"#,
|
||||||
|
r#"<LSX><Event sender=""><OnlineStatusEvent isOnline="true"/></Event></LSX>"#,
|
||||||
|
r#"<LSX><Event sender="ONLINE_STATUS_EVENT"><OnlineStatusEvent isOnline="true"/></Event></LSX>"#,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frames_round_trip_through_the_session_codec() {
|
||||||
|
let k = derive_session_key(&crate::crypto::challenge_response(
|
||||||
|
"18a70055a3541fb27ab8e0f47afad18c",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.unwrap());
|
||||||
|
for f in login_event_frames() {
|
||||||
|
assert_eq!(lsx_decrypt(&lsx_encrypt(&f, &k), &k).unwrap(), f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_match_the_python() {
|
||||||
|
let cfg = EventConfig::from_vars(&env_of(&[])).unwrap();
|
||||||
|
assert_eq!(cfg, EventConfig::default());
|
||||||
|
assert!(cfg.enabled);
|
||||||
|
assert_eq!(cfg.period_secs, 5.0);
|
||||||
|
assert_eq!(cfg.count, 24);
|
||||||
|
assert!(!cfg.login_plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn events_are_disabled_only_by_the_literal_zero() {
|
||||||
|
for (value, enabled) in [("0", false), ("1", true), ("", true), ("false", true)] {
|
||||||
|
let cfg = EventConfig::from_vars(&env_of(&[("OPENFUT_LSX_EVENTS", value)])).unwrap();
|
||||||
|
assert_eq!(cfg.enabled, enabled, "OPENFUT_LSX_EVENTS={value:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn login_plaintext_is_enabled_by_anything_but_zero() {
|
||||||
|
for (value, on) in [("0", false), ("1", true), ("", true), ("no", true)] {
|
||||||
|
let cfg =
|
||||||
|
EventConfig::from_vars(&env_of(&[("OPENFUT_LSX_LOGIN_PLAINTEXT", value)])).unwrap();
|
||||||
|
assert_eq!(cfg.login_plaintext, on, "OPENFUT_LSX_LOGIN_PLAINTEXT={value:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn period_and_count_are_overridable() {
|
||||||
|
let cfg = EventConfig::from_vars(&env_of(&[
|
||||||
|
("OPENFUT_LSX_EVENT_PERIOD", "0.25"),
|
||||||
|
("OPENFUT_LSX_EVENT_COUNT", "3"),
|
||||||
|
]))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(cfg.period_secs, 0.25);
|
||||||
|
assert_eq!(cfg.count, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bad_period_is_refused() {
|
||||||
|
let err = EventConfig::from_vars(&env_of(&[("OPENFUT_LSX_EVENT_PERIOD", "soon")]))
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(err.var, "OPENFUT_LSX_EVENT_PERIOD");
|
||||||
|
let err =
|
||||||
|
EventConfig::from_vars(&env_of(&[("OPENFUT_LSX_EVENT_COUNT", "many")])).unwrap_err();
|
||||||
|
assert_eq!(err.var, "OPENFUT_LSX_EVENT_COUNT");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn period_renders_like_a_python_float() {
|
||||||
|
assert_eq!(fmt_secs(5.0), "5.0");
|
||||||
|
assert_eq!(fmt_secs(0.25), "0.25");
|
||||||
|
assert_eq!(fmt_secs(0.0), "0.0");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
//! The identity LSX reports, ported from `fut_account.py`'s tier-1/tier-2 fields.
|
||||||
|
//!
|
||||||
|
//! SOURCED FROM `fut_account.ACCOUNT`, shared with blaze_responder_v3b.py,
|
||||||
|
//! fut_store.py, fut_seed.py and utas_server.py.
|
||||||
|
//!
|
||||||
|
//! THE CONSTRAINT IS CROSS-LAYER CONSISTENCY, NOT ANY PARTICULAR VALUE: what LSX
|
||||||
|
//! reports here must equal what Blaze returns in LoginResponse.SESS.PDTL and what
|
||||||
|
//! UTAS serves as userInfo.personaId. (An older comment blamed a mismatch for
|
||||||
|
//! AUTH_ERR_INVALID_PERSONA / AUTH_ERR_USER_DOES_NOT_MATCH_PERSONA /
|
||||||
|
//! AUTH_ERR_PERSONA_NOT_FOUND -- those are Blaze *server* error codes and we are
|
||||||
|
//! the server. Neither "CAGE" nor "33068179" appears in FIFA17.exe, CardsDLL or
|
||||||
|
//! dbdata.dll; 33068179 lives only in stp-origin_emu.dll's own ini default. They
|
||||||
|
//! stay the defaults because they are what the working stack asserts.)
|
||||||
|
//!
|
||||||
|
//! PRECEDENCE: env var > built-in default.
|
||||||
|
//!
|
||||||
|
//! `fut_account.py` sits one tier deeper -- env > `fut_account.json` > default --
|
||||||
|
//! but the JSON is deliberately NOT read here: its persisted values for the three
|
||||||
|
//! fields LSX uses (`persona_id`, `persona_name`; `locale` is not even stored) are
|
||||||
|
//! identical to the built-in defaults, and the launcher always passes
|
||||||
|
//! `FUT_PERSONA_ID`/`FUT_PERSONA_NAME` explicitly when it spawns us
|
||||||
|
//! (openfut-launcher/src/local_services.rs), so env decides in every real run.
|
||||||
|
//! Parsing a JSON file to reach the same answer would only add a failure mode --
|
||||||
|
//! and the launcher, not a file next to the Python tools, is the identity owner
|
||||||
|
//! for this binary.
|
||||||
|
|
||||||
|
use crate::{ConfigError, Env};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ tier 1
|
||||||
|
// LOCKED WIRE CONSTANTS. No env override on purpose: these are not preferences.
|
||||||
|
|
||||||
|
/// FIFA 17 EA offer id (retail).
|
||||||
|
pub const CONTENT_ID: &str = "1027460";
|
||||||
|
|
||||||
|
/// `TRIAL_ONLINE_ACCESS` for FIFA17_Trial.exe; retail uses this.
|
||||||
|
pub const ENTITLEMENT_TAG: &str = "ONLINE_ACCESS";
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ tier 2
|
||||||
|
pub const DEFAULT_PERSONA_ID: i64 = 33068179;
|
||||||
|
pub const DEFAULT_PERSONA_NAME: &str = "CAGE";
|
||||||
|
pub const DEFAULT_LOCALE: &str = "en_US";
|
||||||
|
|
||||||
|
/// The identity fields LSX puts on the wire.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Identity {
|
||||||
|
/// Blaze SESS.BUID / SESS.UID / PDTL.PID, LSX PersonaId/UserId, UTAS
|
||||||
|
/// userInfo.personaId and squad.personaId.
|
||||||
|
pub persona_id: i64,
|
||||||
|
/// Blaze PDTL.DSNM / LSX GetProfileResponse Persona / UTAS sellerName.
|
||||||
|
pub persona_name: String,
|
||||||
|
/// LSX `GetSetting LANGUAGE`.
|
||||||
|
pub locale: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Identity {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
persona_id: DEFAULT_PERSONA_ID,
|
||||||
|
persona_name: DEFAULT_PERSONA_NAME.to_string(),
|
||||||
|
locale: DEFAULT_LOCALE.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Identity {
|
||||||
|
/// Read the process environment.
|
||||||
|
pub fn from_env() -> Result<Self, ConfigError> {
|
||||||
|
Self::from_vars(&crate::os_env)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `FUT_PERSONA_ID` that `int()` would reject kills `fut_account.py` at
|
||||||
|
/// import; we refuse to start for the same reason, rather than serve one
|
||||||
|
/// layer of the stack a silently different persona.
|
||||||
|
pub fn from_vars(env: Env<'_>) -> Result<Self, ConfigError> {
|
||||||
|
let persona_id = match env("FUT_PERSONA_ID") {
|
||||||
|
// `int()` tolerates surrounding whitespace and a sign.
|
||||||
|
Some(v) => v.trim().parse::<i64>().map_err(|_| ConfigError {
|
||||||
|
var: "FUT_PERSONA_ID",
|
||||||
|
value: v.clone(),
|
||||||
|
expected: "an integer",
|
||||||
|
})?,
|
||||||
|
None => DEFAULT_PERSONA_ID,
|
||||||
|
};
|
||||||
|
Ok(Self {
|
||||||
|
persona_id,
|
||||||
|
persona_name: env("FUT_PERSONA_NAME").unwrap_or_else(|| DEFAULT_PERSONA_NAME.into()),
|
||||||
|
locale: env("FUT_LOCALE").unwrap_or_else(|| DEFAULT_LOCALE.into()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blaze blazeId / userId (SESS.BUID, SESS.UID, AccountInfo.UID).
|
||||||
|
///
|
||||||
|
/// DERIVED, read-only, and deliberately NOT an independent knob: the client
|
||||||
|
/// sends both `nuc` and `nucleusPersonaId` and both came out equal, so the
|
||||||
|
/// getter->field mapping is undetermined. Do not split them until a live test
|
||||||
|
/// proves Blaze USER_ID may legitimately differ from PERSONA_ID.
|
||||||
|
pub fn user_id(&self) -> i64 {
|
||||||
|
self.persona_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
|
||||||
|
let map: HashMap<String, String> = pairs
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
|
||||||
|
.collect();
|
||||||
|
move |k: &str| map.get(k).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_are_the_working_stacks_values() {
|
||||||
|
let id = Identity::from_vars(&env_of(&[])).unwrap();
|
||||||
|
assert_eq!(id.persona_id, 33068179);
|
||||||
|
assert_eq!(id.persona_name, "CAGE");
|
||||||
|
assert_eq!(id.locale, "en_US");
|
||||||
|
assert_eq!(id.user_id(), id.persona_id);
|
||||||
|
assert_eq!(id, Identity::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn env_wins_over_defaults() {
|
||||||
|
let id = Identity::from_vars(&env_of(&[
|
||||||
|
("FUT_PERSONA_ID", "1234567"),
|
||||||
|
("FUT_PERSONA_NAME", "OTHER"),
|
||||||
|
("FUT_LOCALE", "de_DE"),
|
||||||
|
]))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(id.persona_id, 1234567);
|
||||||
|
assert_eq!(id.persona_name, "OTHER");
|
||||||
|
assert_eq!(id.locale, "de_DE");
|
||||||
|
assert_eq!(id.user_id(), 1234567);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_env_value_still_wins() {
|
||||||
|
// `os.environ.get` returns "" for `FUT_PERSONA_NAME=`, and "" is not None.
|
||||||
|
let id = Identity::from_vars(&env_of(&[("FUT_PERSONA_NAME", "")])).unwrap();
|
||||||
|
assert_eq!(id.persona_name, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn whitespace_around_the_persona_id_is_tolerated_like_int() {
|
||||||
|
let id = Identity::from_vars(&env_of(&[("FUT_PERSONA_ID", " 42 ")])).unwrap();
|
||||||
|
assert_eq!(id.persona_id, 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_non_numeric_persona_id_is_refused() {
|
||||||
|
let err = Identity::from_vars(&env_of(&[("FUT_PERSONA_ID", "CAGE")])).unwrap_err();
|
||||||
|
assert_eq!(err.var, "FUT_PERSONA_ID");
|
||||||
|
assert_eq!(err.to_string(), r#"FUT_PERSONA_ID must be an integer (got "CAGE")"#);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
//! OpenFUT clean-room LSX responder for FIFA 17 -- Rust port of
|
||||||
|
//! `fifa17-recon/tools/lsx_responder_v2.py` (v2, EVENT-PUSHING).
|
||||||
|
//!
|
||||||
|
//! v1 (`lsx_responder.py`) was REQUEST-DRIVEN ONLY. It answered every verb the
|
||||||
|
//! client asked for and never sent an unsolicited frame. That is exactly why the
|
||||||
|
//! client never issued GetAuthCode and never sent Blaze Authentication::login.
|
||||||
|
//!
|
||||||
|
//! THE ORIGIN SDK HAS TWO INDEPENDENT FLAGS, FED BY TWO DIFFERENT MECHANISMS:
|
||||||
|
//!
|
||||||
|
//! 1. "internet is reachable" -> OriginMgr online byte [0x1448a3ac0],
|
||||||
|
//! fed by the REQUEST verb `GetInternetConnectedState -> connected="1"`
|
||||||
|
//! (v1 already beat this; live-confirmed == 1).
|
||||||
|
//! 2. "a user is LOGGED IN" -> `OriginMgr.m_isLoggedIn` [OriginMgr+0x13],
|
||||||
|
//! fed ONLY by a server-PUSHED `<Event sender="LOGIN_EVENT"><Login/>`.
|
||||||
|
//! There is NO request verb that can set it.
|
||||||
|
//!
|
||||||
|
//! v1 fed (1) and never fed (2), so `m_isLoggedIn` was 0 for the whole session,
|
||||||
|
//! FIFA never enqueued an auth-code request into FirstPartyAuthTokenRetriever
|
||||||
|
//! (both request slots live-read as 0x0), DoTick @0x146f199c0 exited immediately,
|
||||||
|
//! OriginRequestAuthCodeSync @0x1470db3c0 was never called, LoginRequest.AUTH
|
||||||
|
//! could never be filled -> no Blaze login -> "Unable to retrieve account
|
||||||
|
//! information."
|
||||||
|
//!
|
||||||
|
//! The binary evidence for the push (dispatcher @0x146f1e060 case 2, the `<Login>`
|
||||||
|
//! matcher @0x147102880 and its silent sender strcmp, the service-name tables at
|
||||||
|
//! 0x144341420 / sdk+0x3b0, the `strcmp(v,"false")` truthiness of `IsLoggedIn`)
|
||||||
|
//! lives in the Python's module docstring; the specific facts that constrain code
|
||||||
|
//! are repeated at the site that depends on them. Nothing here is derived from
|
||||||
|
//! the 2021 EA/FIFA leak: every constant came from our own static+dynamic
|
||||||
|
//! analysis of binaries we own plus traffic we captured ourselves.
|
||||||
|
//!
|
||||||
|
//! Transport: TCP `127.0.0.1:4216`, every message NUL-terminated (send strlen+1).
|
||||||
|
|
||||||
|
pub mod crypto;
|
||||||
|
pub mod events;
|
||||||
|
pub mod identity;
|
||||||
|
pub mod protocol;
|
||||||
|
|
||||||
|
/// Loopback port the Steampunks Origin emulator stub binds; we must own it
|
||||||
|
/// BEFORE FIFA 17 starts so the stub's own `bind()` fails.
|
||||||
|
pub const LSX_PORT: u16 = 4216;
|
||||||
|
|
||||||
|
/// Emu's own advertised challenge (any 32 hex chars work; the client echoes it
|
||||||
|
/// back).
|
||||||
|
pub const CHALLENGE_KEY: &str = "2b8ee7faea76e8a34f5f5d20e5328e32";
|
||||||
|
pub const BUILD: &str = "release";
|
||||||
|
pub const VERSION: &str = "10,4,13,6637";
|
||||||
|
|
||||||
|
/// Success-signal files the run's watch steps poll. Written only from a REAL
|
||||||
|
/// GetAuthCode on a live connection.
|
||||||
|
pub const AUTHCODE_FILE: &str = "/tmp/openfut_authcode.txt";
|
||||||
|
pub const CLIENTID_FILE: &str = "/tmp/openfut_lsx_clientid.txt";
|
||||||
|
|
||||||
|
/// How the modules read environment knobs. Indirected through a closure so the
|
||||||
|
/// tests can pin an environment without mutating the process (env vars are
|
||||||
|
/// process-global and `cargo test` runs threads in parallel).
|
||||||
|
pub type Env<'a> = &'a dyn Fn(&str) -> Option<String>;
|
||||||
|
|
||||||
|
/// The real environment. Present-but-empty is Some(""), which matters: the
|
||||||
|
/// Python's `os.environ.get(...)` returns "" for `FOO=`, and "" is not None, so
|
||||||
|
/// an empty setting WINS over the built-in default.
|
||||||
|
pub fn os_env(name: &str) -> Option<String> {
|
||||||
|
std::env::var_os(name).map(|v| v.to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Python's recurring `os.environ.get(NAME, "0") != "0"` rule: ANY value
|
||||||
|
/// other than the literal "0" enables the knob (including the empty string).
|
||||||
|
pub fn env_flag(env: Env<'_>, name: &str, default_on: bool) -> bool {
|
||||||
|
match env(name) {
|
||||||
|
Some(v) => v != "0",
|
||||||
|
None => default_on,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An env knob was set to something the Python's own `int()`/`float()` would
|
||||||
|
/// reject. The Python dies at import in that case; we refuse to start for the
|
||||||
|
/// same reason -- a silently different persona, or a silently absent heartbeat,
|
||||||
|
/// is worse than a loud failure.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ConfigError {
|
||||||
|
pub var: &'static str,
|
||||||
|
pub value: String,
|
||||||
|
pub expected: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ConfigError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{} must be {} (got {:?})",
|
||||||
|
self.var, self.expected, self.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ConfigError {}
|
||||||
|
|
||||||
|
/// Every diagnostic line carries the `[lsx] ` prefix and is flushed per line:
|
||||||
|
/// the launcher pipes our stdout+stderr into its log buffer and parses some of
|
||||||
|
/// these lines, so the formats are a contract.
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! log {
|
||||||
|
($($arg:tt)*) => {{
|
||||||
|
use std::io::Write;
|
||||||
|
let mut out = std::io::stdout().lock();
|
||||||
|
let _ = writeln!(out, "[lsx] {}", format_args!($($arg)*));
|
||||||
|
let _ = out.flush();
|
||||||
|
}};
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
//! `openfut-lsx` -- the LSX responder process the launcher spawns.
|
||||||
|
//!
|
||||||
|
//! USAGE: bind BEFORE launching FIFA 17 so the Steampunks stub's `bind()` fails.
|
||||||
|
//! This process does NOT auto-start anything; the launcher owns processes.
|
||||||
|
|
||||||
|
use std::io::{self, Read};
|
||||||
|
use std::net::{SocketAddr, TcpListener, TcpStream};
|
||||||
|
use std::process::ExitCode;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use openfut_lsx::crypto;
|
||||||
|
use openfut_lsx::events::{fmt_secs, login_event_frames, Conn, EventConfig};
|
||||||
|
use openfut_lsx::identity::Identity;
|
||||||
|
use openfut_lsx::protocol::{
|
||||||
|
parse_request, push_after, py_repr, resp, safe_xml_for_log, Attrs, ProtocolConfig, Responder,
|
||||||
|
};
|
||||||
|
use openfut_lsx::{log, os_env, BUILD, CHALLENGE_KEY, LSX_PORT, VERSION};
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
if std::env::args().skip(1).any(|a| a == "--selftest") {
|
||||||
|
return match selftest() {
|
||||||
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
|
Err(e) => {
|
||||||
|
log!("SELFTEST FAILED: {e}");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
match serve_forever() {
|
||||||
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
|
Err(e) => {
|
||||||
|
log!("fatal: {e}");
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serve_forever() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let identity = Identity::from_env()?;
|
||||||
|
let events = EventConfig::from_env()?;
|
||||||
|
let responder = Arc::new(Responder::new(identity, ProtocolConfig::from_env()));
|
||||||
|
|
||||||
|
// Readiness IS the bind: nothing is logged until 4216 is ours, so the launcher
|
||||||
|
// never reads a ready line for a listener that does not exist. `TcpListener::bind`
|
||||||
|
// already sets SO_REUSEADDR on every non-Windows target, which is the Python's
|
||||||
|
// explicit `setsockopt(SO_REUSEADDR, 1)` -- do not reach for socket2 to re-add it.
|
||||||
|
let bind_host = os_env("OPENFUT_BIND").unwrap_or_else(|| "127.0.0.1".to_string());
|
||||||
|
let listener = TcpListener::bind((bind_host.as_str(), LSX_PORT))?;
|
||||||
|
|
||||||
|
// Literal "127.0.0.1:4216" even under OPENFUT_BIND, as the Python logs it: this
|
||||||
|
// line is what the launcher watches for.
|
||||||
|
log!("v2 listening on 127.0.0.1:{LSX_PORT} (start FIFA 17 now)");
|
||||||
|
log!(
|
||||||
|
"login-state event push: {} (period={}s count={})",
|
||||||
|
if events.enabled { "ENABLED" } else { "DISABLED" },
|
||||||
|
fmt_secs(events.period_secs),
|
||||||
|
events.count
|
||||||
|
);
|
||||||
|
|
||||||
|
for stream in listener.incoming() {
|
||||||
|
let stream = match stream {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
log!("accept failed: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let peer = stream
|
||||||
|
.peer_addr()
|
||||||
|
.unwrap_or_else(|_| SocketAddr::from(([0, 0, 0, 0], 0)));
|
||||||
|
log!("connection from {}", py_addr(peer));
|
||||||
|
let responder = Arc::clone(&responder);
|
||||||
|
let events = events.clone();
|
||||||
|
std::thread::spawn(move || serve(stream, peer, &responder, events));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Python prints the accept tuple, e.g. `('127.0.0.1', 54321)`.
|
||||||
|
fn py_addr(peer: SocketAddr) -> String {
|
||||||
|
format!("('{}', {})", peer.ip(), peer.port())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serve(mut sock: TcpStream, peer: SocketAddr, responder: &Responder, events: EventConfig) {
|
||||||
|
let conn = match Conn::new(&sock, peer, events) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
log!("connection error: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = session(&mut sock, &conn, responder) {
|
||||||
|
log!("connection error: {e}");
|
||||||
|
}
|
||||||
|
conn.mark_dead();
|
||||||
|
// Dropping our halves closes the socket; the heartbeat thread notices on its
|
||||||
|
// next tick (and its own send would fail regardless).
|
||||||
|
drop(sock);
|
||||||
|
log!("connection closed {}", py_addr(peer));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session(
|
||||||
|
sock: &mut TcpStream,
|
||||||
|
conn: &Arc<Conn>,
|
||||||
|
responder: &Responder,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
// 1. plaintext Challenge
|
||||||
|
conn.send_plain(&format!(
|
||||||
|
r#"<LSX><Event sender="EALS"><Challenge key="{CHALLENGE_KEY}" build="{BUILD}" version="{VERSION}"/></Event></LSX>"#
|
||||||
|
))?;
|
||||||
|
|
||||||
|
// 2. plaintext ChallengeResponse from the client. The emu parses `response="`
|
||||||
|
// BEFORE `key="` (0x180001f10); extract both so challenge_response can echo
|
||||||
|
// the client's own 3rd block (REPACK_INTEL.md C1/C2).
|
||||||
|
let mut first = [0u8; 4096];
|
||||||
|
let n = sock.read(&mut first)?;
|
||||||
|
let txt = String::from_utf8_lossy(&first[..n]);
|
||||||
|
let client_key = first_attr(&txt, "key").unwrap_or(CHALLENGE_KEY);
|
||||||
|
let client_resp = first_attr(&txt, "response").unwrap_or("");
|
||||||
|
let h = crypto::challenge_response(client_key, client_resp)?;
|
||||||
|
conn.set_key(crypto::derive_session_key(&h));
|
||||||
|
log!("handshake accepted; session crypto initialized");
|
||||||
|
|
||||||
|
// 3. plaintext ChallengeAccepted
|
||||||
|
conn.send_plain(&resp(
|
||||||
|
"1",
|
||||||
|
&format!(r#"ChallengeAccepted response="{h}""#),
|
||||||
|
"EALS",
|
||||||
|
))?;
|
||||||
|
|
||||||
|
// 3b. EXPERIMENT (OPENFUT_LSX_LOGIN_PLAINTEXT=1): the shipped emu's ONLY
|
||||||
|
// unsolicited Event is the plaintext, pre-session-key Challenge; there is
|
||||||
|
// zero evidence an *encrypted mid-session* Event routes to the same parser
|
||||||
|
// (REPACK_INTEL.md sec.4 step 2). So push the Login Event here, in
|
||||||
|
// PLAINTEXT, right after ChallengeAccepted -- before the stream goes
|
||||||
|
// encrypted -- and suppress the encrypted heartbeat to keep the A/B clean.
|
||||||
|
if conn.events().login_plaintext && conn.events().enabled {
|
||||||
|
conn.stop_events();
|
||||||
|
for frame in login_event_frames() {
|
||||||
|
conn.send_plain(&frame)?;
|
||||||
|
log!("PUSH (plaintext post-accept) >> {frame}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. encrypted request/response loop
|
||||||
|
let mut heartbeat_started = false;
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut chunk = [0u8; 65536];
|
||||||
|
loop {
|
||||||
|
let n = sock.read(&mut chunk)?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Buffer partial frames: a 64 KiB read can straddle a NUL boundary, and
|
||||||
|
// splitting without keeping the remainder would silently drop the trailing
|
||||||
|
// partial (C3).
|
||||||
|
buf.extend_from_slice(&chunk[..n]);
|
||||||
|
let complete = match buf.iter().rposition(|b| *b == 0) {
|
||||||
|
Some(i) => i + 1,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
let frames: Vec<Vec<u8>> = buf[..complete]
|
||||||
|
.split(|b| *b == 0)
|
||||||
|
.filter(|f| !f.is_empty())
|
||||||
|
.map(<[u8]>::to_vec)
|
||||||
|
.collect();
|
||||||
|
buf.drain(..complete);
|
||||||
|
|
||||||
|
for frame in frames {
|
||||||
|
let key = conn.key().expect("session key set during the handshake");
|
||||||
|
let xml = match crypto::lsx_decrypt(&frame, &key) {
|
||||||
|
Ok(x) => x,
|
||||||
|
Err(e) => {
|
||||||
|
log!("decrypt fail: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Some(req) = parse_request(&xml) else {
|
||||||
|
log!("<< {}", safe_xml_for_log(&xml));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let reply = responder.build_reply(req.id, req.name, &req.attrs, Some(conn), req.recipient);
|
||||||
|
log!(
|
||||||
|
"<< id={} {} recipient={} {}",
|
||||||
|
req.id,
|
||||||
|
req.name,
|
||||||
|
py_repr(req.recipient),
|
||||||
|
req.attrs.py_repr()
|
||||||
|
);
|
||||||
|
log!(">> {}", safe_xml_for_log(&reply));
|
||||||
|
conn.send_enc(&reply)?;
|
||||||
|
|
||||||
|
if let Some(why) = push_after(req.name) {
|
||||||
|
if conn.events().enabled
|
||||||
|
// For GetGameInfo only fire on UPTODATE, otherwise we would push
|
||||||
|
// three times per boot for FREETRIAL/LANGUAGES too.
|
||||||
|
&& (req.name != "GetGameInfo"
|
||||||
|
|| req.attrs.get("GameInfoId") == Some("UPTODATE"))
|
||||||
|
{
|
||||||
|
conn.push_login_state(why);
|
||||||
|
if !heartbeat_started {
|
||||||
|
heartbeat_started = true;
|
||||||
|
let conn = Arc::clone(conn);
|
||||||
|
std::thread::spawn(move || conn.heartbeat());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `re.search(r'name="([^"]*)"', txt)` -- the first occurrence anywhere in the
|
||||||
|
/// plaintext handshake frame, with no word-boundary requirement (which is why the
|
||||||
|
/// needle keeps its `="`).
|
||||||
|
fn first_attr<'a>(txt: &'a str, name: &str) -> Option<&'a str> {
|
||||||
|
let needle = format!("{name}=\"");
|
||||||
|
let start = txt.find(&needle)? + needle.len();
|
||||||
|
let len = txt[start..].find('"')?;
|
||||||
|
Some(&txt[start..start + len])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No live game needed. Proves the crypto is untouched and the event frames
|
||||||
|
/// encrypt/decrypt cleanly through our own codec.
|
||||||
|
fn selftest() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let h = crypto::challenge_response("18a70055a3541fb27ab8e0f47afad18c", "")?;
|
||||||
|
check(h.starts_with("e4f5166209929e15"), &h)?;
|
||||||
|
let k = crypto::derive_session_key(&h);
|
||||||
|
let k_hex = crypto::hex_lower(&k);
|
||||||
|
check(k_hex == "6a9da3e78615153cc2f10eec25ae6382", &k_hex)?;
|
||||||
|
println!("[ok] crypto matches the captured 2026-07-30 session verbatim");
|
||||||
|
|
||||||
|
let frames = login_event_frames();
|
||||||
|
check(
|
||||||
|
frames.len()
|
||||||
|
== openfut_lsx::events::LOGIN_EVENT_SENDERS.len()
|
||||||
|
+ openfut_lsx::events::ONLINE_EVENT_SENDERS.len(),
|
||||||
|
&format!("{} frames", frames.len()),
|
||||||
|
)?;
|
||||||
|
for f in &frames {
|
||||||
|
let round = crypto::lsx_decrypt(&crypto::lsx_encrypt(f, &k), &k)?;
|
||||||
|
check(round == *f, &round)?;
|
||||||
|
println!("[ok] round-trip: {f}");
|
||||||
|
}
|
||||||
|
// "" sender first (correct for the current empty service-name table)
|
||||||
|
check(
|
||||||
|
frames[0].contains(r#"<Event sender=""><Login IsLoggedIn="true"/></Event>"#),
|
||||||
|
&frames[0],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// conn=None: the selftest must not write the run's success-signal files.
|
||||||
|
let responder = Responder::new(Identity::from_env()?, ProtocolConfig::from_env());
|
||||||
|
let attrs: Attrs = [("ClientId", "X"), ("Scope", "Y")].into_iter().collect();
|
||||||
|
let r = responder.build_reply("42", "GetAuthCode", &attrs, None, "");
|
||||||
|
// `value` is the only attribute lsx::AuthCodeT's deserializer (0x1471312a0)
|
||||||
|
// actually reads; Code=/Return= are legacy padding.
|
||||||
|
check(r.contains("<AuthCode value="), &r)?;
|
||||||
|
|
||||||
|
let redacted = safe_xml_for_log(r#"<AuthCode value="secret" Code="secret" Return="secret"/>"#);
|
||||||
|
check(
|
||||||
|
!redacted.contains("secret") && redacted.matches("[REDACTED]").count() == 3,
|
||||||
|
&redacted,
|
||||||
|
)?;
|
||||||
|
let status = safe_xml_for_log(r#"<ErrorSuccess Code="0" Description=""/>"#);
|
||||||
|
check(status.contains(r#"Code="0""#), &status)?;
|
||||||
|
println!("[ok] GetAuthCode response shape and log redaction");
|
||||||
|
println!("[ok] selftest passed");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Python's `assert cond, value`.
|
||||||
|
fn check(cond: bool, value: &str) -> Result<(), io::Error> {
|
||||||
|
if cond {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(io::Error::other(format!("assertion failed: {value}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,914 @@
|
|||||||
|
//! Request parsing, verb dispatch and log redaction.
|
||||||
|
//!
|
||||||
|
//! The dispatch is request-DRIVEN (the Steampunks stub was a blind fixed script),
|
||||||
|
//! and the frame grammar is the Python's three regexes, transcribed by hand so the
|
||||||
|
//! crate needs no regex engine. Where the regexes are tolerant, this is tolerant
|
||||||
|
//! in the same way -- see [`parse_request`].
|
||||||
|
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use crate::events::Conn;
|
||||||
|
use crate::identity::{Identity, CONTENT_ID, ENTITLEMENT_TAG};
|
||||||
|
use crate::{env_flag, log, Env, AUTHCODE_FILE, CLIENTID_FILE};
|
||||||
|
|
||||||
|
/// Attributes of the request's child element, in the order they appeared.
|
||||||
|
///
|
||||||
|
/// Python builds `dict(ATTR_RE.findall(rest))`: a repeated attribute keeps its
|
||||||
|
/// FIRST position but takes its LAST value, and that dict is echoed into the
|
||||||
|
/// `<< id=...` log line, so the ordering is observable.
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Attrs<'a>(Vec<(&'a str, &'a str)>);
|
||||||
|
|
||||||
|
impl<'a> Attrs<'a> {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self(Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert(&mut self, key: &'a str, value: &'a str) {
|
||||||
|
match self.0.iter_mut().find(|(k, _)| *k == key) {
|
||||||
|
Some(slot) => slot.1 = value,
|
||||||
|
None => self.0.push((key, value)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, key: &str) -> Option<&'a str> {
|
||||||
|
self.0.iter().find(|(k, _)| *k == key).map(|(_, v)| *v)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `attrs.get(key, "")`.
|
||||||
|
pub fn get_or_empty(&self, key: &str) -> &'a str {
|
||||||
|
self.get(key).unwrap_or("")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.0.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.0.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn iter(&self) -> impl Iterator<Item = (&'a str, &'a str)> + '_ {
|
||||||
|
self.0.iter().copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `repr(dict)`, because the request log line embeds it verbatim.
|
||||||
|
pub fn py_repr(&self) -> String {
|
||||||
|
let mut s = String::from("{");
|
||||||
|
for (i, (k, v)) in self.0.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
s.push_str(", ");
|
||||||
|
}
|
||||||
|
let _ = write!(s, "{}: {}", py_repr(k), py_repr(v));
|
||||||
|
}
|
||||||
|
s.push('}');
|
||||||
|
s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> FromIterator<(&'a str, &'a str)> for Attrs<'a> {
|
||||||
|
fn from_iter<T: IntoIterator<Item = (&'a str, &'a str)>>(iter: T) -> Self {
|
||||||
|
let mut attrs = Attrs::new();
|
||||||
|
for (k, v) in iter {
|
||||||
|
attrs.insert(k, v);
|
||||||
|
}
|
||||||
|
attrs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One parsed `<Request>` frame.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Request<'a> {
|
||||||
|
/// The digits of `id="N"`, kept as text: it is echoed, never arithmetic.
|
||||||
|
pub id: &'a str,
|
||||||
|
pub name: &'a str,
|
||||||
|
pub attrs: Attrs<'a>,
|
||||||
|
/// The response `sender` must byte-equal this (matcher 0x1471189b0). Captured
|
||||||
|
/// separately from the rest of the frame, and defaulting to "", so a frame
|
||||||
|
/// that ever lacks `recipient` still gets answered fast instead of a 15s
|
||||||
|
/// stall.
|
||||||
|
pub recipient: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `REQ_RE = r'<Request[^>]*\bid="(\d+)"[^>]*>\s*<([A-Za-z]+)([^>]*)/?>'` plus
|
||||||
|
/// `RECIP_RE = r'<Request[^>]*\brecipient="([^"]*)"'`, both as `re.search`.
|
||||||
|
///
|
||||||
|
/// Transcription notes, all of them observable behaviour rather than style:
|
||||||
|
/// * `[^>]*` cannot cross a `>`, so `id` must live inside the `<Request ...>`
|
||||||
|
/// tag itself; the tag must be closed for the pattern to match at all.
|
||||||
|
/// * that leading `[^>]*` is greedy, so with more than one `id="N"` in the tag
|
||||||
|
/// the RIGHTMOST one wins.
|
||||||
|
/// * the trailing `([^>]*)/?>` is greedy too, so the captured attribute region
|
||||||
|
/// keeps a self-closing `/` -- harmless, `ATTR_RE` skips it.
|
||||||
|
/// * `recipient` is searched over the whole frame independently of the element
|
||||||
|
/// match, and its value may legally contain `>`.
|
||||||
|
pub fn parse_request(xml: &str) -> Option<Request<'_>> {
|
||||||
|
const TAG: &str = "<Request";
|
||||||
|
let mut from = 0;
|
||||||
|
while let Some(off) = xml[from..].find(TAG) {
|
||||||
|
let start = from + off;
|
||||||
|
from = start + TAG.len();
|
||||||
|
let tag_end = match xml[from..].find('>') {
|
||||||
|
Some(i) => from + i,
|
||||||
|
None => continue, // `[^>]*>` needs the tag to close
|
||||||
|
};
|
||||||
|
let Some(id) = rightmost_id(xml, from, tag_end) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// `>\s*<`
|
||||||
|
let child = xml[tag_end + 1..].trim_start_matches(char::is_whitespace);
|
||||||
|
let Some(child) = child.strip_prefix('<') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let name_len = child
|
||||||
|
.as_bytes()
|
||||||
|
.iter()
|
||||||
|
.take_while(|c| c.is_ascii_alphabetic())
|
||||||
|
.count();
|
||||||
|
if name_len == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (name, tail) = child.split_at(name_len);
|
||||||
|
let Some(gt) = tail.find('>') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
return Some(Request {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
attrs: parse_attrs(&tail[..gt]),
|
||||||
|
recipient: find_recipient(xml).unwrap_or(""),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rightmost `\bid="(\d+)"` inside `xml[from..tag_end]`.
|
||||||
|
fn rightmost_id(xml: &str, from: usize, tag_end: usize) -> Option<&str> {
|
||||||
|
let tag = &xml[from..tag_end];
|
||||||
|
let mut search_end = tag.len();
|
||||||
|
while let Some(at) = tag[..search_end].rfind("id=\"") {
|
||||||
|
search_end = at;
|
||||||
|
// `\b`: the character before `id` must not be a word character. Look at
|
||||||
|
// the whole frame, so `<Requestid="1">` correctly fails.
|
||||||
|
if !word_boundary_before(xml, from + at) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let value = &tag[at + 4..];
|
||||||
|
let digits = value.as_bytes().iter().take_while(|c| c.is_ascii_digit()).count();
|
||||||
|
if digits > 0 && value.as_bytes().get(digits) == Some(&b'"') {
|
||||||
|
return Some(&value[..digits]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_recipient(xml: &str) -> Option<&str> {
|
||||||
|
const TAG: &str = "<Request";
|
||||||
|
let mut from = 0;
|
||||||
|
while let Some(off) = xml[from..].find(TAG) {
|
||||||
|
from += off + TAG.len();
|
||||||
|
// The literal must start before the tag closes; the VALUE may run past it.
|
||||||
|
let limit = xml[from..].find('>').map_or(xml.len(), |i| from + i);
|
||||||
|
let mut search_end = limit;
|
||||||
|
while let Some(at) = xml[from..search_end].rfind("recipient=\"") {
|
||||||
|
let at = from + at;
|
||||||
|
search_end = at;
|
||||||
|
if !word_boundary_before(xml, at) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let value_start = at + "recipient=\"".len();
|
||||||
|
if let Some(len) = xml[value_start..].find('"') {
|
||||||
|
return Some(&xml[value_start..value_start + len]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ATTR_RE.findall` -- `(\w+)="([^"]*)"`, non-overlapping, left to right.
|
||||||
|
fn parse_attrs(region: &str) -> Attrs<'_> {
|
||||||
|
let mut attrs = Attrs::new();
|
||||||
|
let mut pos = 0;
|
||||||
|
while let Some(off) = region[pos..].find("=\"") {
|
||||||
|
let eq = pos + off;
|
||||||
|
// Greedy `\w+` immediately before `="`.
|
||||||
|
let name_len: usize = region[..eq]
|
||||||
|
.chars()
|
||||||
|
.rev()
|
||||||
|
.take_while(|c| is_word(*c))
|
||||||
|
.map(char::len_utf8)
|
||||||
|
.sum();
|
||||||
|
let value_start = eq + 2;
|
||||||
|
let Some(value_len) = region[value_start..].find('"') else {
|
||||||
|
break; // an unterminated value ends the scan, as the regex does
|
||||||
|
};
|
||||||
|
if name_len > 0 {
|
||||||
|
attrs.insert(
|
||||||
|
®ion[eq - name_len..eq],
|
||||||
|
®ion[value_start..value_start + value_len],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
pos = value_start + value_len + 1;
|
||||||
|
}
|
||||||
|
attrs
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_word(c: char) -> bool {
|
||||||
|
c.is_alphanumeric() || c == '_'
|
||||||
|
}
|
||||||
|
|
||||||
|
fn word_boundary_before(s: &str, at: usize) -> bool {
|
||||||
|
at == 0 || !s[..at].chars().next_back().is_some_and(is_word)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ redaction
|
||||||
|
const SECRET_ATTRS: [&str; 5] = ["AuthCode", "AuthToken", "SessionKey", "Token", "Sid"];
|
||||||
|
const AUTH_CODE_ATTRS: [&str; 3] = ["value", "Code", "Return"];
|
||||||
|
const CHALLENGE_ATTRS: [&str; 1] = ["response"];
|
||||||
|
|
||||||
|
/// Redact credential-bearing LSX attributes from ordinary diagnostics.
|
||||||
|
///
|
||||||
|
/// The blanket pass only catches `Name="..."` pairs; the auth code and the
|
||||||
|
/// challenge response hide behind generic attribute names (`value`, `Code`,
|
||||||
|
/// `Return`, `response`), so those two elements get a second, element-scoped pass
|
||||||
|
/// -- otherwise `<ErrorSuccess Code="0">` would be redacted too and the ordinary
|
||||||
|
/// status lines would stop being readable.
|
||||||
|
pub fn safe_xml_for_log(xml: &str) -> String {
|
||||||
|
let safe = redact_attrs(xml, &SECRET_ATTRS);
|
||||||
|
let safe = if safe.contains("<AuthCode ") {
|
||||||
|
redact_attrs(&safe, &AUTH_CODE_ATTRS)
|
||||||
|
} else {
|
||||||
|
safe
|
||||||
|
};
|
||||||
|
if safe.contains("<ChallengeAccepted ") {
|
||||||
|
redact_attrs(&safe, &CHALLENGE_ATTRS)
|
||||||
|
} else {
|
||||||
|
safe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `re.sub(r'(?i)\b(a|b|c)="[^"]*"', r'\1="[REDACTED]"', xml)`; the attribute name
|
||||||
|
/// keeps the case it was written in.
|
||||||
|
fn redact_attrs(input: &str, names: &[&str]) -> String {
|
||||||
|
let mut out = String::with_capacity(input.len());
|
||||||
|
let mut last = 0;
|
||||||
|
let mut i = 0;
|
||||||
|
while i < input.len() {
|
||||||
|
if !input.is_char_boundary(i) || !word_boundary_before(input, i) {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let rest = &input[i..];
|
||||||
|
let hit = names.iter().find_map(|name| {
|
||||||
|
let after = rest.get(..name.len())?;
|
||||||
|
if !after.eq_ignore_ascii_case(name) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let value = rest[name.len()..].strip_prefix("=\"")?;
|
||||||
|
let len = value.find('"')?;
|
||||||
|
Some((name.len(), name.len() + 2 + len + 1))
|
||||||
|
});
|
||||||
|
match hit {
|
||||||
|
Some((name_len, match_len)) => {
|
||||||
|
out.push_str(&input[last..i]);
|
||||||
|
out.push_str(&rest[..name_len]);
|
||||||
|
out.push_str("=\"[REDACTED]\"");
|
||||||
|
i += match_len;
|
||||||
|
last = i;
|
||||||
|
}
|
||||||
|
None => i += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push_str(&input[last..]);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `repr()` of a Python string, for the log lines that embed one.
|
||||||
|
pub fn py_repr(s: &str) -> String {
|
||||||
|
let quote = if s.contains('\'') && !s.contains('"') {
|
||||||
|
'"'
|
||||||
|
} else {
|
||||||
|
'\''
|
||||||
|
};
|
||||||
|
let mut out = String::with_capacity(s.len() + 2);
|
||||||
|
out.push(quote);
|
||||||
|
for c in s.chars() {
|
||||||
|
match c {
|
||||||
|
'\\' => out.push_str("\\\\"),
|
||||||
|
'\n' => out.push_str("\\n"),
|
||||||
|
'\r' => out.push_str("\\r"),
|
||||||
|
'\t' => out.push_str("\\t"),
|
||||||
|
c if c == quote => {
|
||||||
|
out.push('\\');
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
// Python renders the remaining ASCII controls as \xNN; anything
|
||||||
|
// printable (including non-ASCII) goes through verbatim.
|
||||||
|
c if (c as u32) < 0x20 || c as u32 == 0x7f => {
|
||||||
|
let _ = write!(out, "\\x{:02x}", c as u32);
|
||||||
|
}
|
||||||
|
c => out.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(quote);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ responses
|
||||||
|
pub fn resp(mid: &str, body: &str, sender: &str) -> String {
|
||||||
|
format!(r#"<LSX><Response id="{mid}" sender="{sender}"><{body}/></Response></LSX>"#)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Knobs that shape individual replies.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ProtocolConfig {
|
||||||
|
/// A/B-control integrity: v1 (`lsx_responder.py`) answered GetGameInfo
|
||||||
|
/// FULLGAME_PURCHASED with "false" (it fell through to the default). v2 had
|
||||||
|
/// silently changed it to "true", which meant `OPENFUT_LSX_EVENTS=0` was NOT a
|
||||||
|
/// byte-identical control any more. Keep it OFF by default so events-off ==
|
||||||
|
/// v1 exactly; flip `OPENFUT_LSX_FULLGAME=1` to run the FULLGAME="true"
|
||||||
|
/// experiment on its own.
|
||||||
|
pub fullgame_purchased: bool,
|
||||||
|
/// `OPENFUT_AUTHCODE`.
|
||||||
|
pub auth_code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ProtocolConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
fullgame_purchased: false,
|
||||||
|
auth_code: format!("OPENFUT-{}", "0".repeat(24)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProtocolConfig {
|
||||||
|
pub fn from_env() -> Self {
|
||||||
|
Self::from_vars(&crate::os_env)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_vars(env: Env<'_>) -> Self {
|
||||||
|
Self {
|
||||||
|
fullgame_purchased: env_flag(env, "OPENFUT_LSX_FULLGAME", false),
|
||||||
|
auth_code: env("OPENFUT_AUTHCODE").unwrap_or_else(|| Self::default().auth_code),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The verb dispatcher.
|
||||||
|
pub struct Responder {
|
||||||
|
pub identity: Identity,
|
||||||
|
pub config: ProtocolConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Responder {
|
||||||
|
pub fn new(identity: Identity, config: ProtocolConfig) -> Self {
|
||||||
|
Self { identity, config }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request-DRIVEN dispatch.
|
||||||
|
///
|
||||||
|
/// CRITICAL (2026-07-31, connect-reverse workflow): FIFA's response matcher
|
||||||
|
/// 0x1471189b0 rejects any `<Response>` whose `sender` attribute does not
|
||||||
|
/// byte-equal the `recipient` the client put on the matching `<Request>` (it
|
||||||
|
/// reads `serviceNames[facility]`; with our empty GetConfigResponse all 34
|
||||||
|
/// names are "" so recipient="" for every verb after GetConfig, which itself
|
||||||
|
/// uses the hard-coded literal "EbisuSDK"). We were answering GetProfile/
|
||||||
|
/// GetAuthCode/QueryEntitlements with sender="EbisuSDK" -> silently discarded
|
||||||
|
/// -> GetProfile (the SOLE writer of OriginSDK+0x3a0 default-user) never took
|
||||||
|
/// -> the whole online-login chain stalled at OSDK_INVALID_USER. FIX = ECHO
|
||||||
|
/// the request's recipient back as the response sender, which is what every
|
||||||
|
/// `reply` below does.
|
||||||
|
///
|
||||||
|
/// `conn` is `None` in the selftest; a `None` connection must not touch the
|
||||||
|
/// run's success-signal files.
|
||||||
|
pub fn build_reply(
|
||||||
|
&self,
|
||||||
|
mid: &str,
|
||||||
|
req_name: &str,
|
||||||
|
attrs: &Attrs<'_>,
|
||||||
|
conn: Option<&Conn>,
|
||||||
|
recipient: &str,
|
||||||
|
) -> String {
|
||||||
|
let reply = |body: &str| resp(mid, body, recipient);
|
||||||
|
|
||||||
|
match req_name {
|
||||||
|
// FLAG (1): "internet is reachable". The stub hardcoded
|
||||||
|
// connected="0" -> "log in to Origin". This is NOT the logged-in
|
||||||
|
// flag; see the crate docs.
|
||||||
|
"GetInternetConnectedState" => reply(r#"InternetConnectedState connected="1""#),
|
||||||
|
|
||||||
|
// Request shape is built at 0x14713b8d0:
|
||||||
|
// <GetAuthCode ClientId="..." Scope="..."/>
|
||||||
|
// Response is matched at 0x1470e2b60: outer "LSX", element "AuthCode".
|
||||||
|
//
|
||||||
|
// THE ATTRIBUTE NAME IS "value" -- verified, not guessed: the
|
||||||
|
// "AuthCode" element match at 0x1470e2b63 tail-jumps to 0x14712fac0
|
||||||
|
// -> 0x1471312a0 = the lsx::AuthCodeT deserializer. It builds one
|
||||||
|
// attribute name (ns-prefix for "lsx" @0x14394def0, then "value"
|
||||||
|
// @0x1436c7768, concat at 0x14712d130) and does exactly ONE
|
||||||
|
// get-attribute-as-string call 0x14713fe50(node, "value", &dest).
|
||||||
|
// dest is ctx+0x00 == LSXRequest+0xb8, whose std::string size lands
|
||||||
|
// at +0xc8 -- which is what OriginRequestAuthCodeSync's impl
|
||||||
|
// 0x1470e67f0 reads back at 0x1470e6924 (`mov rbx,[rdi+0xc8]`) as
|
||||||
|
// *out_len. Code=/Return= are NEVER read; with them alone the parsed
|
||||||
|
// string is empty -> out_len 0 -> EbisuMgr+0x948 stays NULL -> the
|
||||||
|
// OSDK classifier 0x14717d5d0 falls into its `test rbp,rbp / je` arm
|
||||||
|
// and reports OSDK_UNDERAGE_ERROR (a mislabelled "no auth code"
|
||||||
|
// fallback). Code=/Return= are kept only as harmless padding.
|
||||||
|
"GetAuthCode" => {
|
||||||
|
let client_id = attrs.get_or_empty("ClientId");
|
||||||
|
let scope = attrs.get_or_empty("Scope");
|
||||||
|
let code = &self.config.auth_code;
|
||||||
|
// Only touch the run's success-signal files on a REAL request. The
|
||||||
|
// selftest passes conn=None; if it wrote these files it would
|
||||||
|
// pre-satisfy watch-step "authcode.txt becomes non-empty" and make
|
||||||
|
// a non-event read as success on the next live run.
|
||||||
|
if let Some(conn) = conn {
|
||||||
|
for (path, val) in [(AUTHCODE_FILE, code.as_str()), (CLIENTID_FILE, client_id)]
|
||||||
|
{
|
||||||
|
if let Err(e) = fs::write(path, val) {
|
||||||
|
log!("could not write {path}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// GetAuthCode has fired: stop the heartbeat so we do not keep
|
||||||
|
// re-pushing Login/OnlineStatus events during Blaze login.
|
||||||
|
conn.stop_events();
|
||||||
|
}
|
||||||
|
log!("*** GetAuthCode ISSUED ***");
|
||||||
|
log!(
|
||||||
|
" ClientId={} Scope={}",
|
||||||
|
py_repr(client_id),
|
||||||
|
py_repr(scope)
|
||||||
|
);
|
||||||
|
log!(" code=[REDACTED] -- issued for Blaze Authentication::login (1/0x0A)");
|
||||||
|
reply(&format!(
|
||||||
|
r#"AuthCode value="{code}" Code="{code}" Return="{code}""#
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The only reply with a child element, so it cannot use the
|
||||||
|
// single-element `resp` helper.
|
||||||
|
"QueryEntitlements" => format!(
|
||||||
|
concat!(
|
||||||
|
r#"<LSX><Response id="{mid}" sender="{recipient}">"#,
|
||||||
|
r#"<QueryEntitlementsResponse>"#,
|
||||||
|
r#"<OriginItem ItemId="{tag}" EntitlementId="1" "#,
|
||||||
|
r#"ResourceId="{content}" OfferId="{content}" "#,
|
||||||
|
r#"GrantDate="2016-09-01T00:00:00Z" bIsOwned="true" Uses="0"/>"#,
|
||||||
|
r#"</QueryEntitlementsResponse>"#,
|
||||||
|
r#"</Response></LSX>"#
|
||||||
|
),
|
||||||
|
mid = mid,
|
||||||
|
recipient = recipient,
|
||||||
|
tag = ENTITLEMENT_TAG,
|
||||||
|
content = CONTENT_ID
|
||||||
|
),
|
||||||
|
|
||||||
|
// This is the ONLY feed for OriginSDK[+0x3a0]/[+0x3a8]
|
||||||
|
// (OriginGetDefaultUser @0x1470da6d0 / OriginGetDefaultPersona
|
||||||
|
// @0x1470da680 are bare reads of those fields, written only by
|
||||||
|
// OriginSDK::Initialize @0x1470e5ad5/0x1470e5ae1). Keep it complete.
|
||||||
|
// ONLY PersonaId/UserId/Persona come from the identity; the rest of
|
||||||
|
// this template (Country/CommerceCountry/GeoCountry/CommerceCurrency/
|
||||||
|
// AvatarId/IsSubscriber/IsUnderAge) is byte-exact per REPACK_INTEL 1.4
|
||||||
|
// and is latched into OriginSDK[+0x3a0]/[+0x3a8] -- leave it verbatim.
|
||||||
|
"GetProfile" => reply(&format!(
|
||||||
|
concat!(
|
||||||
|
r#"GetProfileResponse IsSubscriber="true" PersonaId="{persona}" "#,
|
||||||
|
r#"AvatarId="" Country="US" CommerceCountry="US" GeoCountry="US" "#,
|
||||||
|
r#"UserId="{user}" Persona="{name}" IsUnderAge="false" "#,
|
||||||
|
r#"CommerceCurrency="USD""#
|
||||||
|
),
|
||||||
|
persona = self.identity.persona_id,
|
||||||
|
user = self.identity.user_id(),
|
||||||
|
name = self.identity.persona_name
|
||||||
|
)),
|
||||||
|
|
||||||
|
"GetGameInfo" => match attrs.get("GameInfoId") {
|
||||||
|
Some("LANGUAGES") => reply(concat!(
|
||||||
|
r#"GetGameInfoResponse GameInfo="ar_SA,cs_CZ,da_DK,de_DE,"#,
|
||||||
|
r#"en_US,es_ES,es_MX,fr_FR,it_IT,nl_NL,no_NO,pl_PL,pt_BR,"#,
|
||||||
|
r#"pt_PT,ru_RU,sv_SE,tr_TR,zh_TW""#
|
||||||
|
)),
|
||||||
|
// MUST be true or the client shows "Your title version is
|
||||||
|
// outdated" and blocks all online features.
|
||||||
|
Some("UPTODATE") => reply(r#"GetGameInfoResponse GameInfo="true""#),
|
||||||
|
// OFF by default: v1 answered "false" here (fell through to the
|
||||||
|
// default), and keeping this gated makes OPENFUT_LSX_EVENTS=0
|
||||||
|
// byte-identical to v1.
|
||||||
|
Some("FULLGAME_PURCHASED") if self.config.fullgame_purchased => {
|
||||||
|
reply(r#"GetGameInfoResponse GameInfo="true""#)
|
||||||
|
}
|
||||||
|
// FREETRIAL / FULLGAME_PURCHASED etc. -> false (retail, not a
|
||||||
|
// trial; matches v1 exactly)
|
||||||
|
_ => reply(r#"GetGameInfoResponse GameInfo="false""#),
|
||||||
|
},
|
||||||
|
|
||||||
|
"GetSetting" => {
|
||||||
|
// The client asks in UPPERCASE.
|
||||||
|
match attrs.get_or_empty("SettingId").to_uppercase().as_str() {
|
||||||
|
"ENVIRONMENT" | "ENVIRONMENTNAME" => {
|
||||||
|
reply(r#"GetSettingResponse Setting="production""#)
|
||||||
|
}
|
||||||
|
"LANGUAGE" => reply(&format!(
|
||||||
|
r#"GetSettingResponse Setting="{}""#,
|
||||||
|
self.identity.locale
|
||||||
|
)),
|
||||||
|
_ => reply(r#"GetSettingResponse Setting="false""#),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"GetConfig" => reply(r#"GetConfigResponse Config="false""#),
|
||||||
|
|
||||||
|
"IsProgressiveInstallationAvailable" => reply(concat!(
|
||||||
|
r#"IsProgressiveInstallationAvailableResponse ItemId="" "#,
|
||||||
|
r#"Available="false""#
|
||||||
|
)),
|
||||||
|
|
||||||
|
_ => reply(r#"ErrorSuccess Code="0" Description="""#),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trigger points: push right after answering these verbs. GetProfile is the
|
||||||
|
/// earliest safe moment -- by then the SDK has built its handler set and has a
|
||||||
|
/// default user, so a Login event has somewhere to land.
|
||||||
|
///
|
||||||
|
/// For GetGameInfo the caller only fires on UPTODATE, otherwise we would push
|
||||||
|
/// three times per boot for FREETRIAL/LANGUAGES too.
|
||||||
|
pub fn push_after(req_name: &str) -> Option<&'static str> {
|
||||||
|
match req_name {
|
||||||
|
"GetProfile" => Some("after GetProfile"),
|
||||||
|
"GetInternetConnectedState" => Some("after GetInternetConnectedState"),
|
||||||
|
"GetGameInfo" => Some("after GetGameInfo UPTODATE"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
|
||||||
|
let map: HashMap<String, String> = pairs
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
|
||||||
|
.collect();
|
||||||
|
move |k: &str| map.get(k).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn responder() -> Responder {
|
||||||
|
Responder::new(Identity::default(), ProtocolConfig::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reply(verb: &str, attrs: &[(&str, &str)]) -> String {
|
||||||
|
responder().build_reply("7", verb, &attrs.iter().copied().collect(), None, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- parsing
|
||||||
|
#[test]
|
||||||
|
fn parses_a_request_with_a_recipient() {
|
||||||
|
let xml = r#"<LSX><Request id="12" recipient="EbisuSDK"><GetConfig Locale="en_US" Env="prod"/></Request></LSX>"#;
|
||||||
|
let req = parse_request(xml).unwrap();
|
||||||
|
assert_eq!(req.id, "12");
|
||||||
|
assert_eq!(req.name, "GetConfig");
|
||||||
|
assert_eq!(req.recipient, "EbisuSDK");
|
||||||
|
assert_eq!(req.attrs.get("Locale"), Some("en_US"));
|
||||||
|
assert_eq!(req.attrs.get("Env"), Some("prod"));
|
||||||
|
assert_eq!(req.attrs.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_a_request_without_a_recipient() {
|
||||||
|
// Must still be answered -- fast -- rather than stalling ~15s.
|
||||||
|
let xml = r#"<LSX><Request id="3"><GetProfile/></Request></LSX>"#;
|
||||||
|
let req = parse_request(xml).unwrap();
|
||||||
|
assert_eq!((req.id, req.name, req.recipient), ("3", "GetProfile", ""));
|
||||||
|
assert!(req.attrs.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_an_empty_recipient() {
|
||||||
|
let xml = r#"<LSX><Request id="9" recipient=""><GetSetting SettingId="LANGUAGE"/></Request></LSX>"#;
|
||||||
|
let req = parse_request(xml).unwrap();
|
||||||
|
assert_eq!(req.recipient, "");
|
||||||
|
assert_eq!(req.attrs.get("SettingId"), Some("LANGUAGE"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tolerates_whitespace_between_the_request_and_its_element() {
|
||||||
|
let xml = "<LSX><Request id=\"4\" recipient=\"X\">\n <GetAuthCode ClientId=\"c\" Scope=\"s\" />\n</Request></LSX>";
|
||||||
|
let req = parse_request(xml).unwrap();
|
||||||
|
assert_eq!((req.id, req.name, req.recipient), ("4", "GetAuthCode", "X"));
|
||||||
|
assert_eq!(req.attrs.get("ClientId"), Some("c"));
|
||||||
|
assert_eq!(req.attrs.get("Scope"), Some("s"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_frames_that_are_not_requests() {
|
||||||
|
for xml in [
|
||||||
|
"",
|
||||||
|
r#"<LSX><Event sender=""><Login IsLoggedIn="true"/></Event></LSX>"#,
|
||||||
|
r#"<LSX><Request recipient="X"><GetConfig/></Request></LSX>"#, // no id
|
||||||
|
r#"<LSX><Request id="abc"><GetConfig/></Request></LSX>"#, // id not digits
|
||||||
|
r#"<LSX><Requestid="1"><GetConfig/></Request></LSX>"#, // \b fails
|
||||||
|
r#"<LSX><Request id="1">"#, // no element
|
||||||
|
] {
|
||||||
|
assert!(parse_request(xml).is_none(), "{xml}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_repeated_attribute_keeps_its_place_and_takes_the_last_value() {
|
||||||
|
let xml = r#"<LSX><Request id="1"><V a="1" b="2" a="3"/></Request></LSX>"#;
|
||||||
|
let req = parse_request(xml).unwrap();
|
||||||
|
assert_eq!(req.attrs.py_repr(), "{'a': '3', 'b': '2'}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn greedy_id_takes_the_rightmost_one() {
|
||||||
|
let xml = r#"<LSX><Request id="1" other-id="2"><GetConfig/></Request></LSX>"#;
|
||||||
|
assert_eq!(parse_request(xml).unwrap().id, "2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_self_closing_slash_does_not_become_an_attribute() {
|
||||||
|
let xml = r#"<LSX><Request id="1"><GetGameInfo GameInfoId="UPTODATE"/></Request></LSX>"#;
|
||||||
|
let req = parse_request(xml).unwrap();
|
||||||
|
assert_eq!(req.attrs.len(), 1);
|
||||||
|
assert_eq!(req.attrs.get("GameInfoId"), Some("UPTODATE"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ verbs
|
||||||
|
#[test]
|
||||||
|
fn internet_connected_state_is_one() {
|
||||||
|
assert_eq!(
|
||||||
|
reply("GetInternetConnectedState", &[]),
|
||||||
|
r#"<LSX><Response id="7" sender=""><InternetConnectedState connected="1"/></Response></LSX>"#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_code_carries_the_value_attribute() {
|
||||||
|
let r = reply("GetAuthCode", &[("ClientId", "X"), ("Scope", "Y")]);
|
||||||
|
assert_eq!(
|
||||||
|
r,
|
||||||
|
concat!(
|
||||||
|
r#"<LSX><Response id="7" sender=""><AuthCode "#,
|
||||||
|
r#"value="OPENFUT-000000000000000000000000" "#,
|
||||||
|
r#"Code="OPENFUT-000000000000000000000000" "#,
|
||||||
|
r#"Return="OPENFUT-000000000000000000000000"/></Response></LSX>"#
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_code_honours_the_env_override() {
|
||||||
|
let cfg = ProtocolConfig::from_vars(&env_of(&[("OPENFUT_AUTHCODE", "ABC")]));
|
||||||
|
let r = Responder::new(Identity::default(), cfg).build_reply(
|
||||||
|
"1",
|
||||||
|
"GetAuthCode",
|
||||||
|
&Attrs::new(),
|
||||||
|
None,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
assert!(r.contains(r#"<AuthCode value="ABC" Code="ABC" Return="ABC"/>"#), "{r}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn query_entitlements_owns_the_retail_offer() {
|
||||||
|
assert_eq!(
|
||||||
|
reply("QueryEntitlements", &[]),
|
||||||
|
concat!(
|
||||||
|
r#"<LSX><Response id="7" sender=""><QueryEntitlementsResponse>"#,
|
||||||
|
r#"<OriginItem ItemId="ONLINE_ACCESS" EntitlementId="1" "#,
|
||||||
|
r#"ResourceId="1027460" OfferId="1027460" "#,
|
||||||
|
r#"GrantDate="2016-09-01T00:00:00Z" bIsOwned="true" Uses="0"/>"#,
|
||||||
|
r#"</QueryEntitlementsResponse></Response></LSX>"#
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_carries_the_identity_and_the_verbatim_template() {
|
||||||
|
assert_eq!(
|
||||||
|
reply("GetProfile", &[]),
|
||||||
|
concat!(
|
||||||
|
r#"<LSX><Response id="7" sender=""><GetProfileResponse IsSubscriber="true" "#,
|
||||||
|
r#"PersonaId="33068179" AvatarId="" Country="US" CommerceCountry="US" "#,
|
||||||
|
r#"GeoCountry="US" UserId="33068179" Persona="CAGE" IsUnderAge="false" "#,
|
||||||
|
r#"CommerceCurrency="USD"/></Response></LSX>"#
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_echoes_an_overridden_persona() {
|
||||||
|
let ident = Identity::from_vars(&env_of(&[
|
||||||
|
("FUT_PERSONA_ID", "42"),
|
||||||
|
("FUT_PERSONA_NAME", "ZED"),
|
||||||
|
]))
|
||||||
|
.unwrap();
|
||||||
|
let r = Responder::new(ident, ProtocolConfig::default())
|
||||||
|
.build_reply("1", "GetProfile", &Attrs::new(), None, "");
|
||||||
|
assert!(r.contains(r#"PersonaId="42""#), "{r}");
|
||||||
|
assert!(r.contains(r#"UserId="42""#), "{r}");
|
||||||
|
assert!(r.contains(r#"Persona="ZED""#), "{r}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_info_languages_and_uptodate() {
|
||||||
|
let r = reply("GetGameInfo", &[("GameInfoId", "LANGUAGES")]);
|
||||||
|
assert!(r.contains(r#"GameInfo="ar_SA,cs_CZ,"#), "{r}");
|
||||||
|
assert!(r.ends_with(r#"tr_TR,zh_TW"/></Response></LSX>"#), "{r}");
|
||||||
|
assert!(reply("GetGameInfo", &[("GameInfoId", "UPTODATE")])
|
||||||
|
.contains(r#"GetGameInfoResponse GameInfo="true""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fullgame_purchased_is_false_unless_the_knob_is_set() {
|
||||||
|
// A/B-control integrity: events-off must stay byte-identical to v1.
|
||||||
|
for id in ["FULLGAME_PURCHASED", "FREETRIAL", "ANYTHING_ELSE"] {
|
||||||
|
assert!(
|
||||||
|
reply("GetGameInfo", &[("GameInfoId", id)])
|
||||||
|
.contains(r#"GetGameInfoResponse GameInfo="false""#),
|
||||||
|
"{id}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// ... and a GetGameInfo with no GameInfoId at all.
|
||||||
|
assert!(reply("GetGameInfo", &[]).contains(r#"GameInfo="false""#));
|
||||||
|
|
||||||
|
let cfg = ProtocolConfig::from_vars(&env_of(&[("OPENFUT_LSX_FULLGAME", "1")]));
|
||||||
|
let on = Responder::new(Identity::default(), cfg);
|
||||||
|
assert!(on
|
||||||
|
.build_reply(
|
||||||
|
"7",
|
||||||
|
"GetGameInfo",
|
||||||
|
&[("GameInfoId", "FULLGAME_PURCHASED")].into_iter().collect(),
|
||||||
|
None,
|
||||||
|
""
|
||||||
|
)
|
||||||
|
.contains(r#"GameInfo="true""#));
|
||||||
|
// Still only that one id flips.
|
||||||
|
assert!(on
|
||||||
|
.build_reply(
|
||||||
|
"7",
|
||||||
|
"GetGameInfo",
|
||||||
|
&[("GameInfoId", "FREETRIAL")].into_iter().collect(),
|
||||||
|
None,
|
||||||
|
""
|
||||||
|
)
|
||||||
|
.contains(r#"GameInfo="false""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fullgame_knob_follows_the_not_zero_rule() {
|
||||||
|
for (value, on) in [("0", false), ("1", true), ("", true), ("false", true)] {
|
||||||
|
let cfg = ProtocolConfig::from_vars(&env_of(&[("OPENFUT_LSX_FULLGAME", value)]));
|
||||||
|
assert_eq!(cfg.fullgame_purchased, on, "OPENFUT_LSX_FULLGAME={value:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings() {
|
||||||
|
for id in ["ENVIRONMENT", "environment", "EnvironmentName"] {
|
||||||
|
assert!(
|
||||||
|
reply("GetSetting", &[("SettingId", id)])
|
||||||
|
.contains(r#"GetSettingResponse Setting="production""#),
|
||||||
|
"{id}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(reply("GetSetting", &[("SettingId", "LANGUAGE")])
|
||||||
|
.contains(r#"GetSettingResponse Setting="en_US""#));
|
||||||
|
assert!(reply("GetSetting", &[("SettingId", "WHATEVER")])
|
||||||
|
.contains(r#"GetSettingResponse Setting="false""#));
|
||||||
|
assert!(reply("GetSetting", &[]).contains(r#"Setting="false""#));
|
||||||
|
|
||||||
|
let ident = Identity::from_vars(&env_of(&[("FUT_LOCALE", "fr_FR")])).unwrap();
|
||||||
|
assert!(Responder::new(ident, ProtocolConfig::default())
|
||||||
|
.build_reply(
|
||||||
|
"7",
|
||||||
|
"GetSetting",
|
||||||
|
&[("SettingId", "LANGUAGE")].into_iter().collect(),
|
||||||
|
None,
|
||||||
|
""
|
||||||
|
)
|
||||||
|
.contains(r#"Setting="fr_FR""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_is_empty_which_is_why_every_recipient_is_the_empty_string() {
|
||||||
|
assert!(reply("GetConfig", &[]).contains(r#"GetConfigResponse Config="false""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progressive_installation_is_unavailable() {
|
||||||
|
assert!(reply("IsProgressiveInstallationAvailable", &[]).contains(
|
||||||
|
r#"IsProgressiveInstallationAvailableResponse ItemId="" Available="false""#
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_verbs_fall_through_to_error_success() {
|
||||||
|
assert_eq!(
|
||||||
|
reply("GetSomethingWeHaveNeverSeen", &[]),
|
||||||
|
r#"<LSX><Response id="7" sender=""><ErrorSuccess Code="0" Description=""/></Response></LSX>"#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_response_sender_byte_equals_the_request_recipient() {
|
||||||
|
let r = responder().build_reply("5", "GetProfile", &Attrs::new(), None, "EbisuSDK");
|
||||||
|
assert!(r.starts_with(r#"<LSX><Response id="5" sender="EbisuSDK">"#), "{r}");
|
||||||
|
// Including for the one verb built without the `resp` helper.
|
||||||
|
let q = responder().build_reply("5", "QueryEntitlements", &Attrs::new(), None, "EbisuSDK");
|
||||||
|
assert!(q.starts_with(r#"<LSX><Response id="5" sender="EbisuSDK">"#), "{q}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn push_triggers() {
|
||||||
|
assert_eq!(push_after("GetProfile"), Some("after GetProfile"));
|
||||||
|
assert_eq!(
|
||||||
|
push_after("GetInternetConnectedState"),
|
||||||
|
Some("after GetInternetConnectedState")
|
||||||
|
);
|
||||||
|
assert_eq!(push_after("GetGameInfo"), Some("after GetGameInfo UPTODATE"));
|
||||||
|
assert_eq!(push_after("GetConfig"), None);
|
||||||
|
assert_eq!(push_after("GetAuthCode"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- redaction
|
||||||
|
#[test]
|
||||||
|
fn redacts_credential_attributes() {
|
||||||
|
let safe = safe_xml_for_log(
|
||||||
|
r#"<Thing AuthCode="a" AuthToken="b" SessionKey="c" Token="d" Sid="e" Keep="f"/>"#,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
safe,
|
||||||
|
concat!(
|
||||||
|
r#"<Thing AuthCode="[REDACTED]" AuthToken="[REDACTED]" "#,
|
||||||
|
r#"SessionKey="[REDACTED]" Token="[REDACTED]" Sid="[REDACTED]" Keep="f"/>"#
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn redacts_the_auth_code_element_generically() {
|
||||||
|
let safe =
|
||||||
|
safe_xml_for_log(r#"<AuthCode value="secret" Code="secret" Return="secret"/>"#);
|
||||||
|
assert!(!safe.contains("secret"), "{safe}");
|
||||||
|
assert_eq!(safe.matches("[REDACTED]").count(), 3, "{safe}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn redacts_the_challenge_response() {
|
||||||
|
let safe = safe_xml_for_log(
|
||||||
|
r#"<LSX><Response id="1" sender="EALS"><ChallengeAccepted response="e4f5"/></Response></LSX>"#,
|
||||||
|
);
|
||||||
|
assert!(!safe.contains("e4f5"), "{safe}");
|
||||||
|
assert!(safe.contains(r#"response="[REDACTED]""#), "{safe}");
|
||||||
|
assert!(safe.contains(r#"id="1""#), "{safe}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leaves_ordinary_status_lines_readable() {
|
||||||
|
let status = safe_xml_for_log(r#"<ErrorSuccess Code="0" Description=""/>"#);
|
||||||
|
assert_eq!(status, r#"<ErrorSuccess Code="0" Description=""/>"#);
|
||||||
|
// The element name alone must not trip the blanket pass.
|
||||||
|
assert_eq!(
|
||||||
|
safe_xml_for_log(r#"<GetConfigResponse Config="false"/>"#),
|
||||||
|
r#"<GetConfigResponse Config="false"/>"#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn redaction_is_case_insensitive_and_keeps_the_written_case() {
|
||||||
|
assert_eq!(
|
||||||
|
safe_xml_for_log(r#"<X authtoken="q" SID="r"/>"#),
|
||||||
|
r#"<X authtoken="[REDACTED]" SID="[REDACTED]"/>"#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn redaction_respects_word_boundaries() {
|
||||||
|
// `MySid="x"` has no boundary before `Sid`, so it is left alone.
|
||||||
|
assert_eq!(
|
||||||
|
safe_xml_for_log(r#"<X MySid="x"/>"#),
|
||||||
|
r#"<X MySid="x"/>"#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn python_repr_of_strings() {
|
||||||
|
assert_eq!(py_repr(""), "''");
|
||||||
|
assert_eq!(py_repr("X"), "'X'");
|
||||||
|
assert_eq!(py_repr("it's"), "\"it's\"");
|
||||||
|
assert_eq!(py_repr("a\"b"), "'a\"b'");
|
||||||
|
assert_eq!(py_repr("a\nb"), r"'a\nb'");
|
||||||
|
assert_eq!(py_repr("a\\b"), r"'a\\b'");
|
||||||
|
assert_eq!(Attrs::new().py_repr(), "{}");
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
-60
@@ -1,47 +1,51 @@
|
|||||||
# openfut-utas-host
|
# openfut-utas-host
|
||||||
|
|
||||||
The first live FIFA 17 **UTAS migration host**. It fronts the client-visible UTAS
|
The FIFA 17 UTAS migration boundary. It accepts the client-visible HTTP surface,
|
||||||
port and migrates one route at a time to OpenFUT Core, proxying everything else to
|
serves migrated routes from Rust/Core plus host-owned durable stores, and proxies only
|
||||||
the Python UTAS oracle so the rest of FUT keeps working unchanged.
|
the unclassified tail to the Python behavioral oracle.
|
||||||
|
|
||||||
```
|
```
|
||||||
FIFA 17 ──HTTP──▶ openfut-utas-host
|
FIFA 17 ──HTTP──▶ openfut-utas-host
|
||||||
├── GET …/club ──▶ FIFA17 adapter ──▶ OpenFUT Core (/collection)
|
├── migrated route ──▶ Rust adapter / Core / host stores
|
||||||
└── everything else ──▶ Python UTAS oracle (verbatim reverse proxy)
|
└── unclassified tail ──▶ Python UTAS oracle
|
||||||
```
|
```
|
||||||
|
|
||||||
## What it owns / does not own
|
`src/lib.rs::classify` is the route-level source of truth. The current Rust surface
|
||||||
|
includes club/squad/user reads, club rename, auth/session/client data, Store/economy,
|
||||||
Owns: socket + HTTP/1.1 keep-alive transport, route classification, the Core
|
packs, owned-item moves, market/trade-pile, and the observed hub support routes.
|
||||||
access client, the Python passthrough, and diagnostics. It owns **no** game
|
|
||||||
domain state — filtering/pagination is Core's; wire parsing/shaping is the
|
|
||||||
adapter's. The adapter never learns how Core is reached (the architecture rule):
|
|
||||||
the host holds the [`CoreAccess`] boundary (`GET {core_url}/collection?…` today).
|
|
||||||
|
|
||||||
## Safety model
|
## Safety model
|
||||||
|
|
||||||
- **Classification happens once, before execution.** Exact `GET /ut/game/<title>/club`
|
- Classification happens exactly once before execution. There is no "try Rust then
|
||||||
→ Rust; everything else → Python. No shared path, no "try Rust then Python".
|
Python"; a mutation cannot be double-applied.
|
||||||
- A Core failure on `/club` degrades to a valid empty `{"itemData":[]}` and logs
|
- A route classified to Rust never falls back to Python on a Core/store/projection
|
||||||
an error — it never falls back to Python (which could double-apply a mutation on
|
failure. Each handler uses its captured fail-closed or honest-empty wire contract.
|
||||||
other routes). `/club` is read-only, but the rule is absolute.
|
- `PUT …/club` and `PUT|POST …/user/club` atomically update the shared account JSON.
|
||||||
- Mutating routes (PUT/POST, `/squad`, `/purchased`, quick-sell, market, auth, SBC,
|
Every input returns the required zero-atom `200 {}` response; rejection and
|
||||||
`/club/stats/*`, `/clubUser`) all classify to passthrough and are untouched.
|
persistence failures remain visible in logs.
|
||||||
|
- Numeric `GET …/squad/<n>` returns the one Core-backed current squad, matching the
|
||||||
|
Python oracle's single-current-squad behavior.
|
||||||
|
- Python remains the behavioral oracle and rollback backend for routes not yet
|
||||||
|
classified to Rust. New economy behavior belongs in Rust/Core, never Python.
|
||||||
|
|
||||||
## Configuration (env)
|
## Configuration (env)
|
||||||
|
|
||||||
| Var | Required | Default | Meaning |
|
| Var | Required | Default | Meaning |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `OPENFUT_UTAS_HOST_ADDR` | yes | — | where this host listens (client-visible UTAS addr) |
|
| `OPENFUT_UTAS_HOST_ADDR` | yes | — | client-visible host listen address |
|
||||||
| `OPENFUT_UTAS_PYTHON_URL` | yes | — | Python UTAS oracle base URL for fallback (must differ from this host) |
|
| `OPENFUT_UTAS_PYTHON_URL` | yes | — | Python oracle base for the unclassified tail; must differ from this host |
|
||||||
| `OPENFUT_FIFA17_CATALOG` | yes | — | FIFA 17 card-definition identity catalog (`Fifa17CardCatalog` JSON: card id → asset id) |
|
| `OPENFUT_FIFA17_CATALOG` | yes | — | FIFA 17 definition identity catalog |
|
||||||
| `OPENFUT_IDENTITY_STORE` | yes | — | persistent external-identity store file (owned instance → stable wire id) |
|
| `OPENFUT_IDENTITY_STORE` | yes | — | persistent owned-instance ↔ wire-id store |
|
||||||
| `OPENFUT_PERSONA_ID` | yes | — | FIFA persona id stamped on `GET /squad/active` (must match the persona LSX/Blaze/POW/UTAS agree on) |
|
| `OPENFUT_PERSONA_ID` | yes | — | non-zero FIFA persona id shared by LSX/Blaze/POW/UTAS |
|
||||||
|
| `OPENFUT_MARKET_DB` | yes | — | durable host-owned transfer-market SQLite DB |
|
||||||
|
| `OPENFUT_PILE_DB` | yes | — | durable host-owned item-pile SQLite DB |
|
||||||
| `OPENFUT_CORE_URL` | no | `http://127.0.0.1:8080` | OpenFUT Core base |
|
| `OPENFUT_CORE_URL` | no | `http://127.0.0.1:8080` | OpenFUT Core base |
|
||||||
| `OPENFUT_FIFA17_TABLES_DIR` | no | `fifa17-recon/data/tables` | `leagues/nations/teams.json` for id⇄name |
|
| `OPENFUT_FIFA17_TABLES_DIR` | no | `fifa17-recon/data/tables` | FIFA entity tables |
|
||||||
|
| `OPENFUT_CLIENTDATA_DB` | no | identity-store sibling `clientdata.json` | durable opaque client-data JSON |
|
||||||
|
| `OPENFUT_ACCOUNT_PATH` | no | `FUT_ACCOUNT_PATH`, then identity-store sibling `active_account.json` | shared FIFA account/club JSON |
|
||||||
|
|
||||||
Startup **fails clearly** if the catalog or identity store cannot be loaded —
|
Startup fails if required identity or durable economy state cannot be opened. No
|
||||||
there is no placeholder fallback (exactly one production identity path).
|
placeholder production identity source is substituted.
|
||||||
|
|
||||||
## Identity model (resolved)
|
## Identity model (resolved)
|
||||||
|
|
||||||
@@ -61,39 +65,10 @@ conflated, are resolved by [`Fifa17IdentityResolver`] (the single production pat
|
|||||||
within `(fifa17, owned-item)` — no per-account column is needed because Core
|
within `(fifa17, owned-item)` — no per-account column is needed because Core
|
||||||
owned-instance ids are globally-unique UUIDs.
|
owned-instance ids are globally-unique UUIDs.
|
||||||
|
|
||||||
**Remaining prerequisite for a *rendering* retail `/club`:** Core inventory must
|
Production has a frozen post-P1 baseline and a hot Python rollback. A source change
|
||||||
reference cards that exist in the catalog. The catalog + store + resolver are
|
passing local tests is **not** deployment approval. Build verification, staging, host
|
||||||
built and tested; wiring a controlled real FIFA 17 dev-content inventory (the
|
restart, and live-client promotion remain operator-gated; the current state and
|
||||||
curated per-game dev pack) is the next slice. `rare=SP` ("Special") stays
|
promotion evidence live in the OpenFUT Obsidian vault.
|
||||||
UNSUPPORTED (semantics unproven; parsed, reported, never guessed).
|
|
||||||
|
|
||||||
## Retail A/B runbook (first `/club` gate)
|
|
||||||
|
|
||||||
Change **only** the UTAS routing layer; keep the validated Rust Redirector/Roster
|
|
||||||
and the current Blaze path. Python remains the rollback oracle — do not modify it.
|
|
||||||
|
|
||||||
Preconditions (mirror the proven blaze/roster switch discipline):
|
|
||||||
1. `cargo test -p openfut-utas-host -p openfut-adapter-fifa17` green; `clippy -D warnings` clean; `fmt --check` clean.
|
|
||||||
2. Built binary identity == HEAD (`scripts/verify-build-identity.sh`); no dirty tree.
|
|
||||||
3. Python UTAS directly reachable; the Rust host directly probeable; no stale NAT/switch rules; FIFA fully closed.
|
|
||||||
|
|
||||||
Bring-up:
|
|
||||||
1. Move Python UTAS to an alternate port (`FUT_PORT=8199` in the container/`openfut-fut.sh`); it keeps serving there.
|
|
||||||
2. Start this host on the client-visible UTAS addr:
|
|
||||||
`OPENFUT_UTAS_HOST_ADDR=<lan>:8099 OPENFUT_UTAS_PYTHON_URL=http://127.0.0.1:8199 OPENFUT_CORE_URL=http://127.0.0.1:8080 OPENFUT_FIFA17_CATALOG=<catalog.json> OPENFUT_IDENTITY_STORE=<store.json> OPENFUT_PERSONA_ID=33068179 openfut-utas-host`
|
|
||||||
3. Launch FIFA → FUT → **My Squad** player picker and exercise: no-filter, position, nation, league, league+team, Gold+position, then scroll beyond page one.
|
|
||||||
|
|
||||||
Evidence to capture (all six):
|
|
||||||
- **Switch**: client traffic hits the Rust host.
|
|
||||||
- **Rust positive**: host log `owner=RUST route=club …` for the client IP.
|
|
||||||
- **Python negative for /club**: Python logs no `/club` request in the window.
|
|
||||||
- **Python positive for other UTAS**: unimplemented routes still reach Python.
|
|
||||||
- **Core positive**: Core logs the `/collection` query and returns the expected set.
|
|
||||||
- **Application + pagination**: the UI shows filtered results; later pages differ
|
|
||||||
from page one (no repeated-first-page amplification).
|
|
||||||
|
|
||||||
Rollback: point the UTAS addr back at Python directly; confirm FUT still usable;
|
|
||||||
then re-enable the host and confirm `/club` again (proves reversibility).
|
|
||||||
|
|
||||||
Logs are safe by construction: no auth/session/device/token material — only owner,
|
Logs are safe by construction: no auth/session/device/token material — only owner,
|
||||||
route, filter summary, counts, status.
|
route, filter summary, counts, status.
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
//! Durable FIFA 17 club identity backed by the shared `fut_account.json` shape.
|
||||||
|
//!
|
||||||
|
//! The Python oracle and the launcher use snake-case keys in one account file.
|
||||||
|
//! Rust owns the rename mutation now, but preserves every unrelated account key
|
||||||
|
//! so Blaze/POW and rollback keep reading the same source of truth.
|
||||||
|
|
||||||
|
use std::fs::{self, OpenOptions};
|
||||||
|
use std::io::Write;
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
pub const DEFAULT_CLUB_NAME: &str = "OpenFUT";
|
||||||
|
pub const DEFAULT_CLUB_ABBR: &str = "OFC";
|
||||||
|
pub const DEFAULT_ESTABLISHED: &str = "2026";
|
||||||
|
|
||||||
|
const CLUB_NAME_MIN: usize = 5;
|
||||||
|
const CLUB_NAME_MAX: usize = 15;
|
||||||
|
const CLUB_ABBR_MIN: usize = 1;
|
||||||
|
const CLUB_ABBR_MAX: usize = 3;
|
||||||
|
|
||||||
|
/// The FIFA-facing club fields read by `user`, `userMassInfo`, and account sync.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ClubIdentity {
|
||||||
|
pub name: String,
|
||||||
|
pub abbr: String,
|
||||||
|
pub established: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileSignature = (u64, u64, i64, i64, u64);
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct AccountState {
|
||||||
|
raw: Map<String, Value>,
|
||||||
|
club: ClubIdentity,
|
||||||
|
signature: Option<FileSignature>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of applying a rename body. Every result maps to the protocol's `200 {}`;
|
||||||
|
/// the distinction exists for logging and tests, not for changing wire behavior.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum RenameOutcome {
|
||||||
|
Updated,
|
||||||
|
Unchanged,
|
||||||
|
Rejected(&'static str),
|
||||||
|
PersistFailed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thread-safe account state persisted by atomic replacement on a valid rename.
|
||||||
|
pub struct AccountStore {
|
||||||
|
path: PathBuf,
|
||||||
|
state: Mutex<AccountState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccountStore {
|
||||||
|
/// Load the shared account file. Missing or malformed files use the proven
|
||||||
|
/// production defaults; the first valid rename creates/repairs the file.
|
||||||
|
pub fn open(path: impl Into<PathBuf>) -> Self {
|
||||||
|
let path = path.into();
|
||||||
|
let raw = read_raw(&path);
|
||||||
|
let club = club_from_raw(&raw);
|
||||||
|
let signature = file_signature(&path);
|
||||||
|
Self {
|
||||||
|
path,
|
||||||
|
state: Mutex::new(AccountState {
|
||||||
|
raw,
|
||||||
|
club,
|
||||||
|
signature,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn club(&self) -> ClubIdentity {
|
||||||
|
let mut state = self.state.lock();
|
||||||
|
self.refresh_if_changed(&mut state);
|
||||||
|
state.club.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply `{clubName, clubAbbr}` with the exact Python-oracle semantics:
|
||||||
|
/// either field may be omitted/null, invalid input is ignored, and the caller
|
||||||
|
/// always acknowledges with `200 {}`. A valid change is durable before it
|
||||||
|
/// becomes visible to Rust readers.
|
||||||
|
pub fn rename_from_body(&self, body: &[u8]) -> RenameOutcome {
|
||||||
|
let value: Value = match serde_json::from_slice(body) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) => return RenameOutcome::Rejected("malformed_json"),
|
||||||
|
};
|
||||||
|
let Value::Object(request) = value else {
|
||||||
|
return RenameOutcome::Rejected("body_not_object");
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut state = self.state.lock();
|
||||||
|
self.refresh_if_changed(&mut state);
|
||||||
|
let name = match optional_string(&request, "clubName") {
|
||||||
|
Ok(Some(value)) => value.trim().to_owned(),
|
||||||
|
Ok(None) => state.club.name.clone(),
|
||||||
|
Err(()) => return RenameOutcome::Rejected("club_name_not_string"),
|
||||||
|
};
|
||||||
|
let abbr = match optional_string(&request, "clubAbbr") {
|
||||||
|
Ok(Some(value)) => value.trim().to_owned(),
|
||||||
|
Ok(None) => state.club.abbr.clone(),
|
||||||
|
Err(()) => return RenameOutcome::Rejected("club_abbr_not_string"),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !valid_name(&name) {
|
||||||
|
return RenameOutcome::Rejected("club_name_length");
|
||||||
|
}
|
||||||
|
if !valid_abbr(&abbr) {
|
||||||
|
return RenameOutcome::Rejected("club_abbr_length");
|
||||||
|
}
|
||||||
|
if name == state.club.name && abbr == state.club.abbr {
|
||||||
|
return RenameOutcome::Unchanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut updated = state.raw.clone();
|
||||||
|
updated.insert("club_name".into(), Value::String(name.clone()));
|
||||||
|
updated.insert("club_abbr".into(), Value::String(abbr.clone()));
|
||||||
|
let bytes = match serde_json::to_vec_pretty(&updated) {
|
||||||
|
Ok(bytes) => bytes,
|
||||||
|
Err(error) => return RenameOutcome::PersistFailed(error.to_string()),
|
||||||
|
};
|
||||||
|
if let Err(error) = persist_atomically(&self.path, &bytes) {
|
||||||
|
return RenameOutcome::PersistFailed(error.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
state.raw = updated;
|
||||||
|
state.club.name = name;
|
||||||
|
state.club.abbr = abbr;
|
||||||
|
state.signature = file_signature(&self.path);
|
||||||
|
RenameOutcome::Updated
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_if_changed(&self, state: &mut AccountState) {
|
||||||
|
let signature = file_signature(&self.path);
|
||||||
|
if signature == state.signature {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let raw = read_raw(&self.path);
|
||||||
|
state.club = club_from_raw(&raw);
|
||||||
|
state.raw = raw;
|
||||||
|
state.signature = file_signature(&self.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_raw(path: &Path) -> Map<String, Value> {
|
||||||
|
fs::read(path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn club_from_raw(raw: &Map<String, Value>) -> ClubIdentity {
|
||||||
|
ClubIdentity {
|
||||||
|
name: valid_stored(raw, "club_name", valid_name)
|
||||||
|
.unwrap_or(DEFAULT_CLUB_NAME)
|
||||||
|
.to_owned(),
|
||||||
|
abbr: valid_stored(raw, "club_abbr", valid_abbr)
|
||||||
|
.unwrap_or(DEFAULT_CLUB_ABBR)
|
||||||
|
.to_owned(),
|
||||||
|
established: stored_established(raw).unwrap_or_else(|| DEFAULT_ESTABLISHED.to_owned()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_signature(path: &Path) -> Option<FileSignature> {
|
||||||
|
let metadata = fs::metadata(path).ok()?;
|
||||||
|
Some((
|
||||||
|
metadata.dev(),
|
||||||
|
metadata.ino(),
|
||||||
|
metadata.mtime(),
|
||||||
|
metadata.mtime_nsec(),
|
||||||
|
metadata.len(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn optional_string<'a>(object: &'a Map<String, Value>, key: &str) -> Result<Option<&'a str>, ()> {
|
||||||
|
match object.get(key) {
|
||||||
|
None | Some(Value::Null) => Ok(None),
|
||||||
|
Some(Value::String(value)) => Ok(Some(value)),
|
||||||
|
Some(_) => Err(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_stored<'a>(
|
||||||
|
raw: &'a Map<String, Value>,
|
||||||
|
key: &str,
|
||||||
|
valid: fn(&str) -> bool,
|
||||||
|
) -> Option<&'a str> {
|
||||||
|
raw.get(key)
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.filter(|value| valid(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stored_established(raw: &Map<String, Value>) -> Option<String> {
|
||||||
|
match raw.get("established")? {
|
||||||
|
Value::String(value) if valid_established(value) => Some(value.clone()),
|
||||||
|
Value::Number(value) => value.as_u64().map(|year| year.to_string()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_name(value: &str) -> bool {
|
||||||
|
let len = value.chars().count();
|
||||||
|
(CLUB_NAME_MIN..=CLUB_NAME_MAX).contains(&len) && value == value.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_abbr(value: &str) -> bool {
|
||||||
|
let len = value.chars().count();
|
||||||
|
(CLUB_ABBR_MIN..=CLUB_ABBR_MAX).contains(&len) && value == value.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_established(value: &str) -> bool {
|
||||||
|
!value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persist_atomically(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||||
|
if let Some(parent) = path
|
||||||
|
.parent()
|
||||||
|
.filter(|parent| !parent.as_os_str().is_empty())
|
||||||
|
{
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
|
let temp = path.with_extension(format!(
|
||||||
|
"openfut-tmp-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
COUNTER.fetch_add(1, Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
let result = (|| {
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(&temp)?;
|
||||||
|
file.write_all(bytes)?;
|
||||||
|
file.sync_all()?;
|
||||||
|
drop(file);
|
||||||
|
fs::rename(&temp, path)
|
||||||
|
})();
|
||||||
|
if result.is_err() {
|
||||||
|
let _ = fs::remove_file(&temp);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn temp_path(tag: &str) -> PathBuf {
|
||||||
|
std::env::temp_dir().join(format!(
|
||||||
|
"openfut-account-store-{}-{tag}.json",
|
||||||
|
std::process::id()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn valid_rename_preserves_unrelated_account_fields_and_reopens() {
|
||||||
|
let path = temp_path("persist");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
fs::write(
|
||||||
|
&path,
|
||||||
|
serde_json::to_vec(&json!({
|
||||||
|
"persona_id": 33068179,
|
||||||
|
"persona_name": "CAGE",
|
||||||
|
"club_name": "OpenFUT",
|
||||||
|
"club_abbr": "OFC",
|
||||||
|
"established": 2016,
|
||||||
|
"pow_level": 7
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let store = AccountStore::open(&path);
|
||||||
|
assert_eq!(
|
||||||
|
store.rename_from_body(br#"{"clubName":" Real FUT ","clubAbbr":"RF"}"#),
|
||||||
|
RenameOutcome::Updated
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store.club(),
|
||||||
|
ClubIdentity {
|
||||||
|
name: "Real FUT".into(),
|
||||||
|
abbr: "RF".into(),
|
||||||
|
established: "2016".into()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let raw: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
||||||
|
assert_eq!(raw["persona_id"], 33_068_179);
|
||||||
|
assert_eq!(raw["persona_name"], "CAGE");
|
||||||
|
assert_eq!(raw["pow_level"], 7);
|
||||||
|
assert_eq!(raw["club_name"], "Real FUT");
|
||||||
|
assert_eq!(raw["club_abbr"], "RF");
|
||||||
|
assert_eq!(AccountStore::open(&path).club(), store.club());
|
||||||
|
let _ = fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn external_atomic_replacement_is_reloaded_before_read_and_rename() {
|
||||||
|
let path = temp_path("external-replace");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
fs::write(
|
||||||
|
&path,
|
||||||
|
serde_json::to_vec(&json!({
|
||||||
|
"club_name": "OpenFUT",
|
||||||
|
"club_abbr": "OFC",
|
||||||
|
"pow_level": 7
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let store = AccountStore::open(&path);
|
||||||
|
|
||||||
|
let replacement = serde_json::to_vec(&json!({
|
||||||
|
"club_name": "Other Club",
|
||||||
|
"club_abbr": "OC",
|
||||||
|
"established": "2015",
|
||||||
|
"pow_level": 11
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
persist_atomically(&path, &replacement).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store.club(),
|
||||||
|
ClubIdentity {
|
||||||
|
name: "Other Club".into(),
|
||||||
|
abbr: "OC".into(),
|
||||||
|
established: "2015".into()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
store.rename_from_body(br#"{"clubName":"Third Club"}"#),
|
||||||
|
RenameOutcome::Updated
|
||||||
|
);
|
||||||
|
let raw: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
||||||
|
assert_eq!(raw["club_name"], "Third Club");
|
||||||
|
assert_eq!(raw["club_abbr"], "OC");
|
||||||
|
assert_eq!(raw["pow_level"], 11);
|
||||||
|
let _ = fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn partial_invalid_and_malformed_renames_match_oracle_no_change_semantics() {
|
||||||
|
let path = temp_path("reject");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
let store = AccountStore::open(&path);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
store.rename_from_body(br#"{"clubName":"Open FUT"}"#),
|
||||||
|
RenameOutcome::Updated
|
||||||
|
);
|
||||||
|
assert_eq!(store.club().abbr, DEFAULT_CLUB_ABBR);
|
||||||
|
let before = store.club();
|
||||||
|
assert_eq!(
|
||||||
|
store.rename_from_body(br#"{"clubName":"x","clubAbbr":"TOOLONG"}"#),
|
||||||
|
RenameOutcome::Rejected("club_name_length")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store.rename_from_body(br#"{"clubName":3}"#),
|
||||||
|
RenameOutcome::Rejected("club_name_not_string")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store.rename_from_body(b"not-json"),
|
||||||
|
RenameOutcome::Rejected("malformed_json")
|
||||||
|
);
|
||||||
|
assert_eq!(store.club(), before);
|
||||||
|
let _ = fs::remove_file(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
//! STAGING-ONLY: complete a market sale on behalf of a synthetic Buyer B.
|
||||||
|
//!
|
||||||
|
//! Production has no trigger that decides "your listing sold" — that needs the
|
||||||
|
//! seller-facing sold wire contract, which is exactly what the staging experiment
|
||||||
|
//! is trying to establish. This binary is the synthetic counterparty: it runs the
|
||||||
|
//! REAL settlement path (`CoreEconomy::settle_sale` → Core's atomic
|
||||||
|
//! `POST /economy/settle-sale`) and then flips the host's listing to `sold`, so the
|
||||||
|
//! seller's client sees an authentic completed sale rather than a hand-written row.
|
||||||
|
//!
|
||||||
|
//! It is a separate binary precisely so no production HTTP surface grows an
|
||||||
|
//! experiment hook. It is never deployed and never runs in production.
|
||||||
|
//!
|
||||||
|
//! Ordering is deliberate: **settle first, mark sold second.** If settlement fails
|
||||||
|
//! the listing stays live and nothing has moved. If marking fails after a
|
||||||
|
//! successful settlement, the coins and ownership are already correct and the
|
||||||
|
//! listing is merely still shown as active — recoverable, and it never pays twice
|
||||||
|
//! because `mark_sold` is a once-only transition and clearing is presentation-only.
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! staging-sell --market-db PATH --core-url URL --trade-id ID \
|
||||||
|
//! --item CORE_ITEM_ID --seller CLUB --buyer CLUB [--gross N]
|
||||||
|
//! ```
|
||||||
|
//! `--gross` defaults to 150, the canonical staging sale.
|
||||||
|
|
||||||
|
use openfut_utas_host::market_store::MarketStore;
|
||||||
|
use openfut_utas_host::{CoreEconomy, EconomySale, HttpCoreClient};
|
||||||
|
|
||||||
|
/// FIFA 17's transfer fee, taken from the adapter so this harness can never
|
||||||
|
/// disagree with the shipped policy about what the seller is owed.
|
||||||
|
fn fee_for(gross: i64) -> i64 {
|
||||||
|
openfut_adapter_fifa17::fut::economy_policy::transfer_market_fee(gross)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Args {
|
||||||
|
market_db: String,
|
||||||
|
core_url: String,
|
||||||
|
trade_id: String,
|
||||||
|
item: String,
|
||||||
|
seller: String,
|
||||||
|
buyer: String,
|
||||||
|
gross: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_args() -> Result<Args, String> {
|
||||||
|
let mut market_db = None;
|
||||||
|
let mut core_url = None;
|
||||||
|
let mut trade_id = None;
|
||||||
|
let mut item = None;
|
||||||
|
let mut seller = None;
|
||||||
|
let mut buyer = None;
|
||||||
|
let mut gross = 150i64;
|
||||||
|
let argv: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < argv.len() {
|
||||||
|
let need = |i: usize| -> Result<String, String> {
|
||||||
|
argv.get(i + 1)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| format!("{} needs a value", argv[i]))
|
||||||
|
};
|
||||||
|
match argv[i].as_str() {
|
||||||
|
"--market-db" => market_db = Some(need(i)?),
|
||||||
|
"--core-url" => core_url = Some(need(i)?),
|
||||||
|
"--trade-id" => trade_id = Some(need(i)?),
|
||||||
|
"--item" => item = Some(need(i)?),
|
||||||
|
"--seller" => seller = Some(need(i)?),
|
||||||
|
"--buyer" => buyer = Some(need(i)?),
|
||||||
|
"--gross" => gross = need(i)?.parse().map_err(|e| format!("--gross: {e}"))?,
|
||||||
|
other => return Err(format!("unknown argument {other}")),
|
||||||
|
}
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
Ok(Args {
|
||||||
|
market_db: market_db.ok_or("--market-db is required")?,
|
||||||
|
core_url: core_url.ok_or("--core-url is required")?,
|
||||||
|
trade_id: trade_id.ok_or("--trade-id is required")?,
|
||||||
|
item: item.ok_or("--item is required")?,
|
||||||
|
seller: seller.ok_or("--seller is required")?,
|
||||||
|
buyer: buyer.ok_or("--buyer is required")?,
|
||||||
|
gross,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args = match parse_args() {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("staging-sell: {e}");
|
||||||
|
eprintln!(
|
||||||
|
"usage: staging-sell --market-db PATH --core-url URL --trade-id ID \
|
||||||
|
--item CORE_ITEM_ID --seller CLUB --buyer CLUB [--gross N]"
|
||||||
|
);
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Refuse to run against anything that looks like production state. This binary
|
||||||
|
// exists to keep an experiment isolated, so the guard belongs here rather than
|
||||||
|
// only in the caller.
|
||||||
|
for (label, value) in [
|
||||||
|
("--market-db", &args.market_db),
|
||||||
|
("--core-url", &args.core_url),
|
||||||
|
] {
|
||||||
|
if value.contains("openfut-promotion")
|
||||||
|
|| value.contains(":18080")
|
||||||
|
|| value.contains(":8099")
|
||||||
|
{
|
||||||
|
eprintln!("staging-sell: REFUSING to touch production via {label}={value}");
|
||||||
|
std::process::exit(3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let fee = fee_for(args.gross);
|
||||||
|
let proceeds = args.gross - fee;
|
||||||
|
println!(
|
||||||
|
"staging-sell: gross={} fee={} proceeds={} (floor 5%, fee+proceeds==gross)",
|
||||||
|
args.gross, fee, proceeds
|
||||||
|
);
|
||||||
|
|
||||||
|
let core = HttpCoreClient::new(args.core_url.clone(), "fifa17");
|
||||||
|
let sale = EconomySale {
|
||||||
|
item_id: &args.item,
|
||||||
|
seller_club_id: Some(&args.seller),
|
||||||
|
buyer_club_id: Some(&args.buyer),
|
||||||
|
gross: args.gross,
|
||||||
|
fee,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Settle atomically in Core: buyer debited, item transferred, seller paid net.
|
||||||
|
let receipt = match core.settle_sale(&sale) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("staging-sell: settlement FAILED, listing left live: {e:?}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
"staging-sell: SETTLED item={} card={} seller={} -> buyer={:?} \
|
||||||
|
seller_balance={} buyer_balance={:?} squad_slots_freed={}",
|
||||||
|
receipt.item_id,
|
||||||
|
receipt.card_id,
|
||||||
|
receipt.seller_club_id,
|
||||||
|
receipt.buyer_club_id,
|
||||||
|
receipt.seller_balance,
|
||||||
|
receipt.buyer_balance,
|
||||||
|
receipt.squad_slots_freed
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Only now does the seller's listing become `sold`, so the client can be
|
||||||
|
// shown a completed sale.
|
||||||
|
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(rt) => rt,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("staging-sell: runtime: {e} (settlement already committed)");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rt.block_on(async {
|
||||||
|
let store = match MarketStore::open(&args.market_db).await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"staging-sell: market store {} failed to open: {e} \
|
||||||
|
(settlement already committed — coins/ownership are correct)",
|
||||||
|
args.market_db
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match store.mark_sold(&args.trade_id).await {
|
||||||
|
Ok(true) => println!("staging-sell: listing {} -> sold", args.trade_id),
|
||||||
|
Ok(false) => println!(
|
||||||
|
"staging-sell: listing {} was NOT live (already sold/cancelled) — \
|
||||||
|
no second sale, nothing changed",
|
||||||
|
args.trade_id
|
||||||
|
),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("staging-sell: mark_sold failed: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match store.uncleared_sold().await {
|
||||||
|
Ok(rows) => println!("staging-sell: uncleared sold rows now {}", rows.len()),
|
||||||
|
Err(e) => eprintln!("staging-sell: uncleared_sold read failed: {e}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -44,7 +44,10 @@ impl ClientDataStore {
|
|||||||
|
|
||||||
/// The stored blob for `<persona>:<key>`, or `None` if never written.
|
/// The stored blob for `<persona>:<key>`, or `None` if never written.
|
||||||
pub fn get(&self, persona: i64, key: &str) -> Option<Value> {
|
pub fn get(&self, persona: i64, key: &str) -> Option<Value> {
|
||||||
self.map.lock().get(&Self::compound_key(persona, key)).cloned()
|
self.map
|
||||||
|
.lock()
|
||||||
|
.get(&Self::compound_key(persona, key))
|
||||||
|
.cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Store `value` under `<persona>:<key>` and persist the whole map to disk.
|
/// Store `value` under `<persona>:<key>` and persist the whole map to disk.
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ pub struct HostConfig {
|
|||||||
/// identity store's parent directory + `clientdata.json`. The blobs are
|
/// identity store's parent directory + `clientdata.json`. The blobs are
|
||||||
/// non-authoritative client UI state, so a default path is safe.
|
/// non-authoritative client UI state, so a default path is safe.
|
||||||
pub clientdata_path: String,
|
pub clientdata_path: String,
|
||||||
|
/// Shared FIFA17 account JSON (`fut_account.py` shape), used for club-name
|
||||||
|
/// reads and the Rust-owned rename mutation. Defaults to
|
||||||
|
/// `OPENFUT_ACCOUNT_PATH`, then existing `FUT_ACCOUNT_PATH`, then the
|
||||||
|
/// identity store's parent directory + `active_account.json`.
|
||||||
|
pub account_path: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -78,6 +83,11 @@ impl HostConfig {
|
|||||||
.ok()
|
.ok()
|
||||||
.filter(|v| !v.is_empty())
|
.filter(|v| !v.is_empty())
|
||||||
.unwrap_or_else(|| default_clientdata_path(&identity_store_path));
|
.unwrap_or_else(|| default_clientdata_path(&identity_store_path));
|
||||||
|
let account_path = env::var("OPENFUT_ACCOUNT_PATH")
|
||||||
|
.ok()
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.or_else(|| env::var("FUT_ACCOUNT_PATH").ok().filter(|v| !v.is_empty()))
|
||||||
|
.unwrap_or_else(|| default_account_path(&identity_store_path));
|
||||||
Ok(HostConfig {
|
Ok(HostConfig {
|
||||||
listen_addr: required("OPENFUT_UTAS_HOST_ADDR")?,
|
listen_addr: required("OPENFUT_UTAS_HOST_ADDR")?,
|
||||||
python_upstream: required("OPENFUT_UTAS_PYTHON_URL")?,
|
python_upstream: required("OPENFUT_UTAS_PYTHON_URL")?,
|
||||||
@@ -91,6 +101,7 @@ impl HostConfig {
|
|||||||
pile_db_path: required("OPENFUT_PILE_DB")?,
|
pile_db_path: required("OPENFUT_PILE_DB")?,
|
||||||
identity_store_path,
|
identity_store_path,
|
||||||
clientdata_path,
|
clientdata_path,
|
||||||
|
account_path,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,3 +116,12 @@ fn default_clientdata_path(identity_store_path: &str) -> String {
|
|||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.into_owned()
|
.into_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_account_path(identity_store_path: &str) -> String {
|
||||||
|
std::path::Path::new(identity_store_path)
|
||||||
|
.parent()
|
||||||
|
.map(|p| p.join("active_account.json"))
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from("active_account.json"))
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned()
|
||||||
|
}
|
||||||
|
|||||||
@@ -457,7 +457,7 @@ mod tests {
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::atomic::{AtomicI64, AtomicU32, Ordering};
|
use std::sync::atomic::{AtomicI64, AtomicU32, Ordering};
|
||||||
|
|
||||||
use crate::{EconomyEntitlement, EconomyPurchase};
|
use crate::{EconomyEntitlement, EconomyPurchase, EconomySale, EconomySaleReceipt};
|
||||||
|
|
||||||
// ── Recording economy double ────────────────────────────────────────────
|
// ── Recording economy double ────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -588,6 +588,10 @@ mod tests {
|
|||||||
self.purchased.lock().push((cost, items.to_vec()));
|
self.purchased.lock().push((cost, items.to_vec()));
|
||||||
Ok(self.balance.fetch_sub(cost, Ordering::SeqCst) - cost)
|
Ok(self.balance.fetch_sub(cost, Ordering::SeqCst) - cost)
|
||||||
}
|
}
|
||||||
|
fn settle_sale(&self, _sale: &EconomySale<'_>) -> Result<EconomySaleReceipt, CoreError> {
|
||||||
|
// Sale settlement is not exercised by the Store/quick-sell paths.
|
||||||
|
Err(CoreError::Status(501))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Identity / entity / lookup doubles ──────────────────────────────────
|
// ── Identity / entity / lookup doubles ──────────────────────────────────
|
||||||
|
|||||||
+323
-70
@@ -34,6 +34,7 @@
|
|||||||
//! faked (see `club_response`). With today's empty mapping, `/club` returns
|
//! faked (see `club_response`). With today's empty mapping, `/club` returns
|
||||||
//! `{"itemData":[]}` — the honest state until Core inventory is asset-backed.
|
//! `{"itemData":[]}` — the honest state until Core inventory is asset-backed.
|
||||||
|
|
||||||
|
pub mod account_store;
|
||||||
pub mod async_bridge;
|
pub mod async_bridge;
|
||||||
pub mod clientdata_store;
|
pub mod clientdata_store;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
@@ -41,6 +42,7 @@ pub mod economy_store;
|
|||||||
pub mod market;
|
pub mod market;
|
||||||
pub mod market_store;
|
pub mod market_store;
|
||||||
pub mod pile_store;
|
pub mod pile_store;
|
||||||
|
pub mod sold_experiment;
|
||||||
|
|
||||||
use std::io::{BufRead, BufReader, Write};
|
use std::io::{BufRead, BufReader, Write};
|
||||||
use std::net::{TcpListener, TcpStream};
|
use std::net::{TcpListener, TcpStream};
|
||||||
@@ -78,6 +80,7 @@ use openfut_identity::ExternalIdentityStore;
|
|||||||
use rand::{Rng, SeedableRng};
|
use rand::{Rng, SeedableRng};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use account_store::{AccountStore, RenameOutcome};
|
||||||
use clientdata_store::ClientDataStore;
|
use clientdata_store::ClientDataStore;
|
||||||
use config::HostConfig;
|
use config::HostConfig;
|
||||||
|
|
||||||
@@ -88,6 +91,9 @@ use config::HostConfig;
|
|||||||
pub enum Route {
|
pub enum Route {
|
||||||
/// `GET …/club` — the owned-player search, served from Core.
|
/// `GET …/club` — the owned-player search, served from Core.
|
||||||
Club,
|
Club,
|
||||||
|
/// `PUT …/club` or `PUT/POST …/user/club` — validates and persists the
|
||||||
|
/// FIFA club name/abbreviation, then returns the zero-atom `{}` ack.
|
||||||
|
ClubRename,
|
||||||
/// `PUT …/squad/<n>` — full squad replacement, committed to Core.
|
/// `PUT …/squad/<n>` — full squad replacement, committed to Core.
|
||||||
SquadReplace,
|
SquadReplace,
|
||||||
/// `GET …/squad/list` — the squad summary, projected from Core.
|
/// `GET …/squad/list` — the squad summary, projected from Core.
|
||||||
@@ -147,11 +153,9 @@ pub enum Route {
|
|||||||
/// object `userMassInfo` embeds (shared builder). POST /user (create) stays Python.
|
/// object `userMassInfo` embeds (shared builder). POST /user (create) stays Python.
|
||||||
User,
|
User,
|
||||||
/// `GET …/{season,tournament,champion,clubUser,user/list}` — FUT modes and
|
/// `GET …/{season,tournament,champion,clubUser,user/list}` — FUT modes and
|
||||||
/// the club-identity service are disabled in this emulator, so these reads
|
/// the club-identity read service are disabled in this emulator, so these
|
||||||
/// return `{}`, byte-identical to the Python oracle with `FUT_MODES` /
|
/// reads return `{}`, byte-identical to the Python oracle with `FUT_MODES` /
|
||||||
/// `FUT_CLUB_IDENTITY` off. The mutating club-identity route (`user/club`
|
/// `FUT_CLUB_IDENTITY` off. Club rename is a separate Rust-owned route.
|
||||||
/// rename) stays on Python. Turning a feature on later means a real Rust
|
|
||||||
/// handler here — never a Python fallback.
|
|
||||||
FeatureOffEmpty,
|
FeatureOffEmpty,
|
||||||
/// `GET …/item/resource`, `…/defid` — FUT item-definition lookups. Rust builds
|
/// `GET …/item/resource`, `…/defid` — FUT item-definition lookups. Rust builds
|
||||||
/// `{itemData:[…]}` (one placeholder-or-Ronaldo def per queried id), mirroring
|
/// `{itemData:[…]}` (one placeholder-or-Ronaldo def per queried id), mirroring
|
||||||
@@ -168,16 +172,10 @@ pub enum Route {
|
|||||||
Passthrough,
|
Passthrough,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Classify a request ONCE, before execution. Rust owns exactly:
|
/// Classify a request ONCE, before execution. Rust owns complete route families;
|
||||||
/// * `GET …/club`
|
/// there is no "try Rust then Python", so a mutation can never be double-applied.
|
||||||
/// * `PUT …/squad/<n>` (numeric id)
|
/// Numeric `GET …/squad/<n>` follows the oracle's single-current-squad behavior:
|
||||||
/// * `GET …/squad/list`
|
/// every numeric id returns the one Core-backed active squad.
|
||||||
/// * `GET …/squad/active` (the active squad, projected from Core)
|
|
||||||
/// * `GET …/userMassInfo` (proxied, `.squad` overlaid)
|
|
||||||
///
|
|
||||||
/// Everything else — numeric `GET …/squad/<n>`, `/clubUser`, auth, packs,
|
|
||||||
/// market, other mutations — falls through to Python. There is no
|
|
||||||
/// "try Rust then Python", so a squad mutation can never be double-applied.
|
|
||||||
pub fn classify(method: &str, path: &str) -> Route {
|
pub fn classify(method: &str, path: &str) -> Route {
|
||||||
let get = method.eq_ignore_ascii_case("GET");
|
let get = method.eq_ignore_ascii_case("GET");
|
||||||
let put = method.eq_ignore_ascii_case("PUT");
|
let put = method.eq_ignore_ascii_case("PUT");
|
||||||
@@ -200,11 +198,15 @@ pub fn classify(method: &str, path: &str) -> Route {
|
|||||||
if get && is_exact_club_path(path) {
|
if get && is_exact_club_path(path) {
|
||||||
return Route::Club;
|
return Route::Club;
|
||||||
}
|
}
|
||||||
|
if put && is_exact_club_path(path) {
|
||||||
|
return Route::ClubRename;
|
||||||
|
}
|
||||||
match ut_tail(path) {
|
match ut_tail(path) {
|
||||||
Some("squad/list") if get => Route::SquadList,
|
Some("squad/list") if get => Route::SquadList,
|
||||||
Some("squad/active") if get => Route::SquadActive,
|
Some("squad/active") if get => Route::SquadActive,
|
||||||
Some("squad/0") if get => Route::SquadActive,
|
Some(tail) if get && is_numeric_squad_tail(tail) => Route::SquadActive,
|
||||||
Some("userMassInfo") if get => Route::UserMassInfo,
|
Some("userMassInfo") if get => Route::UserMassInfo,
|
||||||
|
Some("user/club") if put || post => Route::ClubRename,
|
||||||
Some(tail) if tail.starts_with("clientdata/") => Route::ClientData,
|
Some(tail) if tail.starts_with("clientdata/") => Route::ClientData,
|
||||||
Some(tail) if get && tail.starts_with("store/purchasegroup") => Route::StorePurchaseGroup,
|
Some(tail) if get && tail.starts_with("store/purchasegroup") => Route::StorePurchaseGroup,
|
||||||
Some(tail) if put && is_numeric_squad_tail(tail) => Route::SquadReplace,
|
Some(tail) if put && is_numeric_squad_tail(tail) => Route::SquadReplace,
|
||||||
@@ -304,10 +306,9 @@ fn is_exact_club_path(path: &str) -> bool {
|
|||||||
ut_tail(path) == Some("club")
|
ut_tail(path) == Some("club")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `squad/<digits>` — the numeric full-squad target used by `PUT`. The active
|
/// `squad/<digits>` — the numeric full-squad target used by PUT and GET. Core
|
||||||
/// squad READ (`GET …/squad/active`) is routed separately (Core-backed); a
|
/// stores one current squad, matching the oracle: every numeric GET returns that
|
||||||
/// numeric `GET …/squad/<n>` for a non-active squad stays on Python (there is no
|
/// same squad regardless of the requested id.
|
||||||
/// Core model for multiple squads yet).
|
|
||||||
fn is_numeric_squad_tail(tail: &str) -> bool {
|
fn is_numeric_squad_tail(tail: &str) -> bool {
|
||||||
match tail.strip_prefix("squad/") {
|
match tail.strip_prefix("squad/") {
|
||||||
Some(id) => !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()),
|
Some(id) => !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()),
|
||||||
@@ -363,6 +364,11 @@ pub enum EconomyRoute {
|
|||||||
/// contemporaneous FIFA 17 clients use) map here — a DELETE on the plain
|
/// contemporaneous FIFA 17 clients use) map here — a DELETE on the plain
|
||||||
/// spelling otherwise fell into the buy/view arm and silently did nothing.
|
/// spelling otherwise fell into the buy/view arm and silently did nothing.
|
||||||
MarketCancel,
|
MarketCancel,
|
||||||
|
/// Bulk clear-sold: `DELETE /ut/delete/game/<sku>/trade/sold`, the client's
|
||||||
|
/// `RemoveAllSoldFromTradePile`. Carries no trade id, so it MUST NOT reach
|
||||||
|
/// [`EconomyRoute::MarketCancel`], which would parse no id and ack while
|
||||||
|
/// clearing nothing.
|
||||||
|
MarketClearSold,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `item/<digits>` — the single-card quick-sell tail (DELETE).
|
/// `item/<digits>` — the single-card quick-sell tail (DELETE).
|
||||||
@@ -427,6 +433,16 @@ fn is_trade_status_tail(tail: &str) -> bool {
|
|||||||
tail.eq_ignore_ascii_case(STATUS)
|
tail.eq_ignore_ascii_case(STATUS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `trade/sold` — the BULK clear-sold tail, which carries no trade id.
|
||||||
|
///
|
||||||
|
/// PE-proven shape: request builder `0x1801647c0` writes the literal `/sold` when
|
||||||
|
/// its tradeId field is zero and `/%lld` when it is not, onto route base
|
||||||
|
/// `ut/delete/%s/trade`. The client names the operation
|
||||||
|
/// `RemoveAllSoldFromTradePile`.
|
||||||
|
fn is_trade_sold_tail(tail: &str) -> bool {
|
||||||
|
tail.eq_ignore_ascii_case("trade/sold")
|
||||||
|
}
|
||||||
|
|
||||||
/// Classify a FIFA17 economy route from method + path, mirroring the Python
|
/// Classify a FIFA17 economy route from method + path, mirroring the Python
|
||||||
/// oracle's route table (`utas_server.py` §1418-1553). Returns `None` for any
|
/// oracle's route table (`utas_server.py` §1418-1553). Returns `None` for any
|
||||||
/// non-economy path. Path is already query-stripped by the caller.
|
/// non-economy path. Path is already query-stripped by the caller.
|
||||||
@@ -445,6 +461,14 @@ pub fn classify_economy(method: &str, path: &str) -> Option<EconomyRoute> {
|
|||||||
if tail == "item" && post {
|
if tail == "item" && post {
|
||||||
return Some(EconomyRoute::QuickSellBody);
|
return Some(EconomyRoute::QuickSellBody);
|
||||||
}
|
}
|
||||||
|
// MUST precede the generic `trade…` cancel arm. FIFA 17's request
|
||||||
|
// builder 0x1801647c0 emits the literal `/sold` (no numeric id) for the
|
||||||
|
// bulk "RemoveAllSoldFromTradePile" verb, and `/%lld` for one trade. A
|
||||||
|
// `sold` tail carries no id, so the cancel handler would parse nothing
|
||||||
|
// and silently ack while clearing nothing.
|
||||||
|
if delete && is_trade_sold_tail(tail) {
|
||||||
|
return Some(EconomyRoute::MarketClearSold);
|
||||||
|
}
|
||||||
if tail.starts_with("trade") && delete {
|
if tail.starts_with("trade") && delete {
|
||||||
return Some(EconomyRoute::MarketCancel);
|
return Some(EconomyRoute::MarketCancel);
|
||||||
}
|
}
|
||||||
@@ -699,6 +723,41 @@ pub struct EconomyGrantItem {
|
|||||||
pub card_id: String,
|
pub card_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Terms of a completed market sale, for [`CoreEconomy::settle_sale`]. `gross`
|
||||||
|
/// is what the buyer pays; `fee` is the market cut destroyed on settlement, so
|
||||||
|
/// the seller nets `gross - fee`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct EconomySale<'a> {
|
||||||
|
pub item_id: &'a str,
|
||||||
|
/// Club the caller believes owns the item; `None` -> Core's game-scoped
|
||||||
|
/// active club. Core predicates the ownership move on this club, so a
|
||||||
|
/// wrong (or already-settled) seller is rejected rather than silently
|
||||||
|
/// re-run — this doubles as the replay guard.
|
||||||
|
pub seller_club_id: Option<&'a str>,
|
||||||
|
/// Acquiring club; `None` -> a counterparty outside the modelled economy:
|
||||||
|
/// nobody is debited and the item is destroyed.
|
||||||
|
pub buyer_club_id: Option<&'a str>,
|
||||||
|
pub gross: i64,
|
||||||
|
pub fee: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What Core did when settling a sale: the post-settlement balances of both
|
||||||
|
/// sides (`buyer_balance` is `None` for an outside buyer) and how many squad
|
||||||
|
/// slots the sold item vacated.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct EconomySaleReceipt {
|
||||||
|
pub item_id: String,
|
||||||
|
pub card_id: String,
|
||||||
|
pub seller_club_id: String,
|
||||||
|
pub buyer_club_id: Option<String>,
|
||||||
|
pub gross: i64,
|
||||||
|
pub fee: i64,
|
||||||
|
pub proceeds: i64,
|
||||||
|
pub seller_balance: i64,
|
||||||
|
pub buyer_balance: Option<i64>,
|
||||||
|
pub squad_slots_freed: u64,
|
||||||
|
}
|
||||||
|
|
||||||
/// The host's authoritative economy transport to Core. Every method is a single
|
/// The host's authoritative economy transport to Core. Every method is a single
|
||||||
/// durable Core transaction. **Fail-closed:** on any transport/status/parse
|
/// durable Core transaction. **Fail-closed:** on any transport/status/parse
|
||||||
/// error the caller MUST surface a controlled error and NEVER fall back to
|
/// error the caller MUST surface a controlled error and NEVER fall back to
|
||||||
@@ -721,6 +780,13 @@ pub trait CoreEconomy: Send + Sync {
|
|||||||
fn purchase_item(&self, cost: i64, item_id: &str, card_id: &str) -> Result<i64, CoreError>;
|
fn purchase_item(&self, cost: i64, item_id: &str, card_id: &str) -> Result<i64, CoreError>;
|
||||||
/// Debit `cost` and mint several items atomically (open-on-buy Store packs).
|
/// Debit `cost` and mint several items atomically (open-on-buy Store packs).
|
||||||
fn purchase_items(&self, cost: i64, items: &[EconomyGrantItem]) -> Result<i64, CoreError>;
|
fn purchase_items(&self, cost: i64, items: &[EconomyGrantItem]) -> Result<i64, CoreError>;
|
||||||
|
/// Settle a completed market sale in ONE Core transaction: evict the item
|
||||||
|
/// from every squad, move ownership from the seller to `buyer_club_id`
|
||||||
|
/// (or destroy it for an outside buyer), debit a club buyer `gross` and
|
||||||
|
/// credit the seller `gross - fee`. Core rejects a sale whose named seller
|
||||||
|
/// does not own the item, so a replayed settlement is refused, never
|
||||||
|
/// double-paid.
|
||||||
|
fn settle_sale(&self, sale: &EconomySale<'_>) -> Result<EconomySaleReceipt, CoreError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HttpCoreClient {
|
impl HttpCoreClient {
|
||||||
@@ -771,6 +837,12 @@ fn json_str(v: &Value, key: &str) -> Result<String, CoreError> {
|
|||||||
.ok_or_else(|| CoreError::Parse(format!("missing string field `{key}`")))
|
.ok_or_else(|| CoreError::Parse(format!("missing string field `{key}`")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn json_u64(v: &Value, key: &str) -> Result<u64, CoreError> {
|
||||||
|
v.get(key)
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.ok_or_else(|| CoreError::Parse(format!("missing u64 field `{key}`")))
|
||||||
|
}
|
||||||
|
|
||||||
impl CoreEconomy for HttpCoreClient {
|
impl CoreEconomy for HttpCoreClient {
|
||||||
fn balance(&self) -> Result<i64, CoreError> {
|
fn balance(&self) -> Result<i64, CoreError> {
|
||||||
json_i64(&self.economy_get("balance")?, "balance")
|
json_i64(&self.economy_get("balance")?, "balance")
|
||||||
@@ -851,6 +923,48 @@ impl CoreEconomy for HttpCoreClient {
|
|||||||
)?;
|
)?;
|
||||||
json_i64(&v, "balance")
|
json_i64(&v, "balance")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn settle_sale(&self, sale: &EconomySale<'_>) -> Result<EconomySaleReceipt, CoreError> {
|
||||||
|
let v = self.economy_post("settle-sale", &sale_request_body(sale))?;
|
||||||
|
Ok(EconomySaleReceipt {
|
||||||
|
item_id: json_str(&v, "item_id")?,
|
||||||
|
card_id: json_str(&v, "card_id")?,
|
||||||
|
seller_club_id: json_str(&v, "seller_club_id")?,
|
||||||
|
// Absent or null both mean "no club buyer": the outside-sale case.
|
||||||
|
buyer_club_id: v
|
||||||
|
.get("buyer_club_id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::to_string),
|
||||||
|
gross: json_i64(&v, "gross")?,
|
||||||
|
fee: json_i64(&v, "fee")?,
|
||||||
|
proceeds: json_i64(&v, "proceeds")?,
|
||||||
|
seller_balance: json_i64(&v, "seller_balance")?,
|
||||||
|
buyer_balance: v.get("buyer_balance").and_then(Value::as_i64),
|
||||||
|
squad_slots_freed: json_u64(&v, "squad_slots_freed")?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize an [`EconomySale`] into the `POST /economy/settle-sale` JSON body.
|
||||||
|
/// The two club fields are OMITTED when `None` — their absence is what selects
|
||||||
|
/// Core's defaults (the game-scoped active club as seller, a counterparty
|
||||||
|
/// outside the modelled economy as buyer).
|
||||||
|
pub fn sale_request_body(sale: &EconomySale<'_>) -> Value {
|
||||||
|
let mut body = json!({
|
||||||
|
"item_id": sale.item_id,
|
||||||
|
"gross": sale.gross,
|
||||||
|
"fee": sale.fee,
|
||||||
|
});
|
||||||
|
let obj = body
|
||||||
|
.as_object_mut()
|
||||||
|
.expect("the literal above is a JSON object");
|
||||||
|
if let Some(seller) = sale.seller_club_id {
|
||||||
|
obj.insert("seller_club_id".into(), Value::from(seller));
|
||||||
|
}
|
||||||
|
if let Some(buyer) = sale.buyer_club_id {
|
||||||
|
obj.insert("buyer_club_id".into(), Value::from(buyer));
|
||||||
|
}
|
||||||
|
body
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serialize a [`CoreReplaceRequest`] into the `PUT /squad/replace` JSON body.
|
/// Serialize a [`CoreReplaceRequest`] into the `PUT /squad/replace` JSON body.
|
||||||
@@ -1118,7 +1232,6 @@ impl SquadWireResolver for Fifa17IdentityResolver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ───────────────────────────── /club handler ────────────────────────────────
|
// ───────────────────────────── /club handler ────────────────────────────────
|
||||||
|
|
||||||
/// Safe, structured summary of a handled `/club` request (no auth/session/device
|
/// Safe, structured summary of a handled `/club` request (no auth/session/device
|
||||||
@@ -1185,7 +1298,12 @@ fn paginate_items(items: &[Value], offset: Option<i64>, limit: Option<i64>) -> (
|
|||||||
let total = items.len() as i64;
|
let total = items.len() as i64;
|
||||||
let off = offset.unwrap_or(0).max(0) as usize;
|
let off = offset.unwrap_or(0).max(0) as usize;
|
||||||
let paged: Vec<Value> = match limit {
|
let paged: Vec<Value> = match limit {
|
||||||
Some(l) => items.iter().skip(off).take(l.max(0) as usize).cloned().collect(),
|
Some(l) => items
|
||||||
|
.iter()
|
||||||
|
.skip(off)
|
||||||
|
.take(l.max(0) as usize)
|
||||||
|
.cloned()
|
||||||
|
.collect(),
|
||||||
None => items.iter().skip(off).cloned().collect(),
|
None => items.iter().skip(off).cloned().collect(),
|
||||||
};
|
};
|
||||||
(paged, total)
|
(paged, total)
|
||||||
@@ -1986,6 +2104,9 @@ pub struct EconomyServices {
|
|||||||
/// The resolvable FIFA∩Core card universe a pack can award (empty → the Store
|
/// The resolvable FIFA∩Core card universe a pack can award (empty → the Store
|
||||||
/// fail-closes: it draws nothing and debits nothing).
|
/// fail-closes: it draws nothing and debits nothing).
|
||||||
pub pool: Arc<Vec<GeneratedCandidate>>,
|
pub pool: Arc<Vec<GeneratedCandidate>>,
|
||||||
|
/// STAGING-ONLY sold-row experiment. `SoldExperiment::OFF` in production, where
|
||||||
|
/// it changes nothing.
|
||||||
|
pub sold_experiment: crate::sold_experiment::SoldExperiment,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the pack-content candidate pool from Core's current content, evidenced
|
/// Build the pack-content candidate pool from Core's current content, evidenced
|
||||||
@@ -2063,6 +2184,9 @@ pub struct Server {
|
|||||||
/// used to stamp `personaId` on the Core-backed `GET /squad/active` object.
|
/// used to stamp `personaId` on the Core-backed `GET /squad/active` object.
|
||||||
/// Never baked in — it must match the persona LSX/Blaze/POW/UTAS agree on.
|
/// Never baked in — it must match the persona LSX/Blaze/POW/UTAS agree on.
|
||||||
persona_id: i64,
|
persona_id: i64,
|
||||||
|
/// Shared FIFA17 account identity. Rust owns club rename and every Rust
|
||||||
|
/// user/account response reads the same durable name/abbreviation.
|
||||||
|
account: Arc<AccountStore>,
|
||||||
/// Per-login FIFA session/capability authority (empty-My-Packs topology).
|
/// Per-login FIFA session/capability authority (empty-My-Packs topology).
|
||||||
/// The economy (coins, unopened packs, BUY) stays Python's; this owns only
|
/// The economy (coins, unopened packs, BUY) stays Python's; this owns only
|
||||||
/// session/capability state — Rust reads Python's live body, never writes it.
|
/// session/capability state — Rust reads Python's live body, never writes it.
|
||||||
@@ -2095,6 +2219,7 @@ impl Server {
|
|||||||
resolver,
|
resolver,
|
||||||
pass,
|
pass,
|
||||||
persona_id,
|
persona_id,
|
||||||
|
account: Arc::new(AccountStore::open(ephemeral_account_path())),
|
||||||
sessions: Arc::new(Mutex::new(SessionStore::new())),
|
sessions: Arc::new(Mutex::new(SessionStore::new())),
|
||||||
start: Instant::now(),
|
start: Instant::now(),
|
||||||
economy: None,
|
economy: None,
|
||||||
@@ -2147,15 +2272,21 @@ impl Server {
|
|||||||
// Content pool from Core's current inventory (empty ⇒ Store fails closed,
|
// Content pool from Core's current inventory (empty ⇒ Store fails closed,
|
||||||
// never mints/debits — an honest degrade if Core is not yet seeded).
|
// never mints/debits — an honest degrade if Core is not yet seeded).
|
||||||
let pool = Arc::new(build_content_pool(core.as_ref(), resolver.as_ref()));
|
let pool = Arc::new(build_content_pool(core.as_ref(), resolver.as_ref()));
|
||||||
|
// Read once at startup and logged, so a staging capture can never be
|
||||||
|
// mistaken for production output.
|
||||||
|
let sold_experiment = crate::sold_experiment::SoldExperiment::from_env();
|
||||||
|
eprintln!("utas-host {}", sold_experiment.banner());
|
||||||
let economy = Arc::new(EconomyServices {
|
let economy = Arc::new(EconomyServices {
|
||||||
econ,
|
econ,
|
||||||
market,
|
market,
|
||||||
piles,
|
piles,
|
||||||
bridge,
|
bridge,
|
||||||
pool,
|
pool,
|
||||||
|
sold_experiment,
|
||||||
});
|
});
|
||||||
|
|
||||||
let clientdata = Arc::new(ClientDataStore::open(cfg.clientdata_path.clone()));
|
let clientdata = Arc::new(ClientDataStore::open(cfg.clientdata_path.clone()));
|
||||||
|
let account = Arc::new(AccountStore::open(cfg.account_path.clone()));
|
||||||
Ok(Server::new(
|
Ok(Server::new(
|
||||||
core,
|
core,
|
||||||
entities,
|
entities,
|
||||||
@@ -2164,7 +2295,8 @@ impl Server {
|
|||||||
cfg.persona_id,
|
cfg.persona_id,
|
||||||
)
|
)
|
||||||
.with_economy(economy)
|
.with_economy(economy)
|
||||||
.with_clientdata(clientdata))
|
.with_clientdata(clientdata)
|
||||||
|
.with_account(account))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Assemble the shared squad dependencies (Core access + the one production
|
/// Assemble the shared squad dependencies (Core access + the one production
|
||||||
@@ -2193,6 +2325,12 @@ impl Server {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attach the shared durable FIFA17 account store.
|
||||||
|
pub fn with_account(mut self, account: Arc<AccountStore>) -> Self {
|
||||||
|
self.account = account;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Dispatch a FIFA17 economy route to its Rust handler, or `None` if the path
|
/// Dispatch a FIFA17 economy route to its Rust handler, or `None` if the path
|
||||||
/// is not an economy route (or no economy services are wired). This is the
|
/// is not an economy route (or no economy services are wired). This is the
|
||||||
/// handler-wiring entry point exercised by the integration harness; it is
|
/// handler-wiring entry point exercised by the integration harness; it is
|
||||||
@@ -2300,11 +2438,21 @@ impl Server {
|
|||||||
}
|
}
|
||||||
EconomyRoute::MatchEnd => handle_match_end(svc.econ.as_ref(), body),
|
EconomyRoute::MatchEnd => handle_match_end(svc.econ.as_ref(), body),
|
||||||
EconomyRoute::MoveItems => {
|
EconomyRoute::MoveItems => {
|
||||||
let (bridge, piles, resolver) =
|
let (bridge, piles, resolver, market) = (
|
||||||
(svc.bridge.clone(), svc.piles.clone(), self.resolver.clone());
|
svc.bridge.clone(),
|
||||||
|
svc.piles.clone(),
|
||||||
|
self.resolver.clone(),
|
||||||
|
svc.market.clone(),
|
||||||
|
);
|
||||||
let body = body.to_vec();
|
let body = body.to_vec();
|
||||||
bridge.block_on(async move {
|
bridge.block_on(async move {
|
||||||
crate::market::handle_move_items(&body, resolver.as_ref(), piles.as_ref()).await
|
crate::market::handle_move_items(
|
||||||
|
&body,
|
||||||
|
resolver.as_ref(),
|
||||||
|
piles.as_ref(),
|
||||||
|
market.as_ref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
EconomyRoute::MarketList => {
|
EconomyRoute::MarketList => {
|
||||||
@@ -2327,35 +2475,35 @@ impl Server {
|
|||||||
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
|
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
|
||||||
let m = method.to_string();
|
let m = method.to_string();
|
||||||
bridge.block_on(async move {
|
bridge.block_on(async move {
|
||||||
crate::market::handle_market_list(
|
crate::market::handle_market_list(&m, resolved, econ.as_ref(), market.as_ref())
|
||||||
&m,
|
.await
|
||||||
resolved,
|
|
||||||
econ.as_ref(),
|
|
||||||
market.as_ref(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
EconomyRoute::MarketQuery => {
|
EconomyRoute::MarketQuery => {
|
||||||
let (bridge, market, econ) =
|
let (bridge, market, econ) =
|
||||||
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
|
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
|
||||||
|
let exp = svc.sold_experiment;
|
||||||
bridge.block_on(async move {
|
bridge.block_on(async move {
|
||||||
crate::market::handle_market_query(
|
crate::market::handle_market_query(
|
||||||
"active",
|
"active",
|
||||||
econ.as_ref(),
|
econ.as_ref(),
|
||||||
market.as_ref(),
|
market.as_ref(),
|
||||||
|
exp,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
EconomyRoute::MarketCounts => {
|
EconomyRoute::MarketCounts => {
|
||||||
let (bridge, market) = (svc.bridge.clone(), svc.market.clone());
|
let (bridge, market) = (svc.bridge.clone(), svc.market.clone());
|
||||||
bridge
|
let exp = svc.sold_experiment;
|
||||||
.block_on(async move { crate::market::handle_market_counts(market.as_ref()).await })
|
bridge.block_on(async move {
|
||||||
|
crate::market::handle_market_counts(market.as_ref(), exp).await
|
||||||
|
})
|
||||||
}
|
}
|
||||||
EconomyRoute::MarketStatus => {
|
EconomyRoute::MarketStatus => {
|
||||||
let (bridge, market, econ) =
|
let (bridge, market, econ) =
|
||||||
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
|
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
|
||||||
|
let exp = svc.sold_experiment;
|
||||||
// The raw query carries `tradeIds`; `path` is already stripped.
|
// The raw query carries `tradeIds`; `path` is already stripped.
|
||||||
let q = target.split_once('?').map(|(_, q)| q.to_string());
|
let q = target.split_once('?').map(|(_, q)| q.to_string());
|
||||||
bridge.block_on(async move {
|
bridge.block_on(async move {
|
||||||
@@ -2363,6 +2511,7 @@ impl Server {
|
|||||||
q.as_deref(),
|
q.as_deref(),
|
||||||
econ.as_ref(),
|
econ.as_ref(),
|
||||||
market.as_ref(),
|
market.as_ref(),
|
||||||
|
exp,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
})
|
})
|
||||||
@@ -2372,14 +2521,8 @@ impl Server {
|
|||||||
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
|
(svc.bridge.clone(), svc.market.clone(), svc.econ.clone());
|
||||||
let (m, p, body) = (method.to_string(), path.to_string(), body.to_vec());
|
let (m, p, body) = (method.to_string(), path.to_string(), body.to_vec());
|
||||||
bridge.block_on(async move {
|
bridge.block_on(async move {
|
||||||
crate::market::handle_market_buy(
|
crate::market::handle_market_buy(&m, &p, &body, econ.as_ref(), market.as_ref())
|
||||||
&m,
|
.await
|
||||||
&p,
|
|
||||||
&body,
|
|
||||||
econ.as_ref(),
|
|
||||||
market.as_ref(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
EconomyRoute::MarketCancel => {
|
EconomyRoute::MarketCancel => {
|
||||||
@@ -2389,6 +2532,12 @@ impl Server {
|
|||||||
crate::market::handle_market_cancel(&p, owner.as_deref(), market.as_ref()).await
|
crate::market::handle_market_cancel(&p, owner.as_deref(), market.as_ref()).await
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
EconomyRoute::MarketClearSold => {
|
||||||
|
let (bridge, market) = (svc.bridge.clone(), svc.market.clone());
|
||||||
|
bridge.block_on(async move {
|
||||||
|
crate::market::handle_market_clear_sold(market.as_ref()).await
|
||||||
|
})
|
||||||
|
}
|
||||||
};
|
};
|
||||||
Some(resp)
|
Some(resp)
|
||||||
}
|
}
|
||||||
@@ -2449,6 +2598,7 @@ impl Server {
|
|||||||
);
|
);
|
||||||
resp
|
resp
|
||||||
}
|
}
|
||||||
|
Route::ClubRename => self.handle_club_rename(body),
|
||||||
Route::SquadReplace => {
|
Route::SquadReplace => {
|
||||||
let deps = self.squad_deps();
|
let deps = self.squad_deps();
|
||||||
let (resp, log) = handle_put_squad(body, &deps);
|
let (resp, log) = handle_put_squad(body, &deps);
|
||||||
@@ -2583,6 +2733,29 @@ impl Server {
|
|||||||
json_status(200, &non_economy::auth_body(&sid, &server_time))
|
json_status(200, &non_economy::auth_body(&sid, &server_time))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `PUT …/club` / `PUT|POST …/user/club` — persist the validated club
|
||||||
|
/// identity in the shared account file. The response class has zero atoms,
|
||||||
|
/// and the client disconnects on a 4xx, so every input returns `200 {}` just
|
||||||
|
/// like the oracle; rejected/persistence outcomes remain visible in logs.
|
||||||
|
fn handle_club_rename(&self, body: &[u8]) -> WireResponse {
|
||||||
|
let outcome = self.account.rename_from_body(body);
|
||||||
|
match &outcome {
|
||||||
|
RenameOutcome::Updated => {
|
||||||
|
eprintln!("utas-host owner=RUST route=club-rename status=200 outcome=updated")
|
||||||
|
}
|
||||||
|
RenameOutcome::Unchanged => {
|
||||||
|
eprintln!("utas-host owner=RUST route=club-rename status=200 outcome=unchanged")
|
||||||
|
}
|
||||||
|
RenameOutcome::Rejected(reason) => eprintln!(
|
||||||
|
"utas-host owner=RUST route=club-rename status=200 outcome=rejected detail={reason}"
|
||||||
|
),
|
||||||
|
RenameOutcome::PersistFailed(error) => eprintln!(
|
||||||
|
"utas-host ERROR owner=RUST route=club-rename status=200 outcome=persist-failed detail=[{error}]"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
json_status(200, &json!({}))
|
||||||
|
}
|
||||||
|
|
||||||
/// `POST /openfut/account/sync` — launcher control-plane account summary. The
|
/// `POST /openfut/account/sync` — launcher control-plane account summary. The
|
||||||
/// coins/unopened-pack counts are the AUTHORITATIVE Core economy (balance +
|
/// coins/unopened-pack counts are the AUTHORITATIVE Core economy (balance +
|
||||||
/// entitlements), NEVER Python's stale profile funds. Fail-closed 503 on any
|
/// entitlements), NEVER Python's stale profile funds. Fail-closed 503 on any
|
||||||
@@ -2609,7 +2782,11 @@ impl Server {
|
|||||||
coins,
|
coins,
|
||||||
ents.len()
|
ents.len()
|
||||||
);
|
);
|
||||||
json_status(200, &non_economy::account_sync_body(&req, coins, ents.len()))
|
let club = self.account.club();
|
||||||
|
json_status(
|
||||||
|
200,
|
||||||
|
&non_economy::account_sync_body(&req, coins, ents.len(), &club.name, &club.abbr),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET/PUT/POST …/clientdata/<key>` — opaque per-user client blob store. GET
|
/// `GET/PUT/POST …/clientdata/<key>` — opaque per-user client blob store. GET
|
||||||
@@ -2655,8 +2832,17 @@ impl Server {
|
|||||||
HostProjection::Missing => (empty_squad_overlay(self.persona_id), "missing_integrity"),
|
HostProjection::Missing => (empty_squad_overlay(self.persona_id), "missing_integrity"),
|
||||||
HostProjection::Error(_) => (empty_squad_overlay(self.persona_id), "core_error"),
|
HostProjection::Error(_) => (empty_squad_overlay(self.persona_id), "core_error"),
|
||||||
};
|
};
|
||||||
|
let club = self.account.club();
|
||||||
Ok((
|
Ok((
|
||||||
non_economy::user_mass_info_body(squad, coins, ents.len(), self.persona_id),
|
non_economy::user_mass_info_body(
|
||||||
|
squad,
|
||||||
|
coins,
|
||||||
|
ents.len(),
|
||||||
|
self.persona_id,
|
||||||
|
&club.name,
|
||||||
|
&club.abbr,
|
||||||
|
&club.established,
|
||||||
|
),
|
||||||
squad_outcome,
|
squad_outcome,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -2673,7 +2859,10 @@ impl Server {
|
|||||||
json_status(200, &body)
|
json_status(200, &body)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("utas-host owner=RUST route=userMassInfo status={} error=core", e.status);
|
eprintln!(
|
||||||
|
"utas-host owner=RUST route=userMassInfo status={} error=core",
|
||||||
|
e.status
|
||||||
|
);
|
||||||
e
|
e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2689,7 +2878,10 @@ impl Server {
|
|||||||
json_status(200, &json!({ "userInfo": user_info }))
|
json_status(200, &json!({ "userInfo": user_info }))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("utas-host owner=RUST route=user status={} error=core", e.status);
|
eprintln!(
|
||||||
|
"utas-host owner=RUST route=user status={} error=core",
|
||||||
|
e.status
|
||||||
|
);
|
||||||
e
|
e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2832,7 +3024,10 @@ impl Server {
|
|||||||
};
|
};
|
||||||
let (bridge, market) = (svc.bridge.clone(), svc.market.clone());
|
let (bridge, market) = (svc.bridge.clone(), svc.market.clone());
|
||||||
match bridge.block_on(async move { market.query_listings("active").await }) {
|
match bridge.block_on(async move { market.query_listings("active").await }) {
|
||||||
Ok(listings) => listings.into_iter().filter_map(|l| l.core_item_id).collect(),
|
Ok(listings) => listings
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|l| l.core_item_id)
|
||||||
|
.collect(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
eprintln!("utas-host WARN market read failed (club shows all): {e}");
|
eprintln!("utas-host WARN market read failed (club shows all): {e}");
|
||||||
std::collections::HashSet::new()
|
std::collections::HashSet::new()
|
||||||
@@ -2930,13 +3125,16 @@ impl Server {
|
|||||||
.and_then(|s| s.econ.balance().ok())
|
.and_then(|s| s.econ.balance().ok())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
eprintln!("utas-host owner=RUST route=watchlist method=GET status=200 credits={credits}");
|
eprintln!("utas-host owner=RUST route=watchlist method=GET status=200 credits={credits}");
|
||||||
json_status(200, &json!({ "auctionInfo": [], "credits": credits, "total": 0 }))
|
json_status(
|
||||||
|
200,
|
||||||
|
&json!({ "auctionInfo": [], "credits": credits, "total": 0 }),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `GET …/{season,tournament,champion,clubUser,user/list}` — FUT modes and the
|
/// `GET …/{season,tournament,champion,clubUser,user/list}` — FUT modes and the
|
||||||
/// club-identity service are off, so return `{}` (parity with the flag-off
|
/// club-identity read service are off, so return `{}` (parity with the
|
||||||
/// Python oracle). The mutating `user/club` rename is NOT here — it stays on
|
/// flag-off Python oracle). Club rename is handled separately by
|
||||||
/// Python until a real Rust club-identity handler exists.
|
/// [`Server::handle_club_rename`].
|
||||||
fn handle_feature_off_empty(&self, path: &str) -> WireResponse {
|
fn handle_feature_off_empty(&self, path: &str) -> WireResponse {
|
||||||
let tail = ut_tail(path).unwrap_or("");
|
let tail = ut_tail(path).unwrap_or("");
|
||||||
eprintln!("utas-host owner=RUST route=feature-off-empty tail={tail} status=200");
|
eprintln!("utas-host owner=RUST route=feature-off-empty tail={tail} status=200");
|
||||||
@@ -3110,11 +3308,9 @@ fn json_status(status: u16, v: &Value) -> WireResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A unique ephemeral temp-file path for the client-data blob store used by
|
/// A unique ephemeral JSON-state path used by [`Server::new`] tests.
|
||||||
/// [`Server::new`] (tests). Production wires a durable path via
|
/// Production replaces both stores with configured durable paths.
|
||||||
/// [`Server::from_config`]/[`Server::with_clientdata`]. Uniqueness (pid + nanos +
|
fn ephemeral_state_path(kind: &str) -> std::path::PathBuf {
|
||||||
/// a process-local counter) keeps concurrent test servers isolated.
|
|
||||||
fn ephemeral_clientdata_path() -> std::path::PathBuf {
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
static CTR: AtomicU64 = AtomicU64::new(0);
|
static CTR: AtomicU64 = AtomicU64::new(0);
|
||||||
let n = CTR.fetch_add(1, Ordering::Relaxed);
|
let n = CTR.fetch_add(1, Ordering::Relaxed);
|
||||||
@@ -3123,13 +3319,19 @@ fn ephemeral_clientdata_path() -> std::path::PathBuf {
|
|||||||
.map(|d| d.as_nanos())
|
.map(|d| d.as_nanos())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
std::env::temp_dir().join(format!(
|
std::env::temp_dir().join(format!(
|
||||||
"openfut-utas-clientdata-{}-{}-{}.json",
|
"openfut-utas-{kind}-{}-{nanos}-{n}.json",
|
||||||
std::process::id(),
|
std::process::id()
|
||||||
nanos,
|
|
||||||
n
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ephemeral_clientdata_path() -> std::path::PathBuf {
|
||||||
|
ephemeral_state_path("clientdata")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ephemeral_account_path() -> std::path::PathBuf {
|
||||||
|
ephemeral_state_path("account")
|
||||||
|
}
|
||||||
|
|
||||||
/// A validated capability registration request.
|
/// A validated capability registration request.
|
||||||
struct CapabilityRequest {
|
struct CapabilityRequest {
|
||||||
persona_id: Option<i64>,
|
persona_id: Option<i64>,
|
||||||
@@ -3390,6 +3592,41 @@ mod tests {
|
|||||||
}
|
}
|
||||||
Ok(self.balance)
|
Ok(self.balance)
|
||||||
}
|
}
|
||||||
|
fn settle_sale(&self, _sale: &EconomySale<'_>) -> Result<EconomySaleReceipt, CoreError> {
|
||||||
|
// Sale settlement is not exercised through this double.
|
||||||
|
Err(CoreError::Status(501))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settle_sale_body_omits_absent_club_ids() {
|
||||||
|
// Absent club ids are the wire signal for Core's defaults (active club
|
||||||
|
// as seller, outside counterparty as buyer), so they must not appear.
|
||||||
|
let outside = EconomySale {
|
||||||
|
item_id: "item-x",
|
||||||
|
seller_club_id: None,
|
||||||
|
buyer_club_id: None,
|
||||||
|
gross: 15_000,
|
||||||
|
fee: 750,
|
||||||
|
};
|
||||||
|
let b = sale_request_body(&outside);
|
||||||
|
assert_eq!(b["item_id"], "item-x");
|
||||||
|
assert_eq!(b["gross"], 15_000);
|
||||||
|
assert_eq!(b["fee"], 750);
|
||||||
|
assert!(b.get("seller_club_id").is_none());
|
||||||
|
assert!(b.get("buyer_club_id").is_none());
|
||||||
|
|
||||||
|
let between_clubs = EconomySale {
|
||||||
|
seller_club_id: Some("club-seller"),
|
||||||
|
buyer_club_id: Some("club-buyer"),
|
||||||
|
..outside
|
||||||
|
};
|
||||||
|
let b = sale_request_body(&between_clubs);
|
||||||
|
assert_eq!(b["seller_club_id"], "club-seller");
|
||||||
|
assert_eq!(b["buyer_club_id"], "club-buyer");
|
||||||
|
assert_eq!(b["item_id"], "item-x");
|
||||||
|
assert_eq!(b["gross"], 15_000);
|
||||||
|
assert_eq!(b["fee"], 750);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3566,11 +3803,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_club_only_on_exact_get() {
|
fn classify_club_read_and_rename_methods_exactly() {
|
||||||
assert_eq!(classify("GET", "/ut/game/fifa17/club"), Route::Club);
|
assert_eq!(classify("GET", "/ut/game/fifa17/club"), Route::Club);
|
||||||
assert_eq!(classify("get", "/ut/game/fifa18/club"), Route::Club);
|
assert_eq!(classify("get", "/ut/game/fifa18/club"), Route::Club);
|
||||||
// method must be GET
|
assert_eq!(classify("PUT", "/ut/game/fifa17/club"), Route::ClubRename);
|
||||||
assert_eq!(classify("PUT", "/ut/game/fifa17/club"), Route::Passthrough);
|
|
||||||
// bare `club/stats` (no trailing slash) is not a migrated arm -> Python.
|
// bare `club/stats` (no trailing slash) is not a migrated arm -> Python.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify("GET", "/ut/game/fifa17/club/stats"),
|
classify("GET", "/ut/game/fifa17/club/stats"),
|
||||||
@@ -4011,8 +4247,16 @@ mod tests {
|
|||||||
("GET", "/ut/game/fifa17/tradePile", Some(MarketQuery)),
|
("GET", "/ut/game/fifa17/tradePile", Some(MarketQuery)),
|
||||||
("GET", "/ut/game/fifa17/tradepile", Some(MarketQuery)),
|
("GET", "/ut/game/fifa17/tradepile", Some(MarketQuery)),
|
||||||
// The tally is a DISTINCT deserializer from the listing list.
|
// The tally is a DISTINCT deserializer from the listing list.
|
||||||
("GET", "/ut/game/fifa17/tradePile/counts", Some(MarketCounts)),
|
(
|
||||||
("GET", "/ut/game/fifa17/tradepile/counts", Some(MarketCounts)),
|
"GET",
|
||||||
|
"/ut/game/fifa17/tradePile/counts",
|
||||||
|
Some(MarketCounts),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"GET",
|
||||||
|
"/ut/game/fifa17/tradepile/counts",
|
||||||
|
Some(MarketCounts),
|
||||||
|
),
|
||||||
("POST", "/ut/game/fifa17/trade/900000001", Some(MarketBuy)),
|
("POST", "/ut/game/fifa17/trade/900000001", Some(MarketBuy)),
|
||||||
// The live-state poll MUST NOT land in the buy/view arm: `status` is
|
// The live-state poll MUST NOT land in the buy/view arm: `status` is
|
||||||
// not a trade id, so that arm answers every poll with an empty set.
|
// not a trade id, so that arm answers every poll with an empty set.
|
||||||
@@ -4112,6 +4356,9 @@ mod tests {
|
|||||||
("GET", "/ut/game/fifa17/champion", Route::FeatureOffEmpty),
|
("GET", "/ut/game/fifa17/champion", Route::FeatureOffEmpty),
|
||||||
("GET", "/ut/game/fifa17/clubUser", Route::FeatureOffEmpty),
|
("GET", "/ut/game/fifa17/clubUser", Route::FeatureOffEmpty),
|
||||||
("GET", "/ut/game/fifa17/user/list", Route::FeatureOffEmpty),
|
("GET", "/ut/game/fifa17/user/list", Route::FeatureOffEmpty),
|
||||||
|
("PUT", "/ut/game/fifa17/user/club", Route::ClubRename),
|
||||||
|
("POST", "/ut/game/fifa17/user/club", Route::ClubRename),
|
||||||
|
("PUT", "/ut/game/fifa17/club", Route::ClubRename),
|
||||||
("GET", "/ut/game/fifa17/item/resource", Route::ItemDefs),
|
("GET", "/ut/game/fifa17/item/resource", Route::ItemDefs),
|
||||||
("GET", "/ut/game/fifa17/defid", Route::ItemDefs),
|
("GET", "/ut/game/fifa17/defid", Route::ItemDefs),
|
||||||
(
|
(
|
||||||
@@ -4131,9 +4378,9 @@ mod tests {
|
|||||||
("GET", "/ut/game/fifa17/match/reset"),
|
("GET", "/ut/game/fifa17/match/reset"),
|
||||||
("PUT", "/ut/game/fifa17/user/accountinfo"),
|
("PUT", "/ut/game/fifa17/user/accountinfo"),
|
||||||
("POST", "/ut/game/fifa17/season"), // FUT-mode reads are GET-only
|
("POST", "/ut/game/fifa17/season"), // FUT-mode reads are GET-only
|
||||||
("GET", "/ut/game/fifa17/user/club"), // mutating rename stays Python
|
("GET", "/ut/game/fifa17/user/club"), // unknown read, not rename
|
||||||
("POST", "/ut/game/fifa17/item/resource"), // item-defs are GET-only
|
("POST", "/ut/game/fifa17/item/resource"), // item-defs are GET-only
|
||||||
("GET", "/ut/game/fifa17/marketdatafoo"), // not the marketdata route
|
("GET", "/ut/game/fifa17/marketdatafoo"), // not the marketdata route
|
||||||
];
|
];
|
||||||
for (m, p) in proxied {
|
for (m, p) in proxied {
|
||||||
assert_eq!(classify(m, p), Route::Passthrough, "PROXY: {m} {p}");
|
assert_eq!(classify(m, p), Route::Passthrough, "PROXY: {m} {p}");
|
||||||
@@ -4148,7 +4395,10 @@ mod tests {
|
|||||||
fn item_defs_shape_matches_oracle() {
|
fn item_defs_shape_matches_oracle() {
|
||||||
// Two ids: the one hardcoded card (asset 20801) + a placeholder.
|
// Two ids: the one hardcoded card (asset 20801) + a placeholder.
|
||||||
assert_eq!(extract_long_ints("resourceId=20801"), vec![20801]);
|
assert_eq!(extract_long_ints("resourceId=20801"), vec![20801]);
|
||||||
assert_eq!(extract_long_ints("idList=200389,200104&x=12"), vec![200389, 200104]);
|
assert_eq!(
|
||||||
|
extract_long_ints("idList=200389,200104&x=12"),
|
||||||
|
vec![200389, 200104]
|
||||||
|
);
|
||||||
assert!(extract_long_ints("foo=ab").is_empty());
|
assert!(extract_long_ints("foo=ab").is_empty());
|
||||||
|
|
||||||
let body = non_economy::item_defs_body(&[20801, 200389]);
|
let body = non_economy::item_defs_body(&[20801, 200389]);
|
||||||
@@ -4173,7 +4423,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn marketdata_container_types_are_load_bearing() {
|
fn marketdata_container_types_are_load_bearing() {
|
||||||
// /pricelimits MUST be a bare ARRAY (object-where-array froze a live client).
|
// /pricelimits MUST be a bare ARRAY (object-where-array froze a live client).
|
||||||
assert_eq!(extract_defid_param("defId=200389,200104"), vec![200389, 200104]);
|
assert_eq!(
|
||||||
|
extract_defid_param("defId=200389,200104"),
|
||||||
|
vec![200389, 200104]
|
||||||
|
);
|
||||||
let arr = non_economy::marketdata_pricelimits_body(&[200389, 200104]);
|
let arr = non_economy::marketdata_pricelimits_body(&[200389, 200104]);
|
||||||
assert!(arr.is_array(), "pricelimits must be a bare array");
|
assert!(arr.is_array(), "pricelimits must be a bare array");
|
||||||
let arr = arr.as_array().unwrap();
|
let arr = arr.as_array().unwrap();
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
//! FIFA 17 UTAS migration host entrypoint.
|
//! FIFA 17 UTAS migration host entrypoint.
|
||||||
//!
|
//!
|
||||||
//! Serves `GET …/club` from OpenFUT Core and proxies every other UTAS route to
|
//! Serves migrated FIFA 17 UTAS routes from Rust/Core and proxies only the
|
||||||
//! the Python oracle. Config is env-only (see [`openfut_utas_host::config`]);
|
//! remaining tail to the Python oracle. Config is env-only (see
|
||||||
//! bind and Python upstream are required with no default.
|
//! [`openfut_utas_host::config`]).
|
||||||
|
|
||||||
use openfut_utas_host::{config::HostConfig, Server};
|
use openfut_utas_host::{config::HostConfig, Server};
|
||||||
|
|
||||||
@@ -15,8 +15,8 @@ fn main() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"utas-host starting: listen={} python_upstream={} core_url={} tables_dir={} catalog={} identity_store={} persona_id={}",
|
"utas-host starting: listen={} python_upstream={} core_url={} tables_dir={} catalog={} identity_store={} account={} persona_id={}",
|
||||||
cfg.listen_addr, cfg.python_upstream, cfg.core_url, cfg.tables_dir, cfg.catalog_path, cfg.identity_store_path, cfg.persona_id
|
cfg.listen_addr, cfg.python_upstream, cfg.core_url, cfg.tables_dir, cfg.catalog_path, cfg.identity_store_path, cfg.account_path, cfg.persona_id
|
||||||
);
|
);
|
||||||
let server = match Server::from_config(&cfg) {
|
let server = match Server::from_config(&cfg) {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
|
|||||||
+726
-61
File diff suppressed because it is too large
Load Diff
@@ -206,7 +206,11 @@ const CREATE_LISTINGS: &str = "CREATE TABLE IF NOT EXISTS listings (
|
|||||||
state TEXT NOT NULL CHECK (state IN ('active','reserved','sold','cancelled')),
|
state TEXT NOT NULL CHECK (state IN ('active','reserved','sold','cancelled')),
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
item_json TEXT,
|
item_json TEXT,
|
||||||
duration_secs INTEGER
|
duration_secs INTEGER,
|
||||||
|
-- Seller acknowledgement of a SOLD row, separate from the sale itself. Declared
|
||||||
|
-- here so a fresh store never needs the ALTER path below; the additive
|
||||||
|
-- migration exists only for stores created before this column.
|
||||||
|
cleared_at TEXT
|
||||||
)";
|
)";
|
||||||
|
|
||||||
fn now_millis() -> String {
|
fn now_millis() -> String {
|
||||||
@@ -287,6 +291,13 @@ impl MarketStore {
|
|||||||
for (col, decl) in [
|
for (col, decl) in [
|
||||||
("item_json", "TEXT"),
|
("item_json", "TEXT"),
|
||||||
("duration_secs", "INTEGER"),
|
("duration_secs", "INTEGER"),
|
||||||
|
// A SOLD listing is not the end of the seller's involvement: FIFA 17 has
|
||||||
|
// a bulk `DELETE …/trade/sold` verb (builder 0x1801647c0, request name
|
||||||
|
// RemoveAllSoldFromTradePile), which only makes sense if sold rows
|
||||||
|
// PERSIST in the seller's pile until cleared. `cleared_at` records that
|
||||||
|
// acknowledgement separately from the sale itself, so clearing a row can
|
||||||
|
// never be mistaken for re-settling it.
|
||||||
|
("cleared_at", "TEXT"),
|
||||||
] {
|
] {
|
||||||
if !existing.iter().any(|c| c == col) {
|
if !existing.iter().any(|c| c == col) {
|
||||||
sqlx::query(&format!("ALTER TABLE listings ADD COLUMN {col} {decl}"))
|
sqlx::query(&format!("ALTER TABLE listings ADD COLUMN {col} {decl}"))
|
||||||
@@ -477,6 +488,73 @@ impl MarketStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mark a live listing SOLD in one step (`active | reserved -> sold`), for a
|
||||||
|
/// sale driven by a counterparty rather than by this client's own buy-now.
|
||||||
|
/// Returns whether this call was the one that sold it, so a replay is visible
|
||||||
|
/// to the caller instead of silently settling twice.
|
||||||
|
pub async fn mark_sold(&self, listing_id: &str) -> Result<bool, MarketError> {
|
||||||
|
let affected = sqlx::query(
|
||||||
|
"UPDATE listings SET state = 'sold' \
|
||||||
|
WHERE listing_id = ? AND state IN ('active', 'reserved')",
|
||||||
|
)
|
||||||
|
.bind(listing_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db)?
|
||||||
|
.rows_affected();
|
||||||
|
Ok(affected == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sold listings the seller has NOT yet cleared, newest first.
|
||||||
|
///
|
||||||
|
/// Separate from [`Self::query_listings`] because "sold" and "still shown to
|
||||||
|
/// the seller" are different facts: a sold row stays in the pile until the
|
||||||
|
/// client acknowledges it via the bulk clear verb.
|
||||||
|
pub async fn uncleared_sold(&self) -> Result<Vec<Listing>, MarketError> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT * FROM listings WHERE state = 'sold' AND cleared_at IS NULL \
|
||||||
|
ORDER BY created_at DESC",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db)?;
|
||||||
|
Ok(rows.iter().map(row_to_listing).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acknowledge every uncleared sold listing (the bulk `DELETE …/trade/sold`).
|
||||||
|
/// Returns how many rows were cleared.
|
||||||
|
///
|
||||||
|
/// This is PRESENTATION ONLY. It records that the seller has seen the sale; it
|
||||||
|
/// moves no coins and no ownership, because settlement already happened when
|
||||||
|
/// the sale completed. Clearing must never be able to pay anyone twice.
|
||||||
|
pub async fn clear_sold(&self) -> Result<u64, MarketError> {
|
||||||
|
Ok(sqlx::query(
|
||||||
|
"UPDATE listings SET cleared_at = ? \
|
||||||
|
WHERE state = 'sold' AND cleared_at IS NULL",
|
||||||
|
)
|
||||||
|
.bind(now_millis())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db)?
|
||||||
|
.rows_affected())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acknowledge ONE sold listing by id (the per-id `DELETE …/trade/{id}` form,
|
||||||
|
/// if the client turns out to use it for sold rows). Same presentation-only
|
||||||
|
/// contract as [`Self::clear_sold`].
|
||||||
|
pub async fn clear_sold_one(&self, listing_id: &str) -> Result<u64, MarketError> {
|
||||||
|
Ok(sqlx::query(
|
||||||
|
"UPDATE listings SET cleared_at = ? \
|
||||||
|
WHERE listing_id = ? AND state = 'sold' AND cleared_at IS NULL",
|
||||||
|
)
|
||||||
|
.bind(now_millis())
|
||||||
|
.bind(listing_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db)?
|
||||||
|
.rows_affected())
|
||||||
|
}
|
||||||
|
|
||||||
/// Undo a reservation on a downstream failure (`reserved -> active`), so the
|
/// Undo a reservation on a downstream failure (`reserved -> active`), so the
|
||||||
/// listing becomes buyable again. Not in `reserved` -> [`MarketError::Conflict`].
|
/// listing becomes buyable again. Not in `reserved` -> [`MarketError::Conflict`].
|
||||||
pub async fn rollback_reservation(&self, listing_id: &str) -> Result<(), MarketError> {
|
pub async fn rollback_reservation(&self, listing_id: &str) -> Result<(), MarketError> {
|
||||||
@@ -592,6 +670,33 @@ impl MarketStore {
|
|||||||
}
|
}
|
||||||
self.get_listing(listing_id).await
|
self.get_listing(listing_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cancel any `active` listing held by a Core owned item, returning how many
|
||||||
|
/// rows were cancelled (0 when the item has no live auction).
|
||||||
|
///
|
||||||
|
/// This is the RETURN-TO-CLUB transition: the client sends a pile move for an
|
||||||
|
/// expired transfer-list item, and the auction that put it there has to end with
|
||||||
|
/// it. Without this the pile says `club` while the listing row stays `active`,
|
||||||
|
/// so the card is still filtered out of `/club` AND still rendered in the
|
||||||
|
/// Transfer List — the item appears not to move at all.
|
||||||
|
///
|
||||||
|
/// Deliberately scoped to `active`: a `reserved` row is mid-sale and a `sold`
|
||||||
|
/// row is already gone, and cancelling either would let a card be both sold and
|
||||||
|
/// returned.
|
||||||
|
pub async fn cancel_active_for_core_item(
|
||||||
|
&self,
|
||||||
|
core_item_id: &str,
|
||||||
|
) -> Result<u64, MarketError> {
|
||||||
|
Ok(sqlx::query(
|
||||||
|
"UPDATE listings SET state = 'cancelled' \
|
||||||
|
WHERE core_item_id = ? AND state = 'active'",
|
||||||
|
)
|
||||||
|
.bind(core_item_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(db)?
|
||||||
|
.rows_affected())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -717,7 +822,18 @@ mod tests {
|
|||||||
|
|
||||||
async fn seed(store: &MarketStore, id: &str) -> Listing {
|
async fn seed(store: &MarketStore, id: &str) -> Listing {
|
||||||
store
|
store
|
||||||
.create_listing(id, "card_pl_001", None, None, None, 900, 2500, None, None, None)
|
.create_listing(
|
||||||
|
id,
|
||||||
|
"card_pl_001",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
900,
|
||||||
|
2500,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
@@ -748,7 +864,18 @@ mod tests {
|
|||||||
seed(&store, "900000001").await;
|
seed(&store, "900000001").await;
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
store
|
store
|
||||||
.create_listing("900000001", "card_pl_002", None, None, None, 1, 2, None, None, None)
|
.create_listing(
|
||||||
|
"900000001",
|
||||||
|
"card_pl_002",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None
|
||||||
|
)
|
||||||
.await,
|
.await,
|
||||||
Err(MarketError::Conflict)
|
Err(MarketError::Conflict)
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
//! STAGING-ONLY seller-facing SOLD-row experiment.
|
||||||
|
//!
|
||||||
|
//! Static RE has exhausted CardsDLL on one question: for a `closed` row,
|
||||||
|
//! `IS_GLOW = (bidState != none)` and `INBOX = (bidState in {highest, buyNow})`,
|
||||||
|
//! so `closed/highest` and `closed/buyNow` are **bit-identical** to every native
|
||||||
|
//! consumer. But `bidState` is also published to the movie verbatim as `YOURBID`,
|
||||||
|
//! so the FUT ActionScript front end CAN separate them. This module exists to ask
|
||||||
|
//! the client which one it treats as the seller's sale, by holding every other
|
||||||
|
//! field constant and changing exactly that token.
|
||||||
|
//!
|
||||||
|
//! # Production safety
|
||||||
|
//!
|
||||||
|
//! Every knob is OFF unless its environment variable is set explicitly, and
|
||||||
|
//! [`SoldExperiment::enabled`] gates every projection at the call site. With no
|
||||||
|
//! env set this module changes nothing: `/tradePile` and `/trade/status` emit only
|
||||||
|
//! real active auctions (the Fix A invariant) and `/tradePile/counts` reports
|
||||||
|
//! `sold: 0` exactly as production does today. An unrecognised value is treated as
|
||||||
|
//! OFF rather than as a default token, because silently picking a token would
|
||||||
|
//! fabricate the very answer the experiment is meant to measure.
|
||||||
|
//!
|
||||||
|
//! # Why this cannot be "discovery"
|
||||||
|
//!
|
||||||
|
//! Our server IS the server, so nothing here recovers EA's original contract. It
|
||||||
|
//! is a controlled discriminator: the client's *reaction* (which bucket it draws
|
||||||
|
//! the row in, what it counts, and which request it issues to clear it) is the
|
||||||
|
//! observation.
|
||||||
|
|
||||||
|
/// What `/tradePile/counts.count` should report while a sold row exists. FIFA 17's
|
||||||
|
/// exact semantics for `count` are unknown — it is either the number of live
|
||||||
|
/// auctions or the whole Transfer List membership — so it is a controlled variable
|
||||||
|
/// rather than a guess.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum CountMode {
|
||||||
|
/// `count` = active auctions only (current production behaviour).
|
||||||
|
Active,
|
||||||
|
/// `count` = active + uncleared sold (Transfer List membership).
|
||||||
|
ActivePlusSold,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolved experiment configuration.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct SoldExperiment {
|
||||||
|
/// The `bidState` token to emit on a sold seller row. `None` disables every
|
||||||
|
/// part of the experiment.
|
||||||
|
pub bid_state: Option<&'static str>,
|
||||||
|
/// The `coinsProcessed` value to emit (published to Flash as `COINS_AWARDED`).
|
||||||
|
pub coins_processed: i64,
|
||||||
|
pub count_mode: CountMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SoldExperiment {
|
||||||
|
/// All-off. This is what production runs.
|
||||||
|
pub const OFF: Self = Self {
|
||||||
|
bid_state: None,
|
||||||
|
coins_processed: 0,
|
||||||
|
count_mode: CountMode::Active,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Read the configuration from the environment.
|
||||||
|
///
|
||||||
|
/// * `OPENFUT_FIFA17_SOLD_EXPERIMENT` — `highest` | `buyNow`; anything else
|
||||||
|
/// (including absent) is OFF.
|
||||||
|
/// * `OPENFUT_FIFA17_SOLD_COINS_PROCESSED` — `1` to emit 1, else 0.
|
||||||
|
/// * `OPENFUT_FIFA17_SOLD_COUNT_MODE` — `active_plus_sold`, else `active`.
|
||||||
|
pub fn from_env() -> Self {
|
||||||
|
Self::from_values(
|
||||||
|
std::env::var("OPENFUT_FIFA17_SOLD_EXPERIMENT")
|
||||||
|
.ok()
|
||||||
|
.as_deref(),
|
||||||
|
std::env::var("OPENFUT_FIFA17_SOLD_COINS_PROCESSED")
|
||||||
|
.ok()
|
||||||
|
.as_deref(),
|
||||||
|
std::env::var("OPENFUT_FIFA17_SOLD_COUNT_MODE")
|
||||||
|
.ok()
|
||||||
|
.as_deref(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure resolver, so the parsing rules are testable without touching the
|
||||||
|
/// process environment.
|
||||||
|
pub fn from_values(
|
||||||
|
experiment: Option<&str>,
|
||||||
|
coins_processed: Option<&str>,
|
||||||
|
count_mode: Option<&str>,
|
||||||
|
) -> Self {
|
||||||
|
// Matched case-insensitively for operator convenience, but ONLY the two
|
||||||
|
// real FIFA 17 tokens are accepted. `none`/`outbid` are deliberately not
|
||||||
|
// offered: neither can describe a completed sale, and `none` on a closed
|
||||||
|
// row clears IS_GLOW, which would test nothing.
|
||||||
|
let bid_state = match experiment.map(str::trim).unwrap_or("") {
|
||||||
|
s if s.eq_ignore_ascii_case("highest") => Some("highest"),
|
||||||
|
s if s.eq_ignore_ascii_case("buynow") => Some("buyNow"),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
bid_state,
|
||||||
|
coins_processed: i64::from(coins_processed == Some("1")),
|
||||||
|
count_mode: match count_mode.map(str::trim).unwrap_or("") {
|
||||||
|
s if s.eq_ignore_ascii_case("active_plus_sold") => CountMode::ActivePlusSold,
|
||||||
|
_ => CountMode::Active,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether any sold projection is active. Production: always false.
|
||||||
|
pub fn enabled(&self) -> bool {
|
||||||
|
self.bid_state.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A one-line banner for the host's startup log, so a staging run can never be
|
||||||
|
/// mistaken for a production one in a capture.
|
||||||
|
pub fn banner(&self) -> String {
|
||||||
|
match self.bid_state {
|
||||||
|
None => "sold-experiment=OFF (production behaviour)".to_string(),
|
||||||
|
Some(b) => format!(
|
||||||
|
"sold-experiment=ON bidState={b} coinsProcessed={} countMode={:?} \
|
||||||
|
-- STAGING ONLY, never production",
|
||||||
|
self.coins_processed, self.count_mode
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absent_env_is_off_and_matches_production() {
|
||||||
|
let e = SoldExperiment::from_values(None, None, None);
|
||||||
|
assert!(!e.enabled());
|
||||||
|
assert_eq!(e, SoldExperiment::OFF);
|
||||||
|
assert_eq!(e.coins_processed, 0);
|
||||||
|
assert_eq!(e.count_mode, CountMode::Active);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole point of the harness: exactly two tokens, and nothing else may
|
||||||
|
/// turn it on. A typo must not silently select a token and manufacture the
|
||||||
|
/// answer we are trying to measure.
|
||||||
|
#[test]
|
||||||
|
fn only_the_two_real_tokens_enable_it() {
|
||||||
|
for (input, expected) in [
|
||||||
|
("highest", Some("highest")),
|
||||||
|
("HIGHEST", Some("highest")),
|
||||||
|
("buyNow", Some("buyNow")),
|
||||||
|
("buynow", Some("buyNow")),
|
||||||
|
(" highest ", Some("highest")),
|
||||||
|
("off", None),
|
||||||
|
("none", None),
|
||||||
|
("outbid", None),
|
||||||
|
("closed", None),
|
||||||
|
("", None),
|
||||||
|
("hihgest", None), // typo
|
||||||
|
("1", None),
|
||||||
|
] {
|
||||||
|
let e = SoldExperiment::from_values(Some(input), None, None);
|
||||||
|
assert_eq!(e.bid_state, expected, "input {input:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coins_processed_is_strictly_one_or_zero() {
|
||||||
|
for (input, expected) in [
|
||||||
|
(Some("1"), 1),
|
||||||
|
(Some("0"), 0),
|
||||||
|
(Some("true"), 0), // only "1" means 1 — no fuzzy truthiness
|
||||||
|
(Some(""), 0),
|
||||||
|
(None, 0),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
SoldExperiment::from_values(Some("highest"), input, None).coins_processed,
|
||||||
|
expected,
|
||||||
|
"input {input:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn count_mode_defaults_to_production_behaviour() {
|
||||||
|
let mk = |m| SoldExperiment::from_values(Some("highest"), None, m).count_mode;
|
||||||
|
assert_eq!(mk(None), CountMode::Active);
|
||||||
|
assert_eq!(mk(Some("active")), CountMode::Active);
|
||||||
|
assert_eq!(mk(Some("active_plus_sold")), CountMode::ActivePlusSold);
|
||||||
|
assert_eq!(mk(Some("ACTIVE_PLUS_SOLD")), CountMode::ActivePlusSold);
|
||||||
|
assert_eq!(mk(Some("everything")), CountMode::Active, "unknown -> safe");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn banner_names_the_variant_under_test() {
|
||||||
|
assert!(SoldExperiment::OFF.banner().contains("OFF"));
|
||||||
|
let on = SoldExperiment::from_values(Some("buyNow"), Some("1"), None);
|
||||||
|
let b = on.banner();
|
||||||
|
assert!(b.contains("bidState=buyNow"), "{b}");
|
||||||
|
assert!(b.contains("coinsProcessed=1"), "{b}");
|
||||||
|
assert!(b.contains("STAGING ONLY"), "{b}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -164,6 +164,8 @@ fn build_harness(base: &str, dir: &std::path::Path) -> Harness {
|
|||||||
"content pool derived from real Core content"
|
"content pool derived from real Core content"
|
||||||
);
|
);
|
||||||
let services = Arc::new(EconomyServices {
|
let services = Arc::new(EconomyServices {
|
||||||
|
// Production default: the sold experiment is OFF.
|
||||||
|
sold_experiment: openfut_utas_host::sold_experiment::SoldExperiment::OFF,
|
||||||
econ,
|
econ,
|
||||||
market,
|
market,
|
||||||
piles,
|
piles,
|
||||||
|
|||||||
@@ -51,7 +51,12 @@
|
|||||||
//! | market list POST /ah | PARITY | 200; `{"id":<tradeId>}` (a positive listing id). Id SPACES |
|
//! | market list POST /ah | PARITY | 200; `{"id":<tradeId>}` (a positive listing id). Id SPACES |
|
||||||
//! | | | differ (Rust MarketStore vs oracle 900500000+seq) — a |
|
//! | | | differ (Rust MarketStore vs oracle 900500000+seq) — a |
|
||||||
//! | | | wire-insignificant server-private handle. |
|
//! | | | wire-insignificant server-private handle. |
|
||||||
//! | market query tradePile | PARITY | after list -> `auctionInfo` len 1, `tradeState:"active"`. |
|
//! | market query tradePile | DIFFERENT-BY-DESIGN| after list -> `auctionInfo` len 1, `tradeState:"active"`. |
|
||||||
|
//! | | | ONE field diverges deliberately: `itemData.itemState`. The |
|
||||||
|
//! | | | oracle emits `listFS`, which does not exist in FIFA 17 (0 in |
|
||||||
|
//! | | | CardsDLL, 0 in 4.26 GiB of client memory) and decodes to -1; |
|
||||||
|
//! | | | Rust emits `forSale` (5), the client's own token. Parity here |
|
||||||
|
//! | | | passed while BOTH were wrong, which is why it survived. |
|
||||||
//! | market buy POST /trade | DIFFERENT-BY-DESIGN| first buy debits exactly `buyNowPrice` & closes on BOTH, but |
|
//! | market buy POST /trade | DIFFERENT-BY-DESIGN| first buy debits exactly `buyNowPrice` & closes on BOTH, but |
|
||||||
//! | | | Rust's MarketStore is STATEFUL single-debit (a second buy of |
|
//! | | | Rust's MarketStore is STATEFUL single-debit (a second buy of |
|
||||||
//! | | | a sold listing is a no-op: 0 delta, empty `auctionInfo`) |
|
//! | | | a sold listing is a no-op: 0 delta, empty `auctionInfo`) |
|
||||||
@@ -91,8 +96,8 @@
|
|||||||
|
|
||||||
use openfut_adapter_fifa17::fut::catalog::Fifa17CardCatalog;
|
use openfut_adapter_fifa17::fut::catalog::Fifa17CardCatalog;
|
||||||
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
|
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
|
||||||
use openfut_adapter_fifa17::fut::store_session::{SessionStore, StoreMode, SENTINEL_PACK_ID};
|
|
||||||
use openfut_adapter_fifa17::fut::non_economy::PERSONA_DISPLAY_NAME;
|
use openfut_adapter_fifa17::fut::non_economy::PERSONA_DISPLAY_NAME;
|
||||||
|
use openfut_adapter_fifa17::fut::store_session::{SessionStore, StoreMode, SENTINEL_PACK_ID};
|
||||||
use openfut_identity::JsonIdentityStore;
|
use openfut_identity::JsonIdentityStore;
|
||||||
use openfut_utas_host::async_bridge::AsyncBridge;
|
use openfut_utas_host::async_bridge::AsyncBridge;
|
||||||
use openfut_utas_host::market_store::MarketStore;
|
use openfut_utas_host::market_store::MarketStore;
|
||||||
@@ -353,6 +358,8 @@ fn build_econ_server(base: &str, dir: &std::path::Path) -> (Server, HttpCoreClie
|
|||||||
"content pool derived from real Core content"
|
"content pool derived from real Core content"
|
||||||
);
|
);
|
||||||
let services = Arc::new(EconomyServices {
|
let services = Arc::new(EconomyServices {
|
||||||
|
// Production default: the sold experiment is OFF.
|
||||||
|
sold_experiment: openfut_utas_host::sold_experiment::SoldExperiment::OFF,
|
||||||
econ,
|
econ,
|
||||||
market,
|
market,
|
||||||
piles,
|
piles,
|
||||||
@@ -942,15 +949,32 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
|||||||
"an active auction has positive seconds remaining"
|
"an active auction has positive seconds remaining"
|
||||||
);
|
);
|
||||||
// itemData must be the full shaped card on both sides; a stub cannot render.
|
// itemData must be the full shaped card on both sides; a stub cannot render.
|
||||||
|
//
|
||||||
|
// DELIBERATE DIVERGENCE — the one field on this route where the oracle is
|
||||||
|
// WRONG. It stamps `listFS`, which is not a FIFA 17 token at all: zero
|
||||||
|
// occurrences in `CardsDLL_Win64_retail.dll` (md5
|
||||||
|
// 4de3493131d7d2ff7f8b360c5ac9b655), zero in 4.26 GiB of live client memory,
|
||||||
|
// and it decodes to -1 through the itemState table walk, so the client is
|
||||||
|
// handed an unrecognised `CARD_OFFERSTATE`. FIFA 17's value for an item
|
||||||
|
// offered for sale is `forSale` (5), from the 12-row table at 0x180229cc0.
|
||||||
|
//
|
||||||
|
// This assertion used to demand parity, and passed while BOTH sides were
|
||||||
|
// wrong — the reason the defect survived every differential run. Oracle parity
|
||||||
|
// is necessary but not sufficient; where the binary contradicts the oracle,
|
||||||
|
// the binary wins.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
o_rec["itemData"]["itemState"], r_rec["itemData"]["itemState"],
|
o_rec["itemData"]["itemState"], "listFS",
|
||||||
"own-pile itemState parity (listFS)"
|
"pins what the oracle actually emits, so this divergence stays visible"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
r_rec["itemData"]["itemState"], "forSale",
|
||||||
|
"Rust emits FIFA 17's own token, not the oracle's non-existent one"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
r_rec["itemData"]["rating"].is_i64() && r_rec["itemData"]["attributeList"].is_array(),
|
r_rec["itemData"]["rating"].is_i64() && r_rec["itemData"]["attributeList"].is_array(),
|
||||||
"rust tradePile itemData is the full card, not a stub"
|
"rust tradePile itemData is the full card, not a stub"
|
||||||
);
|
);
|
||||||
matrix.push(("market query tradePile", "PARITY"));
|
matrix.push(("market query tradePile", "DIFFERENT-BY-DESIGN"));
|
||||||
|
|
||||||
// ── OP 13: market buy (POST /trade/<id>) — DIFFERENT-BY-DESIGN ─────────
|
// ── OP 13: market buy (POST /trade/<id>) — DIFFERENT-BY-DESIGN ─────────
|
||||||
// Shared invariant: the first buy debits exactly buyNowPrice and closes the
|
// Shared invariant: the first buy debits exactly buyNowPrice and closes the
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ use openfut_utas_host::market_store::MarketStore;
|
|||||||
use openfut_utas_host::pile_store::PileStore;
|
use openfut_utas_host::pile_store::PileStore;
|
||||||
use openfut_utas_host::{
|
use openfut_utas_host::{
|
||||||
build_content_pool, CoreAccess, CoreEconomy, CoreError, EconomyEntitlement, EconomyGrantItem,
|
build_content_pool, CoreAccess, CoreEconomy, CoreError, EconomyEntitlement, EconomyGrantItem,
|
||||||
EconomyPurchase, EconomyServices, Fifa17IdentityResolver, HttpCoreClient, PassClient, Server,
|
EconomyPurchase, EconomySale, EconomySaleReceipt, EconomyServices, Fifa17IdentityResolver,
|
||||||
WireResponse,
|
HttpCoreClient, PassClient, Server, WireResponse,
|
||||||
};
|
};
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -134,6 +134,12 @@ impl CoreEconomy for FaultEconomy {
|
|||||||
}
|
}
|
||||||
self.inner.purchase_items(cost, items)
|
self.inner.purchase_items(cost, items)
|
||||||
}
|
}
|
||||||
|
fn settle_sale(&self, sale: &EconomySale<'_>) -> Result<EconomySaleReceipt, CoreError> {
|
||||||
|
if self.trip("settle_sale") {
|
||||||
|
return Err(Self::injected());
|
||||||
|
}
|
||||||
|
self.inner.settle_sale(sale)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An `ExternalIdentityStore` that forwards to a real `JsonIdentityStore` but can
|
/// An `ExternalIdentityStore` that forwards to a real `JsonIdentityStore` but can
|
||||||
@@ -314,6 +320,8 @@ fn build_fail_harness(base: &str, dir: &std::path::Path) -> FailHarness {
|
|||||||
"content pool derived from real Core content"
|
"content pool derived from real Core content"
|
||||||
);
|
);
|
||||||
let services = Arc::new(EconomyServices {
|
let services = Arc::new(EconomyServices {
|
||||||
|
// Production default: the sold experiment is OFF.
|
||||||
|
sold_experiment: openfut_utas_host::sold_experiment::SoldExperiment::OFF,
|
||||||
econ: econ_dyn,
|
econ: econ_dyn,
|
||||||
market: market.clone(),
|
market: market.clone(),
|
||||||
piles: piles.clone(),
|
piles: piles.clone(),
|
||||||
@@ -349,6 +357,8 @@ impl FailHarness {
|
|||||||
/// generator path never consumes an entitlement.
|
/// generator path never consumes an entitlement.
|
||||||
fn empty_pool_server(&self) -> Server {
|
fn empty_pool_server(&self) -> Server {
|
||||||
let services = Arc::new(EconomyServices {
|
let services = Arc::new(EconomyServices {
|
||||||
|
// Production default: the sold experiment is OFF.
|
||||||
|
sold_experiment: openfut_utas_host::sold_experiment::SoldExperiment::OFF,
|
||||||
econ: {
|
econ: {
|
||||||
let e: Arc<dyn CoreEconomy> = self.econ.clone();
|
let e: Arc<dyn CoreEconomy> = self.econ.clone();
|
||||||
e
|
e
|
||||||
|
|||||||
@@ -325,6 +325,8 @@ fn build_econ_server(
|
|||||||
"content pool derived from real Core content"
|
"content pool derived from real Core content"
|
||||||
);
|
);
|
||||||
let services = Arc::new(EconomyServices {
|
let services = Arc::new(EconomyServices {
|
||||||
|
// Production default: the sold experiment is OFF.
|
||||||
|
sold_experiment: openfut_utas_host::sold_experiment::SoldExperiment::OFF,
|
||||||
econ,
|
econ,
|
||||||
market,
|
market,
|
||||||
piles,
|
piles,
|
||||||
@@ -449,10 +451,8 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
|
|||||||
"POST",
|
"POST",
|
||||||
"/ut/game/fifa17/auctionhouse",
|
"/ut/game/fifa17/auctionhouse",
|
||||||
&[],
|
&[],
|
||||||
format!(
|
format!(r#"{{"itemData":{{"id":{list_wire}}},"buyNowPrice":1000,"startingBid":500}}"#)
|
||||||
r#"{{"itemData":{{"id":{list_wire}}},"buyNowPrice":1000,"startingBid":500}}"#
|
.as_bytes(),
|
||||||
)
|
|
||||||
.as_bytes(),
|
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.expect("market list routed");
|
.expect("market list routed");
|
||||||
@@ -765,6 +765,10 @@ fn from_config(base: &str, dir: &std::path::Path) -> openfut_utas_host::config::
|
|||||||
market_db_path: dir.join("market.db").to_string_lossy().into_owned(),
|
market_db_path: dir.join("market.db").to_string_lossy().into_owned(),
|
||||||
pile_db_path: dir.join("pile.db").to_string_lossy().into_owned(),
|
pile_db_path: dir.join("pile.db").to_string_lossy().into_owned(),
|
||||||
clientdata_path: dir.join("clientdata.json").to_string_lossy().into_owned(),
|
clientdata_path: dir.join("clientdata.json").to_string_lossy().into_owned(),
|
||||||
|
account_path: dir
|
||||||
|
.join("active_account.json")
|
||||||
|
.to_string_lossy()
|
||||||
|
.into_owned(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1186,6 +1190,8 @@ fn build_dead_core_server(dir: &std::path::Path, pass_url: &str) -> Server {
|
|||||||
);
|
);
|
||||||
let econ: Arc<dyn CoreEconomy> = Arc::new(HttpCoreClient::new(dead_url, "fifa17"));
|
let econ: Arc<dyn CoreEconomy> = Arc::new(HttpCoreClient::new(dead_url, "fifa17"));
|
||||||
let services = Arc::new(EconomyServices {
|
let services = Arc::new(EconomyServices {
|
||||||
|
// Production default: the sold experiment is OFF.
|
||||||
|
sold_experiment: openfut_utas_host::sold_experiment::SoldExperiment::OFF,
|
||||||
econ,
|
econ,
|
||||||
market,
|
market,
|
||||||
piles,
|
piles,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use openfut_adapter_fifa17::fut::catalog::Fifa17CardCatalog;
|
|||||||
use openfut_adapter_fifa17::fut::club_response::{CoreOwnedItem, ItemIdentityResolver};
|
use openfut_adapter_fifa17::fut::club_response::{CoreOwnedItem, ItemIdentityResolver};
|
||||||
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
|
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
|
||||||
use openfut_identity::JsonIdentityStore;
|
use openfut_identity::JsonIdentityStore;
|
||||||
|
use openfut_utas_host::account_store::AccountStore;
|
||||||
use openfut_utas_host::{
|
use openfut_utas_host::{
|
||||||
classify, handle_club, handle_put_squad, handle_squad_active, handle_squad_list,
|
classify, handle_club, handle_put_squad, handle_squad_active, handle_squad_list,
|
||||||
handle_user_mass_info, read_request, ClubDeps, CoreAccess, CoreError, CoreExtState, CorePage,
|
handle_user_mass_info, read_request, ClubDeps, CoreAccess, CoreError, CoreExtState, CorePage,
|
||||||
@@ -302,9 +303,33 @@ fn resolver_for(cards: &[(&str, u32)]) -> Arc<Fifa17IdentityResolver> {
|
|||||||
fn club_excludes_listed_items_and_paginates_the_visible_set() {
|
fn club_excludes_listed_items_and_paginates_the_visible_set() {
|
||||||
let core = Arc::new(FakeCore::new(
|
let core = Arc::new(FakeCore::new(
|
||||||
vec![
|
vec![
|
||||||
item("oc1", "card_a", 86, "CDM", "Argentina", "Premier League", "Chelsea"),
|
item(
|
||||||
item("oc2", "card_b", 85, "ST", "Argentina", "Premier League", "Chelsea"),
|
"oc1",
|
||||||
item("oc3", "card_c", 84, "CB", "Argentina", "Premier League", "Chelsea"),
|
"card_a",
|
||||||
|
86,
|
||||||
|
"CDM",
|
||||||
|
"Argentina",
|
||||||
|
"Premier League",
|
||||||
|
"Chelsea",
|
||||||
|
),
|
||||||
|
item(
|
||||||
|
"oc2",
|
||||||
|
"card_b",
|
||||||
|
85,
|
||||||
|
"ST",
|
||||||
|
"Argentina",
|
||||||
|
"Premier League",
|
||||||
|
"Chelsea",
|
||||||
|
),
|
||||||
|
item(
|
||||||
|
"oc3",
|
||||||
|
"card_c",
|
||||||
|
84,
|
||||||
|
"CB",
|
||||||
|
"Argentina",
|
||||||
|
"Premier League",
|
||||||
|
"Chelsea",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
3,
|
3,
|
||||||
));
|
));
|
||||||
@@ -502,21 +527,43 @@ fn passthrough_forwards_verbatim_and_never_calls_core() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mutating_route_classifies_to_passthrough_not_rust() {
|
fn club_rename_is_rust_owned_persistent_and_never_calls_python_or_core() {
|
||||||
// A PUT to the club PATH is NOT the read route; it must go to Python, never
|
assert_eq!(classify("PUT", "/ut/game/fifa17/club"), Route::ClubRename);
|
||||||
// execute Rust/Core (guards against double-applying a mutation).
|
assert_eq!(
|
||||||
assert_eq!(classify("PUT", "/ut/game/fifa17/club"), Route::Passthrough);
|
classify("PUT", "/ut/game/fifa17/user/club"),
|
||||||
|
Route::ClubRename
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify("POST", "/ut/game/fifa17/user/club"),
|
||||||
|
Route::ClubRename
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify("POST", "/ut/game/fifa17/squad/0"),
|
classify("POST", "/ut/game/fifa17/squad/0"),
|
||||||
Route::Passthrough
|
Route::Passthrough
|
||||||
);
|
);
|
||||||
|
|
||||||
let (upstream, _rec) = spawn_mock_python();
|
let (upstream, rec) = spawn_mock_python();
|
||||||
let core = Arc::new(FakeCore::forbidden());
|
let core = Arc::new(FakeCore::forbidden());
|
||||||
let server = build_server(core.clone(), &upstream, None);
|
let account_path = unique_store_path();
|
||||||
let resp = server.handle("PUT", "/ut/game/fifa17/club", &[], br#"{"x":1}"#);
|
let account = Arc::new(AccountStore::open(&account_path));
|
||||||
|
let server = build_server(core.clone(), &upstream, None).with_account(account.clone());
|
||||||
|
let resp = server.handle(
|
||||||
|
"PUT",
|
||||||
|
"/ut/game/fifa17/user/club",
|
||||||
|
&[],
|
||||||
|
br#"{"clubName":"Real FUT","clubAbbr":"RF"}"#,
|
||||||
|
);
|
||||||
assert_eq!(resp.status, 200);
|
assert_eq!(resp.status, 200);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_slice::<Value>(&resp.body).unwrap(),
|
||||||
|
json!({})
|
||||||
|
);
|
||||||
|
assert_eq!(account.club().name, "Real FUT");
|
||||||
|
assert_eq!(account.club().abbr, "RF");
|
||||||
|
assert_eq!(AccountStore::open(&account_path).club(), account.club());
|
||||||
assert_eq!(core.calls(), 0);
|
assert_eq!(core.calls(), 0);
|
||||||
|
assert_eq!(rec.lock().len(), 0, "rename never reached Python");
|
||||||
|
let _ = std::fs::remove_file(account_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── End-to-end over a socket (read_request + write_response + keep-alive) ─────
|
// ── End-to-end over a socket (read_request + write_response + keep-alive) ─────
|
||||||
@@ -874,9 +921,8 @@ fn classify_squad_and_usermassinfo_routes() {
|
|||||||
classify("GET", "/ut/game/fifa17/userMassInfo"),
|
classify("GET", "/ut/game/fifa17/userMassInfo"),
|
||||||
Route::UserMassInfo
|
Route::UserMassInfo
|
||||||
);
|
);
|
||||||
// GET /squad/active is now Core-backed (SquadActive). A squad PUT is never a
|
// GET /squad/active and every numeric GET are the one Core-backed current
|
||||||
// GET. GET /squad/0 IS the active squad (id 0) -> SquadActive; a numeric
|
// squad, matching the oracle's single-squad response regardless of URL id.
|
||||||
// GET /squad/<n> for a NON-active squad (n != 0) stays on Python.
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify("GET", "/ut/game/fifa17/squad/active"),
|
classify("GET", "/ut/game/fifa17/squad/active"),
|
||||||
Route::SquadActive
|
Route::SquadActive
|
||||||
@@ -891,7 +937,7 @@ fn classify_squad_and_usermassinfo_routes() {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify("GET", "/ut/game/fifa17/squad/5"),
|
classify("GET", "/ut/game/fifa17/squad/5"),
|
||||||
Route::Passthrough
|
Route::SquadActive
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify("PUT", "/ut/game/fifa17/squad/list"),
|
classify("PUT", "/ut/game/fifa17/squad/list"),
|
||||||
@@ -900,6 +946,39 @@ fn classify_squad_and_usermassinfo_routes() {
|
|||||||
assert_eq!(classify("GET", "/ut/game/fifa17/club"), Route::Club);
|
assert_eq!(classify("GET", "/ut/game/fifa17/club"), Route::Club);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn numbered_squad_get_returns_current_core_squad_without_python() {
|
||||||
|
let items = vec![gk(), st()];
|
||||||
|
let (resolver, wires) = resolver_with_wires(&items, ASSETS);
|
||||||
|
let core = Arc::new(FakeCore::new(items, 2));
|
||||||
|
let (python, recorded) = spawn_mock_python();
|
||||||
|
let server = Server::new(
|
||||||
|
core,
|
||||||
|
Arc::new(entities()),
|
||||||
|
Arc::new(resolver),
|
||||||
|
Arc::new(PassClient::new(&python)),
|
||||||
|
33_068_179,
|
||||||
|
);
|
||||||
|
let put = server.handle(
|
||||||
|
"PUT",
|
||||||
|
"/ut/game/fifa17/squad/0",
|
||||||
|
&[],
|
||||||
|
&put_body(
|
||||||
|
"f442",
|
||||||
|
wires["oc-a"],
|
||||||
|
&[(0, wires["oc-a"], 1), (1, wires["oc-b"], 9)],
|
||||||
|
"[1,2,3]",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert_eq!(put.status, 200);
|
||||||
|
|
||||||
|
let active = server.handle("GET", "/ut/game/fifa17/squad/active", &[], b"");
|
||||||
|
let numbered = server.handle("GET", "/ut/game/fifa17/squad/5", &[], b"");
|
||||||
|
assert_eq!(numbered.status, 200);
|
||||||
|
assert_eq!(numbered.body, active.body);
|
||||||
|
assert_eq!(recorded.lock().len(), 0, "numeric GET never reached Python");
|
||||||
|
}
|
||||||
|
|
||||||
// ── PUT pipeline ────────────────────────────────────────────────────────────
|
// ── PUT pipeline ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Executable
+113
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Recover FIFA's on-screen error text from the live client, read-only.
|
||||||
|
|
||||||
|
Diagnosing this squad error from the server side has failed repeatedly: the host
|
||||||
|
answers 200/ok for every request, the squad round-trips exactly, and its shape now
|
||||||
|
matches production's known-good squad field for field. So the message the client is
|
||||||
|
actually showing is the missing evidence.
|
||||||
|
|
||||||
|
Scans readable regions of /proc/<pid>/mem for candidate substrings in both ASCII and
|
||||||
|
UTF-16LE (FIFA UI strings are typically wide), and prints the surrounding text so the
|
||||||
|
full sentence and any error code come out. Opens the memory O_RDONLY and only ever
|
||||||
|
pread()s -- it cannot perturb the process.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
NEEDLES = [b"quad Update", b"quad update", b"QUAD_UPDATE", b"quad_update",
|
||||||
|
b"pdate your squad", b"pdating squad", b"quad Management",
|
||||||
|
b"nable to update", b"FUT_ERR", b"SQUAD_ERR"]
|
||||||
|
MAX_REGION = 96 * 1024 * 1024 # skip absurd regions; the UI heap is not that big
|
||||||
|
CONTEXT = 140
|
||||||
|
|
||||||
|
|
||||||
|
def pid_of(name="FIFA17.exe"):
|
||||||
|
for d in os.listdir("/proc"):
|
||||||
|
if not d.isdigit():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if open(f"/proc/{d}/comm").read().strip() == name:
|
||||||
|
return int(d)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def wide(b):
|
||||||
|
"""UTF-16LE form of an ASCII needle."""
|
||||||
|
return b"".join(bytes([c, 0]) for c in b)
|
||||||
|
|
||||||
|
|
||||||
|
def regions(pid):
|
||||||
|
out = []
|
||||||
|
for line in open(f"/proc/{pid}/maps"):
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) < 2 or "r" not in parts[1]:
|
||||||
|
continue
|
||||||
|
lo, _, hi = parts[0].partition("-")
|
||||||
|
lo, hi = int(lo, 16), int(hi, 16)
|
||||||
|
size = hi - lo
|
||||||
|
if 0 < size <= MAX_REGION:
|
||||||
|
out.append((lo, size, parts[-1] if len(parts) > 5 else ""))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def render(buf, pos, is_wide):
|
||||||
|
lo = max(0, pos - CONTEXT)
|
||||||
|
hi = min(len(buf), pos + CONTEXT)
|
||||||
|
chunk = buf[lo:hi]
|
||||||
|
if is_wide:
|
||||||
|
try:
|
||||||
|
txt = chunk.decode("utf-16le", errors="replace")
|
||||||
|
except Exception:
|
||||||
|
txt = repr(chunk)
|
||||||
|
else:
|
||||||
|
txt = chunk.decode("latin-1", errors="replace")
|
||||||
|
txt = re.sub(r"[^\x20-\x7e]+", " ", txt)
|
||||||
|
return re.sub(r"\s{2,}", " ", txt).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
pid = pid_of()
|
||||||
|
if not pid:
|
||||||
|
print("FIFA17.exe not running")
|
||||||
|
return 1
|
||||||
|
print(f"scanning pid {pid}")
|
||||||
|
targets = [(n, False) for n in NEEDLES] + [(wide(n), True) for n in NEEDLES]
|
||||||
|
hits, scanned = [], 0
|
||||||
|
fd = os.open(f"/proc/{pid}/mem", os.O_RDONLY)
|
||||||
|
try:
|
||||||
|
for lo, size, path in regions(pid):
|
||||||
|
try:
|
||||||
|
buf = os.pread(fd, size, lo)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
scanned += size
|
||||||
|
for needle, is_wide in targets:
|
||||||
|
start = 0
|
||||||
|
while True:
|
||||||
|
p = buf.find(needle, start)
|
||||||
|
if p < 0:
|
||||||
|
break
|
||||||
|
hits.append((lo + p, is_wide, render(buf, p, is_wide), path))
|
||||||
|
start = p + 1
|
||||||
|
if len(hits) > 60:
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
print(f"scanned {scanned // (1024*1024)} MiB, {len(hits)} hit(s)\n")
|
||||||
|
seen = set()
|
||||||
|
for addr, is_wide, txt, path in hits:
|
||||||
|
key = txt[:110]
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
kind = "utf16" if is_wide else "ascii"
|
||||||
|
print(f"0x{addr:x} [{kind}] {path}")
|
||||||
|
print(f" {txt}\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+138
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Ensure the deployed launcher starts FIFA with the hook's WINEDLLOVERRIDES.
|
||||||
|
|
||||||
|
The hook ships as a `version.dll` proxy in the game directory, and Proton prefers a
|
||||||
|
local DLL over its own builtin ONLY when WINEDLLOVERRIDES names it. Launcher builds
|
||||||
|
before openfut-launcher c542415 apply `game_profile.env` but never that override, so
|
||||||
|
the hook silently never loads -- and with no hook there is no port rewrite, the
|
||||||
|
openfut.cfg the launcher just wrote is inert, and the client reaches EA's real Blaze
|
||||||
|
ports (production, via /etc/hosts) while looking like a healthy configured launch.
|
||||||
|
|
||||||
|
This writes the override into `game_profile.env`, which the deployed launcher DOES
|
||||||
|
apply, so it works without rebuilding. It is forward-compatible with the fixed
|
||||||
|
launcher: hook_dll_overrides() defers to a profile that already pins `version=`, so
|
||||||
|
the value set here simply wins and nothing conflicts.
|
||||||
|
|
||||||
|
python3 scripts/client-hook-override.py show
|
||||||
|
python3 scripts/client-hook-override.py apply
|
||||||
|
python3 scripts/client-hook-override.py revert
|
||||||
|
|
||||||
|
Safety: the prior value (including its absence) is recorded to a sidecar before the
|
||||||
|
first mutation, so `revert` restores the real previous state rather than guessing;
|
||||||
|
every operation re-reads and prints the result; and it refuses to edit while the
|
||||||
|
launcher is running, because the launcher holds its config in memory and would write
|
||||||
|
the stale value straight back over ours.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
CLIENT = "alex@10.10.0.105"
|
||||||
|
CFG = "/home/alex/.config/openfut-launcher/config.json"
|
||||||
|
SIDECAR = "/home/alex/.config/openfut-launcher/config.json.openfut-prev-dlloverrides"
|
||||||
|
KEY = "WINEDLLOVERRIDES"
|
||||||
|
HOOK = "version=n,b"
|
||||||
|
ABSENT = "<absent>"
|
||||||
|
|
||||||
|
|
||||||
|
def ssh(script):
|
||||||
|
r = subprocess.run(["ssh", "-o", "BatchMode=yes", CLIENT, script],
|
||||||
|
capture_output=True, text=True, timeout=60)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise SystemExit(f"ssh failed ({r.returncode}): {r.stderr.strip()}")
|
||||||
|
return r.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def launcher_running():
|
||||||
|
"""comm is truncated by the kernel to 15 chars ("openfut-launche")."""
|
||||||
|
out = ssh("for d in /proc/[0-9]*; do c=$(cat $d/comm 2>/dev/null); "
|
||||||
|
"case \"$c\" in openfut-launche*) echo ${d#/proc/};; esac; done || true")
|
||||||
|
return bool(out.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def fifa_running():
|
||||||
|
out = ssh("for d in /proc/[0-9]*; do "
|
||||||
|
"[ \"$(cat $d/comm 2>/dev/null)\" = FIFA17.exe ] && echo ${d#/proc/}; "
|
||||||
|
"done || true")
|
||||||
|
return bool(out.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def load():
|
||||||
|
return json.loads(ssh(f'cat "{CFG}"'))
|
||||||
|
|
||||||
|
|
||||||
|
def env_of(d):
|
||||||
|
return d.setdefault("game_profile", {}).setdefault("env", {})
|
||||||
|
|
||||||
|
|
||||||
|
def save(d):
|
||||||
|
body = json.dumps(d, indent=2, sort_keys=True)
|
||||||
|
ssh(f'cat > "{CFG}" <<\'EOF\'\n{body}\nEOF')
|
||||||
|
|
||||||
|
|
||||||
|
def show():
|
||||||
|
d = load()
|
||||||
|
cur = env_of(d).get(KEY, ABSENT)
|
||||||
|
print(f"--- {CFG}")
|
||||||
|
print(f" game_profile.env[{KEY}] = {cur}")
|
||||||
|
print(f" hook active on launch = {'YES' if 'version=' in cur else 'NO'}")
|
||||||
|
side = ssh(f'cat "{SIDECAR}" 2>/dev/null || true').strip()
|
||||||
|
print(f" recorded previous value = {side or '(none recorded yet)'}")
|
||||||
|
print(f" launcher running = {'YES' if launcher_running() else 'no'}")
|
||||||
|
return cur
|
||||||
|
|
||||||
|
|
||||||
|
def guard():
|
||||||
|
if fifa_running():
|
||||||
|
raise SystemExit("REFUSING: a FIFA client is running; exit it first.")
|
||||||
|
if launcher_running():
|
||||||
|
raise SystemExit(
|
||||||
|
"REFUSING: openfut-launcher is running. It holds its config in memory and\n"
|
||||||
|
"would write the old value back over ours. Quit the launcher first.")
|
||||||
|
|
||||||
|
|
||||||
|
def apply_override():
|
||||||
|
guard()
|
||||||
|
d = load()
|
||||||
|
env = env_of(d)
|
||||||
|
prior = env.get(KEY, ABSENT)
|
||||||
|
if not ssh(f'cat "{SIDECAR}" 2>/dev/null || true').strip():
|
||||||
|
ssh(f'cat > "{SIDECAR}" <<\'EOF\'\n{prior}\nEOF')
|
||||||
|
print(f"recorded previous value to {SIDECAR}: {prior}")
|
||||||
|
# Preserve an unrelated override rather than clobbering it, and never double up.
|
||||||
|
if prior != ABSENT and "version=" in prior:
|
||||||
|
print(f"already pins version= ({prior}); leaving it alone")
|
||||||
|
return show()
|
||||||
|
env[KEY] = HOOK if prior == ABSENT else f"{prior};{HOOK}"
|
||||||
|
save(d)
|
||||||
|
after = env_of(load()).get(KEY, ABSENT)
|
||||||
|
if "version=" not in after:
|
||||||
|
raise SystemExit(f"VERIFY FAILED: {KEY} is {after!r}")
|
||||||
|
return show()
|
||||||
|
|
||||||
|
|
||||||
|
def revert():
|
||||||
|
guard()
|
||||||
|
side = ssh(f'cat "{SIDECAR}" 2>/dev/null || true').strip()
|
||||||
|
if not side:
|
||||||
|
raise SystemExit(f"no recorded previous value at {SIDECAR}; refusing to guess")
|
||||||
|
d = load()
|
||||||
|
env = env_of(d)
|
||||||
|
if side == ABSENT:
|
||||||
|
env.pop(KEY, None)
|
||||||
|
else:
|
||||||
|
env[KEY] = side
|
||||||
|
save(d)
|
||||||
|
return show()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) != 2 or sys.argv[1] not in ("show", "apply", "revert"):
|
||||||
|
print(__doc__)
|
||||||
|
return 2
|
||||||
|
{"show": show, "apply": apply_override, "revert": revert}[sys.argv[1]]()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+62
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Diff hub-route SHAPES between production (known-good) and staging.
|
||||||
|
|
||||||
|
Values legitimately differ (different clubs, different piles). What must not differ is
|
||||||
|
structure: a key production sends that staging omits, or a type/null mismatch, is a
|
||||||
|
candidate cause for the client rejecting FUT bootstrap.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
PROD, STAG = "/tmp/hub_prod", "/tmp/hub_stag"
|
||||||
|
|
||||||
|
|
||||||
|
def t(v):
|
||||||
|
return "null" if v is None else type(v).__name__
|
||||||
|
|
||||||
|
|
||||||
|
def shape(obj, prefix="", depth=0):
|
||||||
|
"""path -> type, descending into the FIRST element of lists (representative)."""
|
||||||
|
out = {}
|
||||||
|
if depth > 6:
|
||||||
|
return out
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
for k, v in obj.items():
|
||||||
|
p = f"{prefix}.{k}" if prefix else k
|
||||||
|
out[p] = t(v)
|
||||||
|
out.update(shape(v, p, depth + 1))
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
out[(prefix or "<root>") + "[]"] = "list"
|
||||||
|
if obj:
|
||||||
|
out.update(shape(obj[0], f"{prefix}[0]", depth + 1))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for name in sorted(os.listdir(PROD)):
|
||||||
|
pf, sf = os.path.join(PROD, name), os.path.join(STAG, name)
|
||||||
|
if not os.path.exists(sf):
|
||||||
|
print(f"{name}: MISSING on staging")
|
||||||
|
continue
|
||||||
|
p = json.load(open(pf))
|
||||||
|
s = json.load(open(sf))
|
||||||
|
ps, ss = shape(p.get("body")), shape(s.get("body"))
|
||||||
|
missing = [k for k in ps if k not in ss]
|
||||||
|
types = [(k, ps[k], ss[k]) for k in ps if k in ss and ps[k] != ss[k]]
|
||||||
|
nulls = [k for k in ps if k in ss and ss[k] == "null" and ps[k] != "null"]
|
||||||
|
if p.get("status") != s.get("status"):
|
||||||
|
print(f"\n### {name}: STATUS prod={p.get('status')} staging={s.get('status')}")
|
||||||
|
total += 1
|
||||||
|
if missing or types:
|
||||||
|
print(f"\n### {name}")
|
||||||
|
for k in missing:
|
||||||
|
print(f" MISSING on staging: {k:<44} prod_type={ps[k]}")
|
||||||
|
for k, a, b in types:
|
||||||
|
mark = " <-- NULL" if b == "null" else ""
|
||||||
|
print(f" TYPE {k:<48} prod={a:<7} staging={b}{mark}")
|
||||||
|
total += len(missing) + len(types)
|
||||||
|
|
||||||
|
print(f"\n=== {total} structural difference(s) across {len(os.listdir(PROD))} routes ===")
|
||||||
|
if total == 0:
|
||||||
|
print("Every hub route matches production's shape. The bootstrap failure is not a")
|
||||||
|
print("missing/mistyped field in these responses.")
|
||||||
Executable
+56
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Dump every FUT-hub route from one UTAS to a directory, for prod-vs-staging shape diffing.
|
||||||
|
|
||||||
|
Usage: dump_hub.py <port> <outdir>
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROUTES = {
|
||||||
|
"user_accountinfo": "/ut/game/fifa17/user/accountinfo",
|
||||||
|
"squad_active": "/ut/game/fifa17/squad/active",
|
||||||
|
"squad_0": "/ut/game/fifa17/squad/0",
|
||||||
|
"club": "/ut/game/fifa17/club?count=20",
|
||||||
|
"club_stats_staff": "/ut/game/fifa17/club/stats/staff",
|
||||||
|
"club_stats_club": "/ut/game/fifa17/club/stats/club",
|
||||||
|
"clientdata_store": "/ut/game/fifa17/clientdata/store",
|
||||||
|
"purchased_items": "/ut/game/fifa17/purchased/items",
|
||||||
|
"tradepile": "/ut/game/fifa17/tradePile",
|
||||||
|
"tradepile_counts": "/ut/game/fifa17/tradePile/counts",
|
||||||
|
"watchlist": "/ut/game/fifa17/watchList",
|
||||||
|
"trade_status": "/ut/game/fifa17/trade/status",
|
||||||
|
"usermassinfo": "/ut/game/fifa17/userMassInfo",
|
||||||
|
"settings": "/ut/game/fifa17/settings",
|
||||||
|
}
|
||||||
|
|
||||||
|
port, outdir = int(sys.argv[1]), sys.argv[2]
|
||||||
|
os.makedirs(outdir, exist_ok=True)
|
||||||
|
|
||||||
|
for name, path in ROUTES.items():
|
||||||
|
try:
|
||||||
|
s = socket.create_connection(("127.0.0.1", port), timeout=25)
|
||||||
|
s.sendall((f"GET {path} HTTP/1.1\r\nHost: x\r\n"
|
||||||
|
"X-OpenFUT-Game: fifa17\r\nConnection: close\r\n\r\n").encode())
|
||||||
|
buf = b""
|
||||||
|
while True:
|
||||||
|
c = s.recv(65536)
|
||||||
|
if not c:
|
||||||
|
break
|
||||||
|
buf += c
|
||||||
|
s.close()
|
||||||
|
head, _, body = buf.partition(b"\r\n\r\n")
|
||||||
|
status = head.split(b" ")[1].decode()
|
||||||
|
rec = {"status": status}
|
||||||
|
try:
|
||||||
|
rec["body"] = json.loads(body)
|
||||||
|
except Exception:
|
||||||
|
rec["body"] = None
|
||||||
|
rec["raw"] = body[:200].decode(errors="replace")
|
||||||
|
with open(os.path.join(outdir, f"{name}.json"), "w") as f:
|
||||||
|
json.dump(rec, f)
|
||||||
|
n = len(json.dumps(rec.get("body"))) if rec.get("body") is not None else 0
|
||||||
|
print(f" {status} {name:<18} {n} bytes")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ERR {name:<18} {type(e).__name__}: {e}")
|
||||||
Executable
+56
@@ -0,0 +1,56 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Isolated live confirmation of the roster-cert fix, using the REAL roster_server.py.
|
||||||
|
# Runs under `sudo unshare -n` so port 8081 is free and production is never touched.
|
||||||
|
#
|
||||||
|
# Proves: with a cert from the FIXED generator (IP SAN), a client that verifies the cert
|
||||||
|
# BY THE DIALED IP completes the handshake against the real server and gets the roster
|
||||||
|
# XML; with the OLD DNS-only cert, the same client fails verification (certificate_unknown
|
||||||
|
# class). That is the exact before/after the production fix targets.
|
||||||
|
set -u
|
||||||
|
D=/tmp/roster-iso
|
||||||
|
rm -rf "$D"; mkdir -p "$D"
|
||||||
|
cp /home/alex/OpenFUT/fifa17-recon/tools/roster_server.py "$D/" && sed -i "s@/tmp/roster_server.log@$D/roster_server.log@" "$D/roster_server.py"
|
||||||
|
DIAL=127.0.0.1
|
||||||
|
DNS="DNS:winter15.gosredirector.ea.com,DNS:*.gosredirector.ea.com,DNS:*.ea.com"
|
||||||
|
|
||||||
|
ip link set lo up 2>/dev/null || { echo " FATAL: cannot bring up lo in namespace"; exit 1; }
|
||||||
|
|
||||||
|
gen() { # $1 = san
|
||||||
|
openssl req -x509 -newkey rsa:2048 -nodes -keyout "$D/redir_key.pem" -out "$D/redir_cert.pem" \
|
||||||
|
-days 3650 -subj "/CN=winter15.gosredirector.ea.com" -addext "subjectAltName=$1" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
run_server() {
|
||||||
|
OPENFUT_BIND=127.0.0.1 python3 "$D/roster_server.py" >"$D/srv.log" 2>&1 &
|
||||||
|
echo $! > "$D/srv.pid"
|
||||||
|
for i in $(seq 1 25); do ss -tln 2>/dev/null | grep -q ":8081" && return 0; sleep 0.2; done
|
||||||
|
echo " FATAL: roster_server never bound 8081"; echo " --- srv.log ---"; sed "s/^/ /" "$D/srv.log"; return 1
|
||||||
|
}
|
||||||
|
stop_server() { kill "$(cat "$D/srv.pid" 2>/dev/null)" 2>/dev/null; for i in $(seq 1 25); do ss -tln 2>/dev/null | grep -q ":8081" || return 0; sleep 0.2; done; echo " WARN: 8081 still bound after stop"; }
|
||||||
|
|
||||||
|
rc_new=9; rc_old=9
|
||||||
|
|
||||||
|
echo "=== NEW cert (fixed generator: DNS + IP:$DIAL,IP:10.10.0.120) ==="
|
||||||
|
gen "$DNS,IP:$DIAL,IP:10.10.0.120"
|
||||||
|
echo " SAN: $(openssl x509 -in "$D/redir_cert.pem" -noout -ext subjectAltName | tail -1 | tr -s ' ')"
|
||||||
|
if run_server; then
|
||||||
|
python3 "$(dirname "$0")/roster-cert-verify.py" "$D/redir_cert.pem" "$DIAL" 8081; rc_new=$?
|
||||||
|
stop_server
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== OLD cert (DNS-only, the failing production shape) ==="
|
||||||
|
gen "$DNS"
|
||||||
|
if run_server; then
|
||||||
|
python3 "$(dirname "$0")/roster-cert-verify.py" "$D/redir_cert.pem" "$DIAL" 8081; rc_old=$?
|
||||||
|
stop_server
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== verdict ==="
|
||||||
|
echo " new cert exit=$rc_new (want 0 = verified+200)"
|
||||||
|
echo " old cert exit=$rc_old (want 1 = verify failed)"
|
||||||
|
if [ "$rc_new" = 0 ] && [ "$rc_old" = 1 ]; then
|
||||||
|
echo " PASS: the real roster server serves a by-IP-verifiable cert after the fix, not before"
|
||||||
|
else
|
||||||
|
echo " FAIL: unexpected outcome"
|
||||||
|
fi
|
||||||
|
rm -rf "$D"
|
||||||
Executable
+63
@@ -0,0 +1,63 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Connect to the roster server and VERIFY its cert by the dialed IP, then GET the roster.
|
||||||
|
|
||||||
|
Mirrors the client's failing path: dial the roster by IP over TLS and validate the
|
||||||
|
presented certificate against that IP. Exit 0 only if the cert verifies AND the roster
|
||||||
|
XML comes back 200; exit 1 on cert-verify failure (the certificate_unknown class).
|
||||||
|
|
||||||
|
argv: <cafile> <dial_ip> <port>
|
||||||
|
"""
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import sys
|
||||||
|
|
||||||
|
cafile, dial_ip, port = sys.argv[1], sys.argv[2], int(sys.argv[3])
|
||||||
|
|
||||||
|
ctx = ssl.create_default_context(cafile=cafile) # trust the server's self-signed cert as CA
|
||||||
|
ctx.check_hostname = True
|
||||||
|
try:
|
||||||
|
ctx.minimum_version = ssl.TLSVersion.TLSv1
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
ctx.set_ciphers("ALL:@SECLEVEL=0")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = socket.create_connection((dial_ip, port), timeout=8)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" CONNECT-FAIL {type(e).__name__}: {e}")
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# server_hostname is the IP the roster is dialed by; ssl matches it against the
|
||||||
|
# cert's iPAddress SANs — exactly the check the DNS-only cert failed.
|
||||||
|
s = ctx.wrap_socket(raw, server_hostname=dial_ip)
|
||||||
|
except ssl.SSLCertVerificationError as e:
|
||||||
|
print(f" VERIFY-FAIL {e.verify_message or e}")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" TLS-FAIL {type(e).__name__}: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ver, cipher = s.version(), s.cipher()[0]
|
||||||
|
req = (f"GET /fifa17/fut/rosterupdate.xml HTTP/1.1\r\nHost: {dial_ip}:{port}\r\n"
|
||||||
|
"Connection: close\r\n\r\n")
|
||||||
|
s.sendall(req.encode())
|
||||||
|
buf = b""
|
||||||
|
while True:
|
||||||
|
chunk = s.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
buf += chunk
|
||||||
|
s.close()
|
||||||
|
status = buf.split(b"\r\n", 1)[0].decode(errors="replace")
|
||||||
|
body = buf.split(b"\r\n\r\n", 1)[1] if b"\r\n\r\n" in buf else b""
|
||||||
|
print(f" VERIFIED {ver} {cipher}")
|
||||||
|
print(f" {status} body={len(body)}B head={body[:60]!r}")
|
||||||
|
sys.exit(0 if status.endswith("200 OK") and body else 3)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" GET-FAIL {type(e).__name__}: {e}")
|
||||||
|
sys.exit(3)
|
||||||
Executable
+566
@@ -0,0 +1,566 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""ISOLATED staging harness for the Core SOLD-settlement path.
|
||||||
|
|
||||||
|
Exercises the REAL `openfut-core` binary over REAL HTTP (`POST /economy/settle-sale`)
|
||||||
|
against a THROWAWAY SQLite database in a fresh temp directory, so the transfer-market
|
||||||
|
sold path can be validated with no FIFA client, no UTAS host, and no production state.
|
||||||
|
|
||||||
|
Isolation guarantees (all enforced below, not merely documented):
|
||||||
|
* The database is created by `tempfile.mkdtemp()` and deleted on exit (`--keep` opts out).
|
||||||
|
* The listen port is chosen by binding 127.0.0.1:0 and reading the port back; the
|
||||||
|
PRODUCTION ports 8099 (utas-host), 8199 (oracle) and 18080 (prod Core) are in a
|
||||||
|
hard deny-list and are never bound or contacted.
|
||||||
|
* Nothing under /home/alex/openfut-promotion/state/ (the live DBs) is read or written.
|
||||||
|
* Core runs with an explicit LISTEN_ADDR / DATABASE_URL / DATA_DIR and cwd set to the
|
||||||
|
temp directory, so no relative path can escape into the repo or a live database.
|
||||||
|
|
||||||
|
Canonical two-party fixture reproduced here:
|
||||||
|
seller 1,000 coins owning `item-x`; buyer 20,000; gross 15,000; fee 750.
|
||||||
|
Expected: buyer 20,000 -> 5,000; seller 1,000 -> 15,250; owner seller -> buyer;
|
||||||
|
exactly ONE row for `item-x`; modelled coins 21,000 -> 20,250 (delta == fee).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/settlement-staging.py [--keep] [--no-build]
|
||||||
|
|
||||||
|
Exit code 0 iff every assertion passes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import http.client
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
# --- fixed facts about the repo (read from openfut-core/src/{main,config}.rs) -------
|
||||||
|
# Config::from_env(): LISTEN_ADDR, DATABASE_URL, DATA_DIR, DB_MAX_CONNECTIONS.
|
||||||
|
# Plain `openfut-core` (no subcommand) runs its own migrations, then serves axum.
|
||||||
|
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
CORE_BIN = os.path.join(REPO_ROOT, "target", "release", "openfut-core")
|
||||||
|
CORE_DATA_DIR = os.path.join(REPO_ROOT, "openfut-core", "data")
|
||||||
|
|
||||||
|
# Ports owned by the live production test. Never bind, never speak to.
|
||||||
|
FORBIDDEN_PORTS = frozenset({8099, 8199, 18080})
|
||||||
|
|
||||||
|
GAME = "fifa17"
|
||||||
|
TS = "2026-01-01T00:00:00Z"
|
||||||
|
SELLER_PROFILE, SELLER_CLUB = "prof-seller", "club-seller"
|
||||||
|
BUYER_PROFILE, BUYER_CLUB = "prof-buyer", "club-buyer"
|
||||||
|
ITEM_ID, CARD_ID = "item-x", "def-x"
|
||||||
|
SELLER_START, BUYER_START = 1_000, 20_000
|
||||||
|
GROSS, FEE = 15_000, 750
|
||||||
|
PROCEEDS = GROSS - FEE
|
||||||
|
|
||||||
|
# `app::build` runs a content preflight that rejects any owned card whose card_id is
|
||||||
|
# not a loaded CardDefinition, so the fixture's definition ships as a one-entry
|
||||||
|
# production content pack (shape: openfut-core/src/models/card.rs::CardDefinition).
|
||||||
|
CARD_PACK = [
|
||||||
|
{
|
||||||
|
"id": CARD_ID,
|
||||||
|
"name": "Staging Fixture",
|
||||||
|
"overall": 82,
|
||||||
|
"position": "ST",
|
||||||
|
"nation": "Testland",
|
||||||
|
"league": "Staging League",
|
||||||
|
"club": "Fixture FC",
|
||||||
|
"pace": 80,
|
||||||
|
"shooting": 80,
|
||||||
|
"passing": 80,
|
||||||
|
"dribbling": 80,
|
||||||
|
"defending": 40,
|
||||||
|
"physical": 75,
|
||||||
|
"rarity": "gold",
|
||||||
|
"image_path": None,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
READY_TIMEOUT_S = 30.0
|
||||||
|
|
||||||
|
|
||||||
|
# --- output helpers -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def banner(title: str) -> None:
|
||||||
|
print()
|
||||||
|
print("=" * 72)
|
||||||
|
print(f"== {title}")
|
||||||
|
print("=" * 72)
|
||||||
|
|
||||||
|
|
||||||
|
class Checks:
|
||||||
|
"""Every expectation is printed where it happens AND tallied, so one failure never
|
||||||
|
hides the rest and the RESULT block stays a summary."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.results: list[tuple[str, bool, str]] = []
|
||||||
|
|
||||||
|
def _record(self, label: str, ok: bool, detail: str) -> bool:
|
||||||
|
self.results.append((label, ok, detail))
|
||||||
|
print(f" [{'PASS' if ok else 'FAIL'}] {label}: {detail}")
|
||||||
|
return ok
|
||||||
|
|
||||||
|
def expect(self, label: str, actual, expected) -> bool:
|
||||||
|
return self._record(
|
||||||
|
label, actual == expected, f"expected {expected!r}, got {actual!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def expect_true(self, label: str, ok: bool, detail: str) -> bool:
|
||||||
|
return self._record(label, bool(ok), detail)
|
||||||
|
|
||||||
|
def report(self) -> bool:
|
||||||
|
failed = [label for label, ok, _ in self.results if not ok]
|
||||||
|
print(f" {len(self.results) - len(failed)}/{len(self.results)} checks passed")
|
||||||
|
for label, ok, detail in self.results:
|
||||||
|
if not ok:
|
||||||
|
print(f" [FAIL] {label}: {detail}")
|
||||||
|
return not failed
|
||||||
|
|
||||||
|
|
||||||
|
# --- port / process plumbing --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def pick_free_port() -> int:
|
||||||
|
"""Bind 127.0.0.1:0, read the port back, release it. Production ports refused."""
|
||||||
|
for _ in range(64):
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
s.bind(("127.0.0.1", 0))
|
||||||
|
port = s.getsockname()[1]
|
||||||
|
if port not in FORBIDDEN_PORTS and port > 1024:
|
||||||
|
return port
|
||||||
|
raise RuntimeError("could not obtain a free non-production port")
|
||||||
|
|
||||||
|
|
||||||
|
def build_core() -> None:
|
||||||
|
print("building openfut-core (release)...")
|
||||||
|
subprocess.run(
|
||||||
|
["cargo", "build", "-p", "openfut-core", "--release"],
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def pack_path(workdir: str) -> str:
|
||||||
|
return os.path.join(workdir, "content-pack.json")
|
||||||
|
|
||||||
|
|
||||||
|
def write_content_pack(workdir: str) -> str:
|
||||||
|
path = pack_path(workdir)
|
||||||
|
with open(path, "w") as fh:
|
||||||
|
json.dump(CARD_PACK, fh, indent=2)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def launch_core(db_path: str, port: int, workdir: str, log_path: str, label: str):
|
||||||
|
"""Start the real Core binary against the throwaway DB. Core runs its migrations."""
|
||||||
|
if port in FORBIDDEN_PORTS:
|
||||||
|
raise RuntimeError(f"refusing to bind production port {port}")
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.update(
|
||||||
|
{
|
||||||
|
"LISTEN_ADDR": f"127.0.0.1:{port}",
|
||||||
|
"DATABASE_URL": f"sqlite://{db_path}",
|
||||||
|
"DATA_DIR": CORE_DATA_DIR,
|
||||||
|
"OPENFUT_CONTENT_PACKS": pack_path(workdir),
|
||||||
|
"RUST_LOG": "openfut_core=info",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
log = open(log_path, "ab", buffering=0)
|
||||||
|
log.write(f"\n---- {label} on 127.0.0.1:{port} ----\n".encode())
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[CORE_BIN],
|
||||||
|
cwd=workdir, # temp dir: no relative path can reach a real database
|
||||||
|
env=env,
|
||||||
|
stdout=log,
|
||||||
|
stderr=log,
|
||||||
|
)
|
||||||
|
proc._log = log # type: ignore[attr-defined]
|
||||||
|
return proc
|
||||||
|
|
||||||
|
|
||||||
|
def wait_ready(proc, port: int, log_path: str) -> None:
|
||||||
|
deadline = time.monotonic() + READY_TIMEOUT_S
|
||||||
|
last = ""
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if proc.poll() is not None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Core exited early with code {proc.returncode}\n{tail(log_path)}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=2)
|
||||||
|
conn.request("GET", "/health")
|
||||||
|
resp = conn.getresponse()
|
||||||
|
resp.read()
|
||||||
|
conn.close()
|
||||||
|
if resp.status == 200:
|
||||||
|
return
|
||||||
|
last = f"/health -> HTTP {resp.status}"
|
||||||
|
except OSError as exc:
|
||||||
|
last = f"{type(exc).__name__}: {exc}"
|
||||||
|
time.sleep(0.1)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Core on 127.0.0.1:{port} not ready after {READY_TIMEOUT_S:.0f}s "
|
||||||
|
f"(last: {last})\n{tail(log_path)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def stop_core(proc) -> None:
|
||||||
|
if proc is None or proc.poll() is not None:
|
||||||
|
return
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
proc.wait(timeout=10)
|
||||||
|
finally:
|
||||||
|
log = getattr(proc, "_log", None)
|
||||||
|
if log is not None:
|
||||||
|
log.close()
|
||||||
|
|
||||||
|
|
||||||
|
def tail(log_path: str, lines: int = 25) -> str:
|
||||||
|
try:
|
||||||
|
with open(log_path, "r", errors="replace") as fh:
|
||||||
|
body = fh.read().splitlines()
|
||||||
|
except OSError:
|
||||||
|
return "(no core log)"
|
||||||
|
return "--- core log tail ---\n" + "\n".join(body[-lines:])
|
||||||
|
|
||||||
|
|
||||||
|
# --- database -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def connect(db_path: str) -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(db_path, timeout=10)
|
||||||
|
conn.execute("PRAGMA busy_timeout = 10000")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def seed(db_path: str) -> None:
|
||||||
|
"""Direct-SQL fixture. Column sets match openfut-core/migrations/0001_initial.sql
|
||||||
|
(+ 0016 game_dimension's profiles.game_id).
|
||||||
|
|
||||||
|
Two profiles share one game_id, which services::profile::create_profile would
|
||||||
|
refuse -- that limit is a service rule, not a schema constraint, and the settle
|
||||||
|
route never resolves the active profile when both club ids are named explicitly.
|
||||||
|
"""
|
||||||
|
conn = connect(db_path)
|
||||||
|
try:
|
||||||
|
with conn:
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO profiles (id, username, created_at, updated_at, game_id) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?)",
|
||||||
|
[
|
||||||
|
(SELLER_PROFILE, "seller", TS, TS, GAME),
|
||||||
|
(BUYER_PROFILE, "buyer", TS, TS, GAME),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
[
|
||||||
|
(SELLER_CLUB, SELLER_PROFILE, "Seller FC", SELLER_START, TS, TS),
|
||||||
|
(BUYER_CLUB, BUYER_PROFILE, "Buyer FC", BUYER_START, TS, TS),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) "
|
||||||
|
"VALUES (?, ?, ?, 0, ?)",
|
||||||
|
(ITEM_ID, SELLER_CLUB, CARD_ID, TS),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def snapshot(db_path: str) -> dict:
|
||||||
|
conn = connect(db_path)
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
seller = cur.execute(
|
||||||
|
"SELECT coins FROM clubs WHERE id = ?", (SELLER_CLUB,)
|
||||||
|
).fetchone()[0]
|
||||||
|
buyer = cur.execute(
|
||||||
|
"SELECT coins FROM clubs WHERE id = ?", (BUYER_CLUB,)
|
||||||
|
).fetchone()[0]
|
||||||
|
owners = [
|
||||||
|
row[0]
|
||||||
|
for row in cur.execute(
|
||||||
|
"SELECT club_id FROM owned_cards WHERE id = ?", (ITEM_ID,)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
total = cur.execute("SELECT COALESCE(SUM(coins), 0) FROM clubs").fetchone()[0]
|
||||||
|
return {
|
||||||
|
"seller_coins": seller,
|
||||||
|
"buyer_coins": buyer,
|
||||||
|
"owner": owners[0] if owners else None,
|
||||||
|
"item_rows": len(owners),
|
||||||
|
"total_coins": total,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def print_snapshot(title: str, snap: dict, extra: dict | None = None) -> None:
|
||||||
|
banner(title)
|
||||||
|
print(f" seller club {SELLER_CLUB!r:>14} coins : {snap['seller_coins']:>8,}")
|
||||||
|
print(f" buyer club {BUYER_CLUB!r:>14} coins : {snap['buyer_coins']:>8,}")
|
||||||
|
print(f" owner of {ITEM_ID!r:>17} : {snap['owner']}")
|
||||||
|
print(f" rows in owned_cards for {ITEM_ID!r} : {snap['item_rows']}")
|
||||||
|
print(f" total modelled coins (SUM clubs) : {snap['total_coins']:>8,}")
|
||||||
|
for key, value in (extra or {}).items():
|
||||||
|
print(f" {key:<32} : {value}")
|
||||||
|
|
||||||
|
|
||||||
|
# --- HTTP ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def post_settle(port: int, body: dict) -> tuple[int, str]:
|
||||||
|
if port in FORBIDDEN_PORTS:
|
||||||
|
raise RuntimeError(f"refusing to POST to production port {port}")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"http://127.0.0.1:{port}/economy/settle-sale",
|
||||||
|
data=json.dumps(body).encode(),
|
||||||
|
headers={"Content-Type": "application/json", "X-OpenFUT-Game": GAME},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
return resp.status, resp.read().decode(errors="replace")
|
||||||
|
except urllib.error.HTTPError as exc: # 4xx/5xx carry the rejection body
|
||||||
|
return exc.code, exc.read().decode(errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def show_response(status: int, body: str) -> dict | None:
|
||||||
|
print(f" HTTP {status}")
|
||||||
|
try:
|
||||||
|
parsed = json.loads(body)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
print(f" body (not JSON): {body!r}")
|
||||||
|
return None
|
||||||
|
print(" body:")
|
||||||
|
for line in json.dumps(parsed, indent=2, sort_keys=True).splitlines():
|
||||||
|
print(f" {line}")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
# --- the run ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def run(port: int, db_path: str, workdir: str, log_path: str, checks: Checks) -> None:
|
||||||
|
settle_body = {
|
||||||
|
"item_id": ITEM_ID,
|
||||||
|
"gross": GROSS,
|
||||||
|
"fee": FEE,
|
||||||
|
"seller_club_id": SELLER_CLUB,
|
||||||
|
"buyer_club_id": BUYER_CLUB,
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f" content pack : {write_content_pack(workdir)} (defines {CARD_ID})")
|
||||||
|
|
||||||
|
banner("MIGRATE (real Core creates the throwaway schema)")
|
||||||
|
# Core owns its schema (sqlx migrations, embedded at compile time), so the
|
||||||
|
# fixture cannot be seeded into an empty file. Start Core once purely to
|
||||||
|
# migrate, stop it, THEN seed: the external writer and Core's WAL pool never
|
||||||
|
# coexist, which is the safest ordering.
|
||||||
|
migrator = launch_core(db_path, port, workdir, log_path, "migrate pass")
|
||||||
|
try:
|
||||||
|
wait_ready(migrator, port, log_path)
|
||||||
|
print(f" migrations applied; Core answered /health on 127.0.0.1:{port}")
|
||||||
|
finally:
|
||||||
|
stop_core(migrator)
|
||||||
|
print(" migrate pass stopped")
|
||||||
|
|
||||||
|
banner("SEED (canonical two-party fixture, direct SQL)")
|
||||||
|
seed(db_path)
|
||||||
|
print(f" {SELLER_CLUB}: {SELLER_START:,} coins, owns {ITEM_ID} ({CARD_ID})")
|
||||||
|
print(f" {BUYER_CLUB}: {BUYER_START:,} coins")
|
||||||
|
|
||||||
|
banner(f"LAUNCH Core on 127.0.0.1:{port} (db {db_path})")
|
||||||
|
server = launch_core(db_path, port, workdir, log_path, "serve pass")
|
||||||
|
try:
|
||||||
|
wait_ready(server, port, log_path)
|
||||||
|
print(f" ready: GET /health -> 200 (pid {server.pid})")
|
||||||
|
|
||||||
|
before = snapshot(db_path)
|
||||||
|
print_snapshot("BEFORE", before)
|
||||||
|
|
||||||
|
banner("PURCHASE POST /economy/settle-sale")
|
||||||
|
print(f" request: {json.dumps(settle_body)}")
|
||||||
|
status, body = post_settle(port, settle_body)
|
||||||
|
receipt = show_response(status, body)
|
||||||
|
|
||||||
|
after = snapshot(db_path)
|
||||||
|
print_snapshot(
|
||||||
|
"AFTER",
|
||||||
|
after,
|
||||||
|
{
|
||||||
|
"fee withheld and destroyed": f"{FEE:,}",
|
||||||
|
f"DUPLICATE COUNT for {ITEM_ID!r}": after["item_rows"],
|
||||||
|
"coins destroyed (before-after)": f"{before['total_coins'] - after['total_coins']:,}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
banner("EXPECTATIONS")
|
||||||
|
checks.expect("purchase HTTP status", status, 200)
|
||||||
|
checks.expect("buyer coins", after["buyer_coins"], BUYER_START - GROSS)
|
||||||
|
checks.expect("seller coins", after["seller_coins"], SELLER_START + PROCEEDS)
|
||||||
|
checks.expect("item owner is buyer club", after["owner"], BUYER_CLUB)
|
||||||
|
checks.expect("duplicate count == 1", after["item_rows"], 1)
|
||||||
|
checks.expect(
|
||||||
|
"total coins before", before["total_coins"], SELLER_START + BUYER_START
|
||||||
|
)
|
||||||
|
checks.expect(
|
||||||
|
"total coins after",
|
||||||
|
after["total_coins"],
|
||||||
|
SELLER_START + BUYER_START - FEE,
|
||||||
|
)
|
||||||
|
buyer_debit = before["buyer_coins"] - after["buyer_coins"]
|
||||||
|
seller_credit = after["seller_coins"] - before["seller_coins"]
|
||||||
|
checks.expect_true(
|
||||||
|
"conservation buyer_debit == seller_credit + fee",
|
||||||
|
buyer_debit == seller_credit + FEE,
|
||||||
|
f"{buyer_debit:,} == {seller_credit:,} + {FEE:,}",
|
||||||
|
)
|
||||||
|
if receipt is None:
|
||||||
|
checks.expect_true("receipt is JSON", False, "response body was not JSON")
|
||||||
|
else:
|
||||||
|
checks.expect("receipt.item_id", receipt.get("item_id"), ITEM_ID)
|
||||||
|
checks.expect("receipt.card_id", receipt.get("card_id"), CARD_ID)
|
||||||
|
checks.expect(
|
||||||
|
"receipt.seller_club_id", receipt.get("seller_club_id"), SELLER_CLUB
|
||||||
|
)
|
||||||
|
checks.expect(
|
||||||
|
"receipt.buyer_club_id", receipt.get("buyer_club_id"), BUYER_CLUB
|
||||||
|
)
|
||||||
|
checks.expect("receipt.gross", receipt.get("gross"), GROSS)
|
||||||
|
checks.expect("receipt.fee", receipt.get("fee"), FEE)
|
||||||
|
checks.expect("receipt.proceeds", receipt.get("proceeds"), PROCEEDS)
|
||||||
|
checks.expect(
|
||||||
|
"receipt.seller_balance",
|
||||||
|
receipt.get("seller_balance"),
|
||||||
|
after["seller_coins"],
|
||||||
|
)
|
||||||
|
checks.expect(
|
||||||
|
"receipt.buyer_balance",
|
||||||
|
receipt.get("buyer_balance"),
|
||||||
|
after["buyer_coins"],
|
||||||
|
)
|
||||||
|
checks.expect(
|
||||||
|
"receipt.squad_slots_freed", receipt.get("squad_slots_freed"), 0
|
||||||
|
)
|
||||||
|
|
||||||
|
banner("RETRY (identical request must be rejected, nothing may move)")
|
||||||
|
retry_status, retry_body = post_settle(port, settle_body)
|
||||||
|
show_response(retry_status, retry_body)
|
||||||
|
replay = snapshot(db_path)
|
||||||
|
print_snapshot("AFTER RETRY", replay)
|
||||||
|
checks.expect_true(
|
||||||
|
"retry rejected (4xx)",
|
||||||
|
400 <= retry_status < 500,
|
||||||
|
f"HTTP {retry_status}",
|
||||||
|
)
|
||||||
|
checks.expect("retry left buyer coins", replay["buyer_coins"], after["buyer_coins"])
|
||||||
|
checks.expect(
|
||||||
|
"retry left seller coins", replay["seller_coins"], after["seller_coins"]
|
||||||
|
)
|
||||||
|
checks.expect("retry left owner", replay["owner"], after["owner"])
|
||||||
|
checks.expect("retry left total coins", replay["total_coins"], after["total_coins"])
|
||||||
|
checks.expect("retry left one row", replay["item_rows"], 1)
|
||||||
|
|
||||||
|
# The identical retry above is refused by the AFFORDABILITY guard, because
|
||||||
|
# settle_sale debits before it touches ownership and the buyer no longer holds
|
||||||
|
# 15,000. That alone never exercises the ownership CAS that is the actual
|
||||||
|
# replay guard, so replay the same item at a price the buyer CAN afford: the
|
||||||
|
# only thing left to stop it is "item not owned by the named seller".
|
||||||
|
banner("REPLAY GUARD (affordable re-settle must still fail on ownership)")
|
||||||
|
cheap = dict(settle_body, gross=100, fee=5)
|
||||||
|
print(f" request: {json.dumps(cheap)}")
|
||||||
|
guard_status, guard_body = post_settle(port, cheap)
|
||||||
|
show_response(guard_status, guard_body)
|
||||||
|
guarded = snapshot(db_path)
|
||||||
|
print_snapshot("AFTER REPLAY GUARD", guarded)
|
||||||
|
checks.expect("replay guard rejects with 404", guard_status, 404)
|
||||||
|
checks.expect_true(
|
||||||
|
"replay guard cites ownership",
|
||||||
|
"not owned" in guard_body,
|
||||||
|
f"body: {guard_body}",
|
||||||
|
)
|
||||||
|
checks.expect(
|
||||||
|
"replay guard left buyer coins", guarded["buyer_coins"], after["buyer_coins"]
|
||||||
|
)
|
||||||
|
checks.expect(
|
||||||
|
"replay guard left seller coins",
|
||||||
|
guarded["seller_coins"],
|
||||||
|
after["seller_coins"],
|
||||||
|
)
|
||||||
|
checks.expect("replay guard left owner", guarded["owner"], after["owner"])
|
||||||
|
checks.expect(
|
||||||
|
"replay guard left total coins",
|
||||||
|
guarded["total_coins"],
|
||||||
|
after["total_coins"],
|
||||||
|
)
|
||||||
|
checks.expect("replay guard left one row", guarded["item_rows"], 1)
|
||||||
|
finally:
|
||||||
|
stop_core(server)
|
||||||
|
print("\n Core stopped")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
parser.add_argument(
|
||||||
|
"--keep", action="store_true", help="keep the temp dir (and its DB + core log)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-build", action="store_true", help="use target/release/openfut-core as-is"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.no_build:
|
||||||
|
build_core()
|
||||||
|
if not os.path.isfile(CORE_BIN):
|
||||||
|
print(f"core binary not found: {CORE_BIN}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
port = pick_free_port()
|
||||||
|
workdir = tempfile.mkdtemp(prefix="openfut-settlement-staging-")
|
||||||
|
db_path = os.path.join(workdir, "staging.db")
|
||||||
|
log_path = os.path.join(workdir, "core.log")
|
||||||
|
|
||||||
|
banner("SETTLEMENT STAGING HARNESS (isolated; production untouched)")
|
||||||
|
print(f" temp dir : {workdir}")
|
||||||
|
print(f" throwaway db : {db_path}")
|
||||||
|
print(f" core binary : {CORE_BIN}")
|
||||||
|
print(f" port : {port} (production {sorted(FORBIDDEN_PORTS)} never bound)")
|
||||||
|
|
||||||
|
checks = Checks()
|
||||||
|
try:
|
||||||
|
run(port, db_path, workdir, log_path, checks)
|
||||||
|
except Exception as exc: # report, then still clean up
|
||||||
|
banner("HARNESS ERROR")
|
||||||
|
print(f" {type(exc).__name__}: {exc}")
|
||||||
|
checks.expect_true("harness completed", False, f"{type(exc).__name__}: {exc}")
|
||||||
|
finally:
|
||||||
|
if args.keep:
|
||||||
|
print(f"\n --keep: leaving {workdir} in place")
|
||||||
|
else:
|
||||||
|
shutil.rmtree(workdir, ignore_errors=True)
|
||||||
|
print(f"\n removed {workdir}")
|
||||||
|
|
||||||
|
banner("RESULT")
|
||||||
|
ok = checks.report()
|
||||||
|
print()
|
||||||
|
print(f" {'ALL CHECKS PASSED' if ok else 'FAILURES PRESENT'}")
|
||||||
|
return 0 if ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+375
@@ -0,0 +1,375 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""PRE-LIVE machine differential for the seller-facing sold-row A/B.
|
||||||
|
|
||||||
|
Purpose: prove, before any operator time is spent at a real FIFA client, that the
|
||||||
|
two variants differ in EXACTLY the field under test. A contaminated A/B cannot
|
||||||
|
attribute the client's reaction to anything, so this gate runs first.
|
||||||
|
|
||||||
|
It performs ONE synthetic settlement, then re-reads every seller-facing surface
|
||||||
|
under each variant by restarting only the host (same Core, same DBs, same sale), and
|
||||||
|
diffs the payloads with explicit classification:
|
||||||
|
|
||||||
|
MISSING key present in A, absent in B
|
||||||
|
EXTRA key absent in A, present in B
|
||||||
|
TYPE_MISMATCH same key, different JSON type
|
||||||
|
VALUE_MISMATCH same key and type, different value
|
||||||
|
|
||||||
|
Two orthogonal pairs are checked:
|
||||||
|
|
||||||
|
PRIMARY bidState highest vs buyNow coinsProcessed held at 0
|
||||||
|
ORTHOGONAL coinsProcessed 0 vs 1 bidState held at highest
|
||||||
|
|
||||||
|
Plus the two counts states the live experiment needs to interpret:
|
||||||
|
|
||||||
|
S1 0 active + 1 sold
|
||||||
|
S2 1 active + 1 sold
|
||||||
|
|
||||||
|
Evidence is written per variant under docs/evidence/sold-ab-<date>/ with a sha256
|
||||||
|
of each payload, so variant A evidence can never be confused with variant B.
|
||||||
|
|
||||||
|
ISOLATION: reuses the verified staging bring-up from sold-wire-check.py, which binds
|
||||||
|
only ephemeral loopback ports, refuses every production port, and opens nothing under
|
||||||
|
/home/alex/openfut-promotion/state/.
|
||||||
|
|
||||||
|
python3 scripts/sold-ab-differential.py [--out DIR] [--keep]
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
# Reuse the proven harness helpers rather than re-deriving them. The filename has
|
||||||
|
# hyphens, so it cannot be imported normally.
|
||||||
|
_spec = importlib.util.spec_from_file_location(
|
||||||
|
"sold_wire_check", os.path.join(REPO, "scripts", "sold-wire-check.py"))
|
||||||
|
H = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(H)
|
||||||
|
|
||||||
|
SURFACES = {
|
||||||
|
"tradePile": "/ut/game/fifa17/tradePile",
|
||||||
|
"counts": "/ut/game/fifa17/tradePile/counts",
|
||||||
|
"trade_status_filtered": f"/ut/game/fifa17/trade/status?tradeIds={H.TRADE_ID}",
|
||||||
|
"trade_status_unfiltered": "/ut/game/fifa17/trade/status",
|
||||||
|
}
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(label, ok, detail=""):
|
||||||
|
results.append((label, bool(ok), detail))
|
||||||
|
print(f" [{'PASS' if ok else 'FAIL'}] {label}{(': ' + detail) if detail else ''}")
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def banner(t):
|
||||||
|
print("\n" + "=" * 74)
|
||||||
|
print(f"== {t}")
|
||||||
|
print("=" * 74)
|
||||||
|
|
||||||
|
|
||||||
|
def jtype(v):
|
||||||
|
if isinstance(v, bool):
|
||||||
|
return "bool"
|
||||||
|
if isinstance(v, int):
|
||||||
|
return "int"
|
||||||
|
if isinstance(v, float):
|
||||||
|
return "float"
|
||||||
|
if isinstance(v, str):
|
||||||
|
return "str"
|
||||||
|
if isinstance(v, list):
|
||||||
|
return "list"
|
||||||
|
if isinstance(v, dict):
|
||||||
|
return "dict"
|
||||||
|
return "null"
|
||||||
|
|
||||||
|
|
||||||
|
def classify(a, b, path=""):
|
||||||
|
"""Recursive classified diff. Returns a list of (kind, path, a, b)."""
|
||||||
|
out = []
|
||||||
|
if jtype(a) != jtype(b):
|
||||||
|
return [("TYPE_MISMATCH", path or "<root>", jtype(a), jtype(b))]
|
||||||
|
if isinstance(a, dict):
|
||||||
|
for k in a:
|
||||||
|
if k not in b:
|
||||||
|
out.append(("MISSING", f"{path}.{k}".lstrip("."), a[k], None))
|
||||||
|
for k in b:
|
||||||
|
if k not in a:
|
||||||
|
out.append(("EXTRA", f"{path}.{k}".lstrip("."), None, b[k]))
|
||||||
|
for k in a:
|
||||||
|
if k in b:
|
||||||
|
out += classify(a[k], b[k], f"{path}.{k}".lstrip("."))
|
||||||
|
return out
|
||||||
|
if isinstance(a, list):
|
||||||
|
if len(a) != len(b):
|
||||||
|
out.append(("VALUE_MISMATCH", f"{path}[len]", len(a), len(b)))
|
||||||
|
for i, (x, y) in enumerate(zip(a, b)):
|
||||||
|
out += classify(x, y, f"{path}[{i}]")
|
||||||
|
return out
|
||||||
|
if a != b:
|
||||||
|
out.append(("VALUE_MISMATCH", path or "<root>", a, b))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def capture(port):
|
||||||
|
"""Every seller-facing surface, as parsed JSON plus a sha256 of the raw bytes."""
|
||||||
|
snap = {}
|
||||||
|
for name, path in SURFACES.items():
|
||||||
|
st, body = H.req(port, "GET", path)
|
||||||
|
raw = json.dumps(body, sort_keys=True, separators=(",", ":")).encode()
|
||||||
|
snap[name] = {
|
||||||
|
"status": st,
|
||||||
|
"body": body,
|
||||||
|
"sha256": hashlib.sha256(raw).hexdigest(),
|
||||||
|
}
|
||||||
|
return snap
|
||||||
|
|
||||||
|
|
||||||
|
def sold_row(snap):
|
||||||
|
rows = snap["tradePile"]["body"].get("auctionInfo", [])
|
||||||
|
return rows[0] if rows else {}
|
||||||
|
|
||||||
|
|
||||||
|
def report_diff(label, a, b, allowed):
|
||||||
|
"""Print the classified diff and assert only `allowed` paths differ."""
|
||||||
|
diffs = classify(a, b)
|
||||||
|
print(f" classified diff ({len(diffs)} finding(s)):")
|
||||||
|
for kind, path, av, bv in diffs:
|
||||||
|
print(f" {kind:15s} {path:34s} A={av!r} B={bv!r}")
|
||||||
|
kinds = {k for k, _, _, _ in diffs}
|
||||||
|
for bad in ("MISSING", "EXTRA", "TYPE_MISMATCH"):
|
||||||
|
check(f"{label}: no {bad}", bad not in kinds,
|
||||||
|
", ".join(p for k, p, _, _ in diffs if k == bad) or "none")
|
||||||
|
paths = sorted(p for _, p, _, _ in diffs)
|
||||||
|
check(f"{label}: only {allowed} differ", paths == sorted(allowed), str(paths))
|
||||||
|
return diffs
|
||||||
|
|
||||||
|
|
||||||
|
def restart_host(state, variant, coins_processed):
|
||||||
|
"""Same Core, same DBs, same settled sale — only the host's variant changes."""
|
||||||
|
if state.get("host"):
|
||||||
|
state["host"].terminate()
|
||||||
|
state["host"].wait(timeout=20)
|
||||||
|
state["host"] = H.start_host(state["tmp"], state["host_port"], state["core_port"],
|
||||||
|
state["host_log"], variant,
|
||||||
|
coins_processed=coins_processed)
|
||||||
|
return state["host"]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--out", default=os.path.join(
|
||||||
|
REPO, "docs/evidence", f"sold-ab-{time.strftime('%Y-%m-%d')}"))
|
||||||
|
ap.add_argument("--keep", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
os.makedirs(args.out, exist_ok=True)
|
||||||
|
|
||||||
|
tmp = tempfile.mkdtemp(prefix="openfut-sold-ab-")
|
||||||
|
state = {"tmp": tmp, "host": None}
|
||||||
|
evidence = {"generated": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
|
"variants": {}, "diffs": {}, "counts_states": {}}
|
||||||
|
try:
|
||||||
|
state["core_port"] = H.free_port()
|
||||||
|
state["host_port"] = H.free_port()
|
||||||
|
state["core_log"] = os.path.join(tmp, "core.log")
|
||||||
|
state["host_log"] = os.path.join(tmp, "host.log")
|
||||||
|
banner("ISOLATED STAGING")
|
||||||
|
print(f" tmp {tmp}\n core 127.0.0.1:{state['core_port']}"
|
||||||
|
f"\n host 127.0.0.1:{state['host_port']}")
|
||||||
|
core, core_db = H.start_core(tmp, state["core_port"], state["core_log"])
|
||||||
|
state["core"] = core
|
||||||
|
state["core_db"] = core_db
|
||||||
|
mdb = os.path.join(tmp, "market.db")
|
||||||
|
|
||||||
|
# ---- one authentic listing + one real settlement -------------------
|
||||||
|
restart_host(state, "highest", "0")
|
||||||
|
con = sqlite3.connect(mdb)
|
||||||
|
con.execute(
|
||||||
|
"INSERT INTO listings (listing_id, card_id, core_item_id, wire_item_id, "
|
||||||
|
"wire_resource_id, start_price, buy_now_price, owner, state, created_at, "
|
||||||
|
"item_json, duration_secs) VALUES (?,?,?,?,?,?,?,?, 'active', ?, ?, ?)",
|
||||||
|
(H.TRADE_ID, H.CARD, H.ITEM, 100000178, 212188, H.GROSS, H.GROSS, "CAGE",
|
||||||
|
str(int(time.time() * 1000)), json.dumps({
|
||||||
|
"id": 100000178, "resourceId": 212188, "rating": 75,
|
||||||
|
"preferredPosition": "ST", "itemState": "forSale",
|
||||||
|
"untradeable": False, "assetId": 212188}), 3600))
|
||||||
|
con.commit(); con.close()
|
||||||
|
banner(f"SETTLE — synthetic Buyer B buys at {H.GROSS}")
|
||||||
|
out = subprocess.run(
|
||||||
|
[os.path.join(REPO, "target/release/staging_sell"),
|
||||||
|
"--market-db", mdb, "--core-url", f"http://127.0.0.1:{state['core_port']}",
|
||||||
|
"--trade-id", H.TRADE_ID, "--item", H.ITEM,
|
||||||
|
"--seller", H.SELLER_CLUB, "--buyer", H.BUYER_CLUB,
|
||||||
|
"--gross", str(H.GROSS)],
|
||||||
|
capture_output=True, text=True, timeout=120)
|
||||||
|
print(" " + "\n ".join((out.stdout + out.stderr).strip().splitlines()))
|
||||||
|
check("settlement succeeded", out.returncode == 0)
|
||||||
|
own, n, coins = H.owner_of(core_db, H.ITEM)
|
||||||
|
fee = H.GROSS * 5 // 100
|
||||||
|
check("ownership -> buyer", own == H.BUYER_CLUB, str(own))
|
||||||
|
check("exactly one authoritative instance", n == 1, str(n))
|
||||||
|
check("seller credited net", coins[H.SELLER_CLUB] == 1000 + H.GROSS - fee,
|
||||||
|
str(coins[H.SELLER_CLUB]))
|
||||||
|
check("buyer debited gross", coins[H.BUYER_CLUB] == 20000 - H.GROSS,
|
||||||
|
str(coins[H.BUYER_CLUB]))
|
||||||
|
economy = {"seller": coins[H.SELLER_CLUB], "buyer": coins[H.BUYER_CLUB],
|
||||||
|
"fee": fee, "owner": own, "instances": n}
|
||||||
|
evidence["settlement"] = economy
|
||||||
|
|
||||||
|
# ---- PRIMARY A/B: bidState only ------------------------------------
|
||||||
|
banner("PRIMARY A/B — bidState highest vs buyNow (coinsProcessed held at 0)")
|
||||||
|
snaps = {}
|
||||||
|
for name, variant in (("A_highest", "highest"), ("B_buyNow", "buyNow")):
|
||||||
|
restart_host(state, variant, "0")
|
||||||
|
snaps[name] = capture(state["host_port"])
|
||||||
|
evidence["variants"][name] = {
|
||||||
|
"env": {"OPENFUT_FIFA17_SOLD_EXPERIMENT": variant,
|
||||||
|
"OPENFUT_FIFA17_SOLD_COINS_PROCESSED": "0"},
|
||||||
|
"surfaces": snaps[name],
|
||||||
|
}
|
||||||
|
row = sold_row(snaps[name])
|
||||||
|
print(f" {name}: tradeState={row.get('tradeState')} "
|
||||||
|
f"bidState={row.get('bidState')} currentBid={row.get('currentBid')} "
|
||||||
|
f"coinsProcessed={row.get('coinsProcessed')} atoms={len(row)}")
|
||||||
|
# Every variant must independently be a well-formed sold row.
|
||||||
|
check(f"{name}: tradeState closed", row.get("tradeState") == "closed")
|
||||||
|
check(f"{name}: twelve atoms", len(row) == 12, str(len(row)))
|
||||||
|
check(f"{name}: currentBid == gross", row.get("currentBid") == H.GROSS)
|
||||||
|
check(f"{name}: expires 0", row.get("expires") == 0)
|
||||||
|
check(f"{name}: counts.sold == 1",
|
||||||
|
snaps[name]["counts"]["body"].get("sold") == 1)
|
||||||
|
evidence["diffs"]["primary_bidstate"] = [
|
||||||
|
{"kind": k, "path": p, "a": av, "b": bv}
|
||||||
|
for k, p, av, bv in report_diff(
|
||||||
|
"PRIMARY", snaps["A_highest"]["tradePile"]["body"],
|
||||||
|
snaps["B_buyNow"]["tradePile"]["body"],
|
||||||
|
["auctionInfo[0].bidState"])
|
||||||
|
]
|
||||||
|
# /trade/status must move in lockstep, or the screen would contradict itself.
|
||||||
|
report_diff("PRIMARY /trade/status",
|
||||||
|
snaps["A_highest"]["trade_status_filtered"]["body"],
|
||||||
|
snaps["B_buyNow"]["trade_status_filtered"]["body"],
|
||||||
|
["auctionInfo[0].bidState"])
|
||||||
|
report_diff("PRIMARY counts",
|
||||||
|
snaps["A_highest"]["counts"]["body"],
|
||||||
|
snaps["B_buyNow"]["counts"]["body"], [])
|
||||||
|
|
||||||
|
# ---- ORTHOGONAL A/B: coinsProcessed only ---------------------------
|
||||||
|
banner("ORTHOGONAL A/B — coinsProcessed 0 vs 1 (bidState held at highest)")
|
||||||
|
for name, cp in (("C_cp0", "0"), ("D_cp1", "1")):
|
||||||
|
restart_host(state, "highest", cp)
|
||||||
|
snaps[name] = capture(state["host_port"])
|
||||||
|
evidence["variants"][name] = {
|
||||||
|
"env": {"OPENFUT_FIFA17_SOLD_EXPERIMENT": "highest",
|
||||||
|
"OPENFUT_FIFA17_SOLD_COINS_PROCESSED": cp},
|
||||||
|
"surfaces": snaps[name],
|
||||||
|
}
|
||||||
|
row = sold_row(snaps[name])
|
||||||
|
print(f" {name}: bidState={row.get('bidState')} "
|
||||||
|
f"coinsProcessed={row.get('coinsProcessed')}")
|
||||||
|
evidence["diffs"]["orthogonal_coinsprocessed"] = [
|
||||||
|
{"kind": k, "path": p, "a": av, "b": bv}
|
||||||
|
for k, p, av, bv in report_diff(
|
||||||
|
"ORTHOGONAL", snaps["C_cp0"]["tradePile"]["body"],
|
||||||
|
snaps["D_cp1"]["tradePile"]["body"],
|
||||||
|
["auctionInfo[0].coinsProcessed"])
|
||||||
|
]
|
||||||
|
check("C_cp0 is identical to A_highest (same env => same bytes)",
|
||||||
|
snaps["C_cp0"]["tradePile"]["sha256"]
|
||||||
|
== snaps["A_highest"]["tradePile"]["sha256"],
|
||||||
|
"reproducible")
|
||||||
|
|
||||||
|
# ---- counts states the live run must interpret ----------------------
|
||||||
|
banner("COUNTS STATES")
|
||||||
|
restart_host(state, "highest", "0")
|
||||||
|
s1 = H.req(state["host_port"], "GET", SURFACES["counts"])[1]
|
||||||
|
print(f" S1 0 active + 1 sold -> {json.dumps(s1)}")
|
||||||
|
con = sqlite3.connect(mdb)
|
||||||
|
con.execute(
|
||||||
|
"INSERT INTO listings (listing_id, card_id, core_item_id, wire_item_id, "
|
||||||
|
"wire_resource_id, start_price, buy_now_price, owner, state, created_at, "
|
||||||
|
"item_json, duration_secs) VALUES (?,?,?,?,?,?,?,?, 'active', ?, ?, ?)",
|
||||||
|
("900500999", H.CARD, "core-second-x", 100000179, 212188, 300, 400, "CAGE",
|
||||||
|
str(int(time.time() * 1000)), json.dumps({
|
||||||
|
"id": 100000179, "resourceId": 212188, "rating": 75,
|
||||||
|
"preferredPosition": "ST", "itemState": "forSale",
|
||||||
|
"untradeable": False, "assetId": 212188}), 3600))
|
||||||
|
con.commit(); con.close()
|
||||||
|
s2_active = H.req(state["host_port"], "GET", SURFACES["counts"])[1]
|
||||||
|
print(f" S2 1 active + 1 sold -> {json.dumps(s2_active)} (count mode: active)")
|
||||||
|
restart_host(state, "highest", "0")
|
||||||
|
os.environ["_"] = "_" # no-op; count mode is a host env, set below
|
||||||
|
state["host"].terminate(); state["host"].wait(timeout=20)
|
||||||
|
state["host"] = H.start_host(tmp, state["host_port"], state["core_port"],
|
||||||
|
state["host_log"], "highest",
|
||||||
|
coins_processed="0",
|
||||||
|
count_mode="active_plus_sold")
|
||||||
|
s2_member = H.req(state["host_port"], "GET", SURFACES["counts"])[1]
|
||||||
|
print(f" S2 1 active + 1 sold -> {json.dumps(s2_member)} (count mode: membership)")
|
||||||
|
evidence["counts_states"] = {
|
||||||
|
"S1_zero_active_one_sold": s1,
|
||||||
|
"S2_one_active_one_sold_count_active": s2_active,
|
||||||
|
"S2_one_active_one_sold_count_membership": s2_member,
|
||||||
|
}
|
||||||
|
check("S1 reports sold 1 / selling 0", s1.get("sold") == 1 and s1.get("selling") == 0)
|
||||||
|
check("S2 reports sold 1 / selling 1",
|
||||||
|
s2_active.get("sold") == 1 and s2_active.get("selling") == 1)
|
||||||
|
check("S2 count differs by mode (1 vs 2) — the open question for the client",
|
||||||
|
s2_active.get("count") == 1 and s2_member.get("count") == 2,
|
||||||
|
f"{s2_active.get('count')} vs {s2_member.get('count')}")
|
||||||
|
|
||||||
|
# ---- emit evidence --------------------------------------------------
|
||||||
|
banner("EVIDENCE")
|
||||||
|
for name, v in evidence["variants"].items():
|
||||||
|
path = os.path.join(args.out, f"{name}.json")
|
||||||
|
with open(path, "w") as f:
|
||||||
|
json.dump(v, f, indent=2, sort_keys=True)
|
||||||
|
print(f" {os.path.relpath(path, REPO)} "
|
||||||
|
f"tradePile sha256={v['surfaces']['tradePile']['sha256'][:16]}…")
|
||||||
|
idx = os.path.join(args.out, "differential.json")
|
||||||
|
with open(idx, "w") as f:
|
||||||
|
json.dump(evidence, f, indent=2, sort_keys=True)
|
||||||
|
print(f" {os.path.relpath(idx, REPO)}")
|
||||||
|
|
||||||
|
banner("PRODUCTION UNTOUCHED")
|
||||||
|
alive = subprocess.run(["ps", "-o", "pid=", "-p", "3631953"],
|
||||||
|
capture_output=True, text=True).stdout.strip()
|
||||||
|
check("prod-host pid 3631953 alive", alive == "3631953", alive or "gone")
|
||||||
|
|
||||||
|
banner("RESULT")
|
||||||
|
passed = sum(1 for _, ok, _ in results if ok)
|
||||||
|
failed = [l for l, ok, _ in results if not ok]
|
||||||
|
print(f" {passed}/{len(results)} checks passed")
|
||||||
|
if failed:
|
||||||
|
print(" FAILED: " + "; ".join(failed))
|
||||||
|
print("\n A/B IS CONTAMINATED — do NOT run the live experiment")
|
||||||
|
return 1
|
||||||
|
print("\n A/B IS CLEAN — one-field attribution proven; ready for the live client")
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
for k in ("host", "core"):
|
||||||
|
p = state.get(k)
|
||||||
|
if p:
|
||||||
|
try:
|
||||||
|
p.terminate(); p.wait(timeout=15)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
p.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if args.keep:
|
||||||
|
print(f"\n kept {tmp}")
|
||||||
|
else:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+218
@@ -0,0 +1,218 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Switch the FIFA client's Blaze ports between PRODUCTION and STAGING, reversibly.
|
||||||
|
|
||||||
|
The staging sold experiment needs the client to talk to the staging Blaze, which is
|
||||||
|
what advertises the staging UTAS base. Two files are involved and the distinction
|
||||||
|
matters, because getting it wrong silently runs the whole experiment against
|
||||||
|
production:
|
||||||
|
|
||||||
|
* `openfut.cfg` in the game directory is what the injected hook reads at connect
|
||||||
|
time -- but it is a DERIVED artifact.
|
||||||
|
* `~/.config/openfut-launcher/config.json` is the AUTHORITATIVE source. The
|
||||||
|
launcher reconciles openfut.cfg from it, fail-closed, immediately before every
|
||||||
|
launch. An edit to openfut.cfg alone is therefore destroyed by the next launch:
|
||||||
|
observed live, with openfut.cfg's mtime 2s before the FIFA process start and the
|
||||||
|
hook logging `blaze_redir=42127 blaze_main=42130` after it had been set to
|
||||||
|
staging. So this script owns BOTH, source first.
|
||||||
|
|
||||||
|
Safety properties, in order of importance:
|
||||||
|
|
||||||
|
* Production values are recorded to a sidecar next to each file BEFORE the first
|
||||||
|
edit, and `restore` reads those sidecars rather than assuming what production
|
||||||
|
was. Missing sidecar means restore refuses.
|
||||||
|
* `restore` is idempotent and safe at any time, including after a crash.
|
||||||
|
* Every operation re-reads both files afterwards and prints them, so the result is
|
||||||
|
verified rather than assumed.
|
||||||
|
* It refuses to edit while FIFA is running (the hook reads openfut.cfg at connect
|
||||||
|
time) AND while the launcher is running (the launcher holds its config in
|
||||||
|
memory and would write the stale values straight back over ours).
|
||||||
|
* Only known keys are rewritten. Unknown lines and unrelated JSON keys are passed
|
||||||
|
through untouched, and a missing key is an error rather than a silent append.
|
||||||
|
|
||||||
|
python3 scripts/sold-client-ports.py show
|
||||||
|
python3 scripts/sold-client-ports.py staging # 42327 / 42330
|
||||||
|
python3 scripts/sold-client-ports.py restore # back to the recorded values
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
CLIENT = "alex@10.10.0.105"
|
||||||
|
CFG = "/mnt/games/FIFA 17/openfut.cfg"
|
||||||
|
SIDECAR = "/mnt/games/FIFA 17/openfut.cfg.openfut-prod-ports"
|
||||||
|
KEYS = ("blaze_redirector_port", "blaze_main_port")
|
||||||
|
STAGING = {"blaze_redirector_port": "42327", "blaze_main_port": "42330"}
|
||||||
|
# The launcher is the real owner; openfut.cfg is regenerated from these.
|
||||||
|
LAUNCHER_CFG = "/home/alex/.config/openfut-launcher/config.json"
|
||||||
|
LAUNCHER_SIDECAR = "/home/alex/.config/openfut-launcher/config.json.openfut-prod-ports"
|
||||||
|
LAUNCHER_KEY = {"blaze_redirector_port": "openfut_blaze_redirector_port",
|
||||||
|
"blaze_main_port": "openfut_blaze_main_port"}
|
||||||
|
|
||||||
|
|
||||||
|
def ssh(script):
|
||||||
|
r = subprocess.run(["ssh", "-o", "BatchMode=yes", CLIENT, script],
|
||||||
|
capture_output=True, text=True, timeout=60)
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise SystemExit(f"ssh failed ({r.returncode}): {r.stderr.strip()}")
|
||||||
|
return r.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def read_cfg():
|
||||||
|
return ssh(f'cat "{CFG}"')
|
||||||
|
|
||||||
|
|
||||||
|
def parse(text):
|
||||||
|
out = {}
|
||||||
|
for line in text.splitlines():
|
||||||
|
if "=" in line and not line.strip().startswith("#"):
|
||||||
|
k, _, v = line.partition("=")
|
||||||
|
out[k.strip()] = v.strip()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def fifa_running():
|
||||||
|
"""True if a real FIFA 17 process exists on the client.
|
||||||
|
|
||||||
|
Matches /proc/<pid>/comm exactly rather than `pgrep -f FIFA17.exe`: the pattern
|
||||||
|
form self-matched the remote shell running it (the SSH command line contains the
|
||||||
|
literal string), so the guard was permanently stuck ON and could never report
|
||||||
|
"not running". comm is the executable name, so the shell reads as zsh/bash and
|
||||||
|
only a genuine FIFA process matches. Still fail-closed: any read error or
|
||||||
|
unexpected output is treated as "running".
|
||||||
|
"""
|
||||||
|
out = ssh(
|
||||||
|
"for d in /proc/[0-9]*; do "
|
||||||
|
"[ -r \"$d/comm\" ] && [ \"$(cat $d/comm 2>/dev/null)\" = FIFA17.exe ] "
|
||||||
|
"&& echo ${d#/proc/}; done || true"
|
||||||
|
).strip()
|
||||||
|
return bool(out)
|
||||||
|
|
||||||
|
|
||||||
|
def launcher_running():
|
||||||
|
"""True if openfut-launcher is up. Its `comm` is truncated by the kernel to 15
|
||||||
|
chars ("openfut-launche"), which has bitten this project before, so match the
|
||||||
|
truncated form rather than the full name."""
|
||||||
|
out = ssh(
|
||||||
|
"for d in /proc/[0-9]*; do c=$(cat $d/comm 2>/dev/null); "
|
||||||
|
"case \"$c\" in openfut-launche*) echo ${d#/proc/};; esac; done || true"
|
||||||
|
).strip()
|
||||||
|
return bool(out)
|
||||||
|
|
||||||
|
|
||||||
|
def read_launcher():
|
||||||
|
return ssh(f'cat "{LAUNCHER_CFG}"')
|
||||||
|
|
||||||
|
|
||||||
|
def launcher_ports(text):
|
||||||
|
d = json.loads(text)
|
||||||
|
return {k: str(d.get(LAUNCHER_KEY[k])) for k in KEYS}
|
||||||
|
|
||||||
|
|
||||||
|
def write_launcher(values):
|
||||||
|
"""Rewrite only the two port keys, preserving every other setting and the file's
|
||||||
|
formatting conventions, then verify by re-reading."""
|
||||||
|
text = read_launcher()
|
||||||
|
d = json.loads(text)
|
||||||
|
for k in KEYS:
|
||||||
|
if LAUNCHER_KEY[k] not in d:
|
||||||
|
raise SystemExit(
|
||||||
|
f"key {LAUNCHER_KEY[k]!r} absent from {LAUNCHER_CFG}; refusing to guess")
|
||||||
|
existing = ssh(f'cat "{LAUNCHER_SIDECAR}" 2>/dev/null || true').strip()
|
||||||
|
if not existing:
|
||||||
|
rec = "\n".join(f"{k}={d[LAUNCHER_KEY[k]]}" for k in KEYS)
|
||||||
|
ssh(f'cat > "{LAUNCHER_SIDECAR}" <<\'EOF\'\n{rec}\nEOF')
|
||||||
|
print(f"recorded launcher production ports to {LAUNCHER_SIDECAR}:\n{rec}")
|
||||||
|
for k, v in values.items():
|
||||||
|
d[LAUNCHER_KEY[k]] = int(v)
|
||||||
|
body = json.dumps(d, indent=2, sort_keys=True)
|
||||||
|
ssh(f'cat > "{LAUNCHER_CFG}" <<\'EOF\'\n{body}\nEOF')
|
||||||
|
after = launcher_ports(read_launcher())
|
||||||
|
for k, v in values.items():
|
||||||
|
if after.get(k) != str(v):
|
||||||
|
raise SystemExit(
|
||||||
|
f"VERIFY FAILED in launcher config: {k} is {after.get(k)!r}, want {v!r}")
|
||||||
|
print(f"--- launcher config now: {after}")
|
||||||
|
|
||||||
|
|
||||||
|
def show():
|
||||||
|
text = read_cfg()
|
||||||
|
print(f"--- {CFG}")
|
||||||
|
print(text.rstrip())
|
||||||
|
cur = parse(text)
|
||||||
|
print("--- blaze ports:", {k: cur.get(k) for k in KEYS})
|
||||||
|
side = ssh(f'cat "{SIDECAR}" 2>/dev/null || true').strip()
|
||||||
|
print("--- recorded production ports:", side or "(none recorded yet)")
|
||||||
|
print(f"--- {LAUNCHER_CFG} (authoritative)")
|
||||||
|
print("--- launcher blaze ports:", launcher_ports(read_launcher()))
|
||||||
|
lside = ssh(f'cat "{LAUNCHER_SIDECAR}" 2>/dev/null || true').strip()
|
||||||
|
print("--- recorded launcher production ports:", lside or "(none recorded yet)")
|
||||||
|
print("--- FIFA client running:", "YES" if fifa_running() else "no")
|
||||||
|
print("--- launcher running:", "YES" if launcher_running() else "no")
|
||||||
|
return cur
|
||||||
|
|
||||||
|
|
||||||
|
def write_ports(values, label):
|
||||||
|
if fifa_running():
|
||||||
|
raise SystemExit(
|
||||||
|
"REFUSING to edit while a FIFA client is running.\n"
|
||||||
|
"The hook reads openfut.cfg at connect time; exit FIFA first."
|
||||||
|
)
|
||||||
|
if launcher_running():
|
||||||
|
raise SystemExit(
|
||||||
|
"REFUSING to edit while openfut-launcher is running.\n"
|
||||||
|
"The launcher holds its config in memory and reconciles openfut.cfg from\n"
|
||||||
|
"it fail-closed before every launch, so it would write the production\n"
|
||||||
|
"ports straight back over ours. Quit the launcher first."
|
||||||
|
)
|
||||||
|
# Source of truth first, then the derived file.
|
||||||
|
write_launcher(values)
|
||||||
|
text = read_cfg()
|
||||||
|
cur = parse(text)
|
||||||
|
for k in KEYS:
|
||||||
|
if k not in cur:
|
||||||
|
raise SystemExit(f"key {k!r} is absent from {CFG}; refusing to guess it")
|
||||||
|
# Record production values once, before the first mutation, so restore never
|
||||||
|
# has to assume what they were.
|
||||||
|
existing_sidecar = ssh(f'cat "{SIDECAR}" 2>/dev/null || true').strip()
|
||||||
|
if not existing_sidecar and label == "staging":
|
||||||
|
rec = "\n".join(f"{k}={cur[k]}" for k in KEYS)
|
||||||
|
ssh(f'cat > "{SIDECAR}" <<\'EOF\'\n{rec}\nEOF')
|
||||||
|
print(f"recorded production ports to {SIDECAR}:\n{rec}")
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
k = line.partition("=")[0].strip()
|
||||||
|
out.append(f"{k}={values[k]}" if k in values else line)
|
||||||
|
body = "\n".join(out) + "\n"
|
||||||
|
ssh(f'cat > "{CFG}" <<\'EOF\'\n{body.rstrip()}\nEOF')
|
||||||
|
after = parse(read_cfg())
|
||||||
|
for k, v in values.items():
|
||||||
|
if after.get(k) != v:
|
||||||
|
raise SystemExit(f"VERIFY FAILED: {k} is {after.get(k)!r}, expected {v!r}")
|
||||||
|
print(f"--- now pointing at {label}")
|
||||||
|
print(read_cfg().rstrip())
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) != 2 or sys.argv[1] not in ("show", "staging", "restore"):
|
||||||
|
print(__doc__)
|
||||||
|
return 2
|
||||||
|
cmd = sys.argv[1]
|
||||||
|
if cmd == "show":
|
||||||
|
show()
|
||||||
|
return 0
|
||||||
|
if cmd == "staging":
|
||||||
|
write_ports(STAGING, "staging")
|
||||||
|
return 0
|
||||||
|
side = ssh(f'cat "{SIDECAR}" 2>/dev/null || true').strip()
|
||||||
|
if not side:
|
||||||
|
raise SystemExit(
|
||||||
|
f"no recorded production ports at {SIDECAR}; refusing to guess.\n"
|
||||||
|
"Set them by hand and verify with `show`."
|
||||||
|
)
|
||||||
|
write_ports(parse(side), "production (restored)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+241
@@ -0,0 +1,241 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""ONE-COMMAND live capture for the seller-facing sold-row A/B.
|
||||||
|
|
||||||
|
Run while FIFA 17 sits on the Transfer List against the STAGING stack. Captures,
|
||||||
|
labels and cross-checks everything at once, so a variant can never be half-recorded
|
||||||
|
or mixed with the other:
|
||||||
|
|
||||||
|
1. staging wire surfaces (/tradePile, /tradePile/counts, /trade/status);
|
||||||
|
2. the client's OWN auction record, decoded read-only out of /proc/<pid>/mem --
|
||||||
|
STATE, YOURBID, COINS_AWARDED, MIN_CREDITS, IS_GLOW, INBOX, CARD_OFFERSTATE;
|
||||||
|
3. the staging host's route-log DELTA since the previous capture, which is how a
|
||||||
|
client-issued `DELETE .../trade/sold` is OBSERVED rather than assumed;
|
||||||
|
4. a WIRE-vs-MEMORY cross-check of every shared field, plus the native IS_GLOW /
|
||||||
|
INBOX formulas recomputed from the wire.
|
||||||
|
|
||||||
|
Point 4 is the discipline that matters. It validates the observation mechanism
|
||||||
|
against a known-positive in the same run: if the wire says `bidState: "highest"` and
|
||||||
|
the client's memory decodes `2(highest)`, the probe is demonstrably reading the right
|
||||||
|
struct THIS time. Earlier sessions were misled twice by unvalidated negatives -- a
|
||||||
|
sampler bug that printed "countdown NO", and empty auction containers read while the
|
||||||
|
Transfer List was not bound. An empty read is not an empty pile.
|
||||||
|
|
||||||
|
python3 scripts/sold-live-capture.py --variant A_highest --label pre-clear
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import http.client
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
STAGING_DIR = "/home/alex/openfut-sold-staging"
|
||||||
|
HOST_PORT = 8299
|
||||||
|
CLIENT = "alex@10.10.0.105"
|
||||||
|
PROBE = "/tmp/auction_record_probe.py"
|
||||||
|
CURSOR = os.path.join(STAGING_DIR, ".capture-cursor")
|
||||||
|
FORBIDDEN = {8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216, 8080, 8081, 8094}
|
||||||
|
|
||||||
|
SURFACES = {
|
||||||
|
"tradePile": "/ut/game/fifa17/tradePile",
|
||||||
|
"counts": "/ut/game/fifa17/tradePile/counts",
|
||||||
|
"trade_status_unfiltered": "/ut/game/fifa17/trade/status",
|
||||||
|
}
|
||||||
|
TRADE_STATE = {"active": 1, "inactive": 2, "expired": 3, "closed": 4}
|
||||||
|
BID_STATE = {"none": 0, "outbid": 1, "highest": 2, "buyNow": 3}
|
||||||
|
ITEM_STATE = {"invalid": 0, "free": 1, "forSale": 5, "offered": 6}
|
||||||
|
|
||||||
|
|
||||||
|
def get(path):
|
||||||
|
assert HOST_PORT not in FORBIDDEN, "refusing to contact a production port"
|
||||||
|
c = http.client.HTTPConnection("127.0.0.1", HOST_PORT, timeout=20)
|
||||||
|
c.request("GET", path, headers={"X-OpenFUT-Game": "fifa17"})
|
||||||
|
r = c.getresponse()
|
||||||
|
raw = r.read()
|
||||||
|
c.close()
|
||||||
|
body = json.loads(raw)
|
||||||
|
sha = hashlib.sha256(
|
||||||
|
json.dumps(body, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||||
|
return {"status": r.status, "body": body, "sha256": sha}
|
||||||
|
|
||||||
|
|
||||||
|
def probe_client():
|
||||||
|
"""Decode the client's live auction records. Never fabricates: no client, or an
|
||||||
|
unbound container, is reported as-is."""
|
||||||
|
r = subprocess.run(["ssh", "-o", "BatchMode=yes", CLIENT,
|
||||||
|
f"cd /tmp && python3 {PROBE} 2>&1"],
|
||||||
|
capture_output=True, text=True, timeout=120)
|
||||||
|
text = r.stdout + r.stderr
|
||||||
|
rows = []
|
||||||
|
for m in re.finditer(
|
||||||
|
r"\[\d+\]\s+rec=(\S+)\s+tradeId=(\d+)\s+state=(-?\d+)\((\w+)\)\s+"
|
||||||
|
r"bid=(-?\d+)\((\w+)\)\s+buyNow=(\d+)\s+start=(\d+)\s+cur=(\d+)\s+"
|
||||||
|
r"expires=(-?\d+)\s+coins=(\d+)\s+glow=(\d+)\s+inbox=(\d+)\s+watch=(\d+)"
|
||||||
|
r"(?:.*?\n\s+item=\S+\s+CARD_ID=(\d+)\s+cardType=(\d+)\s+tradeable=(\d+)\s+"
|
||||||
|
r"itemState=(-?\d+)\((\S+?)\)\s+rating=(\d+))?",
|
||||||
|
text, re.S):
|
||||||
|
g = m.groups()
|
||||||
|
rows.append({
|
||||||
|
"record": g[0], "tradeId": int(g[1]),
|
||||||
|
"STATE_tradeState": f"{g[2]}({g[3]})",
|
||||||
|
"YOURBID_bidState": f"{g[4]}({g[5]})",
|
||||||
|
"MAX_CREDITS_buyNowPrice": int(g[6]),
|
||||||
|
"RESERVEDPRICE_startingBid": int(g[7]),
|
||||||
|
"MIN_CREDITS_currentBid": int(g[8]),
|
||||||
|
"TIME_REMAINING_expires": int(g[9]),
|
||||||
|
"COINS_AWARDED_coinsProcessed": int(g[10]),
|
||||||
|
"IS_GLOW": int(g[11]),
|
||||||
|
"INBOX": int(g[12]),
|
||||||
|
"IS_WATCHED": int(g[13]),
|
||||||
|
"CARD_ID": int(g[14]) if g[14] else None,
|
||||||
|
"CARD_TYPE": int(g[15]) if g[15] else None,
|
||||||
|
"tradeable": int(g[16]) if g[16] else None,
|
||||||
|
"CARD_OFFERSTATE_itemState": f"{g[17]}({g[18]})" if g[17] else None,
|
||||||
|
"rating": int(g[19]) if g[19] else None,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"client_running": "not running" not in text,
|
||||||
|
"slide_control": "MATCH" if "FNV control=MATCH" in text else "UNVERIFIED",
|
||||||
|
"rows": rows,
|
||||||
|
"raw": text.strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def route_delta():
|
||||||
|
"""Staging host log lines since the previous capture: how a client-issued DELETE
|
||||||
|
is observed rather than inferred."""
|
||||||
|
log = os.path.join(STAGING_DIR, "logs", "utas-host.log")
|
||||||
|
if not os.path.exists(log):
|
||||||
|
return {"available": False, "lines": []}
|
||||||
|
start = 0
|
||||||
|
if os.path.exists(CURSOR):
|
||||||
|
try:
|
||||||
|
start = int(open(CURSOR).read().strip())
|
||||||
|
except Exception:
|
||||||
|
start = 0
|
||||||
|
lines = open(log, errors="replace").read().splitlines()
|
||||||
|
with open(CURSOR, "w") as f:
|
||||||
|
f.write(str(len(lines)))
|
||||||
|
new = lines[start:]
|
||||||
|
return {
|
||||||
|
"available": True, "from_line": start, "to_line": len(lines), "lines": new,
|
||||||
|
"requests": [l for l in new if "route=" in l],
|
||||||
|
"clear_or_delete": [l for l in new
|
||||||
|
if "clear-sold" in l or "market-cancel" in l],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def cross_check(wire_row, mem_rows):
|
||||||
|
out = []
|
||||||
|
if not wire_row:
|
||||||
|
return [{"field": "<no wire row>", "ok": False,
|
||||||
|
"note": "nothing on the wire to compare against"}]
|
||||||
|
mem = next((m for m in mem_rows if m["tradeId"] == wire_row.get("tradeId")), None)
|
||||||
|
if mem is None:
|
||||||
|
return [{"field": "<no memory row>", "ok": False,
|
||||||
|
"note": "client holds no record for this tradeId -- it is probably not "
|
||||||
|
"on the Transfer List screen. An empty container is NOT an "
|
||||||
|
"empty pile; re-navigate and re-capture."}]
|
||||||
|
|
||||||
|
def cmp(field, wire_val, mem_val, expect=None):
|
||||||
|
ok = (mem_val == expect) if expect is not None else (wire_val == mem_val)
|
||||||
|
out.append({"field": field, "wire": wire_val, "memory": mem_val, "ok": ok})
|
||||||
|
|
||||||
|
ts, bs = wire_row.get("tradeState"), wire_row.get("bidState")
|
||||||
|
cmp("tradeState/STATE", ts, mem["STATE_tradeState"],
|
||||||
|
f"{TRADE_STATE.get(ts)}({ts})")
|
||||||
|
cmp("bidState/YOURBID", bs, mem["YOURBID_bidState"], f"{BID_STATE.get(bs)}({bs})")
|
||||||
|
cmp("currentBid/MIN_CREDITS", wire_row.get("currentBid"), mem["MIN_CREDITS_currentBid"])
|
||||||
|
cmp("buyNowPrice/MAX_CREDITS", wire_row.get("buyNowPrice"), mem["MAX_CREDITS_buyNowPrice"])
|
||||||
|
cmp("startingBid/RESERVEDPRICE", wire_row.get("startingBid"), mem["RESERVEDPRICE_startingBid"])
|
||||||
|
cmp("expires/TIME_REMAINING", wire_row.get("expires"), mem["TIME_REMAINING_expires"])
|
||||||
|
cmp("coinsProcessed/COINS_AWARDED", wire_row.get("coinsProcessed"),
|
||||||
|
mem["COINS_AWARDED_coinsProcessed"])
|
||||||
|
istate = wire_row.get("itemData", {}).get("itemState")
|
||||||
|
cmp("itemState/CARD_OFFERSTATE", istate, mem["CARD_OFFERSTATE_itemState"],
|
||||||
|
f"{ITEM_STATE.get(istate)}({istate})" if istate else None)
|
||||||
|
exp_glow = int(bs != "none") if ts == "closed" else int(bs in ("outbid", "buyNow"))
|
||||||
|
exp_inbox = int(bs in ("highest", "buyNow"))
|
||||||
|
out.append({"field": "IS_GLOW (native formula)", "expected": exp_glow,
|
||||||
|
"memory": mem["IS_GLOW"], "ok": mem["IS_GLOW"] == exp_glow})
|
||||||
|
out.append({"field": "INBOX (native formula)", "expected": exp_inbox,
|
||||||
|
"memory": mem["INBOX"], "ok": mem["INBOX"] == exp_inbox})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--variant", required=True, help="A_highest | B_buyNow | C_cp0 | D_cp1")
|
||||||
|
ap.add_argument("--label", default="capture", help="pre-clear | post-clear | reentry")
|
||||||
|
ap.add_argument("--out", default=os.path.join(
|
||||||
|
REPO, "docs/evidence", f"sold-ab-{time.strftime('%Y-%m-%d')}"))
|
||||||
|
args = ap.parse_args()
|
||||||
|
os.makedirs(args.out, exist_ok=True)
|
||||||
|
|
||||||
|
print("=" * 74)
|
||||||
|
print(f"== LIVE CAPTURE variant={args.variant} label={args.label}")
|
||||||
|
print("=" * 74)
|
||||||
|
banner = ""
|
||||||
|
hlog = os.path.join(STAGING_DIR, "logs", "utas-host.log")
|
||||||
|
if os.path.exists(hlog):
|
||||||
|
for l in open(hlog, errors="replace"):
|
||||||
|
if "sold-experiment" in l:
|
||||||
|
banner = l.strip()
|
||||||
|
print(f" host banner : {banner or '(none)'}")
|
||||||
|
|
||||||
|
surfaces = {n: get(p) for n, p in SURFACES.items()}
|
||||||
|
rows = surfaces["tradePile"]["body"].get("auctionInfo", [])
|
||||||
|
wire_row = rows[0] if rows else {}
|
||||||
|
print(f" wire : total={surfaces['tradePile']['body'].get('total')} "
|
||||||
|
f"tradeState={wire_row.get('tradeState')} bidState={wire_row.get('bidState')} "
|
||||||
|
f"coinsProcessed={wire_row.get('coinsProcessed')}")
|
||||||
|
print(f" counts : {json.dumps(surfaces['counts']['body'])}")
|
||||||
|
|
||||||
|
mem = probe_client()
|
||||||
|
print(f" client : running={mem['client_running']} slide={mem['slide_control']} "
|
||||||
|
f"records={len(mem['rows'])}")
|
||||||
|
for r in mem["rows"]:
|
||||||
|
print(f" tradeId={r['tradeId']} STATE={r['STATE_tradeState']} "
|
||||||
|
f"YOURBID={r['YOURBID_bidState']} "
|
||||||
|
f"COINS_AWARDED={r['COINS_AWARDED_coinsProcessed']} "
|
||||||
|
f"MIN_CREDITS={r['MIN_CREDITS_currentBid']} IS_GLOW={r['IS_GLOW']} "
|
||||||
|
f"INBOX={r['INBOX']} CARD_OFFERSTATE={r['CARD_OFFERSTATE_itemState']}")
|
||||||
|
|
||||||
|
xc = cross_check(wire_row, mem["rows"])
|
||||||
|
print(" cross-check (wire vs the client's own memory):")
|
||||||
|
for c in xc:
|
||||||
|
mark = "ok " if c.get("ok") else "FAIL"
|
||||||
|
print(f" [{mark}] {c['field']:26s} "
|
||||||
|
f"wire/exp={c.get('wire', c.get('expected'))!r} mem={c.get('memory')!r} "
|
||||||
|
f"{c.get('note', '')}")
|
||||||
|
validated = bool(mem["rows"]) and all(c.get("ok") for c in xc)
|
||||||
|
print(f" INSTRUMENTATION {'VALIDATED' if validated else 'NOT VALIDATED'} this run")
|
||||||
|
|
||||||
|
routes = route_delta()
|
||||||
|
print(f" route delta : lines {routes.get('from_line')}..{routes.get('to_line')}, "
|
||||||
|
f"{len(routes.get('requests', []))} request line(s)")
|
||||||
|
for l in routes.get("requests", [])[-30:]:
|
||||||
|
print(f" {l}")
|
||||||
|
if routes.get("clear_or_delete"):
|
||||||
|
print(" *** CLEAR/DELETE observed:")
|
||||||
|
for l in routes["clear_or_delete"]:
|
||||||
|
print(f" *** {l}")
|
||||||
|
|
||||||
|
snap = {"variant": args.variant, "label": args.label,
|
||||||
|
"captured": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
|
"host_banner": banner, "surfaces": surfaces, "client_memory": mem,
|
||||||
|
"cross_check": xc, "instrumentation_validated": validated,
|
||||||
|
"route_delta": routes}
|
||||||
|
path = os.path.join(args.out, f"live-{args.variant}-{args.label}.json")
|
||||||
|
with open(path, "w") as f:
|
||||||
|
json.dump(snap, f, indent=2, sort_keys=True)
|
||||||
|
print(f"\n wrote {os.path.relpath(path, REPO)}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+252
@@ -0,0 +1,252 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tear down the isolated FIFA-17 SOLD staging stack brought up by
|
||||||
|
`scripts/sold-staging-up.py` -- and NOTHING else.
|
||||||
|
|
||||||
|
Kill safety is the whole point of this file. `openfut-utas-host` and `openfut-core`
|
||||||
|
each name TWO live processes on this machine: the staging ones and the PRODUCTION
|
||||||
|
ones. So there is no pattern matching here at all:
|
||||||
|
|
||||||
|
* every pid comes from the manifest the up script wrote;
|
||||||
|
* before any signal, /proc/<pid>/cmdline is read and MUST contain the staging
|
||||||
|
directory -- production's cmdline never can, because staging runs binaries
|
||||||
|
copied into that directory;
|
||||||
|
* the known production pids are refused explicitly, as a second gate;
|
||||||
|
* only the process GROUP the up script created (pgid == pid, via
|
||||||
|
start_new_session) is signalled, so a responder thread/child cannot be orphaned;
|
||||||
|
* afterwards every staging port is proven free and production is proven alive.
|
||||||
|
|
||||||
|
python3 scripts/sold-staging-down.py
|
||||||
|
python3 scripts/sold-staging-down.py --purge # also delete the staging dir
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
DEFAULT_STAGING_DIR = "/home/alex/openfut-sold-staging"
|
||||||
|
|
||||||
|
FORBIDDEN_PATHS = ("/home/alex/openfut-promotion/state",)
|
||||||
|
# Production processes that MUST be alive before and after this script runs. These
|
||||||
|
# two are the ones the batch contract names, and they live in the host pid view.
|
||||||
|
PROD_PIDS = {3631953: "prod utas-host", 3374264: "prod Core"}
|
||||||
|
# Reported but not gated: container pids change when the operator restarts the
|
||||||
|
# container, and a stale entry here would turn a successful teardown into a FATAL.
|
||||||
|
PROD_PIDS_INFO = {2090886: "prod blaze", 2091170: "prod python oracle",
|
||||||
|
2090888: "prod pow"}
|
||||||
|
PROD_PORTS = (8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216, 8080, 8081, 8094)
|
||||||
|
|
||||||
|
|
||||||
|
class Fatal(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def banner(title: str) -> None:
|
||||||
|
print()
|
||||||
|
print("=" * 78)
|
||||||
|
print(f"== {title}")
|
||||||
|
print("=" * 78)
|
||||||
|
|
||||||
|
|
||||||
|
def ok(msg: str) -> None:
|
||||||
|
print(f" [ OK ] {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def step(msg: str) -> None:
|
||||||
|
print(f" {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def safe_path(path: str) -> str:
|
||||||
|
real = os.path.realpath(path)
|
||||||
|
for bad in FORBIDDEN_PATHS:
|
||||||
|
if real == bad or real.startswith(bad + os.sep):
|
||||||
|
raise Fatal(f"REFUSING to touch production state: {path} -> {real}")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def pid_alive(pid: int) -> bool:
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
except ProcessLookupError:
|
||||||
|
return False
|
||||||
|
except PermissionError:
|
||||||
|
return True
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def cmdline_of(pid: int) -> str:
|
||||||
|
try:
|
||||||
|
with open(f"/proc/{pid}/cmdline", "rb") as fh:
|
||||||
|
return fh.read().replace(b"\0", b" ").decode(errors="replace").strip()
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def listening_ports() -> set[int]:
|
||||||
|
"""Ports in state LISTEN, from the kernel socket table. A trial bind() would
|
||||||
|
report EADDRINUSE for a stopped server's TIME_WAIT sockets and wrongly claim the
|
||||||
|
teardown failed."""
|
||||||
|
ports: set[int] = set()
|
||||||
|
for path in ("/proc/net/tcp", "/proc/net/tcp6"):
|
||||||
|
try:
|
||||||
|
with open(path) as fh:
|
||||||
|
next(fh, None) # header
|
||||||
|
for line in fh:
|
||||||
|
fields = line.split()
|
||||||
|
if len(fields) < 4 or fields[3] != "0A": # TCP_LISTEN
|
||||||
|
continue
|
||||||
|
ports.add(int(fields[1].rsplit(":", 1)[1], 16))
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return ports
|
||||||
|
|
||||||
|
|
||||||
|
def port_free(port: int) -> bool:
|
||||||
|
return port not in listening_ports()
|
||||||
|
|
||||||
|
|
||||||
|
def stop_one(rec: dict, staging_dir: str) -> str:
|
||||||
|
"""Stop exactly one recorded process. Returns a human-readable outcome."""
|
||||||
|
name, pid = rec["name"], int(rec["pid"])
|
||||||
|
|
||||||
|
known_prod = {**PROD_PIDS, **PROD_PIDS_INFO}
|
||||||
|
if pid in known_prod:
|
||||||
|
raise Fatal(
|
||||||
|
f"manifest entry {name} names PRODUCTION pid {pid} ({known_prod[pid]}). "
|
||||||
|
"REFUSING to signal anything from this manifest."
|
||||||
|
)
|
||||||
|
if not pid_alive(pid):
|
||||||
|
return f"{name} pid {pid}: already gone"
|
||||||
|
|
||||||
|
live = cmdline_of(pid)
|
||||||
|
if staging_dir not in live:
|
||||||
|
raise Fatal(
|
||||||
|
f"{name} pid {pid} is alive but its cmdline does NOT contain "
|
||||||
|
f"{staging_dir!r} -- pid reuse, or the wrong manifest. REFUSING to "
|
||||||
|
f"signal it.\n cmdline: {live!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
pgid = os.getpgid(pid)
|
||||||
|
except OSError:
|
||||||
|
pgid = pid
|
||||||
|
recorded_pgid = int(rec.get("pgid", pid))
|
||||||
|
if pgid != recorded_pgid:
|
||||||
|
raise Fatal(
|
||||||
|
f"{name} pid {pid} is in process group {pgid} but the manifest recorded "
|
||||||
|
f"{recorded_pgid} -- REFUSING to signal a group we did not create."
|
||||||
|
)
|
||||||
|
if pgid != pid:
|
||||||
|
raise Fatal(
|
||||||
|
f"{name} pid {pid} is not its own group leader (pgid {pgid}) -- the up "
|
||||||
|
"script always starts a new session, so this is not our process."
|
||||||
|
)
|
||||||
|
|
||||||
|
os.killpg(pgid, signal.SIGTERM)
|
||||||
|
deadline = time.monotonic() + 15.0
|
||||||
|
while time.monotonic() < deadline and pid_alive(pid):
|
||||||
|
time.sleep(0.1)
|
||||||
|
if pid_alive(pid):
|
||||||
|
os.killpg(pgid, signal.SIGKILL)
|
||||||
|
deadline = time.monotonic() + 10.0
|
||||||
|
while time.monotonic() < deadline and pid_alive(pid):
|
||||||
|
time.sleep(0.1)
|
||||||
|
if pid_alive(pid):
|
||||||
|
raise Fatal(f"{name} pid {pid} survived SIGKILL")
|
||||||
|
return f"{name} pid {pid} (pgid {pgid}): stopped and verified gone"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
ap.add_argument("--dir", default=os.environ.get("OPENFUT_SOLD_STAGING_DIR",
|
||||||
|
DEFAULT_STAGING_DIR),
|
||||||
|
help=f"staging directory (default: {DEFAULT_STAGING_DIR})")
|
||||||
|
ap.add_argument("--purge", action="store_true",
|
||||||
|
help="delete the staging directory after stopping (default: keep "
|
||||||
|
"the databases and logs as evidence)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
staging_dir = safe_path(os.path.abspath(args.dir))
|
||||||
|
manifest_path = safe_path(os.path.join(staging_dir, "manifest.json"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
banner("STOPPING THE STAGING STACK (recorded pids only)")
|
||||||
|
step(f"staging dir : {staging_dir}")
|
||||||
|
if not os.path.exists(manifest_path):
|
||||||
|
print(f" no manifest at {manifest_path} -- nothing was recorded, so "
|
||||||
|
"nothing will be signalled.")
|
||||||
|
print(" If a staging process is somehow still running, find it with "
|
||||||
|
"its cmdline (it contains the staging dir) and stop it by pid.")
|
||||||
|
return 0
|
||||||
|
with open(manifest_path) as fh:
|
||||||
|
manifest = json.load(fh)
|
||||||
|
if manifest.get("staging_dir") != staging_dir:
|
||||||
|
raise Fatal(
|
||||||
|
f"manifest staging_dir {manifest.get('staging_dir')!r} != "
|
||||||
|
f"{staging_dir!r} -- REFUSING to act on a foreign manifest."
|
||||||
|
)
|
||||||
|
step(f"variant : {manifest.get('variant')}")
|
||||||
|
|
||||||
|
for rec in manifest.get("processes", []):
|
||||||
|
ok(stop_one(rec, staging_dir))
|
||||||
|
|
||||||
|
banner("PROVE STAGING IS GONE")
|
||||||
|
ports = manifest.get("ports", {})
|
||||||
|
for name, port in sorted(ports.items(), key=lambda kv: kv[1]):
|
||||||
|
if port in PROD_PORTS:
|
||||||
|
raise Fatal(f"manifest port {name}={port} is a PRODUCTION port")
|
||||||
|
if not port_free(port):
|
||||||
|
raise Fatal(f"staging port {name}={port} is STILL listening")
|
||||||
|
ok(f"staging port {name} {port} free")
|
||||||
|
|
||||||
|
leftovers = []
|
||||||
|
for rec in manifest.get("processes", []):
|
||||||
|
pid = int(rec["pid"])
|
||||||
|
if pid_alive(pid) and staging_dir in cmdline_of(pid):
|
||||||
|
leftovers.append(f"{rec['name']} pid {pid}")
|
||||||
|
if leftovers:
|
||||||
|
raise Fatal("staging processes still alive: " + ", ".join(leftovers))
|
||||||
|
ok("no recorded staging process is alive")
|
||||||
|
|
||||||
|
banner("PROVE PRODUCTION IS STILL UP")
|
||||||
|
dead = [f"{what} pid {pid}" for pid, what in PROD_PIDS.items()
|
||||||
|
if not pid_alive(pid)]
|
||||||
|
for pid, what in PROD_PIDS.items():
|
||||||
|
if pid_alive(pid):
|
||||||
|
ok(f"{what} pid {pid} alive")
|
||||||
|
if dead:
|
||||||
|
raise Fatal("production process(es) NOT alive: " + ", ".join(dead))
|
||||||
|
for pid, what in PROD_PIDS_INFO.items():
|
||||||
|
state = "alive" if pid_alive(pid) else "not found (informational only)"
|
||||||
|
step(f"{what} pid {pid} {state}")
|
||||||
|
|
||||||
|
if args.purge:
|
||||||
|
shutil.rmtree(safe_path(staging_dir), ignore_errors=True)
|
||||||
|
ok(f"purged {staging_dir}")
|
||||||
|
else:
|
||||||
|
os.replace(manifest_path, safe_path(manifest_path + ".stopped"))
|
||||||
|
ok(f"kept {staging_dir} (manifest renamed to manifest.json.stopped so a "
|
||||||
|
"fresh `up` is allowed)")
|
||||||
|
|
||||||
|
banner("REMINDER: REVERT THE CLIENT")
|
||||||
|
print(' On 10.10.0.105, restore "/mnt/games/FIFA 17/openfut.cfg" to:')
|
||||||
|
print()
|
||||||
|
print(" host=10.10.0.120")
|
||||||
|
print(" https_port=8443")
|
||||||
|
print(" blaze_redirector_port=42127")
|
||||||
|
print(" blaze_main_port=42130")
|
||||||
|
print()
|
||||||
|
print(" then RELAUNCH the FIFA 17 client. See docs/SOLD_STAGING_RUNBOOK.md.")
|
||||||
|
return 0
|
||||||
|
except Fatal as exc:
|
||||||
|
print(f"\nFATAL: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+137
@@ -0,0 +1,137 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Probe every route the FIFA 17 FUT hub touches on the staging stack, and fail loudly
|
||||||
|
on anything that would break it.
|
||||||
|
|
||||||
|
The sold-row A/B kept stalling on preconditions the headless checks never exercised:
|
||||||
|
the identity had no squad (hub refuses to open), and any route not owned by Rust falls
|
||||||
|
through to a deliberately dead Python upstream and answers 502. Each of those cost a
|
||||||
|
full operator cycle -- launch, observe, report, diagnose -- so this front-loads the
|
||||||
|
whole surface instead of discovering the next gap one restart at a time.
|
||||||
|
|
||||||
|
Flags three distinct failure classes, because they need different fixes:
|
||||||
|
* 502 / PYTHON_FALLBACK -- route not implemented in Rust; the staging stack has no
|
||||||
|
Python upstream, so it is fatal here even though production would proxy it.
|
||||||
|
* missing_integrity -- Rust answered 200 but the underlying state is absent
|
||||||
|
(this is exactly what "no extension stored" was before the squad was seeded).
|
||||||
|
* empty-but-required -- 200 with a body the hub cannot work with, e.g. a squad
|
||||||
|
with zero occupied slots. A 200 is not proof the client is satisfied.
|
||||||
|
|
||||||
|
python3 scripts/sold-staging-hub-sweep.py
|
||||||
|
"""
|
||||||
|
import http.client
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
HOST, PORT = "127.0.0.1", 8299
|
||||||
|
FORBIDDEN = {8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216}
|
||||||
|
HEADERS = {"X-OpenFUT-Game": "fifa17"}
|
||||||
|
LOG = "/home/alex/openfut-sold-staging/logs/utas-host.log"
|
||||||
|
|
||||||
|
# Only paths the CLIENT is actually observed to request (host log route names), with
|
||||||
|
# casing exactly as it sends them. An invented path that 502s is a bug in this list,
|
||||||
|
# not in the stack: a first pass here flagged five "fatal" routes that turned out to
|
||||||
|
# be guesses -- `/accountinfo` (the client uses `/user/accountinfo`), bare `/squad`
|
||||||
|
# (it uses `/squad/active`), and `/watchlist` (it is camelCase `watchList`). Crying
|
||||||
|
# wolf about the stack is worse than not checking, so every entry below is verified.
|
||||||
|
ROUTES = [
|
||||||
|
"/ut/game/fifa17/user/accountinfo",
|
||||||
|
"/ut/game/fifa17/squad/0",
|
||||||
|
"/ut/game/fifa17/squad/active",
|
||||||
|
"/ut/game/fifa17/club?count=200",
|
||||||
|
"/ut/game/fifa17/club/stats/staff",
|
||||||
|
"/ut/game/fifa17/club/stats/club",
|
||||||
|
"/ut/game/fifa17/clientdata/store",
|
||||||
|
"/ut/game/fifa17/purchased/items",
|
||||||
|
"/ut/game/fifa17/tradePile",
|
||||||
|
"/ut/game/fifa17/tradePile/counts",
|
||||||
|
"/ut/game/fifa17/watchList",
|
||||||
|
"/ut/game/fifa17/trade/status",
|
||||||
|
"/ut/game/fifa17/userMassInfo",
|
||||||
|
"/ut/game/fifa17/settings",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get(path):
|
||||||
|
assert PORT not in FORBIDDEN, "refusing to touch a production port"
|
||||||
|
c = http.client.HTTPConnection(HOST, PORT, timeout=20)
|
||||||
|
c.request("GET", path, headers=HEADERS)
|
||||||
|
r = c.getresponse()
|
||||||
|
raw = r.read()
|
||||||
|
c.close()
|
||||||
|
try:
|
||||||
|
return r.status, json.loads(raw), raw
|
||||||
|
except Exception:
|
||||||
|
return r.status, None, raw
|
||||||
|
|
||||||
|
|
||||||
|
def squad_occupied(body):
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
return None
|
||||||
|
players = body.get("players")
|
||||||
|
if not isinstance(players, list):
|
||||||
|
return None
|
||||||
|
return sum(1 for p in players if (p.get("itemData") or {}).get("id"))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
start = 0
|
||||||
|
if os.path.exists(LOG):
|
||||||
|
start = len(open(LOG, errors="replace").read().splitlines())
|
||||||
|
|
||||||
|
fatal, warn, ok = [], [], []
|
||||||
|
print(f"{'route':<44} {'code':>4} finding")
|
||||||
|
print("-" * 90)
|
||||||
|
for path in ROUTES:
|
||||||
|
status, body, raw = get(path)
|
||||||
|
note = ""
|
||||||
|
if status == 502:
|
||||||
|
note = "FATAL: no Rust owner -> dead Python upstream"
|
||||||
|
fatal.append((path, note))
|
||||||
|
elif status >= 400:
|
||||||
|
note = f"FATAL: {raw[:60]!r}"
|
||||||
|
fatal.append((path, note))
|
||||||
|
else:
|
||||||
|
occ = squad_occupied(body)
|
||||||
|
if occ is not None and "squad" in path:
|
||||||
|
note = f"squad occupied slots = {occ}"
|
||||||
|
if occ == 0:
|
||||||
|
note += " FATAL: hub cannot open on an empty squad"
|
||||||
|
fatal.append((path, note))
|
||||||
|
else:
|
||||||
|
ok.append(path)
|
||||||
|
else:
|
||||||
|
ok.append(path)
|
||||||
|
if isinstance(body, dict):
|
||||||
|
n = body.get("total", body.get("totalResults"))
|
||||||
|
note = f"ok{'' if n is None else f', total={n}'}"
|
||||||
|
else:
|
||||||
|
note = "ok"
|
||||||
|
print(f"{path:<44} {status:>4} {note}")
|
||||||
|
|
||||||
|
# The host's own classification is the authority on ownership and integrity, so
|
||||||
|
# read what it logged for the requests just made rather than inferring from bodies.
|
||||||
|
if os.path.exists(LOG):
|
||||||
|
new = open(LOG, errors="replace").read().splitlines()[start:]
|
||||||
|
flagged = [l for l in new
|
||||||
|
if "PYTHON_FALLBACK" in l or "missing_integrity" in l
|
||||||
|
or "ERROR" in l]
|
||||||
|
if flagged:
|
||||||
|
print("\nhost-side findings for those requests:")
|
||||||
|
for l in flagged:
|
||||||
|
print(f" {l[:160]}")
|
||||||
|
if "missing_integrity" in l:
|
||||||
|
warn.append(l)
|
||||||
|
|
||||||
|
print(f"\n=== {len(ok)} ok, {len(warn)} integrity warning(s), {len(fatal)} fatal ===")
|
||||||
|
for path, note in fatal:
|
||||||
|
print(f" FATAL {path}: {note}")
|
||||||
|
if fatal:
|
||||||
|
print("\nThe hub will not work until the fatal rows are resolved.")
|
||||||
|
return 1
|
||||||
|
print("Every hub route the client needs is served. Safe to restart the client.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+187
@@ -0,0 +1,187 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Seed the staging seller a valid starting XI so the FIFA hub will open.
|
||||||
|
|
||||||
|
The sold-row A/B only ever needed the tradePile wire, so the staging identity was
|
||||||
|
created with owned items but NO squad. Every headless check passed because none of
|
||||||
|
them asks for a squad -- but the real client refuses to enter the FUT hub with an
|
||||||
|
empty one ("squad update error"), because a squad is a hub precondition, not a
|
||||||
|
Transfer-List detail.
|
||||||
|
|
||||||
|
This seeds it through the REAL route -- `PUT /ut/game/fifa17/squad/0`, the same
|
||||||
|
request the client itself sends -- so the squad is written by
|
||||||
|
parse_squad_put/build_squad_write exactly as a genuine save would be. Writing
|
||||||
|
Core/DB rows by hand would risk a shape the live path would never produce, which is
|
||||||
|
the sort of divergence that invalidates an experiment.
|
||||||
|
|
||||||
|
Wire facts (openfut-adapter-fifa17/src/fut/squad.rs): the players array is a fixed 23
|
||||||
|
slots; 0..=10 are the pitch, 11..=22 bench/reserves, derived from the INDEX alone and
|
||||||
|
never from the formation token. Empty slots are `itemData.id == 0`. The formation
|
||||||
|
token is carried verbatim and never interpreted server-side. A successful save
|
||||||
|
answers `{"id": 0}` and does NOT echo the squad.
|
||||||
|
|
||||||
|
python3 scripts/sold-staging-seed-squad.py # seed, then verify
|
||||||
|
python3 scripts/sold-staging-seed-squad.py --show # read-only
|
||||||
|
"""
|
||||||
|
import http.client
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
HOST, PORT = "127.0.0.1", 8299
|
||||||
|
FORBIDDEN = {8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216}
|
||||||
|
HEADERS = {"X-OpenFUT-Game": "fifa17", "Content-Type": "application/json"}
|
||||||
|
SLOTS = 23
|
||||||
|
STARTERS = 11
|
||||||
|
# 4-3-3, laid out in the conventional FIFA slot order so the pitch reads correctly:
|
||||||
|
# GK, RB, CB, CB, LB, CM, CM, CM, RW, ST, LW.
|
||||||
|
LINEUP = ["GK", "RB", "CB", "CB", "LB", "CM", "CM", "CM", "RW", "ST", "LW"]
|
||||||
|
FORMATION = "f433"
|
||||||
|
SQUAD_TYPE = "REGULAR_SQUAD"
|
||||||
|
CHEMISTRY = 50
|
||||||
|
# Production's squad-manager reference, mirrored verbatim; it resolves to nothing in
|
||||||
|
# production either (absent from its own /club/staff listing), which is exactly why
|
||||||
|
# copying it is safe.
|
||||||
|
PRODUCTION_MANAGER_REF = 100000427
|
||||||
|
# Production's opaque 33-int tactics array. Never parsed server-side; reused because
|
||||||
|
# only the shape (a JSON-encoded int array, not null) matters to the client.
|
||||||
|
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]")
|
||||||
|
|
||||||
|
|
||||||
|
def call(method, path, body=None):
|
||||||
|
assert PORT not in FORBIDDEN, "refusing to touch a production port"
|
||||||
|
c = http.client.HTTPConnection(HOST, PORT, timeout=20)
|
||||||
|
payload = json.dumps(body) if body is not None else None
|
||||||
|
c.request(method, path, body=payload, headers=HEADERS)
|
||||||
|
r = c.getresponse()
|
||||||
|
raw = r.read()
|
||||||
|
c.close()
|
||||||
|
try:
|
||||||
|
return r.status, json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
return r.status, raw.decode(errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def club_players():
|
||||||
|
status, body = call("GET", "/ut/game/fifa17/club?count=200")
|
||||||
|
assert status == 200, f"/club -> {status}"
|
||||||
|
items = body.get("itemData", body.get("items", []))
|
||||||
|
return [i for i in items if i.get("itemType") == "player"]
|
||||||
|
|
||||||
|
|
||||||
|
def pick_xi(players):
|
||||||
|
"""Choose one owned player per lineup position, best rating first, without
|
||||||
|
reusing an instance. A position with no candidate is reported rather than
|
||||||
|
quietly left empty -- an incomplete XI is why the hub errored in the first
|
||||||
|
place, so silently reproducing it would defeat the point."""
|
||||||
|
remaining = sorted(players, key=lambda p: -int(p.get("rating") or 0))
|
||||||
|
chosen, missing = [], []
|
||||||
|
for want in LINEUP:
|
||||||
|
hit = next((p for p in remaining if p.get("preferredPosition") == want), None)
|
||||||
|
if hit is None:
|
||||||
|
hit = next((p for p in remaining), None) # any spare body, flagged below
|
||||||
|
if hit is not None:
|
||||||
|
missing.append(f"{want}->{hit.get('preferredPosition')}")
|
||||||
|
if hit is None:
|
||||||
|
missing.append(f"{want}->NONE")
|
||||||
|
chosen.append(None)
|
||||||
|
continue
|
||||||
|
remaining.remove(hit)
|
||||||
|
chosen.append(hit)
|
||||||
|
return chosen, missing
|
||||||
|
|
||||||
|
|
||||||
|
def build_put(chosen):
|
||||||
|
players = []
|
||||||
|
for idx in range(SLOTS):
|
||||||
|
occupant = chosen[idx] if idx < STARTERS and chosen[idx] else None
|
||||||
|
players.append({
|
||||||
|
"index": idx,
|
||||||
|
"itemData": {"id": int(occupant["id"]) if occupant else 0, "dream": False},
|
||||||
|
"kitNumber": idx + 1 if occupant else 0,
|
||||||
|
})
|
||||||
|
captain = next((int(p["id"]) for p in chosen if p), 0)
|
||||||
|
ratings = [int(p.get("rating") or 0) for p in chosen if p]
|
||||||
|
mean_rating = sum(ratings) // len(ratings) if ratings else 0
|
||||||
|
return {
|
||||||
|
"id": 0,
|
||||||
|
"squadName": "Staging XI",
|
||||||
|
"formation": FORMATION,
|
||||||
|
"captain": captain,
|
||||||
|
"players": players,
|
||||||
|
# A first attempt sent only name/formation/captain/players and the client
|
||||||
|
# STILL refused the hub, while the host logged squad-active 200 ok -- the
|
||||||
|
# route was fine and the body was not. Diffing against production's
|
||||||
|
# known-good squad showed staging returning null for exactly these five,
|
||||||
|
# because the PUT never carried them and the extension stored nothing.
|
||||||
|
# Types matter here: the client wants scalars, not null.
|
||||||
|
"squadType": SQUAD_TYPE,
|
||||||
|
"chemistry": CHEMISTRY,
|
||||||
|
"rating": mean_rating,
|
||||||
|
"starRating": mean_rating,
|
||||||
|
# Opaque 33-int tactics array, carried verbatim and never interpreted
|
||||||
|
# server-side. Reused from production because only its SHAPE matters.
|
||||||
|
"custom": CUSTOM,
|
||||||
|
# Production carries five, all pointing at one player. Mirrored so the
|
||||||
|
# array is populated rather than empty.
|
||||||
|
"kicktakers": [{"index": i, "id": captain, "dream": False} for i in range(5)],
|
||||||
|
# Mirrors production's manager reference verbatim, including the fact that
|
||||||
|
# the id resolves to nothing.
|
||||||
|
#
|
||||||
|
# This looked unfixable at first: production points at instance 100000427
|
||||||
|
# while the staging club holds 11 players and zero staff, so there was
|
||||||
|
# apparently no manager to reference. Then checking production properly
|
||||||
|
# showed 100000427 is absent from its OWN club listing too --
|
||||||
|
# /club/staff returns 1975 items spanning ids 100000001..100004826 and
|
||||||
|
# 100000427 is not one of them. Production's manager reference is dangling
|
||||||
|
# and the client accepts that squad regardless, which proves the client does
|
||||||
|
# not validate the manager id against the club. Only a populated array
|
||||||
|
# matters, so replicating the known-good state exactly is both faithful and
|
||||||
|
# sufficient -- and it beats pointing the manager slot at a player.
|
||||||
|
"manager": [{"id": PRODUCTION_MANAGER_REF, "dream": False}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def show():
|
||||||
|
status, body = call("GET", "/ut/game/fifa17/squad/0")
|
||||||
|
filled = [p for p in body.get("players", []) if p.get("itemData", {}).get("id")]
|
||||||
|
print(f" /squad/0 -> {status} id={body.get('id')} "
|
||||||
|
f"players={len(body.get('players', []))} occupied={len(filled)}")
|
||||||
|
for p in filled:
|
||||||
|
d = p.get("itemData", {})
|
||||||
|
print(f" index={p.get('index')} id={d.get('id')} res={d.get('resourceId')} "
|
||||||
|
f"pos={d.get('preferredPosition')} rating={d.get('rating')}")
|
||||||
|
return len(filled)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=== staging squad, before ===")
|
||||||
|
before = show()
|
||||||
|
if "--show" in sys.argv:
|
||||||
|
return 0
|
||||||
|
players = club_players()
|
||||||
|
print(f"=== owned players available: {len(players)} ===")
|
||||||
|
chosen, missing = pick_xi(players)
|
||||||
|
if missing:
|
||||||
|
# Loud, because an out-of-position XI still opens the hub but is not what a
|
||||||
|
# real save would look like.
|
||||||
|
print(f" NOTE: substitutions made for {len(missing)} slot(s): {missing}")
|
||||||
|
if any(c is None for c in chosen):
|
||||||
|
raise SystemExit("not enough owned players for a starting XI; refusing to "
|
||||||
|
"write a partial squad")
|
||||||
|
put = build_put(chosen)
|
||||||
|
print(f"=== PUT /squad/0 formation={put['formation']} captain={put['captain']} "
|
||||||
|
f"occupied={sum(1 for p in put['players'] if p['itemData']['id'])} ===")
|
||||||
|
status, body = call("PUT", "/ut/game/fifa17/squad/0", put)
|
||||||
|
print(f" -> {status} {json.dumps(body)}")
|
||||||
|
if status != 200 or body != {"id": 0}:
|
||||||
|
raise SystemExit(f"squad save did not acknowledge as {{'id':0}}: {status} {body}")
|
||||||
|
print("=== staging squad, after ===")
|
||||||
|
after = show()
|
||||||
|
if after != STARTERS:
|
||||||
|
raise SystemExit(f"VERIFY FAILED: expected {STARTERS} occupied slots, got {after}")
|
||||||
|
print(f" OK: {before} -> {after} occupied slots; the hub precondition is satisfied")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+1028
File diff suppressed because it is too large
Load Diff
Executable
+378
@@ -0,0 +1,378 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Wire-level verification of the seller-facing SOLD flow, in isolation.
|
||||||
|
|
||||||
|
Proves the harness produces a correct, authentic sold row BEFORE any operator time
|
||||||
|
is spent driving a real FIFA client. Brings up its own Core + utas-host on ephemeral
|
||||||
|
ports against throwaway databases, runs the real settlement through the
|
||||||
|
`staging_sell` binary, then reads every seller-facing surface under BOTH A/B
|
||||||
|
variants and exercises the bulk clear verb.
|
||||||
|
|
||||||
|
ISOLATION: production ports 8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216,
|
||||||
|
8080, 8081 and 8094 are in a hard deny-list checked before every bind and every
|
||||||
|
request, and nothing under /home/alex/openfut-promotion/state/ is opened.
|
||||||
|
|
||||||
|
python3 scripts/sold-wire-check.py [--keep]
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import http.client
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
FORBIDDEN = {8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216, 8080, 8081, 8094}
|
||||||
|
TS = "2026-01-01T00:00:00Z"
|
||||||
|
PERSONA = "33068179"
|
||||||
|
SELLER_CLUB = "club-seller-a"
|
||||||
|
BUYER_CLUB = "club-buyer-b"
|
||||||
|
ITEM = "core-disposable-x"
|
||||||
|
CARD = "def-disposable"
|
||||||
|
TRADE_ID = "900500150"
|
||||||
|
GROSS = 150
|
||||||
|
|
||||||
|
checks = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(label, ok, detail=""):
|
||||||
|
checks.append((label, bool(ok), detail))
|
||||||
|
print(f" [{'PASS' if ok else 'FAIL'}] {label}{(': ' + detail) if detail else ''}")
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def free_port():
|
||||||
|
for _ in range(200):
|
||||||
|
s = socket.socket()
|
||||||
|
s.bind(("127.0.0.1", 0))
|
||||||
|
p = s.getsockname()[1]
|
||||||
|
s.close()
|
||||||
|
if p not in FORBIDDEN and p > 1024:
|
||||||
|
return p
|
||||||
|
raise RuntimeError("no free port")
|
||||||
|
|
||||||
|
|
||||||
|
def req(port, method, path, body=None):
|
||||||
|
assert port not in FORBIDDEN, f"refusing to contact production port {port}"
|
||||||
|
c = http.client.HTTPConnection("127.0.0.1", port, timeout=20)
|
||||||
|
headers = {"X-OpenFUT-Game": "fifa17"}
|
||||||
|
if body is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
c.request(method, path, body=json.dumps(body) if body is not None else None,
|
||||||
|
headers=headers)
|
||||||
|
r = c.getresponse()
|
||||||
|
raw = r.read()
|
||||||
|
c.close()
|
||||||
|
try:
|
||||||
|
return r.status, json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
return r.status, raw.decode("utf-8", "replace")
|
||||||
|
|
||||||
|
|
||||||
|
def wait_http(port, path, timeout=45, proc=None, log=None):
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
if proc is not None and proc.poll() is not None:
|
||||||
|
tail = ""
|
||||||
|
if log and os.path.exists(log):
|
||||||
|
tail = open(log).read()[-1500:]
|
||||||
|
raise RuntimeError(f"process exited {proc.returncode}\n{tail}")
|
||||||
|
try:
|
||||||
|
st, _ = req(port, "GET", path)
|
||||||
|
if st < 500:
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
time.sleep(0.25)
|
||||||
|
tail = open(log).read()[-1500:] if log and os.path.exists(log) else ""
|
||||||
|
raise RuntimeError(f"{path} on {port} never became ready\n{tail}")
|
||||||
|
|
||||||
|
|
||||||
|
def seed(db):
|
||||||
|
"""Two identities by direct SQL: Seller A (the FIFA persona) and synthetic Buyer B."""
|
||||||
|
con = sqlite3.connect(db)
|
||||||
|
for prof, club, coins, game in (
|
||||||
|
("prof-seller-a", SELLER_CLUB, 1_000, "fifa17"),
|
||||||
|
("prof-buyer-b", BUYER_CLUB, 20_000, "buyer-game"),
|
||||||
|
):
|
||||||
|
con.execute(
|
||||||
|
"INSERT INTO profiles (id, username, game_id, created_at, updated_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?)", (prof, prof, game, TS, TS))
|
||||||
|
con.execute(
|
||||||
|
"INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?)", (club, prof, club, coins, TS, TS))
|
||||||
|
con.execute(
|
||||||
|
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) "
|
||||||
|
"VALUES (?, ?, ?, 0, ?)", (ITEM, SELLER_CLUB, CARD, TS))
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
|
def owner_of(db, item):
|
||||||
|
con = sqlite3.connect(db)
|
||||||
|
row = con.execute("SELECT club_id FROM owned_cards WHERE id = ?", (item,)).fetchone()
|
||||||
|
n = con.execute("SELECT COUNT(*) FROM owned_cards WHERE id = ?", (item,)).fetchone()[0]
|
||||||
|
coins = dict(con.execute("SELECT id, coins FROM clubs").fetchall())
|
||||||
|
con.close()
|
||||||
|
return (row[0] if row else None), n, coins
|
||||||
|
|
||||||
|
|
||||||
|
def start_core(tmp, port, log):
|
||||||
|
db = os.path.join(tmp, "core.db")
|
||||||
|
pack = os.path.join(tmp, "pack.json")
|
||||||
|
with open(pack, "w") as f:
|
||||||
|
# A top-level ARRAY: Core's content-pack loader expects a sequence, not a
|
||||||
|
# map. Needed because the preflight refuses to start when an owned card
|
||||||
|
# references a CardDefinitionId no pack defines.
|
||||||
|
json.dump([{
|
||||||
|
"id": CARD, "name": "Disposable", "overall": 75, "position": "ST",
|
||||||
|
"nation": "Nation", "league": "League", "club": "Club",
|
||||||
|
"pace": 75, "shooting": 75, "passing": 75, "dribbling": 75,
|
||||||
|
"defending": 40, "physical": 70, "rarity": "gold",
|
||||||
|
"image_path": None,
|
||||||
|
}], f)
|
||||||
|
env = dict(os.environ,
|
||||||
|
LISTEN_ADDR=f"127.0.0.1:{port}",
|
||||||
|
DATABASE_URL=f"sqlite://{db}",
|
||||||
|
# Core's real data dir (read-only): it needs chemistry_styles.json
|
||||||
|
# and friends. The throwaway DB and the content pack stay in tmp.
|
||||||
|
DATA_DIR=os.path.join(REPO, "openfut-core", "data"),
|
||||||
|
OPENFUT_CONTENT_PACKS=pack)
|
||||||
|
# Migrate-only pass first: Core owns its schema, so the fixture cannot be
|
||||||
|
# seeded into an empty file. Stop it before the external writer touches the db.
|
||||||
|
with open(log, "w") as lf:
|
||||||
|
p = subprocess.Popen([os.path.join(REPO, "target/release/openfut-core")],
|
||||||
|
cwd=tmp, env=env, stdout=lf, stderr=subprocess.STDOUT)
|
||||||
|
wait_http(port, "/health", proc=p, log=log)
|
||||||
|
p.terminate()
|
||||||
|
p.wait(timeout=20)
|
||||||
|
seed(db)
|
||||||
|
with open(log, "a") as lf:
|
||||||
|
p = subprocess.Popen([os.path.join(REPO, "target/release/openfut-core")],
|
||||||
|
cwd=tmp, env=env, stdout=lf, stderr=subprocess.STDOUT)
|
||||||
|
wait_http(port, "/health", proc=p, log=log)
|
||||||
|
return p, db
|
||||||
|
|
||||||
|
|
||||||
|
def start_host(tmp, port, core_port, log, variant, coins_processed="0",
|
||||||
|
count_mode="active"):
|
||||||
|
with open(os.path.join(tmp, "catalog.json"), "w") as f:
|
||||||
|
# Minimal STAGING catalog. Deliberately NOT the production catalog, which
|
||||||
|
# lives under /home/alex/openfut-promotion/state/ and must never be opened.
|
||||||
|
json.dump({"schema_version": 1, "game": "fifa17",
|
||||||
|
"cards": {CARD: {"asset_id": 212188, "version": 0,
|
||||||
|
"rareflag": 1, "kind": "player"}}}, f)
|
||||||
|
env = dict(os.environ,
|
||||||
|
OPENFUT_UTAS_HOST_ADDR=f"127.0.0.1:{port}",
|
||||||
|
OPENFUT_CORE_URL=f"http://127.0.0.1:{core_port}",
|
||||||
|
# Deliberately dead: any Python fallback must fail closed and be
|
||||||
|
# visible, never silently serve production data.
|
||||||
|
OPENFUT_UTAS_PYTHON_URL="http://127.0.0.1:9",
|
||||||
|
OPENFUT_FIFA17_TABLES_DIR=os.path.join(REPO, "fifa17-recon/data/tables"),
|
||||||
|
OPENFUT_IDENTITY_STORE=os.path.join(tmp, "identity.json"),
|
||||||
|
OPENFUT_PERSONA_ID=PERSONA,
|
||||||
|
OPENFUT_MARKET_DB=os.path.join(tmp, "market.db"),
|
||||||
|
OPENFUT_PILE_DB=os.path.join(tmp, "pile.db"),
|
||||||
|
OPENFUT_FIFA17_SOLD_EXPERIMENT=variant,
|
||||||
|
OPENFUT_FIFA17_SOLD_COINS_PROCESSED=coins_processed,
|
||||||
|
OPENFUT_FIFA17_SOLD_COUNT_MODE=count_mode,
|
||||||
|
OPENFUT_FIFA17_CATALOG=os.path.join(tmp, "catalog.json"),
|
||||||
|
RUST_LOG="info")
|
||||||
|
with open(log, "w") as lf:
|
||||||
|
p = subprocess.Popen([os.path.join(REPO, "target/release/openfut-utas-host")],
|
||||||
|
cwd=tmp, env=env, stdout=lf, stderr=subprocess.STDOUT)
|
||||||
|
wait_http(port, "/ut/game/fifa17/tradePile/counts", proc=p, log=log)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def banner(t):
|
||||||
|
print("\n" + "=" * 72)
|
||||||
|
print(f"== {t}")
|
||||||
|
print("=" * 72)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--keep", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
tmp = tempfile.mkdtemp(prefix="openfut-sold-wire-")
|
||||||
|
procs = []
|
||||||
|
try:
|
||||||
|
core_port, host_port = free_port(), free_port()
|
||||||
|
core_log = os.path.join(tmp, "core.log")
|
||||||
|
host_log = os.path.join(tmp, "host.log")
|
||||||
|
banner("ISOLATED STAGING (production untouched)")
|
||||||
|
print(f" tmp : {tmp}")
|
||||||
|
print(f" core : 127.0.0.1:{core_port}")
|
||||||
|
print(f" utas-host : 127.0.0.1:{host_port}")
|
||||||
|
print(f" forbidden : {sorted(FORBIDDEN)}")
|
||||||
|
|
||||||
|
core, core_db = start_core(tmp, core_port, core_log)
|
||||||
|
procs.append(core)
|
||||||
|
host = start_host(tmp, host_port, core_port, host_log, "highest")
|
||||||
|
procs.append(host)
|
||||||
|
print(" both ready")
|
||||||
|
|
||||||
|
bann = [l for l in open(host_log) if "sold-experiment" in l]
|
||||||
|
check("host banner names the variant", any("bidState=highest" in l for l in bann),
|
||||||
|
(bann[0].strip() if bann else "no banner"))
|
||||||
|
|
||||||
|
banner("BEFORE — seller A owns the item, nothing listed")
|
||||||
|
own, n, coins = owner_of(core_db, ITEM)
|
||||||
|
print(f" owner={own} instances={n} coins={coins}")
|
||||||
|
st, pile = req(host_port, "GET", "/ut/game/fifa17/tradePile")
|
||||||
|
st2, counts = req(host_port, "GET", "/ut/game/fifa17/tradePile/counts")
|
||||||
|
print(f" /tradePile total={pile.get('total')} counts={json.dumps(counts)}")
|
||||||
|
check("seller owns the item", own == SELLER_CLUB, str(own))
|
||||||
|
check("no rows before listing", pile.get("total") == 0)
|
||||||
|
check("sold counter starts at 0", counts.get("sold") == 0)
|
||||||
|
|
||||||
|
banner(f"LIST — authentic active listing at {GROSS} coins")
|
||||||
|
# Seed the listing directly into the staging market db: the client normally
|
||||||
|
# does this via POST /auctionhouse, which needs a wire-id mapping we do not
|
||||||
|
# have in this headless check. The LISTING SHAPE is identical either way.
|
||||||
|
mdb = os.path.join(tmp, "market.db")
|
||||||
|
con = sqlite3.connect(mdb)
|
||||||
|
con.execute(
|
||||||
|
"INSERT INTO listings (listing_id, card_id, core_item_id, wire_item_id, "
|
||||||
|
"wire_resource_id, start_price, buy_now_price, owner, state, created_at, "
|
||||||
|
"item_json, duration_secs) VALUES (?,?,?,?,?,?,?,?, 'active', ?, ?, ?)",
|
||||||
|
(TRADE_ID, CARD, ITEM, 100000178, 212188, GROSS, GROSS, "CAGE",
|
||||||
|
str(int(time.time() * 1000)), json.dumps({
|
||||||
|
"id": 100000178, "resourceId": 212188, "rating": 75,
|
||||||
|
"preferredPosition": "ST", "itemState": "forSale",
|
||||||
|
"untradeable": False, "assetId": 212188}), 3600))
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
st, pile = req(host_port, "GET", "/ut/game/fifa17/tradePile")
|
||||||
|
st2, counts = req(host_port, "GET", "/ut/game/fifa17/tradePile/counts")
|
||||||
|
row = pile["auctionInfo"][0]
|
||||||
|
print(f" active row: tradeState={row['tradeState']} bidState={row['bidState']} "
|
||||||
|
f"expires={row['expires']} counts={json.dumps(counts)}")
|
||||||
|
check("active row is active/none", row["tradeState"] == "active" and row["bidState"] == "none")
|
||||||
|
check("counts.selling == 1 while active", counts.get("selling") == 1)
|
||||||
|
check("counts.sold still 0 while active", counts.get("sold") == 0)
|
||||||
|
|
||||||
|
banner("PURCHASE — synthetic Buyer B, through the REAL settlement path")
|
||||||
|
out = subprocess.run(
|
||||||
|
[os.path.join(REPO, "target/release/staging_sell"),
|
||||||
|
"--market-db", mdb, "--core-url", f"http://127.0.0.1:{core_port}",
|
||||||
|
"--trade-id", TRADE_ID, "--item", ITEM,
|
||||||
|
"--seller", SELLER_CLUB, "--buyer", BUYER_CLUB, "--gross", str(GROSS)],
|
||||||
|
capture_output=True, text=True, timeout=120)
|
||||||
|
print(" " + "\n ".join((out.stdout + out.stderr).strip().splitlines()))
|
||||||
|
check("staging_sell succeeded", out.returncode == 0, f"exit {out.returncode}")
|
||||||
|
|
||||||
|
own, n, coins = owner_of(core_db, ITEM)
|
||||||
|
fee = GROSS * 5 // 100
|
||||||
|
print(f" owner={own} instances={n} coins={coins} fee={fee}")
|
||||||
|
check("ownership transferred to buyer", own == BUYER_CLUB, str(own))
|
||||||
|
check("exactly ONE authoritative instance", n == 1, str(n))
|
||||||
|
check("buyer debited gross", coins.get(BUYER_CLUB) == 20_000 - GROSS,
|
||||||
|
str(coins.get(BUYER_CLUB)))
|
||||||
|
check("seller credited net", coins.get(SELLER_CLUB) == 1_000 + GROSS - fee,
|
||||||
|
str(coins.get(SELLER_CLUB)))
|
||||||
|
check("economy shrank by exactly the fee",
|
||||||
|
21_000 - sum(coins.values()) == fee, str(21_000 - sum(coins.values())))
|
||||||
|
|
||||||
|
banner("SOLD ROW — variant A: closed / highest")
|
||||||
|
st, pile = req(host_port, "GET", "/ut/game/fifa17/tradePile")
|
||||||
|
st2, counts = req(host_port, "GET", "/ut/game/fifa17/tradePile/counts")
|
||||||
|
st3, status = req(host_port, "GET", f"/ut/game/fifa17/trade/status?tradeIds={TRADE_ID}")
|
||||||
|
a_row = pile["auctionInfo"][0] if pile.get("auctionInfo") else {}
|
||||||
|
print(" " + json.dumps(a_row, indent=2).replace("\n", "\n "))
|
||||||
|
print(f" counts={json.dumps(counts)}")
|
||||||
|
check("sold row is present in the pile", pile.get("total") == 1)
|
||||||
|
check("tradeState closed", a_row.get("tradeState") == "closed")
|
||||||
|
check("bidState highest (variant A)", a_row.get("bidState") == "highest")
|
||||||
|
check("currentBid == sale price", a_row.get("currentBid") == GROSS)
|
||||||
|
check("expires 0", a_row.get("expires") == 0)
|
||||||
|
check("twelve atoms exactly", len(a_row) == 12, str(len(a_row)))
|
||||||
|
check("counts.sold == 1", counts.get("sold") == 1)
|
||||||
|
check("counts.selling == 0", counts.get("selling") == 0)
|
||||||
|
check("/trade/status agrees", status["auctionInfo"][0]["tradeState"] == "closed"
|
||||||
|
and status["auctionInfo"][0]["bidState"] == "highest")
|
||||||
|
|
||||||
|
banner("VARIANT B — same state, restart host with closed / buyNow")
|
||||||
|
host.terminate(); host.wait(timeout=20); procs.remove(host)
|
||||||
|
# coinsProcessed is held CONSTANT at 0 here: the primary A/B must be
|
||||||
|
# attributable to bidState alone. The orthogonal coinsProcessed test is a
|
||||||
|
# separate variant pair (see scripts/sold-ab-differential.py).
|
||||||
|
host = start_host(tmp, host_port, core_port, host_log, "buyNow",
|
||||||
|
coins_processed="0", count_mode="active_plus_sold")
|
||||||
|
procs.append(host)
|
||||||
|
st, pileb = req(host_port, "GET", "/ut/game/fifa17/tradePile")
|
||||||
|
st2, countsb = req(host_port, "GET", "/ut/game/fifa17/tradePile/counts")
|
||||||
|
b_row = pileb["auctionInfo"][0]
|
||||||
|
print(f" bidState={b_row['bidState']} coinsProcessed={b_row['coinsProcessed']} "
|
||||||
|
f"counts={json.dumps(countsb)}")
|
||||||
|
check("bidState buyNow (variant B)", b_row.get("bidState") == "buyNow")
|
||||||
|
check("coinsProcessed held constant at 0", b_row.get("coinsProcessed") == 0)
|
||||||
|
check("count_mode active_plus_sold counts the sold row",
|
||||||
|
countsb.get("count") == 1 and countsb.get("sold") == 1,
|
||||||
|
json.dumps(countsb))
|
||||||
|
differing = sorted(k for k in a_row if a_row.get(k) != b_row.get(k))
|
||||||
|
check("A/B differ ONLY in bidState (one-field attribution)",
|
||||||
|
differing == ["bidState"], str(differing))
|
||||||
|
|
||||||
|
banner("CLEAR — the PE-proven bulk verb DELETE .../trade/sold")
|
||||||
|
pre_coins = owner_of(core_db, ITEM)[2]
|
||||||
|
st, body = req(host_port, "DELETE", "/ut/delete/game/fifa17/trade/sold")
|
||||||
|
print(f" HTTP {st} body={json.dumps(body)}")
|
||||||
|
st2, pilec = req(host_port, "GET", "/ut/game/fifa17/tradePile")
|
||||||
|
st3, countsc = req(host_port, "GET", "/ut/game/fifa17/tradePile/counts")
|
||||||
|
own2, n2, post_coins = owner_of(core_db, ITEM)
|
||||||
|
print(f" after clear: total={pilec.get('total')} counts={json.dumps(countsc)} "
|
||||||
|
f"owner={own2} instances={n2}")
|
||||||
|
check("clear acks 200 {}", st == 200 and body == {})
|
||||||
|
check("sold row gone from the pile", pilec.get("total") == 0)
|
||||||
|
check("counts.sold back to 0", countsc.get("sold") == 0)
|
||||||
|
check("clear moved NO coins", pre_coins == post_coins, f"{pre_coins} -> {post_coins}")
|
||||||
|
check("buyer still owns the item after clear", own2 == BUYER_CLUB, str(own2))
|
||||||
|
check("still exactly one instance", n2 == 1, str(n2))
|
||||||
|
st, again = req(host_port, "DELETE", "/ut/delete/game/fifa17/trade/sold")
|
||||||
|
check("clearing again is a safe no-op", st == 200)
|
||||||
|
cleared = [l for l in open(host_log) if "market-clear-sold" in l]
|
||||||
|
check("clear is logged for capture", bool(cleared),
|
||||||
|
cleared[-1].strip() if cleared else "no log line")
|
||||||
|
|
||||||
|
banner("PRODUCTION UNTOUCHED")
|
||||||
|
alive = subprocess.run(["ps", "-o", "pid=", "-p", "3631953"],
|
||||||
|
capture_output=True, text=True).stdout.strip()
|
||||||
|
check("prod-host pid 3631953 still alive", alive == "3631953", alive or "gone")
|
||||||
|
opened = subprocess.run(
|
||||||
|
["bash", "-lc",
|
||||||
|
"ls -l /proc/*/fd 2>/dev/null | grep -c openfut-promotion || true"],
|
||||||
|
capture_output=True, text=True).stdout.strip()
|
||||||
|
print(f" staging fds referencing production state: (informational) {opened}")
|
||||||
|
|
||||||
|
banner("RESULT")
|
||||||
|
passed = sum(1 for _, ok, _ in checks if ok)
|
||||||
|
print(f" {passed}/{len(checks)} checks passed")
|
||||||
|
failed = [l for l, ok, _ in checks if not ok]
|
||||||
|
if failed:
|
||||||
|
print(" FAILED: " + "; ".join(failed))
|
||||||
|
print("\n " + ("ALL CHECKS PASSED" if not failed else "FAILURES PRESENT"))
|
||||||
|
return 0 if not failed else 1
|
||||||
|
finally:
|
||||||
|
for p in procs:
|
||||||
|
try:
|
||||||
|
p.terminate(); p.wait(timeout=15)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
p.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if args.keep:
|
||||||
|
print(f"\n kept {tmp}")
|
||||||
|
else:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
print(f"\n removed {tmp}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user