Compare commits
268 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a6355cad5 | |||
| 770029f207 | |||
| 802f0f580f | |||
| 6c7d0856b6 | |||
| dcd470cddc | |||
| 8f98e6adda | |||
| 106cb83988 | |||
| 33300f2ad1 | |||
| 12ad04c9d4 | |||
| 9c2edc4eee | |||
| de747b79e6 | |||
| dddcfb917c | |||
| ff915a306e | |||
| d6aa704b01 | |||
| 054a912357 | |||
| 3442eac6f0 | |||
| db743ffd1f | |||
| ab62440dbf | |||
| 92520de6c3 | |||
| be4c52ae89 | |||
| 967a808d73 | |||
| f60dd4da31 | |||
| c59c7d88f7 | |||
| b24e96b7e6 | |||
| a8078e1d8e | |||
| c6abc435cb | |||
| 9f1fc1b47c | |||
| d8d704d441 | |||
| 07d4a92309 | |||
| afa5f620bd | |||
| 56bd9ddc85 | |||
| 9ddd80993c | |||
| 25f4ad12bc | |||
| d37a9d5b5e | |||
| e5d356e8be | |||
| 0b189b36c5 | |||
| 11c17f3039 | |||
| 871d02406f | |||
| 6811caeab1 | |||
| d71234b03d | |||
| 8e1fb640b6 | |||
| 0019806a3b | |||
| c7c057a31a | |||
| 89dc1b1d85 | |||
| 13a22c4507 | |||
| 1db9acdf6d | |||
| 911c7a34fd | |||
| fcc314afb1 | |||
| b323244ac9 | |||
| 1d6a6fffcc | |||
| f56aa613da | |||
| 8c17f896b4 | |||
| c68c10cf04 | |||
| 7116046195 | |||
| dcac2c546b | |||
| 5c8e2dc0bd | |||
| bd03aec82a | |||
| e8d1c1ddac | |||
| f9740f640d | |||
| 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 | |||
| b1d7ed2570 | |||
| dcbef721f2 | |||
| 772f8a615a | |||
| bf9ae20367 | |||
| 58d1f9426f | |||
| 3cd31c4322 | |||
| ae5feb05b7 | |||
| f2c4927ea6 | |||
| aa2abc2772 | |||
| 1aa84afa9a | |||
| 33e9118329 | |||
| e06fd57211 | |||
| 42fd3c7e90 | |||
| 0bc71dbd74 | |||
| 2ecd830d75 | |||
| 3a51b0ebd4 | |||
| ad406f21bd | |||
| 12fb9fc38b | |||
| 7b580a0070 | |||
| 22443a3810 | |||
| 0fce1e521c | |||
| 71fcf5e251 | |||
| 979e71fbea | |||
| 45e0b0bd95 | |||
| 70eb3fc13f | |||
| b30aa352f6 | |||
| 67cc33cfee | |||
| 6eec3b9ec7 | |||
| 57773b98ec | |||
| fe9b899a0e | |||
| abe9e663c1 | |||
| f5a33eb58c | |||
| a85090c3c6 | |||
| 97d48d8371 | |||
| 5020137050 | |||
| cf05ab2a9e | |||
| a6416a3f1d | |||
| 47ced228de | |||
| b8beeba98d | |||
| df6994c957 | |||
| d74e86c065 | |||
| 76512f6048 | |||
| 93a46d4de7 | |||
| 3ba24a0faf | |||
| 6926bb9528 | |||
| b1643309f6 | |||
| 43917a0051 | |||
| 1fac71e3ef | |||
| 747cc234c1 | |||
| fe72f0def2 | |||
| 884ecbba64 | |||
| 580d80a86e | |||
| 0e2ca5a7c3 | |||
| 4d2b8b9be3 | |||
| 0b31abe1d1 | |||
| 6b652cb0a2 | |||
| 3507c5714d | |||
| 4f78b9a875 | |||
| f3aebafcc7 | |||
| 1df0bc4f00 | |||
| 7d5d0cff06 | |||
| 8d752cb0e4 | |||
| 96e80ab293 | |||
| 49c5185ae3 | |||
| 181bd94341 | |||
| 09675a9f7f | |||
| 56c364e4c9 | |||
| 3021a4e761 | |||
| d240a61157 | |||
| d7c5307045 | |||
| 838d76f95e | |||
| 9791eee67b | |||
| 5d119f5555 | |||
| 46e5f612c8 | |||
| 41494bd18f | |||
| 40ebf7c1e7 | |||
| c7609252d2 | |||
| 4cf388dd3b | |||
| 00d85aa6e4 | |||
| d9e80a774a | |||
| a82407c686 | |||
| 805d754dc8 | |||
| d4c3811665 | |||
| b25761ea31 | |||
| 1c396dd562 | |||
| fc29c2eb9b | |||
| b0d5e04bb9 | |||
| 6746c75302 | |||
| b2697b13dc | |||
| e8ee6c34e7 | |||
| f42279f869 | |||
| 54ad9e8f79 | |||
| 6f16a231fc | |||
| 626c972232 | |||
| 44fcf24d92 | |||
| e187cd49a2 | |||
| 1631d3b1a2 | |||
| c71c2a8d33 | |||
| a51947562c | |||
| 63f02c4fb1 | |||
| 4fd5ee2608 | |||
| c7d4b9f753 | |||
| 37c2e5d7ee | |||
| 7dbd878398 | |||
| c2e2e0d8f2 | |||
| afc909fd3b | |||
| b607ff28cb | |||
| cf86d4e425 | |||
| 85761390a8 | |||
| 4d30d8b3e8 | |||
| 0e30980632 | |||
| 46a81e7a07 | |||
| e09344490f | |||
| 80a8bc4520 | |||
| b50e0359f7 | |||
| 58a300c7f4 | |||
| eb8311a5ee | |||
| 550a59d12c | |||
| 8c1d1ed958 | |||
| fc00b0c6f9 | |||
| 5276dd2066 | |||
| 88da16a11e | |||
| 3ef3bc32ec | |||
| 36fe1caa3f | |||
| f55c401b6c | |||
| b8037b9b22 | |||
| c0a3f68ded | |||
| 04c5043aba | |||
| 0b66662525 | |||
| cdea85e214 | |||
| 05f6147433 | |||
| 8f3b659c33 | |||
| c9ae914910 | |||
| 84e81f2037 | |||
| 696386a9c1 | |||
| aa2679162d | |||
| 096d1c882f | |||
| ca63095786 | |||
| c65e9c54ce | |||
| b1bc7a764e | |||
| d7c0a5521d | |||
| 2ae90b1ea9 | |||
| cfb0435d96 | |||
| fc411bb6f1 | |||
| 5bc39e902d | |||
| e2c4ca6d56 | |||
| 288d990821 | |||
| c03702707b | |||
| 89f77470f3 | |||
| 0d576a14b7 | |||
| c5807c07a9 | |||
| 8f5f54833f | |||
| f451406058 | |||
| 8aab2c0d41 | |||
| ed0ccb8c2b | |||
| b40adac3fc | |||
| bfb7876ed4 | |||
| 55b4e54d5f | |||
| fafa2f1858 | |||
| c84fd14cac | |||
| 23374312bc | |||
| a84a72e0c0 | |||
| 6c102f00c0 | |||
| 48aa955212 | |||
| cf3ddde3a6 | |||
| 468b006008 | |||
| e091921b18 | |||
| a9eb54ae9c | |||
| cf961603fe | |||
| cc3ecddc06 | |||
| a9a816e0ed |
@@ -0,0 +1,25 @@
|
||||
# OpenFUT Docker stack configuration. Copy to .env and adjust.
|
||||
# All values have sensible defaults in docker-compose.yml; override as needed.
|
||||
|
||||
# --- Container registry (Gitea) ---
|
||||
# Images resolve to ${REGISTRY}/${NAMESPACE}/<image>:${TAG}
|
||||
# e.g. git.aleshym.co/openfut/openfut-core:latest
|
||||
REGISTRY=git.aleshym.co
|
||||
NAMESPACE=openfut
|
||||
TAG=latest
|
||||
|
||||
# --- Networking ---
|
||||
# Where the bridge (FIFA client entry point) is published. 0.0.0.0 = all
|
||||
# interfaces so LAN clients can connect. Set to a specific IP to restrict.
|
||||
BRIDGE_PUBLISH=0.0.0.0
|
||||
# Where core's REST API is published. 127.0.0.1 keeps it host-local (the bridge
|
||||
# still reaches it over the internal docker network). Set 0.0.0.0 to expose it.
|
||||
CORE_PUBLISH=127.0.0.1
|
||||
|
||||
# --- Behaviour ---
|
||||
# Bridge returns placeholder JSON + captures unknown routes when true.
|
||||
PLACEHOLDER_MODE=true
|
||||
|
||||
# --- Logging (RUST_LOG filters) ---
|
||||
CORE_LOG=openfut_core=info,tower_http=info
|
||||
BRIDGE_LOG=openfut_bridge=info,tower_http=info
|
||||
@@ -30,3 +30,9 @@ Thumbs.db
|
||||
|
||||
# Frozen baseline archives / inspects / manifests
|
||||
/docker-backups/
|
||||
gate-evidence/
|
||||
|
||||
# Raw Fire2 frame captures — forensic evidence, may contain session material.
|
||||
# Sanitize with `blaze-sanitize` before anything leaves this machine.
|
||||
*.ofcap
|
||||
captures/
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
# AGENTS.md — OpenFUT
|
||||
|
||||
**Read this first.** It is the entry point for AI-assisted work on OpenFUT. It supersedes the
|
||||
root `README.md` and `CLAUDE.md`, which are **stale** (they describe an earlier FIFA 23 plan).
|
||||
|
||||
## Project
|
||||
|
||||
OpenFUT is a preservation / private-server project that restores **offline, single-player FIFA
|
||||
Ultimate Team (FUT)** after EA retired the online servers. You must own the game legitimately; the
|
||||
project does not bypass ownership checks — it only re-serves the dead online services locally.
|
||||
|
||||
**Current active target: FIFA 17 (PC).** A clean-room emulation of the full online + FUT stack
|
||||
was proven working end-to-end on **2026-08-01** (auth → Blaze login → device-trust → FUT hub).
|
||||
This lives in `fifa17-recon/`. The FIFA 17 work is explicitly the **Rosetta Stone for FIFA 23**
|
||||
(identical Blaze/LSX/UTAS wire format), so FIFA 23 remains the eventual second target.
|
||||
|
||||
Three moving parts, kept strictly separate:
|
||||
- **The FIFA client** — the retail game (FIFA 17 now). Unmodified except live cert-verify patches.
|
||||
- **The emulation layer** — Python responders in `fifa17-recon/tools/` (LSX, Blaze, UTAS, roster)
|
||||
that impersonate EA's online services on localhost. This is where all reverse engineering lives.
|
||||
- **OpenFUT Core** — a game-independent REST FUT economy backend (`openfut-core/`), feature-complete
|
||||
and tested. Knows nothing about FIFA. Intended to eventually back the emulation layer's FUT data.
|
||||
|
||||
> The emulation layer and Core are **not yet wired together.** The FIFA 17 UTAS server currently
|
||||
> serves its own hardcoded/JSON payloads, not Core's API. See `docs/PROJECT_STATE.md`.
|
||||
|
||||
## Repository map
|
||||
|
||||
Monorepo. `openfut-core`, `openfut-bridge`, `openfut-launcher`, `fifa-blaze` are **git submodules**
|
||||
(each with independent history — use `tea`/Gitea, not `gh`). `fifa17-recon/` is a plain directory.
|
||||
|
||||
| Path | What it is | Status |
|
||||
|---|---|---|
|
||||
| `fifa17-recon/` | **The live path.** FIFA 17 offline FUT emulation: Python responders, cert patcher, runbook, RE write-ups. | Working |
|
||||
| `openfut-core/` | Rust (Axum + SQLite) FUT economy backend. Game-independent REST API. | Working, tested |
|
||||
| `openfut-bridge/` | Rust FIFA 23 in-process hook / proxy RE effort. | Blocked (see below) |
|
||||
| `fifa-blaze/` | Rust Blaze protocol emulator scaffold for FIFA 23 (capture stub). | Milestone 1 stub |
|
||||
| `openfut-launcher/` | Rust egui/eframe desktop launcher (targets FIFA 23 hook flow). | Legacy plan |
|
||||
| `docs/` | **Mirrors** of the vault (`OpenFUT-Vault`), which is canonical. Direction pivots + context. | — |
|
||||
| `tools/` | Host-side RE helpers (file-watch-diff, exporters, squad-injector) from the FLE-bridge idea. | Legacy plan |
|
||||
| `setup.sh` | FIFA 23 full-stack orchestrator (core+bridge). | Legacy plan |
|
||||
|
||||
**Legacy vs live:** the project pivoted twice — (1) FIFA 23 Blaze backend → (2) FIFA 23 as a match
|
||||
renderer driven by an FLE Lua bridge (`docs/direction.md`) → (3) **FIFA 17 full online emulation,
|
||||
which succeeded and is now the primary path** (`fifa17-recon/`). Treat `openfut-bridge`,
|
||||
`openfut-launcher`, `fifa-blaze`, `tools/`, `setup.sh`, and `docs/direction.md` as historical unless
|
||||
a task explicitly targets the FIFA 23 port.
|
||||
|
||||
## Architecture (live path)
|
||||
|
||||
```
|
||||
FIFA 17 client (Wine/Proton, base 0x140000000)
|
||||
│ autopatch.py NOPs two ProtoSSL cert-verify gates in /proc/PID/mem
|
||||
├─ LSX 127.0.0.1:4216 → lsx_responder_v2.py (Origin login/profile/authcode)
|
||||
├─ TLS 127.0.0.1:42127 → blaze_responder_v3b.py (Blaze redirector, via DNAT of 159.153.51.20)
|
||||
├─ Blaze 42130 / Nucleus 42131 → blaze_responder_v3b.py (Fire2/Heat2 binary + login)
|
||||
├─ easw.easports.com (→127.0.0.1) :8099 → utas_server.py (UTAS/RS4 FUT API + device-trust)
|
||||
└─ roster :8081 → roster_server.py (FUT roster-update XML)
|
||||
|
||||
OpenFUT Core (openfut-core, :8080) ── clean REST FUT economy ── NOT YET CONNECTED to the above
|
||||
```
|
||||
|
||||
Host arming (`root_arm.sh` via `pkexec`, volatile across reboot): `ptrace_scope=0`,
|
||||
`route_localnet=1`, iptables DNAT `159.153.51.20→127.0.0.1:42127`, `/etc/hosts easw.easports.com`.
|
||||
|
||||
## Development commands (verified)
|
||||
|
||||
**FIFA 17 emulation** (from `fifa17-recon/tools/`):
|
||||
- Start everything (idempotent; re-run after reboot): `./openfut-fut.sh start`
|
||||
- Status / stop / restart: `./openfut-fut.sh status | stop | restart`
|
||||
- Then launch the game fresh (`~/Desktop/launch-fifa17.sh`) and pick Ultimate Team.
|
||||
- Logs: `/tmp/{lsx,blaze,roster,utas,autopatch}.log`
|
||||
- Full procedure + gate-ladder troubleshooting: `fifa17-recon/FUT-RUNBOOK.md`
|
||||
|
||||
**OpenFUT Core** (from `openfut-core/`): `cargo run` (creates `openfut.db`) · `cargo test`
|
||||
(full in-memory integration suite; requires `data/`) · `cargo test <name>` for one ·
|
||||
`cargo clippy -- -D warnings` · `cargo fmt`. Env: `LISTEN_ADDR` (127.0.0.1:8080), `DATABASE_URL`
|
||||
(sqlite://openfut.db), `DATA_DIR` (data).
|
||||
|
||||
**Other Rust crates** (`openfut-bridge`, `fifa-blaze`, `openfut-launcher`): standard
|
||||
`cargo run/build/test/clippy/fmt` from within each. `fifa-blaze` is a workspace (`--bin blaze-server`).
|
||||
|
||||
**CI:** only `openfut-core` has it (`.gitea/workflows/ci.yml`): `fmt --check`, `clippy -D warnings`,
|
||||
`build --locked`, `test --locked` on push/PR to main. No CI on the other crates or the recon dir.
|
||||
|
||||
There is **no install step, no Docker, no JS/TS frontend, no typecheck** in this repo. Do not invent them.
|
||||
|
||||
## Coding conventions
|
||||
|
||||
- **Rust (Core):** Axum 0.7 + SQLx 0.7 (SQLite, compile-time-checked queries). Strict layering —
|
||||
`routes/` (handlers, extract state, call services) → `services/` (own **all** DB access + logic)
|
||||
→ `models/` (pure `Serde`/`FromRow` data). Errors via `AppError` (`src/error.rs`) with
|
||||
`IntoResponse`. One file per domain across `routes/`, `services/`, `models/`. **Single-profile
|
||||
design:** every service reads "the active profile" as the first DB row — intentional, don't
|
||||
parameterize it. Content is data-driven: JSON under `data/` loaded at startup into Arc registries
|
||||
in `AppState`. Add content by dropping JSON files, not code. Migrations are numbered SQL in
|
||||
`migrations/`. Keep `clippy -D warnings` and `fmt` clean (CI enforces).
|
||||
- **Python (recon):** stdlib-only servers, no framework. Each responder is a standalone script with
|
||||
the reverse-engineered contract documented in its module docstring (byte offsets, VAs, symbol
|
||||
names). When changing a responder, preserve byte-exactness — the client is the oracle.
|
||||
- **Clean-room, always.** Every finding derives from binaries we own + live observation. **Never**
|
||||
use, reference, or reproduce leaked EA source. If a task seems to need it, stop and say so.
|
||||
|
||||
## AI-agent rules
|
||||
|
||||
1. Read this file before exploring the repo.
|
||||
2. Read the vault file relevant to the task (`../OpenFUT-Vault/`), not the whole tree. Repo
|
||||
`docs/` files are mirrors of the vault — consult them for the same content, but treat the
|
||||
vault as canonical.
|
||||
3. Don't scan the whole repository unless the knowledge base is clearly stale — if you find it
|
||||
stale, update the vault, then its repo `docs/` mirror.
|
||||
4. Search the specific directory (`fifa17-recon/`, `openfut-core/src/<layer>/`) before a repo-wide search.
|
||||
5. Update the vault when architecture materially changes (and sync the matching `docs/` mirror).
|
||||
6. Don't refactor or rewrite unrelated working code.
|
||||
7. Prefer small, testable changes; run the narrowest relevant test first (`cargo test <name>`).
|
||||
8. **Never invent EA/FIFA/Blaze protocol behavior.** Values you don't know are `TODO/CONFIRM`, not
|
||||
confident guesses. The live client is the only oracle for whether a gate is satisfied.
|
||||
9. Clearly separate discovered behavior from hypotheses; record findings in
|
||||
`../OpenFUT-Vault/02 Reverse Engineering/FIFA 17/Protocol Findings.md` under the right confidence
|
||||
tier — never silently promote a hypothesis to a fact.
|
||||
10. Root `README.md` / `CLAUDE.md` and `openfut-bridge/CLAUDE.md` describe superseded FIFA 23 plans;
|
||||
prefer vault + repository evidence over them when they conflict.
|
||||
|
||||
## AI Session Bootstrap
|
||||
|
||||
Future agents should start with:
|
||||
1. Read `AGENTS.md`.
|
||||
2. Read the vault README (`../OpenFUT-Vault/README.md`) to locate the canonical files.
|
||||
3. Identify the subsystem the task affects and read the corresponding vault file: Architecture,
|
||||
Project State, Roadmap/Current Priorities, or Protocol Findings.
|
||||
4. Inspect only the relevant source directories.
|
||||
5. Check `../OpenFUT-Vault/02 Reverse Engineering/FIFA 17/Protocol Findings.md` before assuming
|
||||
anything about FIFA/EA behavior.
|
||||
6. Check `../OpenFUT-Vault/06 Agent Memory/Project State.md` before assuming a feature exists.
|
||||
7. Implement the smallest coherent change.
|
||||
8. Run the narrowest relevant tests.
|
||||
9. Update the vault (and its repo `docs/` mirror) only if the change makes existing knowledge
|
||||
inaccurate.
|
||||
|
||||
Do not reread the entire repository during every session.
|
||||
|
||||
## OpenFUT Knowledge Base
|
||||
|
||||
**The OpenFUT Vault is the canonical project knowledge base.** Repo `docs/` files mirror it; the
|
||||
vault wins on any disagreement. Consult it before starting substantial work and update it after
|
||||
durable discoveries.
|
||||
|
||||
Vault location: `../OpenFUT-Vault/` — start at `../OpenFUT-Vault/README.md`.
|
||||
|
||||
Canonical files:
|
||||
- Dashboard: `00 Dashboard/OpenFUT.md`
|
||||
- Architecture: `01 Architecture/Architecture.md` (repo mirror `docs/ARCHITECTURE.md`)
|
||||
- RE findings: `02 Reverse Engineering/FIFA 17/Protocol Findings.md`
|
||||
(repo mirror `docs/research/KNOWN_FINDINGS.md`)
|
||||
- Direction history: `04 Decisions/Direction History.md`
|
||||
- Project State: `06 Agent Memory/Project State.md` (repo mirror `docs/PROJECT_STATE.md`)
|
||||
- Current Priorities: `06 Agent Memory/Current Priorities.md`
|
||||
- Known Issues: `06 Agent Memory/Known Issues.md`
|
||||
- Important Discoveries: `06 Agent Memory/Important Discoveries.md`
|
||||
- Roadmap: `08 Roadmap/Roadmap.md` (repo mirror `docs/ROADMAP.md`)
|
||||
|
||||
When editing knowledge that exists in both places, edit the vault first, then update the matching
|
||||
`docs/` mirror so they stay in sync.
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
> ⚠️ **Stale (FIFA 23).** This file's status and targets predate the FIFA 17 pivot. Prefer [`docs/PROJECT_STATE.md`](./docs/PROJECT_STATE.md) (canonical). The working target is **FIFA 17**; the canonical server is `fifa17-recon/docker/fifa17-python` (`docker compose up -d`). `openfut-bridge` (FIFA 23) is superseded; `openfut-core` remains the shared backend.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
This is a monorepo containing three independent Rust crates as git submodules:
|
||||
|
||||
Generated
+255
-95
@@ -457,6 +457,29 @@ version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.44.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.7.9"
|
||||
@@ -613,19 +636,6 @@ dependencies = [
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blaze-ssl-async"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6fec08f35919613bda0b3eb3bc772c2f793b3634133923b931874b18e1ac55de"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"num_enum",
|
||||
"rsa",
|
||||
"tokio",
|
||||
"x509-cert",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block"
|
||||
version = "0.1.6"
|
||||
@@ -830,6 +840,15 @@ dependencies = [
|
||||
"error-code",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codespan-reporting"
|
||||
version = "0.11.1"
|
||||
@@ -1062,23 +1081,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"der_derive",
|
||||
"flagset",
|
||||
"pem-rfc7468",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der_derive"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -1187,6 +1193,12 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "ecolor"
|
||||
version = "0.29.1"
|
||||
@@ -1457,12 +1469,6 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "flagset"
|
||||
version = "0.4.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
@@ -1547,6 +1553,12 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.33"
|
||||
@@ -2113,9 +2125,9 @@ dependencies = [
|
||||
"futures-util",
|
||||
"http 0.2.12",
|
||||
"hyper 0.14.32",
|
||||
"rustls",
|
||||
"rustls 0.21.12",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-rustls 0.24.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3126,11 +3138,34 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "openfut-adapter-fifa17"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openfut-protocol-blaze",
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfut-autopatch"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "openfut-blaze-host"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openfut-adapter-fifa17",
|
||||
"openfut-protocol-blaze",
|
||||
"rand",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfut-bridge"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"anyhow",
|
||||
"axum",
|
||||
"bytes",
|
||||
@@ -3141,13 +3176,13 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"rcgen",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"rustls 0.21.12",
|
||||
"rustls-pemfile 1.0.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-rustls 0.24.1",
|
||||
"tokio-stream",
|
||||
"tower 0.4.13",
|
||||
"tower-http",
|
||||
@@ -3156,6 +3191,10 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfut-common"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "openfut-core"
|
||||
version = "0.1.0"
|
||||
@@ -3171,6 +3210,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tower 0.5.3",
|
||||
@@ -3181,10 +3221,40 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfut-hook"
|
||||
name = "openfut-host-config"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"openfut-adapter-fifa17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfut-http"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "openfut-identity"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfut-import-fifa17"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"openfut-adapter-fifa17",
|
||||
"openfut-core",
|
||||
"openfut-identity",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3196,11 +3266,73 @@ dependencies = [
|
||||
"dirs",
|
||||
"eframe",
|
||||
"egui",
|
||||
"openfut-common",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfut-lsx"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"parking_lot",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfut-protocol-blaze"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfut-redirector-host"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openfut-adapter-fifa17",
|
||||
"openfut-host-config",
|
||||
"openfut-http",
|
||||
"openfut-tls",
|
||||
"openssl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfut-roster-host"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openfut-adapter-fifa17",
|
||||
"openfut-host-config",
|
||||
"openfut-http",
|
||||
"openfut-tls",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfut-tls"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openssl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfut-utas-host"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"openfut-adapter-fifa17",
|
||||
"openfut-core",
|
||||
"openfut-http",
|
||||
"openfut-identity",
|
||||
"parking_lot",
|
||||
"rand",
|
||||
"reqwest",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.81"
|
||||
@@ -3232,6 +3364,15 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-src"
|
||||
version = "300.6.1+3.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.117"
|
||||
@@ -3240,6 +3381,7 @@ checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"openssl-src",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
@@ -3680,8 +3822,8 @@ dependencies = [
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"rustls 0.21.12",
|
||||
"rustls-pemfile 1.0.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
@@ -3689,7 +3831,7 @@ dependencies = [
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-rustls 0.24.1",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
@@ -3828,10 +3970,26 @@ checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
|
||||
dependencies = [
|
||||
"log",
|
||||
"ring 0.17.14",
|
||||
"rustls-webpki",
|
||||
"rustls-webpki 0.101.7",
|
||||
"sct",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.43"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring 0.17.14",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki 0.103.13",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "1.0.4"
|
||||
@@ -3841,6 +3999,24 @@ dependencies = [
|
||||
"base64 0.21.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.101.7"
|
||||
@@ -3851,6 +4027,18 @@ dependencies = [
|
||||
"untrusted 0.9.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring 0.17.14",
|
||||
"rustls-pki-types",
|
||||
"untrusted 0.9.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
@@ -4037,15 +4225,17 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blaze-proto",
|
||||
"blaze-ssl-async",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"rustls 0.23.43",
|
||||
"rustls-pemfile 2.2.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tdf",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-util",
|
||||
"toml",
|
||||
"tracing",
|
||||
@@ -4063,6 +4253,12 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1_smol"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -4329,8 +4525,8 @@ dependencies = [
|
||||
"once_cell",
|
||||
"paste",
|
||||
"percent-encoding",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"rustls 0.21.12",
|
||||
"rustls-pemfile 1.0.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -4784,27 +4980,6 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tls_codec"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b"
|
||||
dependencies = [
|
||||
"tls_codec_derive",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tls_codec_derive"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.1"
|
||||
@@ -4849,7 +5024,17 @@ version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"rustls 0.21.12",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls 0.23.43",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -5214,6 +5399,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"sha1_smol",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -6152,18 +6338,6 @@ version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[package]]
|
||||
name = "x509-cert"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"der",
|
||||
"spki",
|
||||
"tls_codec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xcursor"
|
||||
version = "0.3.11"
|
||||
@@ -6388,20 +6562,6 @@ name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
|
||||
+22
-1
@@ -2,9 +2,30 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"openfut-core",
|
||||
"openfut-protocol-blaze",
|
||||
"openfut-adapter-fifa17",
|
||||
"openfut-blaze-host",
|
||||
"openfut-host-config",
|
||||
"openfut-http",
|
||||
"openfut-tls",
|
||||
"openfut-redirector-host",
|
||||
"openfut-roster-host",
|
||||
"openfut-utas-host",
|
||||
"openfut-identity",
|
||||
"openfut-import-fifa17",
|
||||
"openfut-bridge",
|
||||
"openfut-launcher",
|
||||
"openfut-launcher/openfut-hook",
|
||||
# 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/server",
|
||||
]
|
||||
# openfut-hook is a Windows-only version.dll proxy injected into the FIFA client.
|
||||
# It MUST build with its own [profile.release] (panic="abort" — unwinding across
|
||||
# the DllMain/FFI boundary into the game process is UB — plus strip + opt-level="s").
|
||||
# Cargo ignores a non-root member's profile and forbids per-package `panic` overrides,
|
||||
# so the hook is deliberately EXCLUDED from this workspace to build as its own root
|
||||
# (this also lands its artifact in openfut-hook/target/, matching the launcher's
|
||||
# config.rs default hook_dll_path). Build: cargo build --release --target x86_64-pc-windows-gnu.
|
||||
exclude = ["openfut-launcher/openfut-hook"]
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# OpenFUT
|
||||
|
||||
> ⚠️ **Status — see [`docs/PROJECT_STATE.md`](./docs/PROJECT_STATE.md) (canonical).** The working, actively-developed target is **FIFA 17**, not FIFA 23. Everything below this banner describes the **superseded FIFA 23 `bridge` lineage** and is kept for historical context.
|
||||
>
|
||||
> **Run the server (canonical):** `cd fifa17-recon/docker/fifa17-python && docker compose up -d` — see [`fifa17-recon/FUT-RUNBOOK.md`](./fifa17-recon/FUT-RUNBOOK.md). `openfut-core` is the shared offline backend (still used by the FIFA 17 path); `openfut-bridge` is the retired FIFA 23 integration.
|
||||
|
||||
**Offline Ultimate Team — like SPT, but for FIFA 23.**
|
||||
|
||||
OpenFUT replaces EA's retired FUT servers with a fully offline, single-player backend. You own FIFA 23 legitimately. You just want to keep playing after EA shut down the servers.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# ============================================================================
|
||||
# ⚠️ LEGACY (FIFA 23 lineage). This compose runs core + bridge for the
|
||||
# superseded FIFA 23 direction. It is NOT the canonical server bring-up.
|
||||
#
|
||||
# Canonical server (FIFA 17):
|
||||
# cd fifa17-recon/docker/fifa17-python && docker compose up -d
|
||||
# (runbook: fifa17-recon/FUT-RUNBOOK.md)
|
||||
#
|
||||
# `core` (openfut-core) IS still the shared, game-independent backend and is
|
||||
# used by the FIFA 17 UTAS host (OPENFUT_CORE_URL). `bridge` (openfut-bridge)
|
||||
# is the retired FIFA 23 integration, kept for reference.
|
||||
# Status source of truth: docs/PROJECT_STATE.md
|
||||
# ============================================================================
|
||||
# OpenFUT server stack — offline FUT backend (Core) + FIFA proxy (Bridge).
|
||||
#
|
||||
# Bring up: docker compose up -d
|
||||
# Tear down: docker compose down (keeps data/captures volumes)
|
||||
# Wipe state: docker compose down -v (also drops volumes)
|
||||
# Rebuild: docker compose build (or ./scripts/registry.sh build)
|
||||
# Logs: docker compose logs -f
|
||||
#
|
||||
# Images are pulled from / pushed to the Gitea container registry. Override the
|
||||
# registry, namespace, or tag in .env (see .env.example). When REGISTRY is set,
|
||||
# `up` pulls prebuilt images; the build: blocks let you rebuild locally too.
|
||||
|
||||
name: openfut
|
||||
|
||||
services:
|
||||
core:
|
||||
image: ${REGISTRY:-git.aleshym.co}/${NAMESPACE:-openfut}/openfut-core:${TAG:-latest}
|
||||
build:
|
||||
context: ./openfut-core
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
LISTEN_ADDR: 0.0.0.0:8080
|
||||
DATABASE_URL: sqlite:///app/db/openfut.db
|
||||
DATA_DIR: /app/data
|
||||
RUST_LOG: ${CORE_LOG:-openfut_core=info,tower_http=info}
|
||||
volumes:
|
||||
- core-db:/app/db
|
||||
# Bound to localhost by default — the bridge reaches core over the internal
|
||||
# network, so core need not be world-exposed. Set CORE_PUBLISH=0.0.0.0 in
|
||||
# .env if you want to hit the REST API directly from other hosts.
|
||||
ports:
|
||||
- "${CORE_PUBLISH:-127.0.0.1}:8080:8080"
|
||||
networks:
|
||||
- openfut
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
|
||||
interval: 15s
|
||||
timeout: 4s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
bridge:
|
||||
image: ${REGISTRY:-git.aleshym.co}/${NAMESPACE:-openfut}/openfut-bridge:${TAG:-latest}
|
||||
build:
|
||||
context: ./openfut-bridge
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
core:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
BRIDGE_LISTEN_ADDR: 0.0.0.0:8443
|
||||
CORE_URL: http://core:8080
|
||||
CAPTURES_DIR: /app/captures
|
||||
PLACEHOLDER_MODE: ${PLACEHOLDER_MODE:-true}
|
||||
TLS_ENABLED: "true"
|
||||
RUST_LOG: ${BRIDGE_LOG:-openfut_bridge=info,tower_http=info}
|
||||
volumes:
|
||||
- bridge-captures:/app/captures
|
||||
# The FIFA client connects here — publish on all interfaces by default so
|
||||
# LAN clients (e.g. 10.10.0.0/24) can reach it.
|
||||
ports:
|
||||
- "${BRIDGE_PUBLISH:-0.0.0.0}:8443:8443"
|
||||
networks:
|
||||
- openfut
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsSk", "https://127.0.0.1:8443/_bridge/health"]
|
||||
interval: 15s
|
||||
timeout: 4s
|
||||
retries: 5
|
||||
start_period: 8s
|
||||
|
||||
networks:
|
||||
openfut:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
core-db:
|
||||
bridge-captures:
|
||||
@@ -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,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,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.
|
||||
+1
-1
Submodule fifa-blaze updated: d2a9a01ec9...f4f33969f2
@@ -1,11 +1,37 @@
|
||||
# Copy to .env in this directory. Required for remote deployment.
|
||||
#
|
||||
# OPENFUT_ADVERTISE — the address of THIS host as seen from the game machine
|
||||
# (105). The responders advertise it to the client for every next hop (Blaze,
|
||||
# roster, UTAS, POW). Compose refuses to start without it.
|
||||
OPENFUT_ADVERTISE=10.10.0.120
|
||||
# OPENFUT_ADVERTISE — the IP address of THIS host as seen from the game machine
|
||||
# (105). Responders advertise it for Blaze, UTAS, telemetry, and QoS.
|
||||
OPENFUT_ADVERTISE=203.0.113.10 # <- REPLACE with this host's LAN IP
|
||||
|
||||
# OPENFUT_BIND — address the listeners bind inside the container.
|
||||
# Defaults to 0.0.0.0 (container-facing); the original all-on-localhost flow
|
||||
# uses the loopback default baked into the responders when unset.
|
||||
OPENFUT_BIND=0.0.0.0
|
||||
|
||||
# FIFA17's roster verifier accepts dNSName SANs but ignores iPAddress SANs.
|
||||
# Advertise the certificate's DNS identity, then resolve that one hostname to
|
||||
# OPENFUT_ADVERTISE on the client without changing the URL or certificate.
|
||||
OPENFUT_ROSTER_HOST=winter15.gosredirector.ea.com:8081
|
||||
|
||||
# OPENFUT_SERVERS — which Python responders Docker runs (space/comma separated).
|
||||
# Default (unset) = the server-side set: "blaze roster utas pow".
|
||||
#
|
||||
# This host is the SERVER (.120). Docker runs ONLY components that have NOT been
|
||||
# migrated to a Rust host. During migration the Rust hosts (redirector / roster
|
||||
# / utas) run OUTSIDE Docker; as each Python component is replaced, remove its
|
||||
# name here so the two never serve the same role at once.
|
||||
# blaze Blaze redirector + main + nucleus (bundled) :42127 :42130 :42131
|
||||
# roster FUT roster-update XML :8081
|
||||
# utas FUT/UTAS RS4 API :8099
|
||||
# (Rust utas-host still proxies its non-/club routes here for now)
|
||||
# pow POW / EASFC :8094 (+ content :8080)
|
||||
# lsx Origin LSX bootstrap :4216
|
||||
# CLIENT-SIDE: LSX runs on the game machine (.105) with autopatch, NOT
|
||||
# on this server. Leave it OUT unless client and server share one box.
|
||||
#
|
||||
# Example — Rust already owns roster, so Docker should not also serve it:
|
||||
# OPENFUT_SERVERS=blaze utas pow
|
||||
# When you drop a component, also stop advertising / DNAT'ing its port to this
|
||||
# container so the client is routed to the Rust host instead.
|
||||
#OPENFUT_SERVERS=blaze roster utas pow
|
||||
|
||||
@@ -37,17 +37,21 @@ RUN set -eu; \
|
||||
|
||||
COPY data/ /app/data/
|
||||
|
||||
# Redirector 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
|
||||
# git-ignored (*.pem/*.key); regenerate if absent so a fresh checkout builds
|
||||
# without extra steps.
|
||||
# Redirector/roster TLS certificate. FIFA17's roster verifier compares only
|
||||
# dNSName SAN entries, so deployment advertises winter15.gosredirector.ea.com
|
||||
# through OPENFUT_ROSTER_HOST and resolves that hostname on the client. The
|
||||
# entrypoint validates this stable certificate; it never reissues it for an IP
|
||||
# SAN that the verifier ignores.
|
||||
#
|
||||
# OpenSSL remains in the image both to create the git-ignored keypair on a fresh
|
||||
# checkout and to validate the configured DNS identity at startup.
|
||||
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 \
|
||||
apt-get update && apt-get install -y --no-install-recommends openssl && \
|
||||
openssl req -x509 -newkey rsa:2048 -nodes \
|
||||
-keyout tools/redir_key.pem -out tools/redir_cert.pem \
|
||||
-days 3650 -subj "/CN=winter15.gosredirector.ea.com" \
|
||||
-addext "subjectAltName=DNS:winter15.gosredirector.ea.com,DNS:*.gosredirector.ea.com,DNS:*.ea.com" && \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
-addext "subjectAltName=DNS:winter15.gosredirector.ea.com,DNS:*.gosredirector.ea.com,DNS:*.ea.com,IP:127.0.0.1"; \
|
||||
fi
|
||||
|
||||
# Bake a dataset manifest so every image is self-identifying.
|
||||
|
||||
@@ -20,13 +20,15 @@
|
||||
# after these first redirected contacts the game is handed <SERVER_IP> for every
|
||||
# later hop (Blaze main, roster, UTAS, telemetry) and dials the server directly.
|
||||
#
|
||||
# Usage: sudo OPENFUT_SERVER=10.10.0.120 ./client_arm.sh
|
||||
# Usage: sudo OPENFUT_SERVER=203.0.113.10 ./client_arm.sh
|
||||
# (re-run after every reboot; the sysctl/iptables state is volatile)
|
||||
# ============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SERVER="${OPENFUT_SERVER:?set OPENFUT_SERVER to the backend host IP, e.g. 10.10.0.120}"
|
||||
SERVER="${OPENFUT_SERVER:?set OPENFUT_SERVER to the backend host IP, e.g. 203.0.113.10}"
|
||||
GOS_EA_IP="159.153.51.20" # winter15.gosredirector.ea.com (hardcoded in FIFA17)
|
||||
UTAS_HOST="easw.easports.com" # dead UTAS host baked into CardsDLL
|
||||
UTAS_RE="${UTAS_HOST//./\\.}" # same, safe to embed in a regex
|
||||
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "!! must run as root (sudo). Re-run: sudo OPENFUT_SERVER=$SERVER $0" >&2
|
||||
@@ -55,13 +57,46 @@ iptables -t nat -A POSTROUTING -p tcp -d "$SERVER" --dport 42127 -j MASQUERADE
|
||||
# 3) Point the dead hardcoded UTAS host at the server. The port (8099) is carried
|
||||
# in the game's own URL, so only the name needs redirecting. Remove any prior
|
||||
# OpenFUT-managed line (loopback or other server) and write the current one.
|
||||
sed -i '/[[:space:]]easw\.easports\.com\b.*# openfut$/d' /etc/hosts
|
||||
printf '%s\teasw.easports.com\t# openfut\n' "$SERVER" >> /etc/hosts
|
||||
sed -i "/[[:space:]]${UTAS_RE}\b.*# openfut\$/d" /etc/hosts
|
||||
printf '%s\t%s\t# openfut\n' "$SERVER" "$UTAS_HOST" >> /etc/hosts
|
||||
|
||||
echo "[client_arm] --- armed ---"
|
||||
sysctl kernel.yama.ptrace_scope
|
||||
iptables -t nat -L OUTPUT -n | grep -i "$GOS_EA_IP" || echo " (DNAT missing!)"
|
||||
grep 'easw.easports.com' /etc/hosts && echo " /etc/hosts ok" || echo " (/etc/hosts easw missing!)"
|
||||
|
||||
# Verify the hosts entry by EFFECT, not by presence.
|
||||
#
|
||||
# glibc returns the FIRST match in /etc/hosts, so our line can be written
|
||||
# correctly and still lose to an earlier one -- and the sed above only removes
|
||||
# lines this script wrote (`# openfut`), so re-running never clears a foreign
|
||||
# one. The old check here was `grep easw /etc/hosts && echo ok`, which passed on
|
||||
# the shadowing line itself and reported success while resolution was wrong.
|
||||
#
|
||||
# Observed on 2026-08-11: a leftover `127.0.0.1 easw.easports.com` from the
|
||||
# single-machine era shadowed the OpenFUT line, and every re-run said "ok".
|
||||
resolved="$(getent ahosts "$UTAS_HOST" 2>/dev/null | awk '{print $1}' | sort -u | tr '\n' ' ')"
|
||||
# SERVER may be a hostname, so compare address-to-address rather than comparing
|
||||
# the literal string against resolved IPs (which would warn spuriously).
|
||||
server_ips="$(getent ahosts "$SERVER" 2>/dev/null | awk '{print $1}' | sort -u)"
|
||||
[ -n "$server_ips" ] || server_ips="$SERVER"
|
||||
match=0
|
||||
for ip in $server_ips; do
|
||||
printf '%s' "$resolved" | grep -qw -- "$ip" && match=1
|
||||
done
|
||||
if [ "$match" -eq 1 ]; then
|
||||
echo " /etc/hosts ok ($UTAS_HOST -> $resolved)"
|
||||
else
|
||||
echo
|
||||
echo " !! WARNING: $UTAS_HOST resolves to [$resolved], not $SERVER."
|
||||
echo " An earlier /etc/hosts line is shadowing the OpenFUT one:"
|
||||
grep -nE "^[[:space:]]*[^#].*[[:space:]]${UTAS_RE}([[:space:]]|\$)" /etc/hosts \
|
||||
| grep -v '# openfut$' | sed 's/^/ /' || true
|
||||
echo
|
||||
echo " Not fatal: the responders advertise $SERVER, so the game stops using"
|
||||
echo " this name after the first hop. Worth removing the line above anyway."
|
||||
echo " Lines are listed rather than deleted -- this script will not remove"
|
||||
echo " /etc/hosts entries it did not write."
|
||||
fi
|
||||
echo
|
||||
echo "[client_arm] Next: start the LOCAL pieces (LSX + autopatch) with client_local.sh,"
|
||||
echo " ensure the container is up on $SERVER, then launch FIFA 17."
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
# cp .env.example .env # set OPENFUT_ADVERTISE to THIS host's LAN IP
|
||||
# docker compose up -d --build
|
||||
#
|
||||
# Brings up the 5 responders the game dials. OPENFUT_ADVERTISE is the address
|
||||
# the servers hand the client (105) for every next hop (Blaze, roster, UTAS,
|
||||
# POW) and is required — there is no silent loopback fallback in remote mode.
|
||||
# Brings up the 5 responders the game dials. OPENFUT_ADVERTISE is the server IP
|
||||
# handed out for Blaze, UTAS, telemetry, and QoS; OPENFUT_ROSTER_HOST is the
|
||||
# certificate DNS identity handed out for roster HTTPS.
|
||||
#
|
||||
# The client (105) still needs its first-hop redirect (hook or DNAT) plus
|
||||
# autopatch.py running locally; see client_arm.sh and the FIFARUNBOOK.
|
||||
@@ -24,7 +24,10 @@ services:
|
||||
OPENFUT_BIND: "${OPENFUT_BIND:-0.0.0.0}"
|
||||
# Address advertised to the client for the next hop. MUST be this host's
|
||||
# LAN IP as seen from the game machine (105). Required (see .env.example).
|
||||
OPENFUT_ADVERTISE: "${OPENFUT_ADVERTISE:?set OPENFUT_ADVERTISE in .env to this host's LAN IP, e.g. 10.10.0.120}"
|
||||
OPENFUT_ADVERTISE: "${OPENFUT_ADVERTISE:?set OPENFUT_ADVERTISE in .env to this host's LAN IP, e.g. 203.0.113.10}"
|
||||
# FIFA17 roster TLS matches only certificate dNSName SANs. The client must
|
||||
# resolve this hostname to OPENFUT_ADVERTISE.
|
||||
OPENFUT_ROSTER_HOST: "${OPENFUT_ROSTER_HOST:-winter15.gosredirector.ea.com:8081}"
|
||||
# POW content advertises port 8080 by default, which collides with the
|
||||
# openfut-core publish on this host. Remap it to 8085 on the host and
|
||||
# advertise the remapped endpoint.
|
||||
@@ -36,10 +39,14 @@ services:
|
||||
FUT_PROFILE_ROOT: "/state/accounts"
|
||||
FUT_SETTINGS: "off"
|
||||
FUT_MODES: "1"
|
||||
# Which Python responders this SERVER runs. Default excludes lsx (that is
|
||||
# a client-side responder — see below). Drop a name once it is migrated to
|
||||
# a Rust host (run outside Docker) so the two never overlap. See .env.example.
|
||||
OPENFUT_SERVERS: "${OPENFUT_SERVERS:-blaze roster utas pow}"
|
||||
volumes:
|
||||
- "../state:/state"
|
||||
ports:
|
||||
- "4216:4216" # LSX (Origin bootstrap)
|
||||
- "4216:4216" # LSX — CLIENT-SIDE (.105); only used if lsx is enabled for all-on-one-box
|
||||
- "42127:42127" # Blaze redirector (TLS)
|
||||
- "42130:42130" # Blaze main
|
||||
- "42131:42131" # Nucleus OAuth stub
|
||||
|
||||
@@ -8,25 +8,39 @@
|
||||
# autopatch.py is NOT run here: it patches the FIFA17.exe process memory and must
|
||||
# run on the box the game runs on.
|
||||
#
|
||||
# Address behaviour is driven by two env vars (see each responder):
|
||||
# OPENFUT_BIND bind address for every listener (container: 0.0.0.0)
|
||||
# OPENFUT_ADVERTISE address handed to the client for the next hop
|
||||
# (the server's LAN IP, e.g. 10.10.0.120)
|
||||
# Address behaviour is driven by three env vars (see each responder):
|
||||
# OPENFUT_BIND bind address for every listener (container: 0.0.0.0)
|
||||
# OPENFUT_ADVERTISE IP address handed out for Blaze, UTAS, telemetry, and QoS
|
||||
# OPENFUT_ROSTER_HOST certificate DNS host:port handed out for roster HTTPS
|
||||
# ============================================================================
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$(readlink -f "$0")")/tools"
|
||||
|
||||
BIND="${OPENFUT_BIND:-0.0.0.0}"
|
||||
ADV="${OPENFUT_ADVERTISE:?OPENFUT_ADVERTISE must be set to the server LAN IP (e.g. 10.10.0.120)}"
|
||||
ADV="${OPENFUT_ADVERTISE:?OPENFUT_ADVERTISE must be set to the server LAN IP (e.g. 203.0.113.10)}"
|
||||
ROSTER_HOST="${OPENFUT_ROSTER_HOST:-winter15.gosredirector.ea.com:8081}"
|
||||
export OPENFUT_BIND="$BIND"
|
||||
export OPENFUT_ADVERTISE="$ADV"
|
||||
export OPENFUT_ROSTER_HOST="$ROSTER_HOST"
|
||||
# POW keys advertised by blaze must also point at the server, not loopback.
|
||||
export POW_HOST="${POW_HOST:-$ADV:8094}"
|
||||
export POW_CONTENT_HOST="${POW_CONTENT_HOST:-$ADV:8080}"
|
||||
export POW_ADDR="${POW_ADDR:-$BIND:8094}"
|
||||
export POW_CONTENT_ADDR="${POW_CONTENT_ADDR:-$BIND:8080}"
|
||||
|
||||
echo "[openfut] bind=$BIND advertise=$ADV"
|
||||
echo "[openfut] bind=$BIND advertise=$ADV roster=$ROSTER_HOST"
|
||||
|
||||
# FIFA17's roster verifier compares only dNSName SAN entries. It ignores a valid
|
||||
# iPAddress SAN when the advertised URL contains an IP literal, so certificate
|
||||
# regeneration cannot fix that URL. Keep the certificate stable and fail startup
|
||||
# if the configured roster hostname is not already one of its DNS identities.
|
||||
CERT=redir_cert.pem
|
||||
ROSTER_NAME="${ROSTER_HOST%%:*}"
|
||||
if ! openssl x509 -in "$CERT" -noout -checkhost "$ROSTER_NAME" >/dev/null 2>&1; then
|
||||
echo "[openfut] FATAL: TLS cert does not cover roster hostname $ROSTER_NAME" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[openfut] roster certificate matches $ROSTER_NAME; fingerprint: $(openssl x509 -in "$CERT" -noout -fingerprint -sha256)"
|
||||
|
||||
# name script extra-env
|
||||
declare -a SERVERS=(
|
||||
@@ -37,10 +51,40 @@ declare -a SERVERS=(
|
||||
"pow|pow_server.py|-"
|
||||
)
|
||||
|
||||
# ── Component selection ──────────────────────────────────────────────────────
|
||||
# OPENFUT_SERVERS picks which Python responders run (space- or comma-separated).
|
||||
# This container is the SERVER side (.120). It serves ONLY components that have
|
||||
# NOT been migrated to a Rust host — as each moves to Rust (which runs OUTSIDE
|
||||
# Docker during migration), drop its name so the two never serve the same role.
|
||||
# blaze Blaze redirector + main + nucleus (bundled) :42127 :42130 :42131
|
||||
# roster FUT roster-update XML :8081
|
||||
# utas FUT/UTAS RS4 API :8099
|
||||
# (the Rust utas-host currently reverse-proxies its non-/club routes
|
||||
# back here, so keep this enabled until UTAS is fully migrated)
|
||||
# pow POW / EASFC :8094 (+ content :8080)
|
||||
# lsx Origin LSX bootstrap :4216
|
||||
# CLIENT-SIDE — LSX runs on the game machine (.105) with autopatch,
|
||||
# NOT on the server. Excluded by default; enable ONLY for an
|
||||
# all-on-one-box dev setup where client and server share a host.
|
||||
OPENFUT_SERVERS="${OPENFUT_SERVERS:-blaze roster utas pow}"
|
||||
want=" ${OPENFUT_SERVERS//,/ } "
|
||||
known=" lsx blaze roster utas pow "
|
||||
for w in $want; do
|
||||
case "$known" in
|
||||
*" $w "*) ;;
|
||||
*) echo "[openfut] unknown component '$w' in OPENFUT_SERVERS (valid: lsx blaze roster utas pow)" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
echo "[openfut] servers=$OPENFUT_SERVERS"
|
||||
|
||||
pids=()
|
||||
names=()
|
||||
for entry in "${SERVERS[@]}"; do
|
||||
IFS='|' read -r name script env <<<"$entry"
|
||||
case "$want" in
|
||||
*" $name "*) ;;
|
||||
*) echo "[openfut] skipping $name (not in OPENFUT_SERVERS)"; continue ;;
|
||||
esac
|
||||
envprefix=""; [ "$env" != "-" ] && envprefix="env $env"
|
||||
echo "[openfut] starting $name ($script)"
|
||||
# shellcheck disable=SC2086
|
||||
@@ -49,6 +93,11 @@ for entry in "${SERVERS[@]}"; do
|
||||
names+=("$name")
|
||||
done
|
||||
|
||||
if [ "${#pids[@]}" -eq 0 ]; then
|
||||
echo "[openfut] OPENFUT_SERVERS selected no components; nothing to run" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Propagate SIGTERM/SIGINT to children so `docker stop` is clean.
|
||||
term() {
|
||||
echo "[openfut] shutting down…"
|
||||
|
||||
@@ -424,6 +424,25 @@ Chemistry/rating/nation/league-count constraints (`teamChemistry 0x307`, `starRa
|
||||
generically as `{eligibilityKey, eligibilityOperation, eligibilityValue}` triples, **not** as
|
||||
named scalar fields on the record. **FREEZE-RISK: elgReq must be a JSON array of objects.**
|
||||
|
||||
> **2026-08-19 — `eligibilityKey`/`eligibilityOperation` are LOCALIZATION ORDINALS, not the
|
||||
> atom hex ids above.** Reversed from the pinned CardsDLL (`4706a881…`). The client's sole
|
||||
> confirmed consumer of these fields is the requirement-display string builder at
|
||||
> `~0x1800ef900`: it loads the eligibility int fields (`0x148(rcx)`) and formats them through
|
||||
> *indexed localization keys* — `ELIGIBILITY_STRING%d` (`0x1802186b8`), `LOC_SBC_ELG_KEY_%d`
|
||||
> (`0x180226710`), `ELIGIBILITY_OPERATION` (`0x1802186e8`) — appending to a string builder via
|
||||
> vtable `*0x10`/`*0x20`. There is **no comparison/branch**: the client does not validate on
|
||||
> these ints, it renders `LOC_SBC_ELG_KEY_<eligibilityKey>` (and an operation string) as
|
||||
> display text. Therefore `eligibilityKey` is a small ordinal that indexes the **packed FIFA17
|
||||
> locale**, NOT `0x307`/`0x22f`/etc. (those hex values are the atom ids of the *named* fields
|
||||
> the encoding replaces, not the ordinal values). CONSEQUENCE: correct projection needs the
|
||||
> ordinal→locale-string map, which lives only in the packed locale (absent from CardsDLL and
|
||||
> every `fifa17-recon/data` file; a game-dir locale probe on the live client found none) or a
|
||||
> real EA `elgReq` capture (unavailable on a private server). Emitting a *guessed* ordinal
|
||||
> renders the WRONG requirement text to the player, so `elgReq` stays `[]` until the ordinal
|
||||
> map is recovered. This is a display-only gap: SBC submission is fully validated server-side
|
||||
> (Core), and an invalid squad's generic comms modal originates from the server 400, not from
|
||||
> the empty `elgReq`.
|
||||
|
||||
**awards / grantedAwards** — nested array of reward objects (atoms: `rewardType 0x28e`,
|
||||
`rewardValue 0x28f`, `rewardQuantity 0x28d`, `rewardMultiplier 0x28c`, `awardCount 0x40`,
|
||||
`awardSet 0x45`, `awardSetId 0x46`, `prizeSet 0x253`). **FREEZE-RISK: must be array.**
|
||||
|
||||
+141
-38
@@ -24,6 +24,45 @@ STORE_PATCHES = {
|
||||
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.
|
||||
STORE_PATCHES_GUARDED = {
|
||||
0x180014858: (bytes.fromhex("750f"), bytes.fromhex("7f0f")), # JNZ 0x14869 -> JG 0x14869
|
||||
}
|
||||
|
||||
# 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).
|
||||
EMPTY_MYPACKS_RESOLVER_CAPABILITY = "fifa17.empty_mypacks_resolver"
|
||||
EMPTY_MYPACKS_RESOLVER_VERSION = 1
|
||||
|
||||
# The guarded site whose verified enforcement backs the capability above.
|
||||
RESOLVER_GUARD_VA = 0x180014858
|
||||
|
||||
# Per-FIFA-pid guard status (fail-closed; FIFA17_PATCHED_CLIENT_CAPABILITY.md #4).
|
||||
GUARD_NOT_ATTEMPTED = "NOT_ATTEMPTED" # CardsDLL not mapped / guard not yet evaluated
|
||||
GUARD_VERIFIED = "VERIFIED" # live bytes == patch after enforcement (patch or noop)
|
||||
GUARD_UNSUPPORTED_BUILD = "UNSUPPORTED_BUILD" # neither original nor patched (guarded_action -> skip)
|
||||
GUARD_WRITE_FAILED = "WRITE_FAILED" # /proc/<pid>/mem write raised
|
||||
GUARD_VERIFY_FAILED = "VERIFY_FAILED" # post-write re-read != patch
|
||||
|
||||
LOG=os.environ.get("OPENFUT_AUTOPATCH_LOG", f"/tmp/openfut-autopatch-{os.getuid()}.log")
|
||||
|
||||
def log(m):
|
||||
@@ -52,48 +91,112 @@ def wr(pid,va,b):
|
||||
with open(f'/proc/{pid}/mem','r+b') as f:
|
||||
f.seek(va); f.write(b)
|
||||
|
||||
def guarded_action(cur, orig, patch):
|
||||
"""Fail-closed decision for a guarded byte patch (see STORE_PATCHES_GUARDED).
|
||||
|
||||
Returns "noop" when the live bytes are already patched, "patch" when they are the
|
||||
known original (safe to apply), or "skip" for anything else -- an unrecognised
|
||||
CardsDLL build that must never be blindly overwritten.
|
||||
"""
|
||||
if cur == patch:
|
||||
return "noop"
|
||||
if cur == orig:
|
||||
return "patch"
|
||||
return "skip"
|
||||
|
||||
def guard_state_after(cur_before, orig, patch, wrote_ok, cur_after):
|
||||
"""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; guarded_action "noop")
|
||||
- cur_before == orig -> WRITE_FAILED if the write raised, else VERIFIED when the
|
||||
re-read is patch, else VERIFY_FAILED (guarded_action "patch")
|
||||
- otherwise -> UNSUPPORTED_BUILD (guarded_action "skip")
|
||||
"""
|
||||
if cur_before == patch:
|
||||
return GUARD_VERIFIED
|
||||
if cur_before == orig:
|
||||
if not wrote_ok:
|
||||
return GUARD_WRITE_FAILED
|
||||
if cur_after == patch:
|
||||
return GUARD_VERIFIED
|
||||
return GUARD_VERIFY_FAILED
|
||||
return GUARD_UNSUPPORTED_BUILD
|
||||
|
||||
patched=set()
|
||||
store_patched=set()
|
||||
guard_reported=set()
|
||||
|
||||
launcher_pid = None
|
||||
if "--launcher-pid" in sys.argv:
|
||||
try: launcher_pid = int(sys.argv[sys.argv.index("--launcher-pid") + 1])
|
||||
except (ValueError, IndexError): raise SystemExit("invalid --launcher-pid")
|
||||
if __name__ == "__main__":
|
||||
launcher_pid = None
|
||||
if "--launcher-pid" in sys.argv:
|
||||
try: launcher_pid = int(sys.argv[sys.argv.index("--launcher-pid") + 1])
|
||||
except (ValueError, IndexError): raise SystemExit("invalid --launcher-pid")
|
||||
|
||||
log("=== AUTOPATCH watching for FIFA17.exe ===")
|
||||
while True:
|
||||
if launcher_pid and not os.path.exists(f"/proc/{launcher_pid}"):
|
||||
log(f"launcher pid {launcher_pid} exited; stopping autopatch")
|
||||
break
|
||||
for pid in find_pids():
|
||||
if pid not in patched:
|
||||
try:
|
||||
g2=rd(pid,GATE2,3); g1=rd(pid,GATE1,6)
|
||||
except Exception:
|
||||
continue # code not mapped yet
|
||||
if g2==GATE2_PATCH and g1==GATE1_PATCH:
|
||||
log(f"pid {pid}: cert gates already patched"); patched.add(pid)
|
||||
elif g2==GATE2_ORIG and g1==GATE1_ORIG:
|
||||
log("=== AUTOPATCH watching for FIFA17.exe ===")
|
||||
while True:
|
||||
if launcher_pid and not os.path.exists(f"/proc/{launcher_pid}"):
|
||||
log(f"launcher pid {launcher_pid} exited; stopping autopatch")
|
||||
break
|
||||
for pid in find_pids():
|
||||
if pid not in patched:
|
||||
try:
|
||||
wr(pid,GATE2,GATE2_PATCH); wr(pid,GATE1,GATE1_PATCH)
|
||||
log(f"pid {pid}: PATCHED cert gates")
|
||||
patched.add(pid)
|
||||
g2=rd(pid,GATE2,3); g1=rd(pid,GATE1,6)
|
||||
except Exception:
|
||||
continue # code not mapped yet
|
||||
if g2==GATE2_PATCH and g1==GATE1_PATCH:
|
||||
log(f"pid {pid}: cert gates already patched"); patched.add(pid)
|
||||
elif g2==GATE2_ORIG and g1==GATE1_ORIG:
|
||||
try:
|
||||
wr(pid,GATE2,GATE2_PATCH); wr(pid,GATE1,GATE1_PATCH)
|
||||
log(f"pid {pid}: PATCHED cert gates")
|
||||
patched.add(pid)
|
||||
except Exception as e:
|
||||
log(f"pid {pid}: cert patch write failed: {e}")
|
||||
|
||||
# Continuously enforce store patches every tick
|
||||
cbase = cardsdll_base(pid)
|
||||
if cbase is not None:
|
||||
try:
|
||||
for va, data in STORE_PATCHES.items():
|
||||
live = cbase + (va - IMG_BASE)
|
||||
if rd(pid, live, len(data)) != data:
|
||||
wr(pid, live, data)
|
||||
log(f"pid {pid}: ENFORCED store patch @ {live:#x}")
|
||||
for va, (orig, patch) in STORE_PATCHES_GUARDED.items():
|
||||
live = cbase + (va - IMG_BASE)
|
||||
cur = rd(pid, live, len(patch))
|
||||
action = guarded_action(cur, orig, patch)
|
||||
wrote_ok = True
|
||||
cur_after = cur
|
||||
if action == "patch":
|
||||
try:
|
||||
wr(pid, live, patch)
|
||||
log(f"pid {pid}: ENFORCED guarded store patch @ {live:#x} (JNZ->JG, empty My Packs)")
|
||||
except Exception as e:
|
||||
wrote_ok = False
|
||||
log(f"pid {pid}: guarded patch write failed @ {live:#x}: {e}")
|
||||
if wrote_ok:
|
||||
try:
|
||||
cur_after = rd(pid, live, len(patch))
|
||||
except Exception:
|
||||
cur_after = b""
|
||||
elif action == "skip":
|
||||
log(f"pid {pid}: SKIP guarded patch @ {live:#x}: unexpected {cur.hex()} (build mismatch)")
|
||||
# action == "noop": already patched; nothing to write.
|
||||
if va == RESOLVER_GUARD_VA and pid not in guard_reported:
|
||||
state = guard_state_after(cur, orig, patch, wrote_ok, cur_after)
|
||||
if state == GUARD_VERIFIED:
|
||||
log(f"[store-guard] verified capability {EMPTY_MYPACKS_RESOLVER_CAPABILITY}={EMPTY_MYPACKS_RESOLVER_VERSION} fifa_pid={pid}")
|
||||
else:
|
||||
log(f"[store-guard] guard status={state} fifa_pid={pid} (no capability advertised)")
|
||||
guard_reported.add(pid)
|
||||
if pid not in store_patched:
|
||||
log(f"pid {pid}: PATCHED store gates in CardsDLL @ {cbase:#x}")
|
||||
store_patched.add(pid)
|
||||
except Exception as e:
|
||||
log(f"pid {pid}: cert patch write failed: {e}")
|
||||
log(f"pid {pid}: store patch write failed: {e}")
|
||||
|
||||
# Continuously enforce store patches every tick
|
||||
cbase = cardsdll_base(pid)
|
||||
if cbase is not None:
|
||||
try:
|
||||
for va, data in STORE_PATCHES.items():
|
||||
live = cbase + (va - IMG_BASE)
|
||||
if rd(pid, live, len(data)) != data:
|
||||
wr(pid, live, data)
|
||||
log(f"pid {pid}: ENFORCED store patch @ {live:#x}")
|
||||
if pid not in store_patched:
|
||||
log(f"pid {pid}: PATCHED store gates in CardsDLL @ {cbase:#x}")
|
||||
store_patched.add(pid)
|
||||
except Exception as e:
|
||||
log(f"pid {pid}: store patch write failed: {e}")
|
||||
|
||||
time.sleep(1)
|
||||
time.sleep(1)
|
||||
|
||||
@@ -147,14 +147,13 @@ def refresh_account_identity():
|
||||
|
||||
# ================================================================== config
|
||||
#
|
||||
# Client/server split support (OpenFUT dev-container): two env vars, both
|
||||
# defaulting to loopback so the original all-on-localhost flow is byte-identical.
|
||||
# OPENFUT_BIND — the address the listeners bind (0.0.0.0 in a container).
|
||||
# OPENFUT_ADVERTISE — the address this server hands back to the client for the
|
||||
# NEXT hop (Blaze host, roster/UTAS/telemetry/QoS URLs). On
|
||||
# 105-local this is 127.0.0.1; on the 120 server it is the
|
||||
# server's LAN IP so the game dials 120 directly after the
|
||||
# first (hook/DNAT-redirected) contact.
|
||||
# Client/server split support (OpenFUT dev-container): bind and advertise default
|
||||
# to loopback so the original all-on-localhost flow is byte-identical.
|
||||
# OPENFUT_BIND — address the listeners bind (0.0.0.0 in a container).
|
||||
# OPENFUT_ADVERTISE — address handed back for Blaze, UTAS, telemetry, QoS,
|
||||
# and (unless overridden) the roster service.
|
||||
# OPENFUT_ROSTER_HOST — optional roster host:port advertised in HTTPS URLs.
|
||||
# Use a certificate dNSName and resolve it on the client.
|
||||
import os as _os_cfg
|
||||
_ADVERTISE = _os_cfg.environ.get("OPENFUT_ADVERTISE", "127.0.0.1")
|
||||
_BIND = _os_cfg.environ.get("OPENFUT_BIND", "127.0.0.1")
|
||||
@@ -563,9 +562,11 @@ OSDK_TICKER = []
|
||||
# never gets advance/back -> the silent FUT loading-screen hang. The store is the
|
||||
# MERGED '_all' section (getSection @0x14719e050), so any fetched CFID works; this
|
||||
# branch does NOT wrap the value ("https://%s" is only the ini path) -> ABSOLUTE url.
|
||||
# Serve HTTPS (EA's production value is https; the DirtySDK download mgr may reject
|
||||
# http). Our ProtoSSL cert-verify is patched (autopatch), so a self-signed cert is OK.
|
||||
ROSTER_HOST = "%s:8081" % _ADVERTISE
|
||||
# Serve HTTPS (EA's production value is https; the DirtySDK download manager may
|
||||
# reject http). FIFA17's roster verifier accepts dNSName SANs but ignores
|
||||
# iPAddress SANs, so an IP-literal URL fails with certificate_unknown. A remote
|
||||
# deployment can advertise a certificate DNS name without changing other hosts.
|
||||
ROSTER_HOST = os.environ.get("OPENFUT_ROSTER_HOST") or "%s:8081" % _ADVERTISE
|
||||
POW_CONTENT_HOST = os.environ.get("POW_CONTENT_HOST", "127.0.0.1:8080")
|
||||
OSDK_ROSTER = [
|
||||
("ROSTERUPDATE_URL", "https://%s/fifa17/fut/rosterupdate.xml" % ROSTER_HOST),
|
||||
|
||||
@@ -55,6 +55,34 @@ verify_exports() {
|
||||
done
|
||||
}
|
||||
|
||||
# Refuse any DLL that is not a FIFA-17-profile build.
|
||||
#
|
||||
# openfut-hook builds TWO mutually exclusive injection paths from one crate: the
|
||||
# default (FIFA 23) path installs getaddrinfo/connect/ProtoSSL/origin hooks, while
|
||||
# `--features fifa17` installs ONLY the FIFA-17-safe logic (module map, FIFA 17
|
||||
# cert-verify, SBC dispatch, store tab bind). Deploying a default-feature build
|
||||
# into FIFA 17 hijacks the login transport and the client reports "Unable to
|
||||
# connect to the EA servers", with none of the FIFA 17 repairs present.
|
||||
#
|
||||
# That exact mistake happened on 2026-08-19 (artifact 1c71a17a, hand-built without
|
||||
# the feature): two failed launches, diagnosed only by comparing embedded strings.
|
||||
# `build` below passes the feature, but a hand-built DLL can reach `stage`/`deploy`
|
||||
# via OPENFUT_FIFA17_HOOK_DLL, so assert the profile on the bytes themselves.
|
||||
verify_fifa17_profile() {
|
||||
local dll=$1 marker
|
||||
# Markers that MUST be present: the FIFA 17 target module and its repairs.
|
||||
for marker in 'CardsDLL_Win64_retail.dll' 'SBC_DISPATCH'; do
|
||||
grep -qaF -- "$marker" "$dll" ||
|
||||
die "$dll is not a --features fifa17 build (missing $marker); refusing to stage/deploy"
|
||||
done
|
||||
# Markers that MUST be absent: the FIFA-23-only transport hooking.
|
||||
for marker in 'getaddrinfo IAT patched' 'connect: inline-hooked' 'origin_spy'; do
|
||||
if grep -qaF -- "$marker" "$dll"; then
|
||||
die "$dll contains FIFA-23-only hook '$marker'; build with --features fifa17"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
verify_inputs() {
|
||||
command -v sha256sum >/dev/null || die "sha256sum is required"
|
||||
command -v x86_64-w64-mingw32-objdump >/dev/null ||
|
||||
@@ -62,6 +90,7 @@ verify_inputs() {
|
||||
need_file "$hook_dll"
|
||||
need_file "$system_version"
|
||||
verify_pe64 "$hook_dll"
|
||||
verify_fifa17_profile "$hook_dll"
|
||||
}
|
||||
|
||||
inspect() {
|
||||
@@ -129,6 +158,7 @@ deploy() {
|
||||
need_file "$manifest"
|
||||
verify_pe64 "$staged"
|
||||
verify_exports "$staged"
|
||||
verify_fifa17_profile "$staged"
|
||||
local recorded actual
|
||||
recorded="$(awk -F= '$1=="artifact_sha256"{print $2}' "$manifest")"
|
||||
actual="$(sha256 "$staged")"
|
||||
@@ -158,7 +188,7 @@ launch() {
|
||||
local trace_enabled=0
|
||||
local request_trace_enabled=0
|
||||
local notifier_trace_enabled=0
|
||||
local commit_enabled=0
|
||||
local dispatch_enabled=0
|
||||
case "$mode" in
|
||||
baseline)
|
||||
[[ "${OPENFUT_FIFA17_LAUNCH:-}" == "I_ACCEPT_M1_BASELINE_LAUNCH" ]] ||
|
||||
@@ -177,14 +207,11 @@ launch() {
|
||||
request_trace_enabled=1
|
||||
notifier_trace_enabled=1
|
||||
;;
|
||||
commit)
|
||||
[[ "${OPENFUT_FIFA17_COMMIT:-}" == "I_ACCEPT_POST_PARSE_READY_BYTE" ]] ||
|
||||
die "launch-commit requires OPENFUT_FIFA17_COMMIT=I_ACCEPT_POST_PARSE_READY_BYTE"
|
||||
hook_enabled=1
|
||||
trace_enabled=1
|
||||
dispatch)
|
||||
[[ "${OPENFUT_FIFA17_DISPATCH:-}" == "I_ACCEPT_GUARDED_NATIVE_DISPATCH" ]] ||
|
||||
die "launch-dispatch requires OPENFUT_FIFA17_DISPATCH=I_ACCEPT_GUARDED_NATIVE_DISPATCH"
|
||||
request_trace_enabled=1
|
||||
notifier_trace_enabled=1
|
||||
commit_enabled=1
|
||||
dispatch_enabled=1
|
||||
;;
|
||||
*) die "unknown launch mode: $mode" ;;
|
||||
esac
|
||||
@@ -207,7 +234,7 @@ launch() {
|
||||
done
|
||||
mkdir -p "${wine_prefix}/dosdevices"
|
||||
ln -sfn /mnt "${wine_prefix}/dosdevices/w:"
|
||||
note "Launching $mode mode (SBC_HOOK=$hook_enabled; SBC_TRACE=$trace_enabled; SBC_REQUEST_TRACE=$request_trace_enabled; SBC_NOTIFIER_TRACE=$notifier_trace_enabled; SBC_COMMIT=$commit_enabled); log=/tmp/fifa17-hook-m1-launch.log"
|
||||
note "Launching $mode mode (SBC_HOOK=$hook_enabled; SBC_TRACE=$trace_enabled; SBC_REQUEST_TRACE=$request_trace_enabled; SBC_NOTIFIER_TRACE=$notifier_trace_enabled; SBC_DISPATCH=$dispatch_enabled); log=/tmp/fifa17-hook-m1-launch.log"
|
||||
cd "$game_dir"
|
||||
env \
|
||||
GAMEID=fifa17 \
|
||||
@@ -218,8 +245,8 @@ launch() {
|
||||
OPENFUT_SBC_TRACE="$trace_enabled" \
|
||||
OPENFUT_SBC_REQUEST_TRACE="$request_trace_enabled" \
|
||||
OPENFUT_SBC_NOTIFIER_TRACE="$notifier_trace_enabled" \
|
||||
OPENFUT_SBC_DISPATCH=0 \
|
||||
OPENFUT_SBC_COMMIT="$commit_enabled" \
|
||||
OPENFUT_SBC_DISPATCH="$dispatch_enabled" \
|
||||
OPENFUT_SBC_DISPATCH_TRACE=0 \
|
||||
OPENFUT_SBC_ARM_ONLY=0 \
|
||||
OPENFUT_SBC_POPULATE=0 \
|
||||
umu-run _fifa17.exe 2>&1 | tee /tmp/fifa17-hook-m1-launch.log
|
||||
@@ -227,7 +254,7 @@ launch() {
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launch-trace|launch-commit]
|
||||
Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launch-trace|launch-dispatch]
|
||||
|
||||
inspect Read-only PE/hash/export preflight (default).
|
||||
build Cross-build the inert FIFA17 hook, then run inspect.
|
||||
@@ -240,11 +267,11 @@ Usage: fifa17-hook-m1.sh [inspect|build|stage|deploy|launch|launch-resolve|launc
|
||||
Start M2 resolve-only mode (guarded reads/logging, no detours/writes); requires:
|
||||
OPENFUT_FIFA17_RESOLVE=I_ACCEPT_M2_RESOLVE_LAUNCH
|
||||
launch-trace
|
||||
Start the single M3 passive factory/deserializer trace; requires:
|
||||
Start the M3-M6 passive parser/request/notifier trace; requires:
|
||||
OPENFUT_FIFA17_TRACE=I_ACCEPT_M3_PASSIVE_TRACE
|
||||
launch-commit
|
||||
Trace and arm the SBC cache only after a validated native parse; requires:
|
||||
OPENFUT_FIFA17_COMMIT=I_ACCEPT_POST_PARSE_READY_BYTE
|
||||
launch-dispatch
|
||||
Trace and repair only a fully validated native status-999 completion; requires:
|
||||
OPENFUT_FIFA17_DISPATCH=I_ACCEPT_GUARDED_NATIVE_DISPATCH
|
||||
|
||||
Optional path overrides:
|
||||
OPENFUT_FIFA17_HOOK_DLL, OPENFUT_FIFA17_GAME_DIR,
|
||||
@@ -260,7 +287,7 @@ case "${1:-inspect}" in
|
||||
launch) launch baseline ;;
|
||||
launch-resolve) launch resolve ;;
|
||||
launch-trace) launch trace ;;
|
||||
launch-commit) launch commit ;;
|
||||
launch-dispatch) launch dispatch ;;
|
||||
-h|--help|help) usage ;;
|
||||
*) usage >&2; die "unknown command: $1" ;;
|
||||
esac
|
||||
|
||||
+29
-11
@@ -15,16 +15,27 @@ reimplementations (the `tdf` crate cloned in this scratchpad), which were used
|
||||
only as a cross-check of *structure*, never copied.
|
||||
NO EA/FIFA leaked source was consulted.
|
||||
|
||||
VALIDATED RULES (byte-exact round-trip against the 219-byte capture)
|
||||
--------------------------------------------------------------------
|
||||
Fire2 frame header, 16 bytes big-endian:
|
||||
[0:4] u32 payload length (bytes after the header)
|
||||
[4:6] u16 always 0 (observed)
|
||||
[6:8] u16 component
|
||||
[8:10] u16 command
|
||||
[10:12]u16 error / msgId
|
||||
[12] u8 msgType (0x01 ping, 0x02 request, 0x03 pong/response)
|
||||
[13:16]3 reserved bytes (observed 00 00 00)
|
||||
VALIDATED RULES (the TDF body; byte-exact round-trip against the 219-byte capture)
|
||||
---------------------------------------------------------------------------------
|
||||
Fire2 frame header, 16 bytes big-endian.
|
||||
|
||||
!!! SUPERSEDED — the [10:16] FIELD SEMANTICS below are WRONG for FIFA 17. !!!
|
||||
The "byte-exact round-trip" only proves the payload length and the TDF body
|
||||
encoding: decoding then re-encoding with the SAME (mis)labelled header layout
|
||||
trivially reproduces the capture, so it never tested the header's field
|
||||
boundaries. The authoritative, live-driven layout is
|
||||
`openfut-protocol-blaze::fire2` / `blaze_responder_v3b.py::fire2`:
|
||||
[0:4] u32 payload length (bytes after header + metadata)
|
||||
[4:6] u16 metadata length (0 when absent — what this file called "always 0")
|
||||
[6:8] u16 component
|
||||
[8:10] u16 command
|
||||
[10:13] u24 msgNum (this file WRONGLY split it as [10:12] msgId + [12] msgType)
|
||||
[13] u8 (msgType << 5) | (userIndex & 0x1F)
|
||||
[14] u8 options
|
||||
[15] u8 reserved
|
||||
There is NO error field in Fire2 (that is Fire v1) and NO jumbo escape — the
|
||||
length is already a full u32. `build_fire2_frame`/`decode_fire2` below keep the
|
||||
old wrong `>IHHHHB3s` layout; they are dead and retained only for history.
|
||||
|
||||
Heat2 field = 3-byte packed tag + 1 type byte + value.
|
||||
|
||||
@@ -359,7 +370,14 @@ MSG_ERROR = 0x05 # UNVERIFIED
|
||||
|
||||
def build_fire2_frame(component: int, command: int, msgType: int,
|
||||
msgId: int, tdf_bytes: bytes) -> bytes:
|
||||
"""16-byte big-endian Fire2 header + TDF payload."""
|
||||
"""16-byte big-endian Fire2 header + TDF payload.
|
||||
|
||||
WRONG HEADER (dead code): the ``>IHHHHB3s`` layout mislabels [10:16] — it
|
||||
puts a u16 msgId at [10:12] and msgType at [12]. FIFA 17's real Fire2 header
|
||||
is [10:13] u24 msgNum, [13] (msgType<<5)|userIndex, [14] options, [15]
|
||||
reserved, and has no error field. Use ``openfut-protocol-blaze::fire2`` or
|
||||
``blaze_responder_v3b.py::fire2``; this is retained only for history.
|
||||
"""
|
||||
tdf_bytes = bytes(tdf_bytes)
|
||||
hdr = struct.pack(">IHHHHB3s", len(tdf_bytes), 0, component & 0xFFFF,
|
||||
command & 0xFFFF, msgId & 0xFFFF, msgType & 0xFF,
|
||||
|
||||
Executable
+100
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Read back the MANAGER-ONLY chemistry slots the client resolved, and prove
|
||||
whether the server's `nation`/`leagueId` actually land in the record.
|
||||
|
||||
READ-ONLY. /proc/PID/mem is opened 'rb'; there is no write path in this file.
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
`card_identity_probe` reads the PLAYER slots (F_NATION = 0x148, F_LEAGUE =
|
||||
0x154). A manager does not use those, so grading a manager with that tool
|
||||
reports nation=0 / leagueId=0 and looks like a server bug when it is only the
|
||||
wrong offsets.
|
||||
|
||||
`fifa17-recon/tools/fut_staff.py` records the manager layout from Ghidra:
|
||||
|
||||
rec+0x94 teamid (read by the card view-model)
|
||||
rec+0xde nation MANAGER-ONLY slot, u16
|
||||
rec+0xe0 leagueId MANAGER-ONLY slot, u16
|
||||
rec+0xe2 talkrating written by the managercards merge
|
||||
rec+0xe3 negotiation written by the managercards merge
|
||||
|
||||
The merge (FUN_1801356c0) NEVER writes +0xde or +0xe0, so whatever sits there
|
||||
came from OUR JSON and nowhere else. That makes those two u16s a direct,
|
||||
unambiguous test of the server's manager chemistry fields: if they read back as
|
||||
the values we served, the wire contract is PROVEN rather than inferred; if they
|
||||
read zero, the client discarded them and manager chemistry cannot be rendering.
|
||||
|
||||
Usage: python3 manager_chem_probe.py # grade every manager in the map
|
||||
"""
|
||||
import sys
|
||||
|
||||
import watch_club_model as W
|
||||
import card_identity_probe as P
|
||||
|
||||
MANAGER_CARDTYPE = 2 # FUN_1800d8330: cardsubtypeid 4 -> cardtype 2
|
||||
F_CARDTYPE = 0x4C
|
||||
F_RESOURCE = 0x18
|
||||
F_TEAMID = 0x94
|
||||
F_NATION_MGR = 0xDE
|
||||
F_LEAGUE_MGR = 0xE0
|
||||
F_TALKRATING = 0xE2
|
||||
F_NEGOTIATION = 0xE3
|
||||
REC_SIZE = 0x158
|
||||
|
||||
|
||||
def main():
|
||||
pid = W.find_pid()
|
||||
if pid is None:
|
||||
print("FIFA17.exe is not running.")
|
||||
return 1
|
||||
base = W.dll_base(pid)
|
||||
if base is None:
|
||||
print("pid %d is up but %s is not mapped yet." % (pid, W.DLL))
|
||||
return 1
|
||||
mem = W.Mem(pid)
|
||||
obj = mem.q(base + (W.G_CARDSDB - W.IMG_BASE))
|
||||
if not obj:
|
||||
print("CardsDb singleton is NULL (no FUT session loaded).")
|
||||
return 1
|
||||
|
||||
ns = W.nodes(mem, obj) if hasattr(W, "nodes") else P.nodes(mem, obj)
|
||||
print("pid=%d CardsDb=%#x walked=%d" % (pid, obj, len(ns)))
|
||||
print()
|
||||
print("%-10s %-8s %-8s %-8s %-10s %-10s %s"
|
||||
% ("resource", "teamid", "nation", "league", "talkrating", "negot", "verdict"))
|
||||
|
||||
found = 0
|
||||
for n in ns:
|
||||
rec = n + 0x28
|
||||
buf = mem.read(rec, REC_SIZE)
|
||||
if not buf or len(buf) < REC_SIZE:
|
||||
continue
|
||||
if P.u8(buf, F_CARDTYPE) != MANAGER_CARDTYPE:
|
||||
continue
|
||||
found += 1
|
||||
resource = P.u32(buf, F_RESOURCE)
|
||||
teamid = P.u32(buf, F_TEAMID)
|
||||
nation = P.u16(buf, F_NATION_MGR)
|
||||
league = P.u16(buf, F_LEAGUE_MGR)
|
||||
talk = P.u8(buf, F_TALKRATING)
|
||||
negot = P.u8(buf, F_NEGOTIATION)
|
||||
# +0xde and +0xe0 are never written by the merge, so a non-zero value
|
||||
# can only have come from the server's JSON.
|
||||
if nation and league:
|
||||
verdict = "SERVER FIELDS LANDED"
|
||||
elif nation or league:
|
||||
verdict = "PARTIAL -- one slot empty"
|
||||
else:
|
||||
verdict = "EMPTY -- client kept nothing we sent"
|
||||
print("%-10d %-8d %-8d %-8d %-10d %-10d %s"
|
||||
% (resource, teamid, nation, league, talk, negot, verdict))
|
||||
|
||||
if not found:
|
||||
print("(no manager record in the map -- the client has not been served one)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -32,11 +32,26 @@ c() { printf ' %s\n' "$*"; }
|
||||
up() { ss -tlnp 2>/dev/null | grep -q ":$1 "; }
|
||||
|
||||
ensure_cert() {
|
||||
[ -s "$CERT" ] && [ -s "$KEY" ] && return 0
|
||||
echo "[*] generating self-signed TLS cert (redirector MITM; ProtoSSL verify is patched)"
|
||||
# The cert MUST carry the address the client dials in its SAN, or the roster
|
||||
# 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 \
|
||||
-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
|
||||
}
|
||||
|
||||
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Dump the CLASSIFICATION fields the client stored for every card it holds, so
|
||||
the subtype->cardtype map and the itemState runtime values are read from the
|
||||
running game instead of inferred.
|
||||
|
||||
READ-ONLY. /proc/PID/mem is opened 'rb'; there is no write path in this file.
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
Two things this project has repeatedly had to treat as INFERRED:
|
||||
|
||||
1. `FUN_1800d8330`'s cardsubtypeid -> cardtype map. It is read out of Ghidra
|
||||
(0..3->1 players, 4->2 manager, 5->3 headcoach, 6->10 gkcoach, 7->5 physio,
|
||||
8->4 fitnesscoach, 9..b->7), and the kit selector gate `FUN_1801c3480`
|
||||
branches on cardtype == 7. Serving a subtype whose cardtype we guessed
|
||||
wrong fails SILENTLY, because cardtype 9 has no arm in the merge.
|
||||
2. The itemState enum. The table at 0x180229d20 gives the tokens; the RUNTIME
|
||||
values the strings deserialize to (notably activeHomeKit/activeAwayKit ->
|
||||
101/102) have been carried as inferred.
|
||||
|
||||
Both are directly observable: the parser writes cardsubtypeid to rec+0x50, the
|
||||
derived cardtype to rec+0x4c, and the decoded itemState to rec+0x5c. Reading
|
||||
those back for every record turns the pair into measurements.
|
||||
|
||||
rec+0x18 resourceId
|
||||
rec+0x4c cardtype (derived by FUN_1800d8330 from cardsubtypeid)
|
||||
rec+0x50 cardsubtypeid (as sent)
|
||||
rec+0x5c itemState (decoded enum value)
|
||||
|
||||
Usage: python3 record_vocab_probe.py
|
||||
"""
|
||||
import collections
|
||||
import sys
|
||||
|
||||
import watch_club_model as W
|
||||
import card_identity_probe as P
|
||||
|
||||
F_RESOURCE = 0x18
|
||||
F_CARDTYPE = 0x4C
|
||||
F_SUBTYPE = 0x50
|
||||
F_ITEMSTATE = 0x5C
|
||||
REC_SIZE = 0x158
|
||||
|
||||
# What the Ghidra read of FUN_1800d8330 predicts, so a disagreement is loud.
|
||||
EXPECTED_CARDTYPE = {0: 1, 1: 1, 2: 1, 3: 1, 4: 2, 5: 3, 6: 10, 7: 5, 8: 4,
|
||||
9: 7, 10: 7, 11: 7}
|
||||
|
||||
|
||||
def main():
|
||||
pid = W.find_pid()
|
||||
if pid is None:
|
||||
print("FIFA17.exe is not running.")
|
||||
return 1
|
||||
base = W.dll_base(pid)
|
||||
if base is None:
|
||||
print("pid %d is up but %s is not mapped yet." % (pid, W.DLL))
|
||||
return 1
|
||||
mem = W.Mem(pid)
|
||||
obj = mem.q(base + (W.G_CARDSDB - W.IMG_BASE))
|
||||
if not obj:
|
||||
print("CardsDb singleton is NULL (no FUT session loaded).")
|
||||
return 1
|
||||
|
||||
ns = W.nodes(mem, obj) if hasattr(W, "nodes") else P.nodes(mem, obj)
|
||||
print("pid=%d CardsDb=%#x walked=%d\n" % (pid, obj, len(ns)))
|
||||
|
||||
pairs = collections.Counter()
|
||||
states = collections.Counter()
|
||||
rows = []
|
||||
for n in ns:
|
||||
buf = mem.read(n + 0x28, REC_SIZE)
|
||||
if not buf or len(buf) < REC_SIZE:
|
||||
continue
|
||||
resource = P.u32(buf, F_RESOURCE)
|
||||
cardtype = P.u8(buf, F_CARDTYPE)
|
||||
subtype = P.u8(buf, F_SUBTYPE)
|
||||
state = P.u8(buf, F_ITEMSTATE)
|
||||
pairs[(subtype, cardtype)] += 1
|
||||
states[state] += 1
|
||||
rows.append((resource, subtype, cardtype, state))
|
||||
|
||||
print("%-12s %-9s %-9s %s" % ("resource", "subtype", "cardtype", "itemState"))
|
||||
for r in sorted(rows):
|
||||
print("%-12d %-9d %-9d %d" % r)
|
||||
|
||||
print("\n--- MEASURED cardsubtypeid -> cardtype ---")
|
||||
for (sub, ct), n in sorted(pairs.items()):
|
||||
want = EXPECTED_CARDTYPE.get(sub)
|
||||
if want is None:
|
||||
verdict = "no Ghidra prediction for this subtype"
|
||||
elif want == ct:
|
||||
verdict = "agrees with FUN_1800d8330"
|
||||
else:
|
||||
verdict = "DISAGREES -- Ghidra said %d" % want
|
||||
print(" subtype %-4d -> cardtype %-4d (%d record(s)) %s" % (sub, ct, n, verdict))
|
||||
|
||||
print("\n--- MEASURED itemState runtime values ---")
|
||||
for st, n in sorted(states.items()):
|
||||
print(" %-5d %d record(s)" % (st, n))
|
||||
print("\nNOTE: a runtime value only appears here if the client was actually")
|
||||
print("served an item in that state. Absence is not evidence of absence.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pure unit test for the empty-My-Packs store resolver guard in autopatch.py.
|
||||
|
||||
Covers the fail-closed guard decision (original -> PATCH, already-patched -> NOOP,
|
||||
unknown -> SKIP) and pins the guarded patch table to the exact RVA/bytes proven on
|
||||
the tested FIFA 17 build (JNZ 0x14869 -> JG 0x14869 at CardsDLL RVA 0x14858).
|
||||
|
||||
Run: python3 test_autopatch_guard.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import autopatch # importable: runtime loop is guarded by `if __name__ == "__main__"`
|
||||
|
||||
GUARD_VA = 0x180014858
|
||||
ORIG = bytes.fromhex("750f") # JNZ 0x14869
|
||||
PATCH = bytes.fromhex("7f0f") # JG 0x14869
|
||||
|
||||
|
||||
def test_table_exact():
|
||||
assert autopatch.STORE_PATCHES_GUARDED == {GUARD_VA: (ORIG, PATCH)}, \
|
||||
autopatch.STORE_PATCHES_GUARDED
|
||||
# Byte-level pin so a bad hex literal cannot slip through.
|
||||
assert ORIG == b"\x75\x0f" and PATCH == b"\x7f\x0f"
|
||||
|
||||
|
||||
def test_decision():
|
||||
assert autopatch.guarded_action(ORIG, ORIG, PATCH) == "patch" # apply
|
||||
assert autopatch.guarded_action(PATCH, ORIG, PATCH) == "noop" # already patched
|
||||
assert autopatch.guarded_action(b"\x00\x00", ORIG, PATCH) == "skip" # build mismatch
|
||||
assert autopatch.guarded_action(b"\x90", ORIG, PATCH) == "skip" # wrong length
|
||||
|
||||
|
||||
def test_guard_state_after():
|
||||
# already patched (7f0f) -> VERIFIED (guarded_action "noop"); write args irrelevant.
|
||||
assert autopatch.guard_state_after(PATCH, ORIG, PATCH, True, PATCH) == autopatch.GUARD_VERIFIED
|
||||
# original (750f) + write ok + reread 7f0f -> VERIFIED (guarded_action "patch").
|
||||
assert autopatch.guard_state_after(ORIG, ORIG, PATCH, True, PATCH) == autopatch.GUARD_VERIFIED
|
||||
# original + write FAILS -> WRITE_FAILED.
|
||||
assert autopatch.guard_state_after(ORIG, ORIG, PATCH, False, ORIG) == autopatch.GUARD_WRITE_FAILED
|
||||
# original + write ok but reread != 7f0f -> VERIFY_FAILED.
|
||||
assert autopatch.guard_state_after(ORIG, ORIG, PATCH, True, ORIG) == autopatch.GUARD_VERIFY_FAILED
|
||||
assert autopatch.guard_state_after(ORIG, ORIG, PATCH, True, b"") == autopatch.GUARD_VERIFY_FAILED
|
||||
# unknown bytes -> UNSUPPORTED_BUILD (guarded_action "skip"); write args irrelevant.
|
||||
assert autopatch.guard_state_after(b"\x00\x00", ORIG, PATCH, True, PATCH) == autopatch.GUARD_UNSUPPORTED_BUILD
|
||||
|
||||
|
||||
def test_capability_constants():
|
||||
assert autopatch.EMPTY_MYPACKS_RESOLVER_VERSION == 1
|
||||
assert autopatch.EMPTY_MYPACKS_RESOLVER_CAPABILITY == "fifa17.empty_mypacks_resolver"
|
||||
# State constant values are the exact tokens carried in the emitted status line.
|
||||
assert autopatch.GUARD_VERIFIED == "VERIFIED"
|
||||
assert autopatch.GUARD_UNSUPPORTED_BUILD == "UNSUPPORTED_BUILD"
|
||||
assert autopatch.GUARD_WRITE_FAILED == "WRITE_FAILED"
|
||||
assert autopatch.GUARD_VERIFY_FAILED == "VERIFY_FAILED"
|
||||
assert autopatch.GUARD_NOT_ATTEMPTED == "NOT_ATTEMPTED"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_table_exact()
|
||||
test_decision()
|
||||
test_guard_state_after()
|
||||
test_capability_constants()
|
||||
print("OK: autopatch guard table + fail-closed decision + guard-state function + capability constants")
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the FIFA 17 verified-patched-client capability negotiation.
|
||||
|
||||
The additive empty-My-Packs switch on top of the P2 65534 sentinel: the sentinel is
|
||||
suppressed for ONE FIFA session only when the launcher has registered a verified
|
||||
resolver capability (v1) that binds to THAT process's UTAS session (keyed by the
|
||||
per-login-unique X-UT-SID; source IP + persona are auxiliary). Every failure /
|
||||
unknown / late / cross-process / cross-session case is fail-closed to the sentinel.
|
||||
|
||||
The initial prototype keyed by source IP alone; this suite proves the hardened
|
||||
per-session binding, including two sessions that SHARE a source IP.
|
||||
|
||||
Matrix (docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md):
|
||||
A no-capability, zero packs -> sentinel
|
||||
B verified v1, zero packs -> clean (no 65534)
|
||||
C real unopened pack + no capability -> genuine pack, no sentinel
|
||||
D real unopened pack + capability -> genuine pack, no sentinel
|
||||
E unsupported version / capability -> endpoint 400 AND mode sentinel
|
||||
F late capability after sentinel freeze -> stays sentinel
|
||||
G capability disappears after clean freeze -> stays clean (immutable)
|
||||
H two IPs (A verified, B none) -> A clean, B sentinel (no global leak)
|
||||
I new session after reset -> fresh unpatched -> sentinel
|
||||
J autopatch mismatch => never registers -> sentinel
|
||||
K SAME IP, two sessions (A patched, B not) -> A clean, B sentinel
|
||||
L SAME IP+persona relaunch (old ok, new not) -> new session sentinel
|
||||
M SAME IP, failed-patch second session -> first clean, second sentinel
|
||||
N late registration when sessions are frozen -> does not modify active sessions
|
||||
O session cleanup / TTL expiry -> capability gone, sentinel
|
||||
P duplicate registration for a session -> idempotent; no post-freeze change
|
||||
Q register-before-login (pending consumed) -> clean
|
||||
R topology freeze immutable per SID -> no flip either way; new SID fresh
|
||||
|
||||
Standalone unit test in the project style: `python3 test_capability_negotiation.py`.
|
||||
"""
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
TOOLS = os.path.dirname(os.path.abspath(__file__))
|
||||
if TOOLS not in sys.path:
|
||||
sys.path.insert(0, TOOLS)
|
||||
|
||||
SENTINEL_ID = 65534
|
||||
REAL_PACK_ID = 1
|
||||
PERSONA = 111001
|
||||
|
||||
|
||||
class _H:
|
||||
"""Minimal request-handler stand-in: peer IP, optional X-UT-SID, optional body."""
|
||||
|
||||
def __init__(self, ip, body=None, sid=None):
|
||||
self.client_address = (ip, 54321)
|
||||
self.headers = {"X-UT-SID": sid} if sid is not None else {}
|
||||
self._body = json.dumps(body).encode("utf-8") if body is not None else b""
|
||||
|
||||
|
||||
def _ids(catalog):
|
||||
return [p["id"] for p in catalog["purchase"]]
|
||||
|
||||
|
||||
def main():
|
||||
with tempfile.TemporaryDirectory() as state:
|
||||
os.environ["FUT_ACCOUNT_PATH"] = os.path.join(state, "active_account.json")
|
||||
os.environ["FUT_PROFILE_ROOT"] = os.path.join(state, "accounts")
|
||||
os.environ.pop("FUT_PROFILE", None)
|
||||
|
||||
import fut_account
|
||||
import fut_store
|
||||
import fut_accounts
|
||||
import utas_server
|
||||
importlib.reload(fut_account)
|
||||
importlib.reload(fut_store)
|
||||
importlib.reload(fut_accounts)
|
||||
importlib.reload(utas_server)
|
||||
|
||||
us = utas_server
|
||||
CLEAN, SENT = us.FIFA17_MODE_CLEAN, us.FIFA17_MODE_SENTINEL
|
||||
|
||||
_orig_visible = us.visible_unopened_packs
|
||||
|
||||
def set_zero_packs():
|
||||
us.visible_unopened_packs = lambda: []
|
||||
|
||||
def set_real_pack():
|
||||
us.visible_unopened_packs = lambda: [REAL_PACK_ID]
|
||||
|
||||
def reset_state():
|
||||
us._FIFA17_SESSIONS.clear()
|
||||
us._FIFA17_PENDING.clear()
|
||||
|
||||
def auth(sid, ip, persona=PERSONA):
|
||||
"""Simulate /ut/auth opening a per-login session with a chosen sid."""
|
||||
us.fifa17_open_session(sid, ip, persona)
|
||||
|
||||
def register(ip, version, persona=PERSONA, pid=4242):
|
||||
return us.fifa17_capability_route(_H(ip, {
|
||||
"capability": "empty_mypacks_resolver", "version": version,
|
||||
"personaId": persona, "fifaPid": pid,
|
||||
}))
|
||||
|
||||
def store(sid, ip):
|
||||
status, cat = us.store_catalog(_H(ip, sid=sid))
|
||||
assert status == 200, status
|
||||
return _ids(cat)
|
||||
|
||||
def mode_of(sid):
|
||||
return us._FIFA17_SESSIONS[sid]["mode"]
|
||||
|
||||
try:
|
||||
# ---- A. no capability, zero packs -> sentinel ----------------------
|
||||
reset_state(); set_zero_packs()
|
||||
auth("sidA", "10.0.0.1")
|
||||
assert SENTINEL_ID in store("sidA", "10.0.0.1")
|
||||
assert mode_of("sidA") == SENT
|
||||
print("A no-capability zero-packs -> sentinel: OK")
|
||||
|
||||
# ---- B. verified v1, zero packs -> clean ---------------------------
|
||||
reset_state(); set_zero_packs()
|
||||
auth("sidB", "10.0.0.2")
|
||||
assert register("10.0.0.2", 1)[0] == 200
|
||||
ids = store("sidB", "10.0.0.2")
|
||||
assert SENTINEL_ID not in ids, ids
|
||||
assert mode_of("sidB") == CLEAN
|
||||
print("B verified-v1 zero-packs -> clean: OK")
|
||||
|
||||
# ---- C. real pack + no capability -> genuine, no sentinel ----------
|
||||
reset_state(); set_real_pack()
|
||||
auth("sidC", "10.0.0.3")
|
||||
ids = store("sidC", "10.0.0.3")
|
||||
assert REAL_PACK_ID in ids and SENTINEL_ID not in ids, ids
|
||||
print("C real-pack no-capability -> genuine, no sentinel: OK")
|
||||
|
||||
# ---- D. real pack + capability -> genuine, no sentinel -------------
|
||||
reset_state(); set_real_pack()
|
||||
auth("sidD", "10.0.0.4"); register("10.0.0.4", 1)
|
||||
ids = store("sidD", "10.0.0.4")
|
||||
assert REAL_PACK_ID in ids and SENTINEL_ID not in ids, ids
|
||||
print("D real-pack capability -> genuine, no sentinel: OK")
|
||||
|
||||
# ---- E. unsupported version / capability -> 400 + sentinel ---------
|
||||
reset_state(); set_zero_packs()
|
||||
auth("sidE", "10.0.0.5")
|
||||
assert register("10.0.0.5", 2)[0] == 400
|
||||
assert register("10.0.0.5", 99)[0] == 400
|
||||
assert us.fifa17_capability_route(
|
||||
_H("10.0.0.5", {"capability": "bogus", "version": 1}))[0] == 400
|
||||
assert SENTINEL_ID in store("sidE", "10.0.0.5")
|
||||
assert mode_of("sidE") == SENT
|
||||
print("E unsupported version/capability -> 400 + sentinel: OK")
|
||||
|
||||
# ---- F. late capability after sentinel freeze -> sentinel ----------
|
||||
reset_state(); set_zero_packs()
|
||||
auth("sidF", "10.0.0.6")
|
||||
assert SENTINEL_ID in store("sidF", "10.0.0.6") # freezes sentinel
|
||||
assert register("10.0.0.6", 1)[0] == 200 # session frozen -> ignored-late
|
||||
assert SENTINEL_ID in store("sidF", "10.0.0.6")
|
||||
assert mode_of("sidF") == SENT
|
||||
print("F late capability after sentinel freeze -> sentinel: OK")
|
||||
|
||||
# ---- G. capability disappears after clean freeze -> clean ----------
|
||||
reset_state(); set_zero_packs()
|
||||
auth("sidG", "10.0.0.7"); register("10.0.0.7", 1)
|
||||
assert SENTINEL_ID not in store("sidG", "10.0.0.7") # freezes clean
|
||||
us._FIFA17_SESSIONS["sidG"]["resolver"] = None # capability vanishes
|
||||
assert SENTINEL_ID not in store("sidG", "10.0.0.7")
|
||||
assert mode_of("sidG") == CLEAN
|
||||
print("G capability disappears after clean freeze -> clean: OK")
|
||||
|
||||
# ---- H. two IPs (A verified, B none) -> no global leak -------------
|
||||
reset_state(); set_zero_packs()
|
||||
auth("sidH1", "10.0.1.1"); register("10.0.1.1", 1)
|
||||
auth("sidH2", "10.0.1.2")
|
||||
assert SENTINEL_ID not in store("sidH1", "10.0.1.1")
|
||||
assert SENTINEL_ID in store("sidH2", "10.0.1.2")
|
||||
print("H two IPs (A clean, B sentinel) -> no global leak: OK")
|
||||
|
||||
# ---- I. new session after reset -> fresh unpatched -> sentinel -----
|
||||
reset_state(); set_zero_packs()
|
||||
auth("sidI1", "10.0.1.3"); register("10.0.1.3", 1)
|
||||
assert SENTINEL_ID not in store("sidI1", "10.0.1.3") # A clean
|
||||
us.fifa17_clear_pending("10.0.1.3") # relaunch boundary
|
||||
auth("sidI2", "10.0.1.3") # new SID, autopatch failed
|
||||
assert SENTINEL_ID in store("sidI2", "10.0.1.3")
|
||||
print("I new session after reset -> sentinel (no cross-process leak): OK")
|
||||
|
||||
# ---- J. autopatch mismatch => never registers -> sentinel ----------
|
||||
reset_state(); set_zero_packs()
|
||||
auth("sidJ", "10.0.1.4")
|
||||
assert SENTINEL_ID in store("sidJ", "10.0.1.4")
|
||||
print("J autopatch mismatch (never registers) -> sentinel: OK")
|
||||
|
||||
# ---- K. SAME IP, two sessions: patched A clean, unpatched B sent ---
|
||||
reset_state(); set_zero_packs()
|
||||
IP = "10.0.2.1"
|
||||
auth("sidK_A", IP)
|
||||
assert register(IP, 1)[0] == 200 # A sole candidate -> bound
|
||||
auth("sidK_B", IP) # B joins, never registers
|
||||
assert SENTINEL_ID not in store("sidK_A", IP)
|
||||
assert SENTINEL_ID in store("sidK_B", IP)
|
||||
print("K same-IP two sessions -> A clean, B sentinel: OK")
|
||||
|
||||
# ---- L. SAME IP+persona relaunch: old ok, new not -> new sentinel --
|
||||
reset_state(); set_zero_packs()
|
||||
IP = "10.0.2.2"
|
||||
auth("sidL_old", IP, PERSONA); register(IP, 1, PERSONA)
|
||||
assert SENTINEL_ID not in store("sidL_old", IP)
|
||||
us.fifa17_clear_pending(IP)
|
||||
auth("sidL_new", IP, PERSONA) # same persona, unverified
|
||||
assert SENTINEL_ID in store("sidL_new", IP)
|
||||
print("L same-IP+persona relaunch -> new session sentinel: OK")
|
||||
|
||||
# ---- M. SAME IP, failed-patch second session -----------------------
|
||||
reset_state(); set_zero_packs()
|
||||
IP = "10.0.2.3"
|
||||
auth("sidM1", IP); register(IP, 1)
|
||||
assert SENTINEL_ID not in store("sidM1", IP)
|
||||
auth("sidM2", IP) # autopatch failed
|
||||
assert SENTINEL_ID in store("sidM2", IP)
|
||||
print("M same-IP failed-patch second session -> sentinel: OK")
|
||||
|
||||
# ---- N. late reg when sessions frozen -> no active session change --
|
||||
reset_state(); set_zero_packs()
|
||||
IP = "10.0.2.4"
|
||||
auth("sidN1", IP); register(IP, 1)
|
||||
assert SENTINEL_ID not in store("sidN1", IP) # N1 frozen clean
|
||||
auth("sidN2", IP)
|
||||
assert SENTINEL_ID in store("sidN2", IP) # N2 frozen sentinel
|
||||
assert register(IP, 1)[0] == 200 # late: both frozen -> ignored
|
||||
assert SENTINEL_ID not in store("sidN1", IP) # unchanged
|
||||
assert SENTINEL_ID in store("sidN2", IP) # unchanged
|
||||
print("N late registration does not modify active sessions: OK")
|
||||
|
||||
# ---- O. session cleanup / TTL expiry -> capability gone ------------
|
||||
reset_state(); set_zero_packs()
|
||||
IP = "10.0.2.5"
|
||||
auth("sidO", IP); register(IP, 1)
|
||||
assert SENTINEL_ID not in store("sidO", IP) # clean while live
|
||||
us._FIFA17_SESSIONS["sidO"]["last_seen"] = (
|
||||
us._fifa17_now() - us.FIFA17_SESSION_TTL - 10.0)
|
||||
store("sidUNKNOWN", IP) # any op triggers reap
|
||||
assert "sidO" not in us._FIFA17_SESSIONS, "expired session not reaped"
|
||||
assert SENTINEL_ID in store("sidO", IP) # gone -> sentinel
|
||||
print("O session cleanup / TTL expiry -> sentinel: OK")
|
||||
|
||||
# ---- P. duplicate registration -> idempotent, no post-freeze change
|
||||
reset_state(); set_zero_packs()
|
||||
IP = "10.0.2.6"
|
||||
auth("sidP", IP)
|
||||
assert register(IP, 1)[0] == 200 # bound
|
||||
assert register(IP, 1)[0] == 200 # duplicate -> ignored-late
|
||||
assert SENTINEL_ID not in store("sidP", IP) # still clean
|
||||
assert register(IP, 1)[0] == 200 # after freeze
|
||||
assert SENTINEL_ID not in store("sidP", IP) # unchanged
|
||||
assert mode_of("sidP") == CLEAN
|
||||
print("P duplicate registration -> idempotent: OK")
|
||||
|
||||
# ---- Q. register-before-login: pending consumed at auth -> clean ---
|
||||
reset_state(); set_zero_packs()
|
||||
IP = "10.0.2.7"
|
||||
assert register(IP, 1)[0] == 200 # no session yet -> pending
|
||||
assert (IP, PERSONA) in us._FIFA17_PENDING
|
||||
auth("sidQ", IP, PERSONA) # consumes pending
|
||||
assert (IP, PERSONA) not in us._FIFA17_PENDING # single-use
|
||||
assert SENTINEL_ID not in store("sidQ", IP)
|
||||
assert mode_of("sidQ") == CLEAN
|
||||
print("Q register-before-login pending consumed -> clean: OK")
|
||||
|
||||
# ---- R. topology freeze immutable per SID; new SID decides fresh ----
|
||||
# F3 invariant: once a SID's store topology is decided it NEVER flips,
|
||||
# in either direction, and a different SID may decide differently.
|
||||
reset_state(); set_zero_packs()
|
||||
IP = "10.0.2.8"
|
||||
# frozen Sentinel never becomes Clean, even if a capability appears later
|
||||
auth("sidR_s", IP)
|
||||
assert SENTINEL_ID in store("sidR_s", IP) # freeze Sentinel
|
||||
register(IP, 1)
|
||||
us._FIFA17_SESSIONS["sidR_s"]["resolver"] = 1 # force-present capability
|
||||
assert SENTINEL_ID in store("sidR_s", IP) # STILL Sentinel
|
||||
assert mode_of("sidR_s") == SENT
|
||||
# frozen Clean never becomes Sentinel, even if the capability is wiped
|
||||
auth("sidR_c", IP); register(IP, 1)
|
||||
assert SENTINEL_ID not in store("sidR_c", IP) # freeze Clean
|
||||
us._FIFA17_SESSIONS["sidR_c"]["resolver"] = None # capability vanishes
|
||||
assert SENTINEL_ID not in store("sidR_c", IP) # STILL Clean
|
||||
assert mode_of("sidR_c") == CLEAN
|
||||
# a fresh SID (same IP) decides independently
|
||||
auth("sidR_new", IP)
|
||||
assert SENTINEL_ID in store("sidR_new", IP)
|
||||
print("R topology freeze immutable per SID; new SID fresh: OK")
|
||||
|
||||
finally:
|
||||
us.visible_unopened_packs = _orig_visible
|
||||
|
||||
print("capability negotiation matrix A-R: OK")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+136
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression tests for the empty-My-Packs FIFA 17 compatibility workaround (bug 6c).
|
||||
|
||||
Pins the behavior store_catalog() now depends on:
|
||||
- unopenedPackIds == [] -> exactly one synthetic active `mypacks` placeholder id 65534
|
||||
- unopenedPackIds == [70] -> no synthetic placeholder; the genuine owned pack is shown
|
||||
- synthetic id 65534 stays economy-safe (non-resolvable, non-openable, non-granting)
|
||||
- normal store packs (1/5/6/7) are untouched by the empty-state behavior
|
||||
|
||||
See docs/evidence/STORE_TILE_6C.md and FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md.
|
||||
Standalone unit test in the project style: `python3 test_empty_mypacks.py`.
|
||||
"""
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
TOOLS = os.path.dirname(os.path.abspath(__file__))
|
||||
if TOOLS not in sys.path:
|
||||
sys.path.insert(0, TOOLS)
|
||||
|
||||
SENTINEL_ID = 65534
|
||||
|
||||
|
||||
def _set_unopened(fut_store, ids):
|
||||
"""Deterministically set the active profile's owned unopened packs."""
|
||||
p = fut_store.STORE.load()
|
||||
p["unopenedPackIds"] = list(ids)
|
||||
fut_store.STORE._save()
|
||||
|
||||
|
||||
def _mypacks(catalog):
|
||||
return [p for p in catalog["purchase"]
|
||||
if (p.get("displayGroup") or {}).get("value") == "mypacks"]
|
||||
|
||||
|
||||
def _fake_request(command, body):
|
||||
class _H:
|
||||
pass
|
||||
h = _H()
|
||||
h.command = command
|
||||
h._body = body
|
||||
return h
|
||||
|
||||
|
||||
def main():
|
||||
with tempfile.TemporaryDirectory() as state:
|
||||
os.environ["FUT_ACCOUNT_PATH"] = os.path.join(state, "active_account.json")
|
||||
os.environ["FUT_PROFILE_ROOT"] = os.path.join(state, "accounts")
|
||||
os.environ.pop("FUT_PROFILE", None)
|
||||
|
||||
import fut_account
|
||||
import fut_store
|
||||
import fut_accounts
|
||||
import utas_server
|
||||
importlib.reload(fut_account)
|
||||
importlib.reload(fut_store)
|
||||
importlib.reload(fut_accounts)
|
||||
importlib.reload(utas_server)
|
||||
|
||||
fut_accounts.activate({"personaId": 111001, "personaName": "TEST_A"})
|
||||
catalog_ids = [p["id"] for p in fut_store.PACK_CATALOG]
|
||||
|
||||
# ---- A. Empty unopened packs -> one active synthetic 65534 placeholder ----
|
||||
_set_unopened(fut_store, [])
|
||||
utas_server._OPENED_PACK_GRACE.clear()
|
||||
status, cat = utas_server.store_catalog(None)
|
||||
assert status == 200
|
||||
myp = _mypacks(cat)
|
||||
assert len(myp) == 1, "expected exactly one mypacks entry, got %r" % myp
|
||||
s = myp[0]
|
||||
assert s["id"] == SENTINEL_ID, s
|
||||
assert s["state"] == "active", s # the P2 fix: active, not inactive
|
||||
assert (s.get("displayGroup") or {}).get("value") == "mypacks", s
|
||||
assert SENTINEL_ID not in catalog_ids, "65534 must not be in PACK_CATALOG"
|
||||
assert fut_store.pack_by_id(SENTINEL_ID) is None
|
||||
print("A empty-state active placeholder: PASS")
|
||||
|
||||
# ---- D (empty half). Normal packs untouched in empty state ----
|
||||
norm = {p["id"]: p for p in cat["purchase"] if p["id"] in (1, 5, 6, 7)}
|
||||
assert set(norm) == {1, 5, 6, 7}, sorted(norm)
|
||||
assert all(norm[i]["state"] == "active" for i in norm), norm
|
||||
assert norm[1]["packType"] == "BRONZE" and norm[1]["description"] == "Bronze Pack"
|
||||
|
||||
# ---- B. Non-empty unopened packs -> NO synthetic; genuine owned pack shown ----
|
||||
_set_unopened(fut_store, [70])
|
||||
utas_server._OPENED_PACK_GRACE.clear()
|
||||
status, cat = utas_server.store_catalog(None)
|
||||
assert status == 200
|
||||
ids = [p["id"] for p in cat["purchase"]]
|
||||
assert SENTINEL_ID not in ids, "synthetic placeholder must be suppressed when a pack exists"
|
||||
myp = _mypacks(cat)
|
||||
assert len(myp) == 1 and myp[0]["id"] == 70, myp
|
||||
assert myp[0]["state"] == "active" and myp[0]["unopened"] is True, myp[0]
|
||||
# normal packs still intact alongside the owned pack
|
||||
assert {1, 5, 6, 7}.issubset(set(ids)), sorted(ids)
|
||||
print("B non-empty-state genuine pack: PASS")
|
||||
|
||||
# ---- C. Economy safety of the synthetic placeholder ----
|
||||
_set_unopened(fut_store, [])
|
||||
utas_server._OPENED_PACK_GRACE.clear()
|
||||
coins0 = fut_store.STORE.coins()
|
||||
items0 = len(fut_store.STORE.items())
|
||||
next0 = fut_store.STORE.load()["nextItemId"]
|
||||
|
||||
assert fut_store.pack_by_id(SENTINEL_ID) is None
|
||||
|
||||
# store_buy: a confirmed-buy transaction for 65534 must be a no-op {}
|
||||
status, body = utas_server.store_buy(
|
||||
_fake_request("PUT", b'{"packId":65534,"state":"TRANSACTIONCREATED"}'))
|
||||
assert status == 200 and body == {}, (status, body)
|
||||
|
||||
# purchased_items: POST buy for 65534 must not open/grant anything
|
||||
status, body = utas_server.purchased_items(
|
||||
_fake_request("POST", b'{"packId":65534,"useCredits":1,"usePreOrder":0,"currency":"COINS"}'))
|
||||
assert status == 200, (status, body)
|
||||
assert "createPackResponse" not in body, body
|
||||
|
||||
# 65534 cannot enter the owned-pack pile (not a catalog pack)
|
||||
assert fut_store.STORE.grant_unopened_pack(SENTINEL_ID) is False
|
||||
assert SENTINEL_ID not in fut_store.STORE.unopened_packs()
|
||||
|
||||
# nothing mutated
|
||||
assert fut_store.STORE.coins() == coins0, (fut_store.STORE.coins(), coins0)
|
||||
assert len(fut_store.STORE.items()) == items0
|
||||
assert fut_store.STORE.load()["nextItemId"] == next0
|
||||
assert not any(i.get("id") == SENTINEL_ID or i.get("resourceId") == SENTINEL_ID
|
||||
for i in fut_store.STORE.items())
|
||||
print("C economy safety (65534 non-openable / non-granting): PASS")
|
||||
|
||||
print("empty My Packs compatibility: PASS")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Standalone contract test for Blaze roster-host advertisement."""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
TOOLS = os.path.dirname(os.path.abspath(__file__))
|
||||
if TOOLS not in sys.path:
|
||||
sys.path.insert(0, TOOLS)
|
||||
|
||||
ADVERTISE = "192.0.2.10"
|
||||
DNS_HOST = "winter15.gosredirector.ea.com:8081"
|
||||
|
||||
|
||||
def assert_roster_config(blaze, host):
|
||||
config = dict(blaze.OSDK_ROSTER)
|
||||
assert blaze.ROSTER_HOST == host
|
||||
assert config["ROSTERUPDATE_URL"] == (
|
||||
f"https://{host}/fifa17/fut/rosterupdate.xml"
|
||||
)
|
||||
assert config["ROSTER_URL"] == f"https://{host}/fifa17/roster/"
|
||||
assert config["ROSTER_VER"] == "0"
|
||||
assert config["ROSTER_CSUM"] == ""
|
||||
|
||||
|
||||
def main():
|
||||
old_advertise = os.environ.get("OPENFUT_ADVERTISE")
|
||||
old_roster_host = os.environ.get("OPENFUT_ROSTER_HOST")
|
||||
try:
|
||||
os.environ["OPENFUT_ADVERTISE"] = ADVERTISE
|
||||
os.environ.pop("OPENFUT_ROSTER_HOST", None)
|
||||
|
||||
import blaze_responder_v3b as blaze
|
||||
|
||||
blaze = importlib.reload(blaze)
|
||||
assert_roster_config(blaze, f"{ADVERTISE}:8081")
|
||||
|
||||
os.environ["OPENFUT_ROSTER_HOST"] = DNS_HOST
|
||||
blaze = importlib.reload(blaze)
|
||||
assert_roster_config(blaze, DNS_HOST)
|
||||
|
||||
os.environ["OPENFUT_ROSTER_HOST"] = ""
|
||||
blaze = importlib.reload(blaze)
|
||||
assert_roster_config(blaze, f"{ADVERTISE}:8081")
|
||||
finally:
|
||||
if old_advertise is None:
|
||||
os.environ.pop("OPENFUT_ADVERTISE", None)
|
||||
else:
|
||||
os.environ["OPENFUT_ADVERTISE"] = old_advertise
|
||||
if old_roster_host is None:
|
||||
os.environ.pop("OPENFUT_ROSTER_HOST", None)
|
||||
else:
|
||||
os.environ["OPENFUT_ROSTER_HOST"] = old_roster_host
|
||||
|
||||
print("PASS: roster host defaults, override, and URLs")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read the FIFA 17 TRADING gate byte out of the live client. READ-ONLY.
|
||||
|
||||
Extends tools/gate_byte_probe.py with vtable slot +0x270 (IS_TRADING_ENABLED,
|
||||
displacement 0x1fd2e) plus the two pile-size dwords, which the transfer-market
|
||||
analysis names as the market screen's CardsDLL-supplied inputs.
|
||||
|
||||
Opens /proc/<pid>/mem O_RDONLY and preads. Nothing here can write.
|
||||
"""
|
||||
import os, struct
|
||||
|
||||
pid = None
|
||||
for d in os.listdir('/proc'):
|
||||
if d.isdigit():
|
||||
try:
|
||||
if open('/proc/%s/comm' % d).read().strip() == 'FIFA17.exe':
|
||||
pid = int(d)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
assert pid, "FIFA17.exe not running"
|
||||
|
||||
base = None
|
||||
for ln in open('/proc/%d/maps' % pid):
|
||||
if 'CardsDLL' in ln:
|
||||
base = int(ln.split('-')[0], 16)
|
||||
assert base, "CardsDLL not mapped (client has not reached Ultimate Team)"
|
||||
slide = base - 0x180000000
|
||||
|
||||
fd = os.open('/proc/%d/mem' % pid, os.O_RDONLY)
|
||||
|
||||
|
||||
def rd(va, n):
|
||||
return os.pread(fd, n, va)
|
||||
|
||||
|
||||
# Control: the FNV atom-hash prologue must match the on-disk PE before any other
|
||||
# address is trusted.
|
||||
pe = open('/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll', 'rb').read()
|
||||
|
||||
|
||||
def f(va):
|
||||
return va - 0x180000000 - 0x1000 + 0x400
|
||||
|
||||
|
||||
ok = pe[f(0x180180d00):f(0x180180d00) + 32] == rd(0x180180d00 + slide, 32)
|
||||
print("pid=%d slide=%#x FNV control=%s" % (pid, slide, "MATCH" if ok else "MISMATCH"))
|
||||
assert ok, "slide not proven; refusing to read further"
|
||||
|
||||
obj = struct.unpack('<Q', rd(0x1802e6398 + slide, 8))[0]
|
||||
vt = struct.unpack('<Q', rd(obj, 8))[0]
|
||||
print("model=%#x vtable(static)=%#x" % (obj, vt - slide))
|
||||
|
||||
SLOTS = [
|
||||
(0x270, 'IS_TRADING_ENABLED '),
|
||||
(0x2b0, 'IS_FRIENDLY_SEASON '),
|
||||
(0x2c8, 'IS_DRAFT_MODE '),
|
||||
(0x2e0, 'packOpeningAnimation '),
|
||||
]
|
||||
print("\n-- gate bytes decoded from their accessor stubs --")
|
||||
for off, name in SLOTS:
|
||||
slot = struct.unpack('<Q', rd(vt + off, 8))[0]
|
||||
stub = rd(slot, 8)
|
||||
if stub[:3] == b'\x0f\xb6\x81':
|
||||
disp = struct.unpack('<I', stub[3:7])[0]
|
||||
val = rd(obj + disp, 1)[0]
|
||||
print(" slot +%#05x %s disp=%#x VALUE=%d" % (off, name, disp, val))
|
||||
else:
|
||||
print(" slot +%#05x %s NOT a movzx stub: %s" % (off, name, stub.hex()))
|
||||
|
||||
print("\n-- market screen inputs --")
|
||||
for disp, name in [(0x1fd1c, 'TRADE_PILE_SIZE'), (0x1fd20, 'watchListSize '),
|
||||
(0x1fd2e, 'tradingEnabled '), (0x1fd2f, 'storeEnabled ')]:
|
||||
print(" model+%#x %s = %d" % (disp, name, rd(obj + disp, 1)[0]))
|
||||
|
||||
os.close(fd)
|
||||
@@ -11,7 +11,7 @@ Rules (from CardsDLL 0x18016D230 / 0x1801a33a0):
|
||||
* body must parse as JSON (else err 0x3E6); 204 + empty body is accepted.
|
||||
* [resp+0x1c] == 0 is the success test; 404 is OK only on the first user GET.
|
||||
"""
|
||||
import copy, datetime, json, os, random, re, sys, http.server
|
||||
import copy, datetime, json, os, random, re, sys, threading, time, http.server
|
||||
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
@@ -52,6 +52,173 @@ def visible_unopened_packs():
|
||||
return STORE.unopened_packs() + list(_OPENED_PACK_GRACE)
|
||||
|
||||
|
||||
# ---- FIFA17 empty-My-Packs capability negotiation (PER-SESSION, hardened) ----
|
||||
# The synthetic 65534 sentinel (store_catalog) is the universal P2 fallback. It is
|
||||
# suppressed for ONE FIFA session only when the launcher has registered that THAT
|
||||
# process positively verified the CardsDLL resolver guard (RVA 0x14858 == JG).
|
||||
#
|
||||
# BINDING: the authoritative key is the per-login-unique UTAS session id (X-UT-SID),
|
||||
# minted fresh at every /ut/auth and echoed by the client on every later call incl.
|
||||
# /store/purchasegroup (live-confirmed present on real store requests). The initial
|
||||
# prototype keyed on source IP ALONE; that was rejected because two FIFA processes
|
||||
# (concurrent or relaunched) share an IP, so an unverified process could inherit a
|
||||
# verified one's clean topology and crash. IP + persona are retained only as
|
||||
# auxiliary data: a fail-closed sid/ip sanity check and the (ip,persona) key for the
|
||||
# short-lived launcher->session hand-off.
|
||||
#
|
||||
# The launcher verifies out-of-band (autopatch) and cannot know the SID, so its
|
||||
# registration is staged as a SINGLE-USE, short-TTL PENDING keyed by (ip,persona)
|
||||
# and bound to exactly one FIFA session (directly if that session already exists,
|
||||
# else consumed at the session's login or its first store request). Fail-closed
|
||||
# everywhere: unknown / expired / absent / ambiguous / late => sentinel.
|
||||
# See docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md (§Session binding).
|
||||
FIFA17_EMPTY_MYPACKS_RESOLVER_VERSION = 1
|
||||
FIFA17_MODE_SENTINEL = "sentinel"
|
||||
FIFA17_MODE_CLEAN = "clean-v1"
|
||||
FIFA17_SESSION_TTL = 3600.0 # reap a FIFA session after this many idle seconds
|
||||
FIFA17_PENDING_TTL = 120.0 # a launcher capability may await its session this long
|
||||
|
||||
# sid -> {"ip","persona","resolver": Optional[int],"mode": Optional[str],"created","last_seen"}
|
||||
_FIFA17_SESSIONS = {}
|
||||
# (ip, persona) -> {"resolver": int, "ts"}: single-use launcher->session hand-off.
|
||||
_FIFA17_PENDING = {}
|
||||
_FIFA17_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _fifa17_now():
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def _fifa17_client_ip(h):
|
||||
"""Peer IP for the handler, or None when unavailable (e.g. h is None)."""
|
||||
try:
|
||||
return h.client_address[0]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _fifa17_sid(h):
|
||||
"""The client's UTAS session id (X-UT-SID) for this request, or None."""
|
||||
try:
|
||||
return h.headers.get("X-UT-SID")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _fifa17_sidlog(sid):
|
||||
"""A short, non-secret tag for correlating a session in logs."""
|
||||
return ("\u2026" + sid[-6:]) if sid else "-"
|
||||
|
||||
|
||||
def _fifa17_mint_sid():
|
||||
"""A fresh, per-login-unique UTAS session id (same shape/length as the legacy
|
||||
constant). Uniqueness -- not unpredictability -- is what the binding needs."""
|
||||
return "OPENFUT-SID-%016X" % random.getrandbits(64)
|
||||
|
||||
|
||||
def _fifa17_reap_locked(now):
|
||||
for sid in [s for s, r in _FIFA17_SESSIONS.items()
|
||||
if now - r["last_seen"] > FIFA17_SESSION_TTL]:
|
||||
del _FIFA17_SESSIONS[sid]
|
||||
for key in [k for k, p in _FIFA17_PENDING.items()
|
||||
if now - p["ts"] > FIFA17_PENDING_TTL]:
|
||||
del _FIFA17_PENDING[key]
|
||||
|
||||
|
||||
def _fifa17_take_pending_locked(ip, persona, now):
|
||||
"""Single-use: remove and return a fresh pending resolver for (ip,persona)."""
|
||||
p = _FIFA17_PENDING.get((ip, persona))
|
||||
if p is not None and now - p["ts"] <= FIFA17_PENDING_TTL:
|
||||
del _FIFA17_PENDING[(ip, persona)]
|
||||
return p["resolver"]
|
||||
return None
|
||||
|
||||
|
||||
def fifa17_session_known(sid):
|
||||
"""True if sid is a live session (or the legacy constant, accepted by the
|
||||
retired security-question gate ONLY -- never used to grant clean store mode)."""
|
||||
if sid == SID:
|
||||
return True
|
||||
with _FIFA17_LOCK:
|
||||
return sid in _FIFA17_SESSIONS
|
||||
|
||||
|
||||
def fifa17_open_session(sid, ip, persona):
|
||||
"""/ut/auth: open a per-login session and bind any pending launcher capability
|
||||
for (ip,persona) that arrived before login."""
|
||||
if not sid:
|
||||
return
|
||||
now = _fifa17_now()
|
||||
with _FIFA17_LOCK:
|
||||
_fifa17_reap_locked(now)
|
||||
resolver = _fifa17_take_pending_locked(ip, persona, now)
|
||||
_FIFA17_SESSIONS[sid] = {"ip": ip, "persona": persona, "resolver": resolver,
|
||||
"mode": None, "created": now, "last_seen": now}
|
||||
log("[fifa17-store] session opened %s (ip=%s persona=%s resolver=%s)"
|
||||
% (_fifa17_sidlog(sid), ip, persona, resolver))
|
||||
|
||||
|
||||
def fifa17_clear_pending(ip):
|
||||
"""/openfut/account/sync hygiene: drop any stale pending for this machine so a
|
||||
new launch's unverified session cannot inherit a leftover capability."""
|
||||
now = _fifa17_now()
|
||||
with _FIFA17_LOCK:
|
||||
_fifa17_reap_locked(now)
|
||||
for key in [k for k in _FIFA17_PENDING if k[0] == ip]:
|
||||
del _FIFA17_PENDING[key]
|
||||
|
||||
|
||||
def fifa17_register_capability(ip, persona, version):
|
||||
"""Launcher registration. Returns one of:
|
||||
"bound" exactly one live, unfrozen, unbound session for (ip,persona)
|
||||
existed (registration after login -- the common case): bound now.
|
||||
"pending" no session for (ip,persona) yet (before login): staged single-use.
|
||||
"ignored-late" a session for (ip,persona) exists but is frozen or ambiguous
|
||||
(>1 unbound): NOT staged, so no later/unverified process can
|
||||
inherit it. Fail-closed.
|
||||
Never authorizes more than one session."""
|
||||
now = _fifa17_now()
|
||||
with _FIFA17_LOCK:
|
||||
_fifa17_reap_locked(now)
|
||||
sessions = [r for r in _FIFA17_SESSIONS.values()
|
||||
if r["ip"] == ip and r["persona"] == persona]
|
||||
candidates = [r for r in sessions if r["mode"] is None and r["resolver"] is None]
|
||||
if len(candidates) == 1:
|
||||
candidates[0]["resolver"] = version
|
||||
return "bound"
|
||||
if sessions:
|
||||
return "ignored-late"
|
||||
_FIFA17_PENDING[(ip, persona)] = {"resolver": version, "ts": now}
|
||||
return "pending"
|
||||
|
||||
|
||||
def fifa17_empty_mypacks_mode(sid, ip):
|
||||
"""Freeze (once) and return the empty-My-Packs mode for FIFA session `sid`.
|
||||
Freeze point = the first /store/purchasegroup of the session. Fail-closed: an
|
||||
unknown session, or a sid presented from a different IP than it was opened on,
|
||||
resolves to the sentinel."""
|
||||
now = _fifa17_now()
|
||||
with _FIFA17_LOCK:
|
||||
_fifa17_reap_locked(now)
|
||||
rec = _FIFA17_SESSIONS.get(sid)
|
||||
if rec is None:
|
||||
return FIFA17_MODE_SENTINEL
|
||||
rec["last_seen"] = now
|
||||
if rec["ip"] is not None and ip is not None and rec["ip"] != ip:
|
||||
log("[fifa17-store] sid %s ip mismatch (session %s != request %s) -> sentinel"
|
||||
% (_fifa17_sidlog(sid), rec["ip"], ip))
|
||||
return FIFA17_MODE_SENTINEL
|
||||
if rec["mode"] is None:
|
||||
if rec["resolver"] is None:
|
||||
rec["resolver"] = _fifa17_take_pending_locked(rec["ip"], rec["persona"], now)
|
||||
rec["mode"] = (FIFA17_MODE_CLEAN
|
||||
if rec["resolver"] == FIFA17_EMPTY_MYPACKS_RESOLVER_VERSION
|
||||
else FIFA17_MODE_SENTINEL)
|
||||
log("[fifa17-store] session %s empty-mypacks mode frozen: %s"
|
||||
% (_fifa17_sidlog(sid), rec["mode"]))
|
||||
return rec["mode"]
|
||||
|
||||
|
||||
def now():
|
||||
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
@@ -102,7 +269,7 @@ def security_question_route(h):
|
||||
well-formed value without retaining or comparing it. Account selection has
|
||||
already initialized the server-owned verified compatibility state.
|
||||
"""
|
||||
if h.headers.get("X-UT-SID") != SID:
|
||||
if not fifa17_session_known(h.headers.get("X-UT-SID")):
|
||||
log("[FUT] security-question request has no matching OpenFUT session")
|
||||
return 400, {"reason": "invalid_session"}
|
||||
|
||||
@@ -193,11 +360,19 @@ def auth_body(h=None):
|
||||
except Exception as e: # adoption must never break auth
|
||||
log(" AUTH: adopt failed (%s: %s) -- keeping %s/%r"
|
||||
% (type(e).__name__, e, before[0], before[1]))
|
||||
return {"protocol": 1, "sid": SID, "serverTime": now(), "lastOnlineTime": now()}
|
||||
sid = _fifa17_mint_sid()
|
||||
fifa17_open_session(sid, _fifa17_client_ip(h), ACCOUNT.persona_id)
|
||||
return {"protocol": 1, "sid": sid, "serverTime": now(), "lastOnlineTime": now()}
|
||||
|
||||
|
||||
def account_sync_route(h):
|
||||
"""Launcher-only active-profile selection, before LSX/Blaze login starts."""
|
||||
# Pre-launch hygiene: drop any stale launcher capability still pending for this
|
||||
# machine so a new launch's unverified FIFA session cannot inherit it. The real
|
||||
# per-process session is opened later, at /ut/auth (keyed by the minted X-UT-SID).
|
||||
ip = _fifa17_client_ip(h)
|
||||
fifa17_clear_pending(ip)
|
||||
log(" ACCOUNT: cleared stale FIFA17 pending capability for ip %s" % ip)
|
||||
try:
|
||||
body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
|
||||
account = activate_account(body)
|
||||
@@ -209,6 +384,33 @@ def account_sync_route(h):
|
||||
return 200, {"account": account, "status": "OK"}
|
||||
|
||||
|
||||
def fifa17_capability_route(h):
|
||||
"""POST /openfut/fifa17/capability -- launcher registers a verified resolver
|
||||
capability for the current FIFA process (bound to the peer IP). Fail-closed:
|
||||
anything but capability==empty_mypacks_resolver && version==current is a 400
|
||||
that records NOTHING (the session stays on the sentinel fallback)."""
|
||||
try:
|
||||
body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
|
||||
except Exception:
|
||||
return 400, {"error": "unsupported capability"}
|
||||
if not isinstance(body, dict):
|
||||
return 400, {"error": "unsupported capability"}
|
||||
try:
|
||||
version = int(body.get("version"))
|
||||
except (TypeError, ValueError):
|
||||
return 400, {"error": "unsupported capability"}
|
||||
if (body.get("capability") != "empty_mypacks_resolver"
|
||||
or version != FIFA17_EMPTY_MYPACKS_RESOLVER_VERSION):
|
||||
return 400, {"error": "unsupported capability"}
|
||||
ip = _fifa17_client_ip(h)
|
||||
persona = body.get("personaId")
|
||||
fifa_pid = body.get("fifaPid", "?")
|
||||
status = fifa17_register_capability(ip, persona, version)
|
||||
log("[fifa17-store] capability empty_mypacks_resolver=%s ip=%s persona=%s "
|
||||
"fifa_pid=%s -> %s" % (version, ip, persona, fifa_pid, status))
|
||||
return 200, {"status": "OK"}
|
||||
|
||||
|
||||
def current_squad():
|
||||
"""The squad the client should see: the persisted one (item refs re-embedded
|
||||
from the club) or the seed ladder squad on first run.
|
||||
@@ -1203,6 +1405,10 @@ ROUTES = [
|
||||
# Launcher control-plane endpoint. It is intentionally outside /ut so FIFA
|
||||
# never calls it; launch is blocked unless this succeeds first.
|
||||
(re.compile(r"^/openfut/account/sync$"), lambda m, h: account_sync_route(h)),
|
||||
# Launcher registers a verified per-FIFA-process resolver capability (bound to
|
||||
# peer IP). Adjacent to account/sync, above the generic /ut routes; FIFA never
|
||||
# calls it. Fail-closed: absent/late/wrong-version => sentinel (store_catalog).
|
||||
(re.compile(r"^/openfut/fifa17/capability$"), lambda m, h: fifa17_capability_route(h)),
|
||||
# ---- FUT item-definition endpoints (must precede generic /item, /user) ----
|
||||
(re.compile(G + r"/item/resource"), lambda m, h: defs_route(h)),
|
||||
(re.compile(G + r"/defid"), lambda m, h: defs_route(h)),
|
||||
@@ -3426,24 +3632,54 @@ def store_catalog(h):
|
||||
if owned:
|
||||
packs.append(_pack_body(owned, idx, owned=True))
|
||||
if not owned_ids:
|
||||
# GOTO_STORE_MYPACK resolves the hard-coded `mypacks` group before it
|
||||
# renders rows. If the group is absent FIFA falls back to Bronze and
|
||||
# shows the empty-category dialog over the wrong tab. Retain an inactive
|
||||
# zero-item sentinel so the destination resolves, while state != active
|
||||
# keeps it out of the visible row list. Its id is deliberately absent
|
||||
# from PACK_CATALOG, so both purchase/open handlers reject it as well.
|
||||
sentinel = {
|
||||
"id": 65534,
|
||||
"name": "",
|
||||
"price": 0,
|
||||
"count": 0,
|
||||
"gold": True,
|
||||
"specialChance": 0.0,
|
||||
}
|
||||
empty = _pack_body(sentinel, 1, owned=True)
|
||||
empty["state"] = "inactive"
|
||||
empty["unopened"] = False
|
||||
packs.append(empty)
|
||||
# ADDITIVE capability switch (see docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md
|
||||
# §7/§9). This is the session-freeze point: the empty-mypacks decision for
|
||||
# this FIFA session (keyed by its X-UT-SID) is committed here at the first
|
||||
# /store/purchasegroup and is immutable for the session thereafter.
|
||||
mode = fifa17_empty_mypacks_mode(_fifa17_sid(h), _fifa17_client_ip(h))
|
||||
if mode == FIFA17_MODE_CLEAN:
|
||||
# Verified patched client: emit NO mypacks group; the CardsDLL resolver
|
||||
# guard (RVA 0x14858 JG) routes the -1 ordinal to Browse instead of
|
||||
# dereferencing a null group. (append nothing)
|
||||
pass
|
||||
else:
|
||||
# EMPTY MY PACKS -- FIFA 17 client-compatibility workaround (bug 6c, P2).
|
||||
#
|
||||
# The Store/Scaleform path RESOLVES the `mypacks` category even when the
|
||||
# account owns zero unopened packs (the category is chosen client-side from
|
||||
# the movie's CATEGORY_ID -> screen+0x290; no server field gates it).
|
||||
# CardsDLL FUN_1800147f0 then dereferences the resolved group with NO null
|
||||
# guard, so if no `mypacks` group exists the client CRASHES
|
||||
# (CardsDLL_Win64_retail.dll+0x14882, read of [NULL+0x48] -- confirmed by
|
||||
# minidump). We therefore MUST emit a `mypacks` group when empty.
|
||||
#
|
||||
# state="inactive" avoids the crash but makes the client report the pack
|
||||
# unavailable immediately on Store entry and bounce to the Hub. state="active"
|
||||
# keeps the group structurally valid AND lets the Store open normally; the
|
||||
# empty tile renders as "0 items" and an explicit open is rejected
|
||||
# CLIENT-SIDE ("This pack is no longer available") -- it sends NO backend
|
||||
# request and mutates nothing.
|
||||
#
|
||||
# id 65534 is deliberately ABSENT from PACK_CATALOG, so pack_by_id() returns
|
||||
# None and store_buy()/purchased_items() cannot open it, grant items/coins,
|
||||
# or add it to unopenedPackIds. This is a compatibility shim for FIFA 17
|
||||
# client behavior, NOT an EA-authentic empty-My-Packs representation, and it
|
||||
# is FIFA17-specific (do not lift into game-independent Core). A fully clean
|
||||
# zero-pack UX requires a client-side fix -- see
|
||||
# docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md and the evidence in
|
||||
# docs/evidence/STORE_TILE_6C.md / FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md.
|
||||
sentinel = {
|
||||
"id": 65534,
|
||||
"name": "",
|
||||
"price": 0,
|
||||
"count": 0,
|
||||
"gold": True,
|
||||
"specialChance": 0.0,
|
||||
}
|
||||
empty = _pack_body(sentinel, 1, owned=True)
|
||||
empty["state"] = "active"
|
||||
empty["unopened"] = False
|
||||
packs.append(empty)
|
||||
return 200, {"purchase": packs, "timestamp": 1596326400}
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "openfut-adapter-fifa17"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "FIFA 17 game adapter: Blaze command tables, response bodies and dispatch"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
openfut-protocol-blaze = { path = "../openfut-protocol-blaze" }
|
||||
# Reads the bundled fetchClientConfig table (227-243 rows per CFID), which is
|
||||
# generated from the Python oracle rather than transcribed by hand. Unlike the
|
||||
# protocol crate below it, this crate is ordinary server-side code, so a real
|
||||
# JSON parser is the right call — hand-rolling one to preserve a zero-dependency
|
||||
# streak would be reinventing a solved problem in the riskiest possible place.
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# Seeded RNG for the Store pack-content generator (`fut::pack_content`). The
|
||||
# generator is pure over an injected `rand::Rng`, so packs are deterministic
|
||||
# under a seeded `StdRng` in tests and reproducible in production.
|
||||
rand = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
# Differential fixtures are JSONL; the runtime dependency already covers it.
|
||||
@@ -0,0 +1,115 @@
|
||||
# openfut-adapter-fifa17
|
||||
|
||||
The FIFA 17 game adapter. Everything true of *FIFA 17 specifically* lives here,
|
||||
so that neither OpenFUT Core nor the generic protocol crates have to know about
|
||||
it.
|
||||
|
||||
```
|
||||
openfut-protocol-blaze generic Blaze: Fire2 framing, Heat2/TDF codec
|
||||
▲
|
||||
openfut-adapter-fifa17 THIS: command tables, response bodies, dispatch order
|
||||
▲
|
||||
OpenFUT Core game-independent FUT domain (not yet wired)
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
| Surface | Port | State |
|
||||
|---|---|---|
|
||||
| **Blaze / Fire2 RPC** | 42130 | **Implemented**, byte-for-byte parity-tested |
|
||||
| Redirector (HTTPS + XML) | 42127 | Python only |
|
||||
| Nucleus OAuth stub | 42131 | Python only |
|
||||
| LSX / Origin | 4216 | Python only |
|
||||
| Roster XML | 8081 | Python only |
|
||||
| UTAS / RS4 | 8099 | Python only |
|
||||
| POW / EASFC | 8094 / 8080 | Python only |
|
||||
|
||||
**Nothing here is wired into the running backend.** The crate answers frames; it
|
||||
opens no socket, terminates no TLS and owns no runtime. The Python backend
|
||||
remains the live service and the behavioural oracle.
|
||||
|
||||
## What the adapter owns, and what it must not
|
||||
|
||||
Owns: component/command/notification IDs, response body shapes, dispatch
|
||||
ordering, session identity, the `fetchClientConfig` tables.
|
||||
|
||||
Must not own: FUT domain state. Blaze is an auth/session/config protocol — no
|
||||
coins, packs, clubs or squads appear on this wire — so `Session` holds a session
|
||||
key, a locale, a service name, an auth code and a flag, and that is all. When
|
||||
UTAS is migrated that boundary will need active defending; here it comes free.
|
||||
|
||||
## Parity
|
||||
|
||||
```bash
|
||||
./check-parity.sh # oracle freshness + byte-for-byte replay
|
||||
./check-parity.sh --regen # after an intentional oracle change
|
||||
```
|
||||
|
||||
`fixtures/blaze_transactions.jsonl` holds 49 request→response(s) transactions
|
||||
produced by calling the real `blaze_responder_v3b.dispatch()`. They replay in
|
||||
order against a shared session per connection, so ordering-dependent behaviour
|
||||
is exercised rather than assumed: preAuth captures the locale that later `ALOC`
|
||||
fields echo, and login sets the auth code `getAuthToken` returns afterwards.
|
||||
|
||||
Comparison is byte-for-byte including frame count and order — a missing
|
||||
post-login notification or a reply where the oracle stays silent fails here.
|
||||
|
||||
The suite was **mutation-tested**: swapping two post-login notifications,
|
||||
flipping one enum deep inside `AccountInfo`, and hardcoding an address in
|
||||
`utas_base()`/`nucleus_base()` were each verified to turn it red. The third
|
||||
initially did *not*, because the config templating had made those helpers dead
|
||||
code; the table now templates on URL-level tokens so they are the single place a
|
||||
URL shape is defined.
|
||||
|
||||
## Three behaviours that are easy to get wrong
|
||||
|
||||
* **Login answers with four frames, in order**: reply, then `UserAuthenticated`,
|
||||
`UserSessionExtendedDataUpdate`, `UserAdded`.
|
||||
* **An unimplemented RPC still gets an empty reply.** Silence makes the client
|
||||
wait for a timeout; an empty reply lets every field fall back to a client-side
|
||||
default and the boot continues.
|
||||
* **Non-request message types get nothing at all.**
|
||||
|
||||
No error replies are emitted. `msgType` 3 exists, but the error-code placement
|
||||
is UNRESOLVED — three clean-room sources disagree between `header[14:16]`, a
|
||||
metadata `ERRC`, and a payload `CNTX`/`ERRC` — so emitting one would be a guess
|
||||
on the wire.
|
||||
|
||||
## The client config table
|
||||
|
||||
`fixtures/client_config.json` carries 227–243 rows per CFID, generated from the
|
||||
Python oracle and templated on `{utas_base}`, `{nucleus_base}`,
|
||||
`{pow_content_url}`, `{advertise}`, `{bind}`, `{pow_host}`. It is
|
||||
reverse-engineered *data*, not logic, and deriving it mechanically removes a
|
||||
class of transcription typo no reviewer could catch. The generator does not take
|
||||
its own templating on trust: it substitutes real addresses back in and diffs
|
||||
against the oracle for every section before writing the file.
|
||||
|
||||
The table must be *complete*, not representative. The client resolves a per-call
|
||||
key (`FUT_RS4_URL_<CALL>`) before a per-module one, and any unresolved call falls
|
||||
back to a real, dead EA host — that is what produced "there has been an error
|
||||
connecting to FIFA 17 Ultimate Team" mid-session when only the boot subset was
|
||||
served.
|
||||
|
||||
## Known defect reproduced deliberately
|
||||
|
||||
`nucleusConnect` and `nucleusConnectTrusted` are built from the **bind** address,
|
||||
not the advertised one. On the live split deployment that means the backend
|
||||
tells a client on another machine to reach Nucleus at `http://0.0.0.0:42131`,
|
||||
which it cannot. Verified against the running container, not inferred.
|
||||
|
||||
This is reproduced exactly, because it is what the only proven-working
|
||||
configuration does and changing it would break parity. It also implies the
|
||||
Nucleus stub is not actually reached in the current remote flow. Fixing it is a
|
||||
separate change that needs live validation — see the vault.
|
||||
|
||||
## Configuration
|
||||
|
||||
Nothing is hardcoded. `AdapterConfig` carries `Identity` (persona, ids, email,
|
||||
namespace, entitlement group, …) and `Endpoints` (advertise, bind, POW hosts,
|
||||
telemetry/ticker/QoS ports). `Default` gives the project's synthetic offline
|
||||
identity on loopback; a remote deployment must override `advertise`.
|
||||
|
||||
Bind and advertise are deliberately distinct: an advertised URL must carry the
|
||||
address the *client* can reach, which on a two-machine deployment is not the
|
||||
address the server binds.
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# Differential check: the Rust FIFA 17 Blaze adapter vs the Python responder.
|
||||
#
|
||||
# 1. assert the committed fixtures still match what the Python oracle emits
|
||||
# 2. replay every recorded transaction through the Rust adapter, byte-for-byte
|
||||
#
|
||||
# Read-only with respect to the running backend: the oracle is imported as a
|
||||
# library, no responder is started, no port is bound, no live service is
|
||||
# touched. Safe to run while the Python backend is serving a live FIFA client.
|
||||
#
|
||||
# Use --regen to rewrite the fixtures after an intentional oracle change.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$(readlink -f "$0")")"
|
||||
|
||||
if [[ "${1:-}" == "--regen" ]]; then
|
||||
echo "==> regenerating fixtures from the Python oracle"
|
||||
python3 fixtures/generate.py
|
||||
else
|
||||
echo "==> checking committed fixtures against the Python oracle"
|
||||
python3 fixtures/generate.py --check
|
||||
fi
|
||||
|
||||
echo "==> replaying transactions through the Rust adapter"
|
||||
cargo test -p openfut-adapter-fifa17
|
||||
|
||||
echo
|
||||
echo "PARITY OK — the adapter reproduces the Python dispatcher byte-for-byte."
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,460 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Freeze the Python Blaze responder's DISPATCH contract as replayable fixtures.
|
||||
|
||||
The crate-level fixtures in `openfut-protocol-blaze` pin the *codec*: given a
|
||||
field tree, what bytes come out. This file pins the layer above: given an
|
||||
inbound Fire2 frame and a session, **which frames go back, in what order**.
|
||||
|
||||
That is the whole contract of a Blaze adapter, and it is the thing a rewrite can
|
||||
silently get wrong in ways a codec test cannot see — a missing post-login
|
||||
notification, a reply where the oracle stays silent, notifications in the wrong
|
||||
order, session state not carried between RPCs.
|
||||
|
||||
Every transaction is produced by calling the real
|
||||
`blaze_responder_v3b.dispatch()`. Session state is threaded across a scripted
|
||||
connection exactly as it would be on a live socket, so ordering-dependent
|
||||
behaviour (preAuth captures the locale; login sets the auth code that
|
||||
getAuthToken later returns) is captured rather than assumed.
|
||||
|
||||
Determinism: the oracle's clock is pinned and its PRNG seeded, and the
|
||||
deployment-dependent addresses are set before import (the responder reads them
|
||||
at import time). See the sibling generator in openfut-protocol-blaze.
|
||||
|
||||
NO SECRETS. The identity here (persona 33068179 / "CAGE") is the project's fixed
|
||||
synthetic offline identity. Session keys are minted from a seeded PRNG.
|
||||
|
||||
Usage: python3 fixtures/generate.py (write)
|
||||
python3 fixtures/generate.py --check (verify committed files are current)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
from collections import OrderedDict
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
TOOLS = os.path.normpath(os.path.join(HERE, "..", "..", "fifa17-recon", "tools"))
|
||||
if not os.path.isdir(TOOLS):
|
||||
sys.exit("cannot find the Python oracle at %s" % TOOLS)
|
||||
sys.path.insert(0, TOOLS)
|
||||
|
||||
CHECK_ONLY = "--check" in sys.argv[1:]
|
||||
# Internal mode: re-exec of this script with sentinel addresses, used to derive
|
||||
# the templated client-config table (see emit_config_table).
|
||||
CONFIG_TABLE_MODE = "--_config_table" in sys.argv[1:]
|
||||
sys.argv = [sys.argv[0]]
|
||||
|
||||
# Sentinels substituted back into template tokens. Deliberately not IP-shaped so
|
||||
# a stray literal cannot be mistaken for a real address.
|
||||
SENTINELS = [
|
||||
("ADVERTISE-SENTINEL", "{advertise}"),
|
||||
("BIND-SENTINEL", "{bind}"),
|
||||
("POWCONTENT-SENTINEL", "{pow_content_host}"),
|
||||
("POWHOST-SENTINEL", "{pow_host}"),
|
||||
]
|
||||
|
||||
if CONFIG_TABLE_MODE:
|
||||
os.environ["OPENFUT_ADVERTISE"] = "ADVERTISE-SENTINEL"
|
||||
os.environ["OPENFUT_BIND"] = "BIND-SENTINEL"
|
||||
os.environ["POW_CONTENT_HOST"] = "POWCONTENT-SENTINEL"
|
||||
os.environ["POW_HOST"] = "POWHOST-SENTINEL"
|
||||
import blaze_responder_v3b as _B # noqa: E402
|
||||
out = {cfid: _B.client_config_for(cfid) for cfid in sorted(_B.CLIENT_CONFIGS)}
|
||||
out["__default__"] = _B.client_config_for("__no_such_section__")
|
||||
print(json.dumps(out))
|
||||
raise SystemExit(0)
|
||||
|
||||
# Pin deployment config BEFORE import — the responder snapshots these at import
|
||||
# time into module globals used by the response builders.
|
||||
#
|
||||
# Distinct, obviously-fake values on purpose: if the Rust adapter hardcoded an
|
||||
# address instead of reading its config, these make the failure loud rather than
|
||||
# accidentally matching a loopback default.
|
||||
ADVERTISE = "198.51.100.7"
|
||||
BIND = "0.0.0.0"
|
||||
POW_CONTENT_HOST = "198.51.100.7:8085"
|
||||
POW_HOST = "198.51.100.7:8094"
|
||||
|
||||
os.environ["OPENFUT_ADVERTISE"] = ADVERTISE
|
||||
os.environ["OPENFUT_BIND"] = BIND
|
||||
os.environ["POW_CONTENT_HOST"] = POW_CONTENT_HOST
|
||||
os.environ["POW_HOST"] = POW_HOST
|
||||
|
||||
import heat2 # noqa: E402
|
||||
import blaze_responder_v3b as B # noqa: E402
|
||||
from fut_account import ACCOUNT # noqa: E402
|
||||
|
||||
FIXED_NOW = 1754870400
|
||||
INT, STRING, STRUCT, LIST, MAP, BLOB = (
|
||||
heat2.INT, heat2.STRING, heat2.STRUCT, heat2.LIST, heat2.MAP, heat2.BLOB)
|
||||
|
||||
RECORDS = []
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ helpers
|
||||
|
||||
def req_frame(component, command, fields=None, msg_num=1, msg_type=None,
|
||||
user_index=0):
|
||||
"""Build an inbound request frame the way the client would."""
|
||||
msg_type = B.MESSAGE if msg_type is None else msg_type
|
||||
payload = heat2.encode_tdf(fields) if fields else b""
|
||||
return B.fire2(component, command, msg_num, msg_type, payload,
|
||||
user_index=user_index)
|
||||
|
||||
|
||||
def tx(session, name, frame, note=""):
|
||||
"""Run one frame through the real dispatcher and record what came back."""
|
||||
hdr = B.parse_fire2_header(frame)
|
||||
body = frame[16 + hdr["metadata_len"]:]
|
||||
fields = heat2.decode_tdf(body) if body else OrderedDict()
|
||||
out = B.dispatch(hdr, fields, body, session["sess"])
|
||||
|
||||
RECORDS.append(OrderedDict((
|
||||
("kind", "tx"),
|
||||
("session", session["id"]),
|
||||
("name", name),
|
||||
("note", note),
|
||||
("request_hex", frame.hex()),
|
||||
("responses", [f.hex() for f in out]),
|
||||
)))
|
||||
return out
|
||||
|
||||
|
||||
def new_session(sid):
|
||||
s = {"id": sid, "sess": B.Session()}
|
||||
RECORDS.append(OrderedDict((
|
||||
("kind", "session"),
|
||||
("id", sid),
|
||||
# Minted per connection by the oracle; the Rust side must be able to
|
||||
# inject it, because it appears in LoginResponse.SESS.KEY, the
|
||||
# UserAuthenticated push and PostAuthResponse.TELE.SESS and all three
|
||||
# must be the same string.
|
||||
("session_key", s["sess"].session_key),
|
||||
("account_locale", s["sess"].account_locale),
|
||||
("service_name", s["sess"].service_name),
|
||||
)))
|
||||
return s
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ script
|
||||
|
||||
def build():
|
||||
RECORDS.append(OrderedDict((
|
||||
("kind", "config"),
|
||||
("advertise", ADVERTISE),
|
||||
("bind", BIND),
|
||||
("pow_content_host", POW_CONTENT_HOST),
|
||||
("pow_host", POW_HOST),
|
||||
("now", FIXED_NOW),
|
||||
("identity", OrderedDict((
|
||||
("persona_id", ACCOUNT.persona_id),
|
||||
("persona_name", ACCOUNT.persona_name),
|
||||
("user_id", ACCOUNT.user_id),
|
||||
("ext_id", ACCOUNT.ext_id),
|
||||
("email", ACCOUNT.email),
|
||||
("namespace", ACCOUNT.NAMESPACE),
|
||||
("client_platform", ACCOUNT.CLIENT_PLATFORM),
|
||||
("persona_status", ACCOUNT.PERSONA_STATUS),
|
||||
("user_session_type", ACCOUNT.USER_SESSION_TYPE),
|
||||
("account_locale_int", ACCOUNT.account_locale_int),
|
||||
("locale", ACCOUNT.locale),
|
||||
("content_id", ACCOUNT.CONTENT_ID),
|
||||
("entitlement_tag", ACCOUNT.ENTITLEMENT_TAG),
|
||||
("entitlement_group", ACCOUNT.ENTITLEMENT_GROUP),
|
||||
("title_id", ACCOUNT.TITLE_ID),
|
||||
("client_id", ACCOUNT.CLIENT_ID),
|
||||
("platform", ACCOUNT.PLATFORM),
|
||||
("server_version", B.SERVER_VERSION),
|
||||
))),
|
||||
)))
|
||||
|
||||
# ================= main connection: the real boot order =================
|
||||
#
|
||||
# Mirrors what FIFA 17 actually does, because ordering is load-bearing:
|
||||
# preAuth captures the locale that later ALOC fields echo, and login sets
|
||||
# the auth code that getAuthToken returns afterwards.
|
||||
m = new_session("main")
|
||||
|
||||
tx(m, "preauth", req_frame(B.COMP_UTIL, B.CMD_PREAUTH, OrderedDict([
|
||||
("CDAT", (STRUCT, OrderedDict([
|
||||
("IITO", (INT, 0)),
|
||||
("LANG", (INT, 0x656E5553)), # 'enUS'
|
||||
("SVCN", (STRING, "fifa-2017-pc")), # echoed back as INST
|
||||
("TYPE", (INT, 0)),
|
||||
]))),
|
||||
("CINF", (STRUCT, OrderedDict([
|
||||
("BSDK", (STRING, "15.1.1.3.0")),
|
||||
("CLNT", (STRING, "FIFA17")),
|
||||
("ENV", (STRING, "prod")),
|
||||
("LOC", (INT, 0x656E5553)),
|
||||
]))),
|
||||
("FCCR", (STRUCT, OrderedDict([("CFID", (STRING, "BlazeSDK"))]))),
|
||||
])), "first RPC; echoes SVCN as INST and captures LANG for ALOC")
|
||||
|
||||
tx(m, "ping", req_frame(B.COMP_UTIL, B.CMD_PING, msg_num=2),
|
||||
"Util::ping -> STIM only")
|
||||
|
||||
# Every section the responder knows, plus unknown ones. The known sections
|
||||
# each add their own rows on top of the shared FUT/RS4 base — OSDK_ROSTER in
|
||||
# particular carries the roster URL, itself a documented loading gate — so
|
||||
# covering only "BlazeSDK" would leave most of the table unverified.
|
||||
for cfid in ("BlazeSDK", "netres", "IdentityParams", "OSDK_CORE",
|
||||
"OSDK_CLIENT", "OSDK_NUCLEUS", "OSDK_ROSTER", "OSDK_TICKER",
|
||||
"OSDK_WEBOFFER", "OSDK_POW", "OSDK_ABUSE_REPORTING",
|
||||
"OSDK_XMS_ABUSE_REPORTING", "UTAS", "FUT", "",
|
||||
"TOTALLY_UNKNOWN"):
|
||||
tx(m, "fetch_config_%s" % (cfid or "empty"),
|
||||
req_frame(B.COMP_UTIL, B.CMD_FETCHCLIENTCONFIG,
|
||||
OrderedDict([("CFID", (STRING, cfid))]), msg_num=3),
|
||||
"unknown CFIDs still get the shared FUT/POW rows")
|
||||
|
||||
tx(m, "get_auth_token_before_login",
|
||||
req_frame(B.COMP_AUTH, B.CMD_GETAUTHTOKEN, msg_num=4),
|
||||
"no auth code yet -> synthesised OPENFUT-<key[:16]> token")
|
||||
|
||||
tx(m, "logout_before_login", req_frame(B.COMP_AUTH, B.CMD_LOGOUT, msg_num=5),
|
||||
"routine LoginStateLogout (state 500), NOT a failure; empty reply")
|
||||
|
||||
tx(m, "login", req_frame(B.COMP_AUTH, B.CMD_LOGIN, OrderedDict([
|
||||
("AUTH", (STRING, "OPENFUT-TEST-AUTHCODE")),
|
||||
("EXTB", (BLOB, b"")),
|
||||
("PNAM", (STRING, "")),
|
||||
]), msg_num=6),
|
||||
"reply THEN three UserSessions pushes, in that order")
|
||||
|
||||
tx(m, "get_auth_token_after_login",
|
||||
req_frame(B.COMP_AUTH, B.CMD_GETAUTHTOKEN, msg_num=7),
|
||||
"now echoes the login's AUTH verbatim")
|
||||
|
||||
tx(m, "get_account", req_frame(B.COMP_AUTH, B.CMD_GETACCOUNT, msg_num=8),
|
||||
"the RPC behind 'Unable to retrieve account information'")
|
||||
tx(m, "get_persona", req_frame(B.COMP_AUTH, B.CMD_GETPERSONA, msg_num=9))
|
||||
tx(m, "list_personas", req_frame(B.COMP_AUTH, B.CMD_LISTPERSONAS, msg_num=10))
|
||||
|
||||
for cmd, label in ((B.CMD_LISTUSERENTITLEMENTS2, "listUserEntitlements2"),
|
||||
(0x20, "listEntitlements"),
|
||||
(0x30, "listPersonaEntitlements2"),
|
||||
(0x27, "grantEntitlement2")):
|
||||
tx(m, "entitlements_%s" % label,
|
||||
req_frame(B.COMP_AUTH, cmd, msg_num=11),
|
||||
"all four aliases return the same two ONLINE_ACCESS records")
|
||||
|
||||
tx(m, "post_auth", req_frame(B.COMP_UTIL, B.CMD_POSTAUTH, msg_num=12),
|
||||
"TELE/TICK/UROP; TELE.SESS must equal the login session key")
|
||||
tx(m, "fetch_qos_config", req_frame(B.COMP_UTIL, 0x15, msg_num=13))
|
||||
tx(m, "user_settings_load",
|
||||
req_frame(B.COMP_UTIL, B.CMD_USERSETTINGSLOAD, msg_num=14))
|
||||
tx(m, "user_settings_save",
|
||||
req_frame(B.COMP_UTIL, B.CMD_USERSETTINGSSAVE, msg_num=15),
|
||||
"accepted and discarded; empty reply")
|
||||
tx(m, "set_client_state",
|
||||
req_frame(B.COMP_UTIL, B.CMD_SETCLIENTSTATE, msg_num=16))
|
||||
tx(m, "set_client_metrics",
|
||||
req_frame(B.COMP_UTIL, B.CMD_SETCLIENTMETRICS, msg_num=17))
|
||||
|
||||
tx(m, "update_network_info",
|
||||
req_frame(B.COMP_USERSESSIONS, B.CMD_UPDATENETWORKINFO, msg_num=18),
|
||||
"empty reply PLUS an unsolicited ExtendedDataUpdate push")
|
||||
|
||||
tx(m, "get_lists", req_frame(B.COMP_ASSOCLISTS, B.CMD_GETLISTS, msg_num=19))
|
||||
|
||||
tx(m, "census_subscribe",
|
||||
req_frame(B.COMP_CENSUSDATA, B.CMD_SUBSCRIBETOCENSUSDATAUPDATES,
|
||||
OrderedDict([("RSUB", (INT, 1))]), msg_num=20),
|
||||
"non-zero TimeValues or the client storms at ~30/s and hangs the FUT load")
|
||||
|
||||
tx(m, "logout_after_login",
|
||||
req_frame(B.COMP_AUTH, B.CMD_LOGOUT, msg_num=21),
|
||||
"session teardown after a login; still an empty reply")
|
||||
|
||||
# ============================ fallback behaviour ========================
|
||||
f = new_session("fallbacks")
|
||||
|
||||
tx(f, "transport_ping",
|
||||
req_frame(B.COMP_UTIL, B.CMD_PING, msg_num=30, msg_type=B.PING),
|
||||
"msgType PING -> PING_REPLY with an empty body, whatever the command")
|
||||
|
||||
for mt, label in ((B.REPLY, "reply"), (B.NOTIFICATION, "notification"),
|
||||
(B.ERROR_REPLY, "error_reply"),
|
||||
(B.PING_REPLY, "ping_reply")):
|
||||
tx(f, "ignores_%s" % label,
|
||||
req_frame(B.COMP_UTIL, B.CMD_PING, msg_num=31, msg_type=mt),
|
||||
"not a request -> NO frames at all")
|
||||
|
||||
tx(f, "unknown_command",
|
||||
req_frame(B.COMP_UTIL, 0x0FFF, msg_num=32),
|
||||
"unimplemented RPC still gets an EMPTY reply so the client cannot hang")
|
||||
tx(f, "unknown_component",
|
||||
req_frame(0x1234, 0x0001, msg_num=33),
|
||||
"same fallback for an entirely unknown component")
|
||||
|
||||
tx(f, "user_index_is_echoed",
|
||||
req_frame(B.COMP_UTIL, B.CMD_PING, msg_num=34, user_index=7),
|
||||
"a reply echoes component/command/msgNum/userIndex verbatim")
|
||||
|
||||
# ================= locale echo on a non-default client ==================
|
||||
loc = new_session("locale")
|
||||
tx(loc, "preauth_de_locale",
|
||||
req_frame(B.COMP_UTIL, B.CMD_PREAUTH, OrderedDict([
|
||||
("CDAT", (STRUCT, OrderedDict([
|
||||
("LANG", (INT, 0x64654445)), # 'deDE'
|
||||
("SVCN", (STRING, "fifa-2017-pc-de")),
|
||||
]))),
|
||||
])),
|
||||
"a non-enUS client: SVCN echo AND the captured locale must both change")
|
||||
tx(loc, "login_with_de_locale",
|
||||
req_frame(B.COMP_AUTH, B.CMD_LOGIN, msg_num=41),
|
||||
"UserAuthenticated.ALOC must carry the captured deDE locale")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- output
|
||||
|
||||
def emit_config_table():
|
||||
"""Derive the fetchClientConfig tables as address-TEMPLATED data.
|
||||
|
||||
These are 227-243 key/value rows per CFID, almost all of them the same URL.
|
||||
Hand-transcribing them into Rust would be 400 lines of string literals that
|
||||
nobody can review and one typo can break; deriving them mechanically from
|
||||
the oracle removes that whole class of error and keeps them regenerable.
|
||||
They are reverse-engineered *data*, not logic — the same reason
|
||||
`openfut-core` loads its content from `data/` rather than from source.
|
||||
|
||||
The values are templated on {advertise}/{bind}/{pow_content_host}/{pow_host}
|
||||
so the adapter stays configurable; baking an address in here would recreate
|
||||
exactly the hardcoding the client/server split removed.
|
||||
|
||||
Correctness is not assumed: the caller substitutes real addresses back in
|
||||
and diffs against the oracle. See verify_config_table.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
raw = subprocess.run(
|
||||
[sys.executable, os.path.abspath(__file__), "--_config_table"],
|
||||
capture_output=True, text=True, check=True,
|
||||
# Inherit nothing address-shaped; the child sets its own sentinels.
|
||||
env={k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("OPENFUT_", "POW_", "FUT_"))},
|
||||
).stdout
|
||||
table = json.loads(raw)
|
||||
|
||||
def templatise(value):
|
||||
for sentinel, token in SENTINELS:
|
||||
value = value.replace(sentinel, token)
|
||||
# Collapse whole URLs to URL-level tokens where one exists, so the Rust
|
||||
# side builds them in exactly one place (AdapterConfig::utas_base and
|
||||
# friends) instead of re-deriving the shape here. Without this the
|
||||
# helpers become dead code and a hardcoded address in them goes
|
||||
# undetected — verified by mutation testing. Longest first.
|
||||
for whole, token in (
|
||||
("http://{advertise}:8099/", "{utas_base}"),
|
||||
("http://{bind}:42131", "{nucleus_base}"),
|
||||
("http://{pow_content_host}", "{pow_content_url}"),
|
||||
):
|
||||
if value == whole:
|
||||
return token
|
||||
return value
|
||||
|
||||
return {cfid: [[k, templatise(v)] for k, v in rows]
|
||||
for cfid, rows in table.items()}
|
||||
|
||||
|
||||
def verify_config_table(table):
|
||||
"""Substitute the real addresses back and require the oracle's exact rows.
|
||||
|
||||
This is what makes the templated table trustworthy rather than plausible.
|
||||
"""
|
||||
subst = {
|
||||
"{utas_base}": "http://%s:8099/" % ADVERTISE,
|
||||
"{nucleus_base}": "http://%s:42131" % BIND,
|
||||
"{pow_content_url}": "http://%s" % POW_CONTENT_HOST,
|
||||
"{advertise}": ADVERTISE,
|
||||
"{bind}": BIND,
|
||||
"{pow_content_host}": POW_CONTENT_HOST,
|
||||
"{pow_host}": POW_HOST,
|
||||
}
|
||||
|
||||
def render(v):
|
||||
for token, real in subst.items():
|
||||
v = v.replace(token, real)
|
||||
return v
|
||||
|
||||
for cfid, rows in table.items():
|
||||
expected = B.client_config_for(
|
||||
"__no_such_section__" if cfid == "__default__" else cfid)
|
||||
got = [(k, render(v)) for k, v in rows]
|
||||
if got != [(k, v) for k, v in expected]:
|
||||
for (gk, gv), (ek, ev) in zip(got, expected):
|
||||
if (gk, gv) != (ek, ev):
|
||||
sys.exit("config template mismatch in %s: %r -> %r, oracle "
|
||||
"has %r -> %r" % (cfid, gk, gv, ek, ev))
|
||||
sys.exit("config template row-count mismatch in %s: %d vs %d"
|
||||
% (cfid, len(got), len(expected)))
|
||||
print("config table verified against the oracle for %d sections"
|
||||
% len(table))
|
||||
|
||||
|
||||
def frozen_clock():
|
||||
import time as _time
|
||||
original = _time.time
|
||||
_time.time = lambda: float(FIXED_NOW)
|
||||
return original, _time
|
||||
|
||||
|
||||
def write(path, records):
|
||||
body = "".join(json.dumps(r, separators=(",", ":")) + "\n" for r in records)
|
||||
if CHECK_ONLY:
|
||||
if not os.path.exists(path):
|
||||
sys.exit("MISSING: %s has never been generated" % path)
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
if fh.read() != body:
|
||||
sys.exit("STALE: %s does not match the oracle; re-run without "
|
||||
"--check" % path)
|
||||
print("current: %s (%d records)" % (os.path.basename(path), len(records)))
|
||||
return
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(body)
|
||||
print("wrote %s (%d records)" % (os.path.basename(path), len(records)))
|
||||
|
||||
|
||||
def write_json(path, obj):
|
||||
body = json.dumps(obj, indent=1, sort_keys=True) + "\n"
|
||||
if CHECK_ONLY:
|
||||
if not os.path.exists(path):
|
||||
sys.exit("MISSING: %s has never been generated" % path)
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
if fh.read() != body:
|
||||
sys.exit("STALE: %s does not match the oracle" % path)
|
||||
print("current: %s" % os.path.basename(path))
|
||||
return
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(body)
|
||||
print("wrote %s (%d sections)" % (os.path.basename(path), len(obj)))
|
||||
|
||||
|
||||
def main():
|
||||
# The oracle logs every dispatch to stdout; useful live, pure noise here.
|
||||
B.log = lambda *_a, **_k: None
|
||||
|
||||
table = emit_config_table()
|
||||
verify_config_table(table)
|
||||
write_json(os.path.join(HERE, "client_config.json"), table)
|
||||
|
||||
random.seed(0xB1A2E)
|
||||
original_time, time_mod = frozen_clock()
|
||||
try:
|
||||
build()
|
||||
finally:
|
||||
time_mod.time = original_time
|
||||
|
||||
write(os.path.join(HERE, "blaze_transactions.jsonl"), RECORDS)
|
||||
txs = [r for r in RECORDS if r["kind"] == "tx"]
|
||||
frames = sum(len(r["responses"]) for r in txs)
|
||||
print("%d transactions, %d response frames, %d sessions"
|
||||
% (len(txs), frames,
|
||||
len([r for r in RECORDS if r["kind"] == "session"])))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Capture the roster oracle's responses byte-for-byte.
|
||||
|
||||
generate_roster.py [--check] [host:port]
|
||||
|
||||
Unlike `generate.py`, which imports the Blaze responder and calls its pure
|
||||
functions, this captures over the wire. The roster response is shaped as much by
|
||||
`http.server.BaseHTTPRequestHandler` as by the handler code -- HTTP/1.0 status
|
||||
line, `Server:`/`Date:` injected ahead of the handler's own headers, POST
|
||||
answered without a body -- and only the real socket shows all of that.
|
||||
|
||||
Two fields are volatile and are MASKED rather than recorded:
|
||||
|
||||
Date: changes every second
|
||||
Server: carries the container's Python version
|
||||
|
||||
They are masked, not dropped, so their presence and position are still asserted.
|
||||
The Server string is additionally recorded verbatim under `observed_server`, so
|
||||
a drift between the container's Python and the adapter's `ORACLE_SERVER`
|
||||
constant is visible rather than silent.
|
||||
|
||||
`--check` re-captures and compares. If the oracle is unreachable it FAILS rather
|
||||
than passing: a check that cannot check must not report success.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import ssl
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
OUT = os.path.join(HERE, "roster.json")
|
||||
PATH = "/fifa17/fut/rosterupdate.xml"
|
||||
|
||||
DATE_RE = re.compile(rb"^Date: .+?\r\n", re.M)
|
||||
SERVER_RE = re.compile(rb"^Server: (.+?)\r\n", re.M)
|
||||
|
||||
|
||||
def fetch(host, port, method, body=None):
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
ctx.set_ciphers("ALL:@SECLEVEL=0")
|
||||
s = ctx.wrap_socket(socket.create_connection((host, port), timeout=8),
|
||||
server_hostname="fixture")
|
||||
req = "%s %s HTTP/1.1\r\nHost: %s:%d\r\nAccept: */*\r\n" % (method, PATH, host, port)
|
||||
if body is not None:
|
||||
req += "Content-Length: %d\r\n" % len(body)
|
||||
req += "\r\n"
|
||||
s.sendall(req.encode() + (body or b""))
|
||||
out = b""
|
||||
while True:
|
||||
chunk = s.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
out += chunk
|
||||
s.close()
|
||||
return out
|
||||
|
||||
|
||||
def capture(host, port):
|
||||
result = {"path": PATH, "responses": {}}
|
||||
servers = set()
|
||||
for method, body in (("GET", None), ("HEAD", None), ("POST", b"probe=1")):
|
||||
raw = fetch(host, port, method, body)
|
||||
m = SERVER_RE.search(raw)
|
||||
if m:
|
||||
servers.add(m.group(1).decode())
|
||||
masked = DATE_RE.sub(b"Date: <MASKED>\r\n", raw)
|
||||
masked = SERVER_RE.sub(b"Server: <MASKED>\r\n", masked)
|
||||
result["responses"][method] = masked.hex()
|
||||
if len(servers) != 1:
|
||||
raise SystemExit("oracle returned inconsistent Server headers: %r" % servers)
|
||||
result["observed_server"] = servers.pop()
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
check = "--check" in sys.argv
|
||||
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||||
host, port = (args[0].split(":") if args else ("127.0.0.1", "8081"))[0], \
|
||||
int((args[0].split(":")[1] if args and ":" in args[0] else "8081"))
|
||||
|
||||
try:
|
||||
fresh = capture(host, port)
|
||||
except Exception as e:
|
||||
# Explicitly a failure. A --check that silently passes when it could not
|
||||
# reach the oracle is exactly the class of self-confirming tooling this
|
||||
# project has been bitten by repeatedly.
|
||||
raise SystemExit("cannot reach the roster oracle at %s:%d (%s). "
|
||||
"Refusing to report success." % (host, port, e))
|
||||
|
||||
if check:
|
||||
if not os.path.exists(OUT):
|
||||
raise SystemExit("no fixture at %s -- run without --check first" % OUT)
|
||||
with open(OUT) as f:
|
||||
stored = json.load(f)
|
||||
if stored.get("responses") != fresh["responses"]:
|
||||
for m in sorted(set(stored.get("responses", {})) | set(fresh["responses"])):
|
||||
a = stored.get("responses", {}).get(m)
|
||||
b = fresh["responses"].get(m)
|
||||
if a != b:
|
||||
print("MISMATCH %s\n stored: %s\n live : %s" % (m, a, b))
|
||||
raise SystemExit("roster fixtures differ from the live oracle")
|
||||
if stored.get("observed_server") != fresh["observed_server"]:
|
||||
raise SystemExit(
|
||||
"the oracle's Server header changed: %r -> %r.\n"
|
||||
"Update roster::ORACLE_SERVER and regenerate."
|
||||
% (stored.get("observed_server"), fresh["observed_server"]))
|
||||
print("roster fixtures match the live oracle (%d responses, server=%r)"
|
||||
% (len(fresh["responses"]), fresh["observed_server"]))
|
||||
return
|
||||
|
||||
with open(OUT, "w") as f:
|
||||
json.dump(fresh, f, indent=2, sort_keys=True)
|
||||
f.write("\n")
|
||||
print("wrote %s (%d responses, server=%r)"
|
||||
% (OUT, len(fresh["responses"]), fresh["observed_server"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"127.0.0.1": "485454502f312e3120323030204f4b0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a203331350d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a3c3f786d6c2076657273696f6e3d22312e302220656e636f64696e673d225554462d38223f3e0a3c736572766572696e7374616e6365696e666f3e0a093c61646472657373206d656d6265723d2230223e0a09093c76616c753e0a0909093c686f73746e616d653e3132372e302e302e313c2f686f73746e616d653e0a0909093c69703e323133303730363433333c2f69703e0a0909093c706f72743e34323133303c2f706f72743e0a09093c2f76616c753e0a093c2f616464726573733e0a093c7365637572653e303c2f7365637572653e0a093c747269616c736572766963656e616d653e3c2f747269616c736572766963656e616d653e0a093c64656661756c74646e73616464726573733e303c2f64656661756c74646e73616464726573733e0a3c2f736572766572696e7374616e6365696e666f3e0a",
|
||||
"192.0.2.1": "485454502f312e3120323030204f4b0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a203331350d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a3c3f786d6c2076657273696f6e3d22312e302220656e636f64696e673d225554462d38223f3e0a3c736572766572696e7374616e6365696e666f3e0a093c61646472657373206d656d6265723d2230223e0a09093c76616c753e0a0909093c686f73746e616d653e3139322e302e322e313c2f686f73746e616d653e0a0909093c69703e333232313232353938353c2f69703e0a0909093c706f72743e34323133303c2f706f72743e0a09093c2f76616c753e0a093c2f616464726573733e0a093c7365637572653e303c2f7365637572653e0a093c747269616c736572766963656e616d653e3c2f747269616c736572766963656e616d653e0a093c64656661756c74646e73616464726573733e303c2f64656661756c74646e73616464726573733e0a3c2f736572766572696e7374616e6365696e666f3e0a",
|
||||
"198.51.100.7": "485454502f312e3120323030204f4b0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a203331380d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a3c3f786d6c2076657273696f6e3d22312e302220656e636f64696e673d225554462d38223f3e0a3c736572766572696e7374616e6365696e666f3e0a093c61646472657373206d656d6265723d2230223e0a09093c76616c753e0a0909093c686f73746e616d653e3139382e35312e3130302e373c2f686f73746e616d653e0a0909093c69703e333332353235363731313c2f69703e0a0909093c706f72743e34323133303c2f706f72743e0a09093c2f76616c753e0a093c2f616464726573733e0a093c7365637572653e303c2f7365637572653e0a093c747269616c736572766963656e616d653e3c2f747269616c736572766963656e616d653e0a093c64656661756c74646e73616464726573733e303c2f64656661756c74646e73616464726573733e0a3c2f736572766572696e7374616e6365696e666f3e0a",
|
||||
"203.0.113.42": "485454502f312e3120323030204f4b0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a203331380d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a3c3f786d6c2076657273696f6e3d22312e302220656e636f64696e673d225554462d38223f3e0a3c736572766572696e7374616e6365696e666f3e0a093c61646472657373206d656d6265723d2230223e0a09093c76616c753e0a0909093c686f73746e616d653e3230332e302e3131332e34323c2f686f73746e616d653e0a0909093c69703e333430353830333831383c2f69703e0a0909093c706f72743e34323133303c2f706f72743e0a09093c2f76616c753e0a093c2f616464726573733e0a093c7365637572653e303c2f7365637572653e0a093c747269616c736572766963656e616d653e3c2f747269616c736572766963656e616d653e0a093c64656661756c74646e73616464726573733e303c2f64656661756c74646e73616464726573733e0a3c2f736572766572696e7374616e6365696e666f3e0a"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"observed_server": "BaseHTTP/0.6 Python/3.12.13",
|
||||
"path": "/fifa17/fut/rosterupdate.xml",
|
||||
"responses": {
|
||||
"GET": "485454502f312e3020323030204f4b0d0a5365727665723a203c4d41534b45443e0d0a446174653a203c4d41534b45443e0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a2036370d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a3c3f786d6c2076657273696f6e3d22312e302220656e636f64696e673d227574662d38223f3e0a3c726f737465727570646174652076657273696f6e3d2230222f3e0a",
|
||||
"HEAD": "485454502f312e3020323030204f4b0d0a5365727665723a203c4d41534b45443e0d0a446174653a203c4d41534b45443e0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a2036370d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a",
|
||||
"POST": "485454502f312e3020323030204f4b0d0a5365727665723a203c4d41534b45443e0d0a446174653a203c4d41534b45443e0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a2036370d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
|
||||
MARKER at-player-search 19:03:01 UTC
|
||||
watermark=13
|
||||
MARKER NO_FILTER 19:04:35 UTC
|
||||
MARKER GOLD 19:16:44 UTC
|
||||
NOTE: the FIFA 17 My Squad UI labels this filter SPECIAL, not Rare. Captured under label SPECIAL.
|
||||
MARKER SPECIAL 19:21:05 UTC
|
||||
MARKER POSITION_ST 19:22:00 UTC
|
||||
MARKER NATION_ARGENTINA 19:22:53 UTC
|
||||
MARKER LEAGUE_PREMIER 19:23:34 UTC
|
||||
MARKER CLUB_CHELSEA 19:24:34 UTC
|
||||
MARKER GOLD_PLUS_ST 19:25:26 UTC
|
||||
MARKER LEAGUE_PLUS_ST 19:27:27 UTC
|
||||
MARKER PAGINATION 19:28:49 UTC
|
||||
NOTE: no sort control exists in the My Squad UI; sort=desc is a client constant.
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"routes": {
|
||||
"accountInfo": {
|
||||
"body_len": 2,
|
||||
"fields": {
|
||||
"keys": []
|
||||
},
|
||||
"sha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
|
||||
"status": 200
|
||||
},
|
||||
"activeSquad": {
|
||||
"body_len": 8034,
|
||||
"fields": {
|
||||
"formation": "f442",
|
||||
"id": 0,
|
||||
"players.count": 23,
|
||||
"slots": {
|
||||
"0": 100000003,
|
||||
"1": 100000006,
|
||||
"10": 100000010,
|
||||
"11": 0,
|
||||
"12": 0,
|
||||
"13": 0,
|
||||
"14": 0,
|
||||
"15": 0,
|
||||
"16": 0,
|
||||
"17": 0,
|
||||
"18": 0,
|
||||
"19": 0,
|
||||
"2": 100000005,
|
||||
"20": 0,
|
||||
"21": 0,
|
||||
"22": 0,
|
||||
"3": 100000008,
|
||||
"4": 100000007,
|
||||
"5": 100000002,
|
||||
"6": 100000004,
|
||||
"7": 100000009,
|
||||
"8": 100000001,
|
||||
"9": 100000025
|
||||
}
|
||||
},
|
||||
"sha256": "07e330ed358fbefe31379cd2462aaac4bdc9c85ee28b7500dd2d963e5ea565bd",
|
||||
"status": 200
|
||||
},
|
||||
"credits": {
|
||||
"body_len": 148,
|
||||
"fields": {
|
||||
"credits": 28112944
|
||||
},
|
||||
"sha256": "0a5f3bac80c3a8ee6f088ccf180f5fdcbcbe8a2ef19c7026e4f21876016d7786",
|
||||
"status": 200
|
||||
},
|
||||
"tradePile": {
|
||||
"body_len": 862,
|
||||
"fields": {
|
||||
"auctionInfo.count": 1
|
||||
},
|
||||
"sha256": "b42bab98202209bd8309beb0eca73dba471688e69fef3e014b59901fbc21fe04",
|
||||
"status": 200
|
||||
},
|
||||
"unassigned": {
|
||||
"body_len": 16,
|
||||
"fields": {
|
||||
"itemData.count": 0
|
||||
},
|
||||
"sha256": "873f8bba8baf9c573fc51b54d100c357b3bf0caeb2ccd104245c295e073cf342",
|
||||
"status": 200
|
||||
},
|
||||
"userMassInfo": {
|
||||
"body_len": 8929,
|
||||
"fields": {
|
||||
"clubAbbr": "OFC",
|
||||
"clubName": "OpenFUT",
|
||||
"personaId": 33068179,
|
||||
"trophies": 0
|
||||
},
|
||||
"sha256": "a616aca1742263c47ade9409693e66ec13b50114e608d88bb94141ce80237b66",
|
||||
"status": 200
|
||||
}
|
||||
},
|
||||
"unix": 1786476617.899911,
|
||||
"upstream": "127.0.0.1:8099"
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"routes": {
|
||||
"accountInfo": {
|
||||
"body_len": 2,
|
||||
"fields": {
|
||||
"keys": []
|
||||
},
|
||||
"sha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
|
||||
"status": 200
|
||||
},
|
||||
"activeSquad": {
|
||||
"body_len": 8034,
|
||||
"fields": {
|
||||
"formation": "f442",
|
||||
"id": 0,
|
||||
"players.count": 23,
|
||||
"slots": {
|
||||
"0": 100000003,
|
||||
"1": 100000006,
|
||||
"10": 100000010,
|
||||
"11": 0,
|
||||
"12": 0,
|
||||
"13": 0,
|
||||
"14": 0,
|
||||
"15": 0,
|
||||
"16": 0,
|
||||
"17": 0,
|
||||
"18": 0,
|
||||
"19": 0,
|
||||
"2": 100000005,
|
||||
"20": 0,
|
||||
"21": 0,
|
||||
"22": 0,
|
||||
"3": 100000008,
|
||||
"4": 100000007,
|
||||
"5": 100000002,
|
||||
"6": 100000004,
|
||||
"7": 100000009,
|
||||
"8": 100000001,
|
||||
"9": 100000025
|
||||
}
|
||||
},
|
||||
"sha256": "07e330ed358fbefe31379cd2462aaac4bdc9c85ee28b7500dd2d963e5ea565bd",
|
||||
"status": 200
|
||||
},
|
||||
"credits": {
|
||||
"body_len": 148,
|
||||
"fields": {
|
||||
"credits": 28112944
|
||||
},
|
||||
"sha256": "0a5f3bac80c3a8ee6f088ccf180f5fdcbcbe8a2ef19c7026e4f21876016d7786",
|
||||
"status": 200
|
||||
},
|
||||
"tradePile": {
|
||||
"body_len": 862,
|
||||
"fields": {
|
||||
"auctionInfo.count": 1
|
||||
},
|
||||
"sha256": "b42bab98202209bd8309beb0eca73dba471688e69fef3e014b59901fbc21fe04",
|
||||
"status": 200
|
||||
},
|
||||
"unassigned": {
|
||||
"body_len": 16,
|
||||
"fields": {
|
||||
"itemData.count": 0
|
||||
},
|
||||
"sha256": "873f8bba8baf9c573fc51b54d100c357b3bf0caeb2ccd104245c295e073cf342",
|
||||
"status": 200
|
||||
},
|
||||
"userMassInfo": {
|
||||
"body_len": 8929,
|
||||
"fields": {
|
||||
"clubAbbr": "OFC",
|
||||
"clubName": "OpenFUT",
|
||||
"personaId": 33068179,
|
||||
"trophies": 0
|
||||
},
|
||||
"sha256": "a616aca1742263c47ade9409693e66ec13b50114e608d88bb94141ce80237b66",
|
||||
"status": 200
|
||||
}
|
||||
},
|
||||
"unix": 1786474768.2925124,
|
||||
"upstream": "127.0.0.1:8099"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"squad": [{"rating": 89, "chemistry": 59, "formation": "f442", "id": 0, "squadName": "OpenFUT", "squadType": "REGULAR_SQUAD"}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"id": 0, "custom": "[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,50,50,0,50,40,65,0,65,50,50,1]", "squadName": "OpenFUT", "chemistry": 49, "starRating": 90, "rating": 90, "formation": "f433", "squadType": "REGULAR_SQUAD", "manager": [{"id": 100000427, "dream": false}], "players": [{"index": 0, "itemData": {"id": 100000003, "dream": false}, "kitNumber": 1}, {"index": 1, "itemData": {"id": 100000006, "dream": false}, "kitNumber": 4}, {"index": 2, "itemData": {"id": 100000005, "dream": false}, "kitNumber": 3}, {"index": 3, "itemData": {"id": 100000008, "dream": false}, "kitNumber": 6}, {"index": 4, "itemData": {"id": 100000007, "dream": false}, "kitNumber": 5}, {"index": 5, "itemData": {"id": 100000002, "dream": false}, "kitNumber": 9}, {"index": 6, "itemData": {"id": 100000004, "dream": false}, "kitNumber": 2}, {"index": 7, "itemData": {"id": 100000009, "dream": false}, "kitNumber": 7}, {"index": 8, "itemData": {"id": 100000010, "dream": false}, "kitNumber": 10}, {"index": 9, "itemData": {"id": 100000025, "dream": false}, "kitNumber": 11}, {"index": 10, "itemData": {"id": 100000001, "dream": false}, "kitNumber": 8}, {"index": 11, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 12, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 13, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 14, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 15, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 16, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 17, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 18, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 19, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 20, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 21, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 22, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}], "captain": 100000001, "kicktakers": [{"index": 0, "id": 100000001, "dream": false}, {"index": 1, "id": 100000001, "dream": false}, {"index": 2, "id": 100000001, "dream": false}, {"index": 3, "id": 100000001, "dream": false}, {"index": 4, "id": 100000001, "dream": false}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"id":0,"custom":"[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,50,50,0,50,40,65,0,65,50,50,1]","squadName":"OpenFUT","chemistry":52,"starRating":90,"rating":90,"formation":"f442","squadType":"REGULAR_SQUAD","manager":[{"id":100000427,"dream":false}],"players":[{"index":0,"itemData":{"id":100000003,"dream":false},"kitNumber":1},{"index":1,"itemData":{"id":100000010,"dream":false},"kitNumber":10},{"index":2,"itemData":{"id":100000005,"dream":false},"kitNumber":3},{"index":3,"itemData":{"id":100000008,"dream":false},"kitNumber":6},{"index":4,"itemData":{"id":100000007,"dream":false},"kitNumber":5},{"index":5,"itemData":{"id":100000006,"dream":false},"kitNumber":4},{"index":6,"itemData":{"id":100000004,"dream":false},"kitNumber":2},{"index":7,"itemData":{"id":100000009,"dream":false},"kitNumber":7},{"index":8,"itemData":{"id":100000001,"dream":false},"kitNumber":8},{"index":9,"itemData":{"id":100000002,"dream":false},"kitNumber":9},{"index":10,"itemData":{"id":100000025,"dream":false},"kitNumber":11},{"index":11,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":12,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":13,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":14,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":15,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":16,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":17,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":18,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":19,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":20,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":21,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":22,"itemData":{"id":0,"dream":false},"kitNumber":0}],"captain":100000001,"kicktakers":[{"index":0,"id":100000001,"dream":false},{"index":1,"id":100000001,"dream":false},{"index":2,"id":100000001,"dream":false},{"index":3,"id":100000001,"dream":false},{"index":4,"id":100000001,"dream":false}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"id": 0, "custom": "[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,50,50,0,50,40,65,0,65,50,50,1]", "squadName": "OpenFUT", "chemistry": 58, "starRating": 90, "rating": 90, "formation": "f442", "squadType": "REGULAR_SQUAD", "manager": [{"id": 100000427, "dream": false}], "players": [{"index": 0, "itemData": {"id": 100000003, "dream": false}, "kitNumber": 1}, {"index": 1, "itemData": {"id": 100000006, "dream": false}, "kitNumber": 4}, {"index": 2, "itemData": {"id": 100000005, "dream": false}, "kitNumber": 3}, {"index": 3, "itemData": {"id": 100000008, "dream": false}, "kitNumber": 6}, {"index": 4, "itemData": {"id": 100000007, "dream": false}, "kitNumber": 5}, {"index": 5, "itemData": {"id": 100000002, "dream": false}, "kitNumber": 9}, {"index": 6, "itemData": {"id": 100000004, "dream": false}, "kitNumber": 2}, {"index": 7, "itemData": {"id": 100000009, "dream": false}, "kitNumber": 7}, {"index": 8, "itemData": {"id": 100000001, "dream": false}, "kitNumber": 8}, {"index": 9, "itemData": {"id": 100000010, "dream": false}, "kitNumber": 10}, {"index": 10, "itemData": {"id": 100000025, "dream": false}, "kitNumber": 11}, {"index": 11, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 12, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 13, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 14, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 15, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 16, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 17, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 18, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 19, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 20, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 21, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 22, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}], "captain": 100000001, "kicktakers": [{"index": 0, "id": 100000001, "dream": false}, {"index": 1, "id": 100000001, "dream": false}, {"index": 2, "id": 100000001, "dream": false}, {"index": 3, "id": 100000001, "dream": false}, {"index": 4, "id": 100000001, "dream": false}]}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"routes": {
|
||||
"accountInfo": {
|
||||
"body_len": 2,
|
||||
"fields": {
|
||||
"keys": []
|
||||
},
|
||||
"sha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
|
||||
"status": 200
|
||||
},
|
||||
"activeSquad": {
|
||||
"body_len": 8034,
|
||||
"fields": {
|
||||
"formation": "f442",
|
||||
"id": 0,
|
||||
"players.count": 23,
|
||||
"slots": {
|
||||
"0": 100000003,
|
||||
"1": 100000006,
|
||||
"10": 100000010,
|
||||
"11": 0,
|
||||
"12": 0,
|
||||
"13": 0,
|
||||
"14": 0,
|
||||
"15": 0,
|
||||
"16": 0,
|
||||
"17": 0,
|
||||
"18": 0,
|
||||
"19": 0,
|
||||
"2": 100000005,
|
||||
"20": 0,
|
||||
"21": 0,
|
||||
"22": 0,
|
||||
"3": 100000008,
|
||||
"4": 100000007,
|
||||
"5": 100000002,
|
||||
"6": 100000004,
|
||||
"7": 100000009,
|
||||
"8": 100000001,
|
||||
"9": 100000025
|
||||
}
|
||||
},
|
||||
"sha256": "07e330ed358fbefe31379cd2462aaac4bdc9c85ee28b7500dd2d963e5ea565bd",
|
||||
"status": 200
|
||||
},
|
||||
"credits": {
|
||||
"body_len": 148,
|
||||
"fields": {
|
||||
"credits": 28112944
|
||||
},
|
||||
"sha256": "0a5f3bac80c3a8ee6f088ccf180f5fdcbcbe8a2ef19c7026e4f21876016d7786",
|
||||
"status": 200
|
||||
},
|
||||
"tradePile": {
|
||||
"body_len": 862,
|
||||
"fields": {
|
||||
"auctionInfo.count": 1
|
||||
},
|
||||
"sha256": "b42bab98202209bd8309beb0eca73dba471688e69fef3e014b59901fbc21fe04",
|
||||
"status": 200
|
||||
},
|
||||
"unassigned": {
|
||||
"body_len": 16,
|
||||
"fields": {
|
||||
"itemData.count": 0
|
||||
},
|
||||
"sha256": "873f8bba8baf9c573fc51b54d100c357b3bf0caeb2ccd104245c295e073cf342",
|
||||
"status": 200
|
||||
},
|
||||
"userMassInfo": {
|
||||
"body_len": 8929,
|
||||
"fields": {
|
||||
"clubAbbr": "OFC",
|
||||
"clubName": "OpenFUT",
|
||||
"personaId": 33068179,
|
||||
"trophies": 0
|
||||
},
|
||||
"sha256": "a616aca1742263c47ade9409693e66ec13b50114e608d88bb94141ce80237b66",
|
||||
"status": 200
|
||||
}
|
||||
},
|
||||
"unix": 1786472465.339646,
|
||||
"upstream": "127.0.0.1:8099"
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"routes": {
|
||||
"accountInfo": {
|
||||
"body_len": 2,
|
||||
"fields": {
|
||||
"keys": []
|
||||
},
|
||||
"sha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
|
||||
"status": 200
|
||||
},
|
||||
"activeSquad": {
|
||||
"body_len": 8034,
|
||||
"fields": {
|
||||
"formation": "f442",
|
||||
"id": 0,
|
||||
"players.count": 23,
|
||||
"slots": {
|
||||
"0": 100000003,
|
||||
"1": 100000010,
|
||||
"10": 100000001,
|
||||
"11": 100000002,
|
||||
"12": 0,
|
||||
"13": 0,
|
||||
"14": 0,
|
||||
"15": 0,
|
||||
"16": 0,
|
||||
"17": 0,
|
||||
"18": 0,
|
||||
"19": 0,
|
||||
"2": 100000009,
|
||||
"20": 0,
|
||||
"21": 0,
|
||||
"22": 0,
|
||||
"3": 100000008,
|
||||
"4": 100000007,
|
||||
"5": 100000006,
|
||||
"6": 100000005,
|
||||
"7": 100000004,
|
||||
"8": 100000025,
|
||||
"9": 0
|
||||
}
|
||||
},
|
||||
"sha256": "768bb0584ad953ac5f088ad029f161d302ee1be3a63826eb186405acaf1b6232",
|
||||
"status": 200
|
||||
},
|
||||
"credits": {
|
||||
"body_len": 148,
|
||||
"fields": {
|
||||
"credits": 28020656
|
||||
},
|
||||
"sha256": "8d39fee51c24ddee9f12d98d7833af6cd3d3399a0e7567ab7062945a1180e30f",
|
||||
"status": 200
|
||||
},
|
||||
"tradePile": {
|
||||
"body_len": 862,
|
||||
"fields": {
|
||||
"auctionInfo.count": 1
|
||||
},
|
||||
"sha256": "1575046afb8ca60c76de64427ee0c70e1d4ca2da032819a60db95d72d272d11d",
|
||||
"status": 200
|
||||
},
|
||||
"unassigned": {
|
||||
"body_len": 16,
|
||||
"fields": {
|
||||
"itemData.count": 0
|
||||
},
|
||||
"sha256": "873f8bba8baf9c573fc51b54d100c357b3bf0caeb2ccd104245c295e073cf342",
|
||||
"status": 200
|
||||
},
|
||||
"userMassInfo": {
|
||||
"body_len": 8929,
|
||||
"fields": {
|
||||
"clubAbbr": "OFC",
|
||||
"clubName": "OpenFUT",
|
||||
"personaId": 33068179,
|
||||
"trophies": 0
|
||||
},
|
||||
"sha256": "3d89d0497661fc62f107081208a14c4fa5753ee4e6482eeda825fe4b622f871f",
|
||||
"status": 200
|
||||
}
|
||||
},
|
||||
"unix": 1786472102.908128,
|
||||
"upstream": "127.0.0.1:8099"
|
||||
}
|
||||
Executable
+108
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env bash
|
||||
# FIFA 17 squad-adapter mutation battery.
|
||||
#
|
||||
# Each mutation injects a specific WRONG behaviour into the committed source,
|
||||
# runs the one test that defends the invariant, and requires that test to FAIL
|
||||
# (non-zero exit) — i.e. the mutant is killed. The source is reverted via
|
||||
# `git checkout` after every mutation, so the tree is left untouched.
|
||||
#
|
||||
# A mutant that SURVIVES (its guard test still passes) means the invariant is
|
||||
# not actually defended; the battery then exits non-zero.
|
||||
#
|
||||
# Run from the adapter crate root: bash mutation-battery.sh
|
||||
set -u
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
FUT=src/fut
|
||||
PASS=0
|
||||
FAIL=0
|
||||
declare -a SURVIVORS=()
|
||||
|
||||
# mutate <file> <literal-old> <literal-new> (literal, multiline-safe)
|
||||
mutate() {
|
||||
OLD="$2" NEW="$3" perl -0777 -pi -e \
|
||||
's/\Q$ENV{OLD}\E/$ENV{NEW}/g or die "MUTATION PATTERN NOT FOUND in '"$1"'\n"' "$1"
|
||||
}
|
||||
|
||||
# kill <n> <label> <test-filter> <file> <old> <new>
|
||||
kill_test() {
|
||||
local n="$1" label="$2" filter="$3" file="$4" old="$5" new="$6"
|
||||
git checkout -- "$file"
|
||||
mutate "$file" "$old" "$new" || { echo " [#$n] SETUP ERROR"; FAIL=$((FAIL+1)); SURVIVORS+=("#$n $label (setup)"); return; }
|
||||
if cargo test --quiet "$filter" >/dev/null 2>&1; then
|
||||
echo " [#$n] SURVIVED — $label (test '$filter' still passed)"
|
||||
FAIL=$((FAIL+1)); SURVIVORS+=("#$n $label")
|
||||
else
|
||||
echo " [#$n] killed — $label"
|
||||
PASS=$((PASS+1))
|
||||
fi
|
||||
git checkout -- "$file"
|
||||
}
|
||||
|
||||
echo "== FIFA17 squad adapter mutation battery =="
|
||||
|
||||
kill_test 1 "kitNumber keyed by slot index, not owned item" \
|
||||
kit_number_is_keyed_by_owned_item_not_slot "$FUT/squad_ext.rs" \
|
||||
'(s.owned_card_id.clone(), s.kit_number)' '(s.index.to_string(), s.kit_number)'
|
||||
|
||||
kill_test 2 "captain emitted using resourceId (asset), not wire id" \
|
||||
fresh_projects_full_23_slot_array_with_captain_wire_id "$FUT/squad_projection.rs" \
|
||||
'captain_wire = id.item_id as i64;' 'captain_wire = id.asset_id as i64;'
|
||||
|
||||
kill_test 3 "client chemistry silently reconciled (shadow lost)" \
|
||||
swap_moves_two_players_with_their_kits_and_round_trips "$FUT/squad_projection.rs" \
|
||||
'"chemistry": ext.client_reported.chemistry,' '"chemistry": 0,'
|
||||
|
||||
kill_test 4 "custom[] regenerated instead of round-tripped verbatim" \
|
||||
custom_is_preserved_byte_for_byte "$FUT/squad_ext.rs" \
|
||||
'custom: put.custom.clone(),' 'custom: Some("[]".to_string()),'
|
||||
|
||||
kill_test 5 "index derived (zeroed) instead of round-tripped" \
|
||||
baseline_projects_the_known_squad_round_trip "$FUT/squad.rs" \
|
||||
'index: p.index,' 'index: 0,'
|
||||
|
||||
kill_test 6 "stale extension accepted and projected" \
|
||||
stale_is_never_applied "$FUT/squad_projection.rs" \
|
||||
'SquadExtInput::Stale(_) => return Ok(SquadProjection::Stale),' 'SquadExtInput::Stale(ext) => ext,'
|
||||
|
||||
kill_test 7 "missing extension fabricates default fields" \
|
||||
missing_is_explicit_never_fabricated "$FUT/squad_projection.rs" \
|
||||
'SquadExtInput::Missing => return Ok(SquadProjection::Missing),' \
|
||||
'SquadExtInput::Missing => return Ok(SquadProjection::Projected(json!({"custom":"[]","manager":[]}))),'
|
||||
|
||||
kill_test 8 "shaper fabricates asset id, bypassing real FIFA identity" \
|
||||
shapes_real_identity_and_reverse_entity_ids "$FUT/item.rs" \
|
||||
'let asset = id.asset_id;' 'let asset = 0;'
|
||||
|
||||
kill_test 9 "duplicate definition collapses owned instances (id=asset)" \
|
||||
two_owned_copies_of_one_definition_stay_distinct_on_the_wire "$FUT/item.rs" \
|
||||
'"id": id.item_id,' '"id": id.asset_id,'
|
||||
|
||||
kill_test 10 "PUT treated as a partial slot diff (drops slots)" \
|
||||
full_replacement_carries_every_occupied_slot_no_diff "$FUT/squad.rs" \
|
||||
'if p.item_data.id == 0 {' 'if p.item_data.id == 0 || p.index > 1 {'
|
||||
|
||||
kill_test 11 "FIFA wire item id stored in canonical replacement" \
|
||||
full_replacement_carries_every_occupied_slot_no_diff "$FUT/squad.rs" \
|
||||
'ProposedSlot {
|
||||
owned_card_id,' 'ProposedSlot {
|
||||
owned_card_id: p.item_data.id.to_string(),'
|
||||
|
||||
kill_test 12 "projector rebuilds items independently of shared shaper" \
|
||||
persisted_read_round_trips_via_reconstructed_canonical_and_extension "$FUT/squad_projection.rs" \
|
||||
'"itemData": shape_item(item, id, ent),' '"itemData": json!({"id": id.item_id}),'
|
||||
|
||||
kill_test 13 "extension schema version ignored on read" \
|
||||
unknown_schema_version_is_rejected_not_coerced "$FUT/squad_ext.rs" \
|
||||
'if schema_version != EXT_SCHEMA_VERSION {' 'if false {'
|
||||
|
||||
kill_test 14 "player state keyed by CardDefinitionId not OwnedItemId" \
|
||||
two_owned_copies_of_one_definition_project_as_distinct_players "$FUT/squad_projection.rs" \
|
||||
'.get(&slot.owned_card_id)' '.get(&item.card_id)'
|
||||
|
||||
echo "== mutants killed: $PASS / $((PASS+FAIL)) =="
|
||||
if [ "$FAIL" -ne 0 ]; then
|
||||
printf 'SURVIVORS:\n'; printf ' - %s\n' "${SURVIVORS[@]}"
|
||||
exit 1
|
||||
fi
|
||||
echo "all mutants killed"
|
||||
@@ -0,0 +1,177 @@
|
||||
//! `Util::fetchClientConfig` tables.
|
||||
//!
|
||||
//! Between 227 and 243 key/value rows per CFID, overwhelmingly the same RS4
|
||||
//! base URL repeated across 212 endpoint keys. The client resolves a per-call
|
||||
//! key (`FUT_RS4_URL_<CALL>`) before a per-module one
|
||||
//! (`FUT_RS4_APIURL_<MODULE>`), and any call left unresolved falls back to a
|
||||
//! real (dead) EA host — which is what produced "there has been an error
|
||||
//! connecting to FIFA 17 Ultimate Team" mid-session when only the boot subset
|
||||
//! was served. The table has to be complete, not representative.
|
||||
//!
|
||||
//! # Why this is data and not code
|
||||
//!
|
||||
//! The rows are reverse-engineered *configuration*, not logic. They live in
|
||||
//! `fixtures/client_config.json`, derived mechanically from the Python oracle
|
||||
//! and templated on `{advertise}`, `{bind}`, `{pow_content_host}` and
|
||||
//! `{pow_host}` so the adapter stays deployable anywhere. Hand-transcribing 400
|
||||
//! string literals would add a class of silent typo no reviewer can catch, and
|
||||
//! `openfut-core` already loads its content from `data/` for the same reason.
|
||||
//!
|
||||
//! The generator does not take its own templating on trust: it substitutes real
|
||||
//! addresses back in and diffs against the oracle for every section before
|
||||
//! writing the file.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use super::config::AdapterConfig;
|
||||
|
||||
/// Rows for every known CFID, plus `__default__` for unknown ones.
|
||||
const TABLE_JSON: &str = include_str!("../../fixtures/client_config.json");
|
||||
|
||||
type Table = BTreeMap<String, Vec<(String, String)>>;
|
||||
|
||||
fn table() -> &'static Table {
|
||||
static TABLE: OnceLock<Table> = OnceLock::new();
|
||||
TABLE.get_or_init(|| {
|
||||
serde_json::from_str(TABLE_JSON).expect("bundled client_config.json is valid")
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve the rows for a CFID, with addresses substituted in.
|
||||
///
|
||||
/// Unknown CFIDs deliberately still receive the shared FUT/RS4/POW rows: those
|
||||
/// consumers read a merged `_all` store and which section contributes is
|
||||
/// unproven, so a present-but-shared table is safer than an empty one.
|
||||
pub fn rows_for(cfid: &str, cfg: &AdapterConfig) -> Vec<(String, String)> {
|
||||
let t = table();
|
||||
let rows = t
|
||||
.get(cfid)
|
||||
.or_else(|| t.get("__default__"))
|
||||
.expect("client_config.json always carries a __default__ section");
|
||||
|
||||
// URL-level tokens resolve through AdapterConfig so those helpers are the
|
||||
// single place a URL shape is defined. Host-level tokens cover the values
|
||||
// that are not one of the three standard URLs (roster, POW API).
|
||||
let utas_base = cfg.utas_base();
|
||||
let nucleus_base = cfg.nucleus_base();
|
||||
let pow_content_url = cfg.pow_content_url();
|
||||
|
||||
rows.iter()
|
||||
.map(|(k, v)| {
|
||||
let v = if v.contains('{') {
|
||||
v.replace("{utas_base}", &utas_base)
|
||||
.replace("{nucleus_base}", &nucleus_base)
|
||||
.replace("{pow_content_url}", &pow_content_url)
|
||||
.replace("{advertise}", &cfg.endpoints.advertise)
|
||||
.replace("{bind}", &cfg.endpoints.bind)
|
||||
.replace("{pow_content_host}", &cfg.endpoints.pow_content_host)
|
||||
.replace("{pow_host}", &cfg.endpoints.pow_host)
|
||||
} else {
|
||||
v.clone()
|
||||
};
|
||||
debug_assert!(!v.contains('{'), "unsubstituted token left in {k}: {v}");
|
||||
(k.clone(), v)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Fingerprint of the bundled config table.
|
||||
///
|
||||
/// The table is generated data, so "which binary is this?" is only half the
|
||||
/// question — "which data does it carry?" is the other half. A running host
|
||||
/// logs this at startup so a live FIFA trace can be tied to an exact table, and
|
||||
/// a rebuild that silently picked up regenerated fixtures is visible.
|
||||
///
|
||||
/// FNV-1a, not a security hash and never used as one.
|
||||
pub fn table_fingerprint() -> u64 {
|
||||
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for byte in TABLE_JSON.as_bytes() {
|
||||
hash ^= *byte as u64;
|
||||
hash = hash.wrapping_mul(0x1000_0000_01b3);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
/// Every CFID with its own section. Unknown CFIDs are still valid requests.
|
||||
pub fn known_sections() -> Vec<&'static str> {
|
||||
table()
|
||||
.keys()
|
||||
.filter(|k| k.as_str() != "__default__")
|
||||
.map(String::as_str)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cfg() -> AdapterConfig {
|
||||
let mut c = AdapterConfig::loopback();
|
||||
c.endpoints.advertise = "198.51.100.7".into();
|
||||
c.endpoints.bind = "0.0.0.0".into();
|
||||
c.endpoints.pow_content_host = "198.51.100.7:8085".into();
|
||||
c.endpoints.pow_host = "198.51.100.7:8094".into();
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_table_parses() {
|
||||
assert!(table().contains_key("__default__"));
|
||||
assert!(table().contains_key("BlazeSDK"));
|
||||
assert!(known_sections().len() >= 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_section_is_the_shared_fut_base() {
|
||||
let rows = rows_for("literally-anything", &cfg());
|
||||
assert_eq!(rows.len(), 227);
|
||||
assert!(rows.iter().any(|(k, _)| k == "FUT_RS4_BASE_URL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn addresses_are_substituted_not_baked() {
|
||||
let rows = rows_for("BlazeSDK", &cfg());
|
||||
let base = rows
|
||||
.iter()
|
||||
.find(|(k, _)| k == "FUT_RS4_BASE_URL")
|
||||
.expect("base url present");
|
||||
assert_eq!(base.1, "http://198.51.100.7:8099/");
|
||||
assert!(
|
||||
!rows.iter().any(|(_, v)| v.contains('{')),
|
||||
"a template token survived substitution"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nucleus_follows_bind_reproducing_the_oracle() {
|
||||
let rows = rows_for("BlazeSDK", &cfg());
|
||||
let n = rows.iter().find(|(k, _)| k == "nucleusConnect").unwrap();
|
||||
assert_eq!(n.1, "http://0.0.0.0:42131");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roster_section_carries_the_roster_urls() {
|
||||
let rows = rows_for("OSDK_ROSTER", &cfg());
|
||||
let r = rows.iter().find(|(k, _)| k == "ROSTER_URL").unwrap();
|
||||
assert_eq!(r.1, "https://198.51.100.7:8081/fifa17/roster/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_are_sorted_as_the_wire_requires() {
|
||||
// The oracle sorts; the TDF map encoder does not, so order is ours to keep.
|
||||
let rows = rows_for("BlazeSDK", &cfg());
|
||||
let mut sorted = rows.clone();
|
||||
sorted.sort();
|
||||
assert_eq!(rows, sorted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_known_section_substitutes_cleanly() {
|
||||
for cfid in known_sections() {
|
||||
for (k, v) in rows_for(cfid, &cfg()) {
|
||||
assert!(!v.contains('{'), "{cfid}/{k} kept a token: {v}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Adapter configuration: identity and endpoints.
|
||||
//!
|
||||
//! Everything deployment-dependent lives here, injected by the caller. No
|
||||
//! address, port or persona is baked into the response builders — the
|
||||
//! client/server split exists precisely because the Python responders used to
|
||||
//! assume loopback, and rebuilding that assumption in Rust would undo it.
|
||||
//!
|
||||
//! Note the deliberate asymmetry between *bind* and *advertise*: an advertised
|
||||
//! URL must carry the address the CLIENT can reach, which on a two-machine
|
||||
//! deployment is not the address the server binds.
|
||||
|
||||
/// The forged account the whole stack agrees on.
|
||||
///
|
||||
/// Identity has to be byte-identical across LSX, Blaze, POW and UTAS or the
|
||||
/// client rejects the session, so this is one struct passed everywhere rather
|
||||
/// than constants per responder.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Identity {
|
||||
pub persona_id: i64,
|
||||
pub persona_name: String,
|
||||
/// blazeId / userId. Must be non-zero or login is refused.
|
||||
pub user_id: i64,
|
||||
/// XREF externalId.
|
||||
pub ext_id: i64,
|
||||
pub email: String,
|
||||
/// Must equal `PreAuthResponse.NASP`.
|
||||
pub namespace: String,
|
||||
/// `Blaze::ClientPlatformType`; 4 = pc.
|
||||
pub client_platform: i64,
|
||||
/// `PersonaStatus::Code`; 2 = ACTIVE.
|
||||
pub persona_status: i64,
|
||||
/// `Blaze::UserSessionType`; 0 = normal user.
|
||||
pub user_session_type: i64,
|
||||
/// Fallback locale as a packed four-char int (`'enUS'`). Overwritten per
|
||||
/// session by the client's own preAuth `LANG`/`LOC`.
|
||||
pub account_locale: i64,
|
||||
/// `AccountInfo.LN`, e.g. `"en_US"`.
|
||||
pub locale: String,
|
||||
/// EA offer id.
|
||||
pub content_id: String,
|
||||
pub entitlement_tag: String,
|
||||
/// Must contain `"FIFA17PCBoxContent"` or `"FIFA16PC"` or FUT drops the
|
||||
/// entitlement and the store comes up empty.
|
||||
pub entitlement_group: String,
|
||||
pub title_id: String,
|
||||
pub client_id: String,
|
||||
pub platform: String,
|
||||
}
|
||||
|
||||
impl Default for Identity {
|
||||
/// The project's fixed synthetic offline identity.
|
||||
///
|
||||
/// A default, not a constant: the launcher can select a different persona,
|
||||
/// and FUT saves are isolated per persona id.
|
||||
fn default() -> Identity {
|
||||
Identity {
|
||||
persona_id: 33_068_179,
|
||||
persona_name: "CAGE".into(),
|
||||
user_id: 33_068_179,
|
||||
ext_id: 33_068_179,
|
||||
email: "cage@openfut.local".into(),
|
||||
namespace: "cem_ea_id".into(),
|
||||
client_platform: 4,
|
||||
persona_status: 2,
|
||||
user_session_type: 0,
|
||||
account_locale: 0x656E_5553, // 'enUS'
|
||||
locale: "en_US".into(),
|
||||
content_id: "1027460".into(),
|
||||
entitlement_tag: "ONLINE_ACCESS".into(),
|
||||
entitlement_group: "FIFA17PCBoxContent".into(),
|
||||
title_id: "309111".into(),
|
||||
client_id: "FIFA17-PC-SERVER-BLAZE".into(),
|
||||
platform: "pc".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the client should be told to go next.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Endpoints {
|
||||
/// Address handed to the CLIENT for every next hop. On a split deployment
|
||||
/// this is the backend's LAN address as the game machine sees it.
|
||||
pub advertise: String,
|
||||
/// Address the server binds. Not interchangeable with `advertise`.
|
||||
pub bind: String,
|
||||
/// `host:port` for POW content.
|
||||
pub pow_content_host: String,
|
||||
/// `host:port` for the POW/EASFC API.
|
||||
pub pow_host: String,
|
||||
/// Blaze port ADVERTISED to the client by the redirector.
|
||||
///
|
||||
/// Our choice, not a protocol constant — the client goes wherever
|
||||
/// `<serverinstanceinfo>` sends it. Configurable so a sidecar can be
|
||||
/// advertised on a different port without a rebuild.
|
||||
pub blaze_port: u16,
|
||||
/// UTAS/RS4 port in generated `FUT_RS4_*` URLs.
|
||||
///
|
||||
/// 8099 is the client's own built-in default (`http://easw.easports.com:8099/`
|
||||
/// in CardsDLL), so it is the sane value — but it is still deployment
|
||||
/// configuration, not a constant we are entitled to bake in.
|
||||
pub utas_port: u16,
|
||||
pub telemetry_port: i64,
|
||||
pub ticker_port: i64,
|
||||
pub qos_port: i64,
|
||||
}
|
||||
|
||||
// NOTE: there is deliberately NO `impl Default for Endpoints`.
|
||||
//
|
||||
// A default would silently supply loopback, and a remote deployment that forgot
|
||||
// to set an address would then advertise `127.0.0.1` to a client on another
|
||||
// machine — failing far from the cause. Choosing loopback has to be an explicit
|
||||
// act, so it is a named constructor.
|
||||
|
||||
impl Endpoints {
|
||||
/// Endpoints for a backend the client reaches at `advertise`.
|
||||
///
|
||||
/// POW hosts DERIVE from the advertised host, matching what the deployed
|
||||
/// Python entrypoint does (`POW_HOST="${POW_HOST:-$ADV:8094}"`). They must
|
||||
/// not fall back to loopback independently: that would leave a remote
|
||||
/// deployment emitting loopback POW URLs while every other URL was correct.
|
||||
pub fn advertising(advertise: impl Into<String>) -> Endpoints {
|
||||
let advertise = advertise.into();
|
||||
Endpoints {
|
||||
pow_content_host: format!("{advertise}:8080"),
|
||||
pow_host: format!("{advertise}:8094"),
|
||||
bind: advertise.clone(),
|
||||
advertise,
|
||||
blaze_port: 42130,
|
||||
utas_port: 8099,
|
||||
telemetry_port: 9988,
|
||||
ticker_port: 8999,
|
||||
qos_port: 17502,
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicit local-only / oracle mode: game and backend on one host.
|
||||
///
|
||||
/// Named rather than defaulted so that "everything is loopback" is always a
|
||||
/// decision someone made, and greppable.
|
||||
pub fn loopback() -> Endpoints {
|
||||
Endpoints::advertising("127.0.0.1")
|
||||
}
|
||||
}
|
||||
|
||||
/// Full adapter configuration.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AdapterConfig {
|
||||
pub identity: Identity,
|
||||
pub endpoints: Endpoints,
|
||||
/// `PreAuthResponse.SVER`. Carries a trailing newline in the oracle; kept
|
||||
/// because it is on the wire, not because it is meaningful.
|
||||
pub server_version: String,
|
||||
}
|
||||
|
||||
/// `PreAuthResponse.SVER`. On the wire, so it is config rather than a literal.
|
||||
pub const DEFAULT_SERVER_VERSION: &str = "Blaze 15.1.1.3.0 (OpenFUT)\n";
|
||||
|
||||
// No `Default` here either, for the same reason as `Endpoints`.
|
||||
|
||||
impl AdapterConfig {
|
||||
/// Adapter serving a client that reaches this backend at `advertise`.
|
||||
pub fn advertising(advertise: impl Into<String>) -> AdapterConfig {
|
||||
AdapterConfig {
|
||||
identity: Identity::default(),
|
||||
endpoints: Endpoints::advertising(advertise),
|
||||
server_version: DEFAULT_SERVER_VERSION.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicit local-only / oracle mode.
|
||||
pub fn loopback() -> AdapterConfig {
|
||||
AdapterConfig::advertising("127.0.0.1")
|
||||
}
|
||||
|
||||
/// `http://<advertise>:8099/` — the RS4/UTAS base.
|
||||
///
|
||||
/// The trailing slash and the scheme are both mandatory: CardsDLL's
|
||||
/// `ServerSettings::resolve` uses the value verbatim once it contains
|
||||
/// `"://"`, and the auth path breaks without the slash.
|
||||
pub fn utas_base(&self) -> String {
|
||||
format!(
|
||||
"http://{}:{}/",
|
||||
self.endpoints.advertise, self.endpoints.utas_port
|
||||
)
|
||||
}
|
||||
|
||||
/// `http://<bind>:42131` — the Nucleus OAuth stub.
|
||||
///
|
||||
/// This derives from **bind**, not advertise, faithfully reproducing the
|
||||
/// Python oracle. On the live split deployment that makes it
|
||||
/// `http://0.0.0.0:42131`, which the client cannot dial — see the crate
|
||||
/// README and the vault. Reproduced deliberately: changing it would break
|
||||
/// byte parity with the only configuration ever proven to work, and the
|
||||
/// fix belongs in a separate, live-validated change.
|
||||
pub fn nucleus_base(&self) -> String {
|
||||
format!("http://{}:42131", self.endpoints.bind)
|
||||
}
|
||||
|
||||
pub fn pow_content_url(&self) -> String {
|
||||
format!("http://{}", self.endpoints.pow_content_host)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn utas_base_keeps_scheme_and_trailing_slash() {
|
||||
let mut cfg = AdapterConfig::loopback();
|
||||
cfg.endpoints.advertise = "10.0.0.5".into();
|
||||
assert_eq!(cfg.utas_base(), "http://10.0.0.5:8099/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nucleus_follows_bind_not_advertise() {
|
||||
// Documents the oracle's behaviour, including its consequence.
|
||||
let mut cfg = AdapterConfig::loopback();
|
||||
cfg.endpoints.advertise = "10.0.0.5".into();
|
||||
cfg.endpoints.bind = "0.0.0.0".into();
|
||||
assert_eq!(cfg.nucleus_base(), "http://0.0.0.0:42131");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pow_content_url_has_no_trailing_slash() {
|
||||
let mut cfg = AdapterConfig::loopback();
|
||||
cfg.endpoints.pow_content_host = "10.0.0.5:8085".into();
|
||||
assert_eq!(cfg.pow_content_url(), "http://10.0.0.5:8085");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
//! Blaze RPC dispatch: inbound frame → outbound frames.
|
||||
//!
|
||||
//! Three behaviours here are load-bearing and none of them are obvious from the
|
||||
//! individual response shapes:
|
||||
//!
|
||||
//! * **Login answers with four frames, in order**: the reply first, then
|
||||
//! `UserAuthenticated`, `UserSessionExtendedDataUpdate`, `UserAdded`.
|
||||
//! * **An unimplemented RPC still gets an empty reply.** Silence makes the
|
||||
//! client wait for a timeout; an empty reply lets every field fall back to a
|
||||
//! client-side default and the boot continues.
|
||||
//! * **Non-request message types get nothing at all** — answering a reply or a
|
||||
//! notification would desynchronise the client's own correlation.
|
||||
//!
|
||||
//! No error replies are emitted. `msgType` 3 exists, but the error-code
|
||||
//! placement is UNRESOLVED — three clean-room sources disagree between
|
||||
//! `header[14:16]`, a metadata `ERRC`, and a payload `CNTX`/`ERRC` — so
|
||||
//! emitting one would be a guess on the wire. Do not add one without a capture.
|
||||
|
||||
use openfut_protocol_blaze::fire2::{Frame, Header, MsgType};
|
||||
use openfut_protocol_blaze::heat2::{self, Struct, Value};
|
||||
|
||||
use super::config::AdapterConfig;
|
||||
use super::ids::{association_lists, auth, census_data, component, user_sessions, util};
|
||||
use super::responses as r;
|
||||
use super::session::Session;
|
||||
|
||||
/// Everything needed to answer one RPC.
|
||||
pub struct Adapter {
|
||||
pub config: AdapterConfig,
|
||||
}
|
||||
|
||||
impl Adapter {
|
||||
pub fn new(config: AdapterConfig) -> Adapter {
|
||||
Adapter { config }
|
||||
}
|
||||
|
||||
/// Answer one inbound frame.
|
||||
///
|
||||
/// `now` is passed in rather than read from the clock so responses are
|
||||
/// reproducible: several bodies stamp a timestamp, and a hidden clock read
|
||||
/// would make every fixture unrepeatable.
|
||||
pub fn dispatch(
|
||||
&self,
|
||||
header: &Header,
|
||||
body: &Struct,
|
||||
session: &mut Session,
|
||||
now: i64,
|
||||
) -> Vec<Frame> {
|
||||
// Transport-level ping, whatever the component/command.
|
||||
if header.msg_type == MsgType::Ping {
|
||||
return vec![reply(header, Vec::new(), MsgType::PingReply)];
|
||||
}
|
||||
// Only requests are answered.
|
||||
if header.msg_type != MsgType::Message {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let cfg = &self.config;
|
||||
match (header.component, header.command) {
|
||||
// ------------------------------------------------------- Util
|
||||
(component::UTIL, util::PRE_AUTH) => {
|
||||
// preAuth is where the session learns who it is talking to:
|
||||
// the service name is echoed back, and the locale is captured
|
||||
// for every later ALOC field.
|
||||
session.service_name = service_name_of(body);
|
||||
if let Some(loc) =
|
||||
find_nested_int(body, "LANG").or_else(|| find_nested_int(body, "LOC"))
|
||||
{
|
||||
session.account_locale = loc;
|
||||
}
|
||||
let svc = session.service_name.clone();
|
||||
reply_tdf(header, &r::preauth_response(&svc, cfg))
|
||||
}
|
||||
|
||||
(component::UTIL, util::PING) => reply_tdf(header, &r::ping_response(now)),
|
||||
|
||||
(component::UTIL, util::FETCH_CLIENT_CONFIG) => {
|
||||
let cfid = get_str(body, "CFID");
|
||||
reply_tdf(header, &r::fetch_config_response(&cfid, cfg))
|
||||
}
|
||||
|
||||
(component::UTIL, util::POST_AUTH) => {
|
||||
reply_tdf(header, &r::post_auth_response(session, cfg))
|
||||
}
|
||||
|
||||
(component::UTIL, util::FETCH_QOS_CONFIG) => reply_tdf(header, &r::qos_config(cfg)),
|
||||
|
||||
(component::UTIL, util::USER_SETTINGS_LOAD) => {
|
||||
reply_tdf(header, &r::user_settings_response())
|
||||
}
|
||||
|
||||
// Accepted and discarded; the client only needs the ack.
|
||||
(component::UTIL, util::USER_SETTINGS_SAVE)
|
||||
| (component::UTIL, util::SET_CLIENT_STATE)
|
||||
| (component::UTIL, util::SET_CLIENT_METRICS) => empty_reply(header),
|
||||
|
||||
// --------------------------------------------- Authentication
|
||||
(component::AUTHENTICATION, auth::LOGIN) => {
|
||||
session.auth_code = get_str(body, "AUTH");
|
||||
session.logged_in = true;
|
||||
session.login_time = now;
|
||||
self.login_burst(header, session, now)
|
||||
}
|
||||
|
||||
// Same forged session; the request fields differ and are ignored.
|
||||
(component::AUTHENTICATION, auth::TRUSTED_LOGIN)
|
||||
| (component::AUTHENTICATION, auth::EXPRESS_LOGIN) => {
|
||||
session.logged_in = true;
|
||||
session.login_time = now;
|
||||
self.login_burst(header, session, now)
|
||||
}
|
||||
|
||||
// Receiving logout is NORMAL, not a failure: the OSDK state table
|
||||
// orders Connect -> Logout -> VersionCheck -> PCLogin, so this is
|
||||
// the routine "drop any stale session" step before login. It is
|
||||
// only a symptom if login never follows.
|
||||
(component::AUTHENTICATION, auth::LOGOUT) => empty_reply(header),
|
||||
|
||||
(component::AUTHENTICATION, auth::LIST_USER_ENTITLEMENTS2)
|
||||
| (component::AUTHENTICATION, auth::LIST_ENTITLEMENTS)
|
||||
| (component::AUTHENTICATION, auth::LIST_PERSONA_ENTITLEMENTS2)
|
||||
| (component::AUTHENTICATION, auth::GRANT_ENTITLEMENT2) => {
|
||||
reply_tdf(header, &r::entitlements_response(cfg))
|
||||
}
|
||||
|
||||
(component::AUTHENTICATION, auth::GET_AUTH_TOKEN) => {
|
||||
reply_tdf(header, &r::get_auth_token_response(session))
|
||||
}
|
||||
(component::AUTHENTICATION, auth::GET_ACCOUNT) => {
|
||||
reply_tdf(header, &r::account_info(now, cfg))
|
||||
}
|
||||
(component::AUTHENTICATION, auth::GET_PERSONA) => {
|
||||
reply_tdf(header, &r::get_persona_response(now, cfg))
|
||||
}
|
||||
(component::AUTHENTICATION, auth::LIST_PERSONAS) => {
|
||||
reply_tdf(header, &r::list_personas_response(now, cfg))
|
||||
}
|
||||
|
||||
// ---------------------------------------------- UserSessions
|
||||
(component::USER_SESSIONS, user_sessions::UPDATE_NETWORK_INFO) => {
|
||||
// Ack, then re-push the extended data so the client's cached
|
||||
// copy reflects the network info it just reported.
|
||||
vec![
|
||||
reply(header, Vec::new(), MsgType::Reply),
|
||||
notify(
|
||||
component::USER_SESSIONS,
|
||||
user_sessions::notify::EXTENDED_DATA_UPDATE,
|
||||
&r::user_session_extended_data_update(cfg),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
// ------------------------------------------ AssociationLists
|
||||
(component::ASSOCIATION_LISTS, association_lists::GET_LISTS) => {
|
||||
reply_tdf(header, &r::get_lists_response())
|
||||
}
|
||||
|
||||
// ----------------------------------------------- CensusData
|
||||
(component::CENSUS_DATA, census_data::SUBSCRIBE_TO_CENSUS_DATA_UPDATES) => {
|
||||
reply_tdf(header, &r::census_subscribe_response())
|
||||
}
|
||||
|
||||
// An empty reply, never silence: see the module docs.
|
||||
_ => empty_reply(header),
|
||||
}
|
||||
}
|
||||
|
||||
/// Login reply followed by the three UserSessions pushes, in order.
|
||||
///
|
||||
/// The order is the oracle's ("pamplona" order: reply first). The
|
||||
/// alternative ("grid-blaze": notifications first) is also reported to
|
||||
/// work, but only this one is proven against our client, so it is the one
|
||||
/// reproduced.
|
||||
fn login_burst(&self, header: &Header, session: &Session, now: i64) -> Vec<Frame> {
|
||||
let cfg = &self.config;
|
||||
vec![
|
||||
reply(
|
||||
header,
|
||||
heat2::encode(&r::login_response(session, now, cfg)),
|
||||
MsgType::Reply,
|
||||
),
|
||||
notify(
|
||||
component::USER_SESSIONS,
|
||||
user_sessions::notify::USER_AUTHENTICATED,
|
||||
&r::user_session_login_info(session, now, cfg),
|
||||
),
|
||||
notify(
|
||||
component::USER_SESSIONS,
|
||||
user_sessions::notify::EXTENDED_DATA_UPDATE,
|
||||
&r::user_session_extended_data_update(cfg),
|
||||
),
|
||||
notify(
|
||||
component::USER_SESSIONS,
|
||||
user_sessions::notify::USER_ADDED,
|
||||
&r::user_data(session, cfg),
|
||||
),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ helpers
|
||||
|
||||
fn reply(request: &Header, payload: Vec<u8>, msg_type: MsgType) -> Frame {
|
||||
let mut frame = Frame::new(
|
||||
request.component,
|
||||
request.command,
|
||||
request.msg_num,
|
||||
msg_type,
|
||||
payload,
|
||||
);
|
||||
// A reply echoes routing verbatim and changes only the msgType bits.
|
||||
frame.header.user_index = request.user_index;
|
||||
frame
|
||||
}
|
||||
|
||||
fn reply_tdf(request: &Header, body: &Struct) -> Vec<Frame> {
|
||||
vec![reply(request, heat2::encode(body), MsgType::Reply)]
|
||||
}
|
||||
|
||||
fn empty_reply(request: &Header) -> Vec<Frame> {
|
||||
vec![reply(request, Vec::new(), MsgType::Reply)]
|
||||
}
|
||||
|
||||
fn notify(component: u16, notify_id: u16, body: &Struct) -> Frame {
|
||||
Frame::notification(component, notify_id, heat2::encode(body))
|
||||
}
|
||||
|
||||
/// `PreAuthRequest.CDAT.SVCN`, echoed back as `INST`.
|
||||
fn service_name_of(body: &Struct) -> String {
|
||||
body.get("CDAT")
|
||||
.and_then(Value::as_struct)
|
||||
.and_then(|c| c.get("SVCN"))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(super::session::DEFAULT_SERVICE_NAME)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Depth-first search for an INT member anywhere in a decoded body.
|
||||
///
|
||||
/// The client has moved which struct carries `LANG`/`LOC` between builds, so
|
||||
/// the oracle searches rather than addressing a fixed path.
|
||||
fn find_nested_int(body: &Struct, tag: &str) -> Option<i64> {
|
||||
for (t, v) in body.iter() {
|
||||
if t.to_label() == tag {
|
||||
if let Value::Int(n) = v {
|
||||
return Some(*n);
|
||||
}
|
||||
}
|
||||
if let Value::Struct(inner) = v {
|
||||
if let Some(found) = find_nested_int(inner, tag) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn get_str(body: &Struct, tag: &str) -> String {
|
||||
body.get(tag)
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use openfut_protocol_blaze::heat2::Struct as S;
|
||||
|
||||
fn adapter() -> Adapter {
|
||||
Adapter::new(AdapterConfig::loopback())
|
||||
}
|
||||
|
||||
fn req(component: u16, command: u16) -> Header {
|
||||
Header::new(component, command, 7, MsgType::Message)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_answers_with_reply_then_three_pushes_in_order() {
|
||||
let a = adapter();
|
||||
let mut sess = Session::new("k", 0);
|
||||
let out = a.dispatch(
|
||||
&req(component::AUTHENTICATION, auth::LOGIN),
|
||||
&S::new(),
|
||||
&mut sess,
|
||||
1,
|
||||
);
|
||||
|
||||
assert_eq!(out.len(), 4);
|
||||
assert_eq!(out[0].header.msg_type, MsgType::Reply);
|
||||
let ids: Vec<u16> = out[1..].iter().map(|f| f.header.command).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec![
|
||||
user_sessions::notify::USER_AUTHENTICATED,
|
||||
user_sessions::notify::EXTENDED_DATA_UPDATE,
|
||||
user_sessions::notify::USER_ADDED,
|
||||
]
|
||||
);
|
||||
for f in &out[1..] {
|
||||
assert_eq!(f.header.msg_type, MsgType::Notification);
|
||||
assert_eq!(f.header.msg_num, 0, "notifications are uncorrelated");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unimplemented_rpcs_get_an_empty_reply_not_silence() {
|
||||
let a = adapter();
|
||||
let mut sess = Session::new("k", 0);
|
||||
let out = a.dispatch(&req(0x1234, 0x0001), &S::new(), &mut sess, 1);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].header.msg_type, MsgType::Reply);
|
||||
assert!(out[0].payload.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_requests_are_ignored_entirely() {
|
||||
let a = adapter();
|
||||
let mut sess = Session::new("k", 0);
|
||||
for mt in [
|
||||
MsgType::Reply,
|
||||
MsgType::Notification,
|
||||
MsgType::ErrorReply,
|
||||
MsgType::PingReply,
|
||||
] {
|
||||
let h = Header::new(component::UTIL, util::PING, 1, mt);
|
||||
assert!(a.dispatch(&h, &S::new(), &mut sess, 1).is_empty(), "{mt:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_ping_gets_an_empty_ping_reply() {
|
||||
let a = adapter();
|
||||
let mut sess = Session::new("k", 0);
|
||||
let h = Header::new(component::UTIL, util::PING, 1, MsgType::Ping);
|
||||
let out = a.dispatch(&h, &S::new(), &mut sess, 1);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].header.msg_type, MsgType::PingReply);
|
||||
assert!(out[0].payload.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replies_echo_routing_including_user_index() {
|
||||
let a = adapter();
|
||||
let mut sess = Session::new("k", 0);
|
||||
let mut h = req(component::UTIL, util::PING);
|
||||
h.user_index = 7;
|
||||
h.msg_num = 0x4242;
|
||||
let out = a.dispatch(&h, &S::new(), &mut sess, 1);
|
||||
assert_eq!(out[0].header.user_index, 7);
|
||||
assert_eq!(out[0].header.msg_num, 0x4242);
|
||||
assert_eq!(out[0].header.component, component::UTIL);
|
||||
assert_eq!(out[0].header.command, util::PING);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preauth_captures_locale_and_service_name() {
|
||||
let a = adapter();
|
||||
let mut sess = Session::new("k", 0x656E5553);
|
||||
let body = S::new().with(
|
||||
"CDAT",
|
||||
Value::Struct(
|
||||
S::new()
|
||||
.with("LANG", Value::Int(0x64654445))
|
||||
.with("SVCN", Value::String("fifa-2017-pc-de".into())),
|
||||
),
|
||||
);
|
||||
a.dispatch(&req(component::UTIL, util::PRE_AUTH), &body, &mut sess, 1);
|
||||
assert_eq!(sess.account_locale, 0x64654445);
|
||||
assert_eq!(sess.service_name, "fifa-2017-pc-de");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preauth_without_svcn_falls_back_to_the_default() {
|
||||
let a = adapter();
|
||||
let mut sess = Session::new("k", 0);
|
||||
a.dispatch(
|
||||
&req(component::UTIL, util::PRE_AUTH),
|
||||
&S::new(),
|
||||
&mut sess,
|
||||
1,
|
||||
);
|
||||
assert_eq!(
|
||||
sess.service_name,
|
||||
super::super::session::DEFAULT_SERVICE_NAME
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_records_the_auth_code_for_later_get_auth_token() {
|
||||
let a = adapter();
|
||||
let mut sess = Session::new("k", 0);
|
||||
let body = S::new().with("AUTH", Value::String("CODE-123".into()));
|
||||
a.dispatch(
|
||||
&req(component::AUTHENTICATION, auth::LOGIN),
|
||||
&body,
|
||||
&mut sess,
|
||||
1,
|
||||
);
|
||||
|
||||
let out = a.dispatch(
|
||||
&req(component::AUTHENTICATION, auth::GET_AUTH_TOKEN),
|
||||
&S::new(),
|
||||
&mut sess,
|
||||
1,
|
||||
);
|
||||
let decoded = heat2::decode(&out[0].payload).unwrap();
|
||||
assert_eq!(
|
||||
decoded.get("AUTH").and_then(Value::as_str),
|
||||
Some("CODE-123")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_network_info_acks_then_pushes() {
|
||||
let a = adapter();
|
||||
let mut sess = Session::new("k", 0);
|
||||
let out = a.dispatch(
|
||||
&req(component::USER_SESSIONS, user_sessions::UPDATE_NETWORK_INFO),
|
||||
&S::new(),
|
||||
&mut sess,
|
||||
1,
|
||||
);
|
||||
assert_eq!(out.len(), 2);
|
||||
assert!(out[0].payload.is_empty());
|
||||
assert_eq!(out[1].header.msg_type, MsgType::Notification);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_four_entitlement_aliases_agree() {
|
||||
let a = adapter();
|
||||
let mut sess = Session::new("k", 0);
|
||||
let bodies: Vec<Vec<u8>> = [
|
||||
auth::LIST_USER_ENTITLEMENTS2,
|
||||
auth::LIST_ENTITLEMENTS,
|
||||
auth::LIST_PERSONA_ENTITLEMENTS2,
|
||||
auth::GRANT_ENTITLEMENT2,
|
||||
]
|
||||
.iter()
|
||||
.map(|&cmd| {
|
||||
a.dispatch(
|
||||
&req(component::AUTHENTICATION, cmd),
|
||||
&S::new(),
|
||||
&mut sess,
|
||||
1,
|
||||
)[0]
|
||||
.payload
|
||||
.clone()
|
||||
})
|
||||
.collect();
|
||||
assert!(bodies.windows(2).all(|w| w[0] == w[1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_a_nested_int_at_any_depth() {
|
||||
let body = S::new().with(
|
||||
"A",
|
||||
Value::Struct(S::new().with("B", Value::Struct(S::new().with("LANG", Value::Int(42))))),
|
||||
);
|
||||
assert_eq!(find_nested_int(&body, "LANG"), Some(42));
|
||||
assert_eq!(find_nested_int(&body, "NOPE"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
//! FIFA 17 Blaze component, command and notification IDs.
|
||||
//!
|
||||
//! This is exactly the knowledge that must NOT live in
|
||||
//! `openfut-protocol-blaze`: the generic layer routes on numbers, and what
|
||||
//! those numbers mean is per-title.
|
||||
//!
|
||||
//! # Provenance
|
||||
//!
|
||||
//! The Util table was recovered from FIFA17.exe's own `getCommandName` switch
|
||||
//! (jump table `0x141b17af4`). The Authentication table could not be recovered
|
||||
//! statically — the name pool is Denuvo-mutated — so it was obtained by CALLING
|
||||
//! the client's own `getCommandName` (`0x146e0d2a0`) in-process over ids 1..320,
|
||||
//! validated by reproducing the known Util names, and cross-checked against a
|
||||
//! static REST-binding struct (`0x143896a80` → `trustedLogin = 0x0B`).
|
||||
//! UserSessions notification ids come from the clean, unmutated
|
||||
//! `getNotificationName` jump table at `0x141b03f70`.
|
||||
//!
|
||||
//! Names are for diagnostics only. Dispatch matches on the numeric constants.
|
||||
|
||||
pub mod component {
|
||||
pub const AUTHENTICATION: u16 = 0x0001;
|
||||
pub const GAME_MANAGER: u16 = 0x0004;
|
||||
pub const REDIRECTOR: u16 = 0x0005;
|
||||
pub const STATS: u16 = 0x0007;
|
||||
pub const UTIL: u16 = 0x0009;
|
||||
pub const CENSUS_DATA: u16 = 0x000A;
|
||||
pub const CLUBS: u16 = 0x000B;
|
||||
pub const MESSAGING: u16 = 0x000F;
|
||||
pub const ASSOCIATION_LISTS: u16 = 0x0019;
|
||||
pub const GAME_REPORTING: u16 = 0x001C;
|
||||
pub const SPONSORED_EVENTS: u16 = 0x081C;
|
||||
pub const OSDK_SETTINGS: u16 = 0x08C9;
|
||||
pub const USER_SESSIONS: u16 = 0x7802;
|
||||
}
|
||||
|
||||
pub mod util {
|
||||
pub const FETCH_CLIENT_CONFIG: u16 = 0x0001;
|
||||
pub const PING: u16 = 0x0002;
|
||||
pub const PRE_AUTH: u16 = 0x0007;
|
||||
pub const POST_AUTH: u16 = 0x0008;
|
||||
pub const USER_SETTINGS_LOAD: u16 = 0x000A;
|
||||
pub const USER_SETTINGS_SAVE: u16 = 0x000B;
|
||||
pub const FETCH_QOS_CONFIG: u16 = 0x0015;
|
||||
pub const SET_CLIENT_METRICS: u16 = 0x0016;
|
||||
pub const SET_CLIENT_STATE: u16 = 0x001C;
|
||||
}
|
||||
|
||||
pub mod auth {
|
||||
pub const LOGIN: u16 = 0x000A;
|
||||
pub const TRUSTED_LOGIN: u16 = 0x000B;
|
||||
pub const LIST_USER_ENTITLEMENTS2: u16 = 0x001D;
|
||||
pub const GET_ACCOUNT: u16 = 0x001E;
|
||||
pub const LIST_ENTITLEMENTS: u16 = 0x0020;
|
||||
pub const GET_AUTH_TOKEN: u16 = 0x0024;
|
||||
pub const GRANT_ENTITLEMENT2: u16 = 0x0027;
|
||||
pub const LIST_PERSONA_ENTITLEMENTS2: u16 = 0x0030;
|
||||
pub const EXPRESS_LOGIN: u16 = 0x003C;
|
||||
/// Routine "drop any stale session" step before PCLogin, NOT a failure.
|
||||
pub const LOGOUT: u16 = 0x0046;
|
||||
pub const GET_PERSONA: u16 = 0x005A;
|
||||
pub const LIST_PERSONAS: u16 = 0x0064;
|
||||
}
|
||||
|
||||
pub mod user_sessions {
|
||||
pub const UPDATE_NETWORK_INFO: u16 = 0x0014;
|
||||
|
||||
/// Notification ids live in a separate number space from commands.
|
||||
pub mod notify {
|
||||
pub const EXTENDED_DATA_UPDATE: u16 = 0x0001;
|
||||
pub const USER_ADDED: u16 = 0x0002;
|
||||
pub const USER_REMOVED: u16 = 0x0003;
|
||||
pub const USER_UPDATED: u16 = 0x0005;
|
||||
pub const USER_AUTHENTICATED: u16 = 0x0008;
|
||||
pub const USER_UNAUTHENTICATED: u16 = 0x0009;
|
||||
pub const SERVER_DRAINING: u16 = 0x000C;
|
||||
}
|
||||
}
|
||||
|
||||
pub mod association_lists {
|
||||
pub const GET_LISTS: u16 = 0x0006;
|
||||
}
|
||||
|
||||
pub mod census_data {
|
||||
pub const SUBSCRIBE_TO_CENSUS_DATA_UPDATES: u16 = 0x0005;
|
||||
}
|
||||
|
||||
/// Components advertised in `PreAuthResponse.CIDS`.
|
||||
///
|
||||
/// Order is the oracle's and is preserved: `CIDS` is a TDF list, and list
|
||||
/// elements are NOT reordered by the encoder the way struct members are.
|
||||
pub const ADVERTISED_COMPONENT_IDS: [i64; 9] = [
|
||||
component::AUTHENTICATION as i64,
|
||||
component::GAME_MANAGER as i64,
|
||||
component::REDIRECTOR as i64,
|
||||
component::STATS as i64,
|
||||
component::UTIL as i64,
|
||||
component::MESSAGING as i64,
|
||||
component::ASSOCIATION_LISTS as i64,
|
||||
component::GAME_REPORTING as i64,
|
||||
component::USER_SESSIONS as i64,
|
||||
];
|
||||
|
||||
pub fn component_name(component: u16) -> Option<&'static str> {
|
||||
Some(match component {
|
||||
component::AUTHENTICATION => "Authentication",
|
||||
component::GAME_MANAGER => "GameManager",
|
||||
component::REDIRECTOR => "Redirector",
|
||||
component::STATS => "Stats",
|
||||
component::UTIL => "Util",
|
||||
component::CENSUS_DATA => "CensusData",
|
||||
component::CLUBS => "Clubs",
|
||||
component::MESSAGING => "Messaging",
|
||||
component::ASSOCIATION_LISTS => "AssociationLists",
|
||||
component::GAME_REPORTING => "GameReporting",
|
||||
component::SPONSORED_EVENTS => "SponsoredEvents",
|
||||
component::OSDK_SETTINGS => "OSDKSettings",
|
||||
component::USER_SESSIONS => "UserSessions",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn command_name(component: u16, command: u16) -> Option<&'static str> {
|
||||
Some(match (component, command) {
|
||||
(component::UTIL, 0x01) => "fetchClientConfig",
|
||||
(component::UTIL, 0x02) => "ping",
|
||||
(component::UTIL, 0x03) => "setClientData",
|
||||
(component::UTIL, 0x04) => "localizeStrings",
|
||||
(component::UTIL, 0x05) => "getTelemetryServer",
|
||||
(component::UTIL, 0x06) => "getTickerServer",
|
||||
(component::UTIL, 0x07) => "preAuth",
|
||||
(component::UTIL, 0x08) => "postAuth",
|
||||
(component::UTIL, 0x0A) => "userSettingsLoad",
|
||||
(component::UTIL, 0x0B) => "userSettingsSave",
|
||||
(component::UTIL, 0x0C) => "userSettingsLoadAll",
|
||||
(component::UTIL, 0x0E) => "userSettingsDelete",
|
||||
(component::UTIL, 0x0F) => "userSettingsLoadAllForUser",
|
||||
(component::UTIL, 0x14) => "filterForProfanity",
|
||||
(component::UTIL, 0x15) => "fetchQosConfig",
|
||||
(component::UTIL, 0x16) => "setClientMetrics",
|
||||
(component::UTIL, 0x17) => "setConnectionState",
|
||||
(component::UTIL, 0x19) => "getUserOptions",
|
||||
(component::UTIL, 0x1A) => "setUserOptions",
|
||||
(component::UTIL, 0x1B) => "suspendUserPing",
|
||||
(component::UTIL, 0x1C) => "setClientState",
|
||||
|
||||
(component::AUTHENTICATION, 0x0A) => "login",
|
||||
(component::AUTHENTICATION, 0x0B) => "trustedLogin",
|
||||
(component::AUTHENTICATION, 0x14) => "updateAccount",
|
||||
(component::AUTHENTICATION, 0x15) => "upgradeAccount",
|
||||
(component::AUTHENTICATION, 0x1D) => "listUserEntitlements2",
|
||||
(component::AUTHENTICATION, 0x1E) => "getAccount",
|
||||
(component::AUTHENTICATION, 0x1F) => "grantEntitlement",
|
||||
(component::AUTHENTICATION, 0x20) => "listEntitlements",
|
||||
(component::AUTHENTICATION, 0x22) => "getUseCount",
|
||||
(component::AUTHENTICATION, 0x23) => "decrementUseCount",
|
||||
(component::AUTHENTICATION, 0x24) => "getAuthToken",
|
||||
(component::AUTHENTICATION, 0x26) => "getPasswordRules",
|
||||
(component::AUTHENTICATION, 0x27) => "grantEntitlement2",
|
||||
(component::AUTHENTICATION, 0x2B) => "modifyEntitlement2",
|
||||
(component::AUTHENTICATION, 0x2C) => "consumecode",
|
||||
(component::AUTHENTICATION, 0x2D) => "passwordForgot",
|
||||
(component::AUTHENTICATION, 0x2F) => "getPrivacyPolicyContent",
|
||||
(component::AUTHENTICATION, 0x30) => "listPersonaEntitlements2",
|
||||
(component::AUTHENTICATION, 0x33) => "checkAgeReq",
|
||||
(component::AUTHENTICATION, 0x34) => "getOptIn",
|
||||
(component::AUTHENTICATION, 0x35) => "enableOptIn",
|
||||
(component::AUTHENTICATION, 0x36) => "disableOptIn",
|
||||
(component::AUTHENTICATION, 0x3C) => "expressLogin",
|
||||
(component::AUTHENTICATION, 0x46) => "logout",
|
||||
(component::AUTHENTICATION, 0x5A) => "getPersona",
|
||||
(component::AUTHENTICATION, 0x64) => "listPersonas",
|
||||
(component::AUTHENTICATION, 0x65) => "expressCreateAccount",
|
||||
(component::AUTHENTICATION, 0xE6) => "createWalUserSession",
|
||||
(component::AUTHENTICATION, 0xF1) => "acceptLegalDocs",
|
||||
(component::AUTHENTICATION, 0xF2) => "getEmailOptInSettings",
|
||||
(component::AUTHENTICATION, 0xF6) => "getTermsOfServiceContent",
|
||||
(component::AUTHENTICATION, 0x104) => "getOriginPersona",
|
||||
(component::AUTHENTICATION, 0x10E) => "checkEmail",
|
||||
(component::AUTHENTICATION, 0x118) => "getPersonaNameSuggestions",
|
||||
(component::AUTHENTICATION, 0x122) => "guestLogin",
|
||||
|
||||
(component::CENSUS_DATA, 0x01) => "subscribeToCensusData",
|
||||
(component::CENSUS_DATA, 0x02) => "unsubscribeFromCensusData",
|
||||
(component::CENSUS_DATA, 0x03) => "getRegionCounts",
|
||||
(component::CENSUS_DATA, 0x04) => "getLatestCensusData",
|
||||
(component::CENSUS_DATA, 0x05) => "subscribeToCensusDataUpdates",
|
||||
|
||||
(component::USER_SESSIONS, 0x14) => "updateNetworkInfo",
|
||||
(component::ASSOCIATION_LISTS, 0x06) => "getLists",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn notification_name(component: u16, notify_id: u16) -> Option<&'static str> {
|
||||
if component != component::USER_SESSIONS {
|
||||
return None;
|
||||
}
|
||||
Some(match notify_id {
|
||||
0x01 => "UserSessionExtendedDataUpdate",
|
||||
0x02 => "UserAdded",
|
||||
0x03 => "UserRemoved",
|
||||
0x05 => "UserUpdated",
|
||||
0x08 => "UserAuthenticated",
|
||||
0x09 => "UserUnauthenticated",
|
||||
0x0C => "ServerDraining",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Human-readable label for a route, for logs and captures.
|
||||
pub fn describe(component: u16, command: u16, is_notification: bool) -> String {
|
||||
let comp = component_name(component)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("Component:0x{component:04x}"));
|
||||
if is_notification {
|
||||
if let Some(n) = notification_name(component, command) {
|
||||
return format!("{comp}::<{n}>");
|
||||
}
|
||||
return format!("{comp}::<notify:0x{command:04x}>");
|
||||
}
|
||||
match command_name(component, command) {
|
||||
Some(name) => format!("{comp}::{name}"),
|
||||
None => format!("{comp}::cmd:0x{command:04x}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn describes_the_first_rpc_fifa_sends() {
|
||||
assert_eq!(
|
||||
describe(component::UTIL, util::PRE_AUTH, false),
|
||||
"Util::preAuth"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_and_notifications_are_separate_number_spaces() {
|
||||
// 0x0002 is UserSessions "UserAdded" as a notification, but is not a
|
||||
// known UserSessions *command*.
|
||||
assert_eq!(
|
||||
describe(component::USER_SESSIONS, 0x0002, true),
|
||||
"UserSessions::<UserAdded>"
|
||||
);
|
||||
assert_eq!(
|
||||
describe(component::USER_SESSIONS, 0x0002, false),
|
||||
"UserSessions::cmd:0x0002"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_routes_degrade_to_numbers() {
|
||||
assert_eq!(
|
||||
describe(0x1234, 0x0001, false),
|
||||
"Component:0x1234::cmd:0x0001"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advertised_components_are_in_oracle_order() {
|
||||
// A list, not a struct: the encoder will NOT sort these, so the order
|
||||
// here is the order on the wire.
|
||||
assert_eq!(ADVERTISED_COMPONENT_IDS[0], 0x0001);
|
||||
assert_eq!(ADVERTISED_COMPONENT_IDS[8], 0x7802);
|
||||
assert_eq!(ADVERTISED_COMPONENT_IDS.len(), 9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! FIFA 17 Blaze adapter.
|
||||
//!
|
||||
//! Sits on `openfut-protocol-blaze` (Fire2 framing + Heat2/TDF) and supplies
|
||||
//! everything the generic layer deliberately refuses to know: which component
|
||||
//! and command numbers mean what, what each response body must contain, and in
|
||||
//! what order frames leave the server.
|
||||
//!
|
||||
//! ```text
|
||||
//! FIFA 17 client
|
||||
//! │ Fire2 frames
|
||||
//! openfut-protocol-blaze generic: framing + codec
|
||||
//! │ Header + Struct
|
||||
//! blaze::Adapter THIS: FIFA 17 ids, bodies, ordering
|
||||
//! │ (future) semantic calls
|
||||
//! OpenFUT Core game-independent FUT domain
|
||||
//! ```
|
||||
//!
|
||||
//! Blaze is an auth/session/config protocol: no coins, packs, clubs or squads
|
||||
//! appear on this wire, so the adapter carries no FUT domain state and has no
|
||||
//! reason to grow into a second backend.
|
||||
|
||||
pub mod client_config;
|
||||
pub mod config;
|
||||
pub mod dispatch;
|
||||
pub mod ids;
|
||||
pub mod responses;
|
||||
pub mod session;
|
||||
|
||||
pub use config::{AdapterConfig, Endpoints, Identity};
|
||||
pub use dispatch::Adapter;
|
||||
pub use session::Session;
|
||||
@@ -0,0 +1,623 @@
|
||||
//! FIFA 17 Blaze response bodies.
|
||||
//!
|
||||
//! Every builder here mirrors a `Blaze::*` TDF class reversed from FIFA17.exe's
|
||||
//! own reflection metadata. Member counts and tags are not guesses, and the
|
||||
//! comments carry the class addresses so a future reader can re-derive them.
|
||||
//!
|
||||
//! Two recurring rules, both learned the hard way:
|
||||
//!
|
||||
//! * **An absent member is safe; a wrongly-typed one is fatal.** A member the
|
||||
//! client does not receive keeps its client-side default. A member encoded
|
||||
//! with the wrong wire type desynchronises the whole TDF parse. That is why
|
||||
//! `CGID`, `ADDR`, `CVAR` and `ULST` are omitted rather than guessed — their
|
||||
//! union/objid encodings are UNVERIFIED.
|
||||
//! * **Identity must be byte-identical across responses.** `MAIL`/`UID`/`ASRC`
|
||||
//! in `AccountInfo` must match `LoginResponse.SESS` and `PreAuthResponse.NASP`,
|
||||
//! and the session key must be the same string in three places.
|
||||
//!
|
||||
//! Member order in the source below is the oracle's for readability; the
|
||||
//! encoder sorts by packed tag, so source order never reaches the wire.
|
||||
|
||||
use openfut_protocol_blaze::heat2::{Struct, TypeId, Value};
|
||||
|
||||
use super::client_config;
|
||||
use super::config::AdapterConfig;
|
||||
use super::ids::ADVERTISED_COMPONENT_IDS;
|
||||
use super::session::Session;
|
||||
|
||||
fn s(v: impl Into<String>) -> Value {
|
||||
Value::String(v.into())
|
||||
}
|
||||
|
||||
fn i(v: i64) -> Value {
|
||||
Value::Int(v)
|
||||
}
|
||||
|
||||
/// `Blaze::Util::FetchConfigResponse` @0x1448752e0 — a single `CONF`
|
||||
/// map<string,string>.
|
||||
///
|
||||
/// NOT double-nested. The extra nesting exists only inside `PreAuthResponse`,
|
||||
/// where `CONF` is itself a `FetchConfigResponse` whose own single member is
|
||||
/// also called `CONF`. Easy to get wrong.
|
||||
pub fn fetch_config_response(cfid: &str, cfg: &AdapterConfig) -> Struct {
|
||||
let entries = client_config::rows_for(cfid, cfg)
|
||||
.into_iter()
|
||||
.map(|(k, v)| (Value::String(k), Value::String(v)))
|
||||
.collect();
|
||||
Struct::new().with(
|
||||
"CONF",
|
||||
Value::Map {
|
||||
key: TypeId::String,
|
||||
val: TypeId::String,
|
||||
entries,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// `Blaze::QosConfigInfo` — 4 members.
|
||||
///
|
||||
/// FIFA 17's descriptor has no `SVID`, unlike Mirror's Edge Catalyst; do not
|
||||
/// add one back from another title's emulator.
|
||||
pub fn qos_config(cfg: &AdapterConfig) -> Struct {
|
||||
Struct::new()
|
||||
.with(
|
||||
"BWPS",
|
||||
Value::Struct(
|
||||
Struct::new()
|
||||
.with("PSA", s(&cfg.endpoints.advertise))
|
||||
.with("PSP", i(cfg.endpoints.qos_port)),
|
||||
),
|
||||
)
|
||||
.with("LNP", i(10))
|
||||
.with(
|
||||
"LTPS",
|
||||
Value::Map {
|
||||
key: TypeId::String,
|
||||
val: TypeId::Struct,
|
||||
entries: vec![],
|
||||
},
|
||||
)
|
||||
.with("TIME", i(5_000_000))
|
||||
}
|
||||
|
||||
/// `Blaze::Util::PreAuthResponse`.
|
||||
pub fn preauth_response(service_name: &str, cfg: &AdapterConfig) -> Struct {
|
||||
let id = &cfg.identity;
|
||||
Struct::new()
|
||||
.with("ASRC", s(&id.title_id))
|
||||
.with(
|
||||
"CIDS",
|
||||
Value::List {
|
||||
elem: TypeId::Int,
|
||||
items: ADVERTISED_COMPONENT_IDS.iter().copied().map(i).collect(),
|
||||
},
|
||||
)
|
||||
.with("CLID", s(&id.client_id))
|
||||
.with(
|
||||
"CONF",
|
||||
Value::Struct(fetch_config_response("BlazeSDK", cfg)),
|
||||
)
|
||||
.with("ESRC", s(&id.title_id))
|
||||
.with("INST", s(service_name)) // echo of CDAT.SVCN
|
||||
.with("MAID", i(0))
|
||||
.with("MINR", i(0))
|
||||
.with("NASP", s(&id.namespace))
|
||||
.with("PILD", s(""))
|
||||
.with("PLAT", s(&id.platform))
|
||||
.with("QOSS", Value::Struct(qos_config(cfg)))
|
||||
.with("RSRC", s(&id.title_id))
|
||||
.with("SVER", s(&cfg.server_version))
|
||||
}
|
||||
|
||||
/// `Blaze::Util::PingResponse` @0x144875560 — exactly one member.
|
||||
///
|
||||
/// v2 also sent `TIME`; that is MEC's field, not FIFA 17's.
|
||||
pub fn ping_response(now: i64) -> Struct {
|
||||
Struct::new().with("STIM", i(now))
|
||||
}
|
||||
|
||||
/// `Blaze::CensusData::SubscribeToCensusDataUpdatesResponse` — 3 TimeValues,
|
||||
/// encoded as INT microseconds.
|
||||
///
|
||||
/// These must be non-zero. The client computes `delay_ms = (CNP + NTMT) / 1000`
|
||||
/// and re-arms a resend timer; an empty reply gives delay 0, which lands the job
|
||||
/// on the scheduler's ready list and produces a ~30/s re-subscribe storm that
|
||||
/// hangs the FUT loading screen.
|
||||
pub fn census_subscribe_response() -> Struct {
|
||||
Struct::new()
|
||||
.with("CNP", i(30 * 1_000_000))
|
||||
.with("NTMT", i(90 * 1_000_000))
|
||||
.with("RTMT", i(300 * 1_000_000))
|
||||
}
|
||||
|
||||
/// `Blaze::Authentication::PersonaDetails` @0x14487cab0 — 6 members.
|
||||
pub fn persona_details(now: i64, cfg: &AdapterConfig) -> Struct {
|
||||
let id = &cfg.identity;
|
||||
Struct::new()
|
||||
.with("DSNM", s(&id.persona_name))
|
||||
.with("LAST", i(now))
|
||||
.with("PID", i(id.persona_id))
|
||||
.with("PLAT", i(id.client_platform))
|
||||
.with("STAS", i(id.persona_status))
|
||||
.with("XREF", i(id.ext_id))
|
||||
}
|
||||
|
||||
/// `Blaze::Authentication::UserLoginInfo` @0x14487cb00 — 8 members.
|
||||
///
|
||||
/// `'1CON'` packs to 0x11, which sorts below `'A'` = 0x21, so it leads.
|
||||
pub fn user_login_info(sess: &Session, now: i64, cfg: &AdapterConfig) -> Struct {
|
||||
let id = &cfg.identity;
|
||||
Struct::new()
|
||||
.with("1CON", i(0))
|
||||
.with("BUID", i(id.user_id)) // must be non-zero
|
||||
.with("FRST", i(0))
|
||||
.with("KEY", s(&sess.session_key)) // must be non-empty
|
||||
.with("LLOG", i(now))
|
||||
.with("MAIL", s(&id.email))
|
||||
.with("PDTL", Value::Struct(persona_details(now, cfg)))
|
||||
.with("UID", i(id.user_id)) // must be non-zero
|
||||
}
|
||||
|
||||
/// `Blaze::Authentication::LoginResponse` @0x14487d170 — exactly 5 members.
|
||||
///
|
||||
/// Diverges from both public MEC emulators, which emit `CNTX`, `ERRC` and a
|
||||
/// top-level `SKEY`. FIFA 17 has none of those: `CNTX`/`ERRC` are the Blaze
|
||||
/// error-metadata block, and the session key lives at `SESS.KEY`.
|
||||
pub fn login_response(sess: &Session, now: i64, cfg: &AdapterConfig) -> Struct {
|
||||
Struct::new()
|
||||
.with("ANON", i(0))
|
||||
.with("NTOS", i(0)) // 1 would divert to the legal-doc flow
|
||||
.with("SESS", Value::Struct(user_login_info(sess, now, cfg)))
|
||||
.with("SPAM", i(1))
|
||||
.with("UNDR", i(0))
|
||||
}
|
||||
|
||||
/// ISO-8601 UTC, matching the oracle's `%Y-%m-%dT%H:%M:%SZ`.
|
||||
///
|
||||
/// Hand-rolled from a Unix timestamp to keep this crate free of a date
|
||||
/// dependency for one format string. Proleptic Gregorian, no leap seconds —
|
||||
/// the same calendar `time.gmtime` uses.
|
||||
fn iso8601_utc(unix: i64) -> String {
|
||||
let days = unix.div_euclid(86_400);
|
||||
let secs = unix.rem_euclid(86_400);
|
||||
let (h, mi, sec) = (secs / 3600, (secs % 3600) / 60, secs % 60);
|
||||
|
||||
// Civil-from-days (Howard Hinnant's algorithm), shifted to a March-based year.
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
|
||||
format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{sec:02}Z")
|
||||
}
|
||||
|
||||
/// `Blaze::Authentication::AccountInfo` @0x14487c810 — exactly 16 members.
|
||||
///
|
||||
/// The RPC behind the "Unable to retrieve account information" popup: before it
|
||||
/// was implemented, the empty-reply fallback produced an AccountInfo with
|
||||
/// `UID=0`/`CO=""` and the popup appeared one layer later.
|
||||
///
|
||||
/// Member tags come from the reflection tag table @0x1448775a0; wire types from
|
||||
/// each member's subtype descriptor (string subtype 0x144867628 covers ASRC CO
|
||||
/// DOB DTCR LATH LN MAIL PML; the other eight are int/enum). Enum values:
|
||||
/// `STAS` = AccountStatus ACTIVE = 1, `STAT` = EmailStatus VERIFIED = 2,
|
||||
/// `RC` = StatusReason none = 0.
|
||||
pub fn account_info(now: i64, cfg: &AdapterConfig) -> Struct {
|
||||
let id = &cfg.identity;
|
||||
Struct::new()
|
||||
.with("AMU", i(0))
|
||||
.with("ASRC", s(&id.namespace)) // == PreAuthResponse.NASP
|
||||
.with("CO", s("US"))
|
||||
.with("DOB", s("1990-01-01T00:00:00Z"))
|
||||
.with("DTCR", s("2016-09-01T00:00:00Z"))
|
||||
.with("GOPT", i(0))
|
||||
.with("LATH", s(iso8601_utc(now)))
|
||||
.with("LN", s(&id.locale))
|
||||
.with("MAIL", s(&id.email)) // == LoginResponse.SESS.MAIL
|
||||
.with("PML", s(""))
|
||||
.with("RC", i(0))
|
||||
.with("STAS", i(1))
|
||||
.with("STAT", i(2))
|
||||
.with("TPOT", i(0))
|
||||
.with("UDU", i(0))
|
||||
.with("UID", i(id.user_id)) // == LoginResponse.SESS.UID
|
||||
}
|
||||
|
||||
/// `Blaze::Authentication::PersonaInfo` @0x14487c7c0 — 7 members.
|
||||
///
|
||||
/// `STAS` here is PersonaStatus ACTIVE = 2 (table 0x14487ad20) — a different
|
||||
/// enum from AccountInfo's `STAS`, which is AccountStatus ACTIVE = 1. `LADT`'s
|
||||
/// wire type is a best guess (INT timestamp); it is only reachable via
|
||||
/// getPersona/listPersonas, off the critical getAccount path.
|
||||
pub fn persona_info(now: i64, cfg: &AdapterConfig) -> Struct {
|
||||
let id = &cfg.identity;
|
||||
Struct::new()
|
||||
.with("DSNM", s(&id.persona_name))
|
||||
.with("DTCR", s("2016-09-01T00:00:00Z"))
|
||||
.with("LADT", i(now))
|
||||
.with("NSNM", s(&id.namespace))
|
||||
.with("PID", i(id.persona_id))
|
||||
.with("STAS", i(2))
|
||||
.with("STRC", i(0))
|
||||
}
|
||||
|
||||
/// `Blaze::Authentication::GetPersonaResponse` @0x14487d1c0 — PINF + UID.
|
||||
pub fn get_persona_response(now: i64, cfg: &AdapterConfig) -> Struct {
|
||||
Struct::new()
|
||||
.with("PINF", Value::Struct(persona_info(now, cfg)))
|
||||
.with("UID", i(cfg.identity.user_id))
|
||||
}
|
||||
|
||||
/// `Blaze::Authentication::ListPersonasResponse` @0x14487d210 — one member.
|
||||
pub fn list_personas_response(now: i64, cfg: &AdapterConfig) -> Struct {
|
||||
Struct::new().with(
|
||||
"PINF",
|
||||
Value::List {
|
||||
elem: TypeId::Struct,
|
||||
items: vec![Value::Struct(persona_info(now, cfg))],
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// `Blaze::UserSessionLoginInfo` @0x14486f920 — 16 members.
|
||||
///
|
||||
/// A superset of `UserLoginInfo` with the persona fields flattened in rather
|
||||
/// than nested. `KEY` must be byte-identical to `LoginResponse.SESS.KEY`.
|
||||
///
|
||||
/// `CGID` (a connectionGroup ObjectId) is omitted: the OBJID encoding is
|
||||
/// UNVERIFIED and a wrong one desynchronises the parse, while an absent member
|
||||
/// simply keeps its default.
|
||||
pub fn user_session_login_info(sess: &Session, now: i64, cfg: &AdapterConfig) -> Struct {
|
||||
let id = &cfg.identity;
|
||||
Struct::new()
|
||||
.with("1CON", i(0))
|
||||
.with("ALOC", i(sess.account_locale)) // echo the client's own locale
|
||||
.with("BUID", i(id.user_id))
|
||||
.with("DSNM", s(&id.persona_name))
|
||||
.with("FRST", i(0))
|
||||
.with("KEY", s(&sess.session_key)) // same string as LoginResponse
|
||||
.with("LAST", i(now))
|
||||
.with("LLOG", i(now))
|
||||
.with("MAIL", s(&id.email))
|
||||
.with("NASP", s(&id.namespace))
|
||||
.with("PID", i(id.persona_id))
|
||||
.with("PLAT", i(id.client_platform))
|
||||
.with("UID", i(id.user_id))
|
||||
.with("USTP", i(id.user_session_type))
|
||||
.with("XREF", i(id.ext_id))
|
||||
}
|
||||
|
||||
/// `Blaze::Util::NetworkQosData` @0x14486e680 — 5 members. `NATT` 0 = OPEN.
|
||||
pub fn network_qos_data() -> Struct {
|
||||
Struct::new()
|
||||
.with("BWHR", i(0))
|
||||
.with("DBPS", i(100_000))
|
||||
.with("NAHR", i(0))
|
||||
.with("NATT", i(0))
|
||||
.with("UBPS", i(100_000))
|
||||
}
|
||||
|
||||
/// `Blaze::UserSessionExtendedData` @0x144870390.
|
||||
///
|
||||
/// Two FIFA-17-specific deltas from the MEC emulators: FIFA HAS `PSLM`
|
||||
/// (latencyList), which they lack, and FIFA carries `BPS` as a top-level string
|
||||
/// whereas they bury it inside the `ADDR` union. Follow FIFA's layout.
|
||||
///
|
||||
/// `ADDR`, `CVAR` and `ULST` are omitted — unverified union/objid encodings.
|
||||
pub fn user_session_extended_data() -> Struct {
|
||||
Struct::new()
|
||||
.with("BPS", s("openfut"))
|
||||
.with("CTY", s("US"))
|
||||
.with(
|
||||
"DMAP",
|
||||
Value::Map {
|
||||
key: TypeId::Int,
|
||||
val: TypeId::Int,
|
||||
entries: vec![],
|
||||
},
|
||||
)
|
||||
.with("HWFG", i(0))
|
||||
.with("ISP", s("OpenFUT"))
|
||||
.with(
|
||||
"PSLM",
|
||||
Value::List {
|
||||
elem: TypeId::Int,
|
||||
items: vec![i(0)],
|
||||
},
|
||||
)
|
||||
.with("QDAT", Value::Struct(network_qos_data()))
|
||||
.with("TZ", s(""))
|
||||
.with("UATT", i(0))
|
||||
}
|
||||
|
||||
/// `Blaze::UserSessionExtendedDataUpdate` @0x1448703e0 — 3 members.
|
||||
pub fn user_session_extended_data_update(cfg: &AdapterConfig) -> Struct {
|
||||
Struct::new()
|
||||
.with("DATA", Value::Struct(user_session_extended_data()))
|
||||
.with("SUBS", i(1))
|
||||
.with("USID", i(cfg.identity.user_id))
|
||||
}
|
||||
|
||||
/// `Blaze::UserIdentification` @0x14486ebc0 — 9 members.
|
||||
pub fn user_identification(sess: &Session, cfg: &AdapterConfig) -> Struct {
|
||||
let id = &cfg.identity;
|
||||
Struct::new()
|
||||
.with("AID", i(id.user_id))
|
||||
.with("ALOC", i(sess.account_locale))
|
||||
.with("EXBB", Value::Blob(vec![]))
|
||||
.with("EXID", i(id.ext_id))
|
||||
.with("ID", i(id.user_id))
|
||||
.with("NAME", s(&id.persona_name))
|
||||
.with("NASP", s(&id.namespace))
|
||||
.with("ORIG", i(id.persona_id))
|
||||
.with("PIDI", i(id.persona_id))
|
||||
}
|
||||
|
||||
/// `Blaze::UserData` @0x1448706b0 — payload of the `UserAdded` push.
|
||||
/// `FLGS` is a UserDataFlags bitfield; bit 0 = online/authenticated.
|
||||
pub fn user_data(sess: &Session, cfg: &AdapterConfig) -> Struct {
|
||||
Struct::new()
|
||||
.with("EDAT", Value::Struct(user_session_extended_data()))
|
||||
.with("FLGS", i(3))
|
||||
.with("USER", Value::Struct(user_identification(sess, cfg)))
|
||||
}
|
||||
|
||||
/// `Blaze::Util::PostAuthResponse` @0x144875810 — TELE, TICK, UROP.
|
||||
///
|
||||
/// Telemetry and ticker point at dead local ports on purpose: the client gets a
|
||||
/// well-formed config and then fails to connect quietly, rather than resolving
|
||||
/// a real EA hostname.
|
||||
pub fn post_auth_response(sess: &Session, cfg: &AdapterConfig) -> Struct {
|
||||
let tele = Struct::new()
|
||||
.with("ADRS", s(&cfg.endpoints.advertise))
|
||||
.with("ANON", i(0))
|
||||
.with("DISA", s(""))
|
||||
.with("EDCT", i(0))
|
||||
.with("FILT", s(""))
|
||||
.with("LOC", i(sess.account_locale))
|
||||
.with("MINR", i(0))
|
||||
.with("NOOK", s(""))
|
||||
.with("PORT", i(cfg.endpoints.telemetry_port))
|
||||
.with("SDLY", i(15_000))
|
||||
.with("SESS", s(&sess.session_key)) // same key as login
|
||||
.with("SKEY", s(""))
|
||||
.with("SPCT", i(75))
|
||||
.with("STIM", s(""))
|
||||
.with("SVNM", s("telemetry-openfut"));
|
||||
|
||||
let tick = Struct::new()
|
||||
.with("ADRS", s(&cfg.endpoints.advertise))
|
||||
.with("PORT", i(cfg.endpoints.ticker_port))
|
||||
.with("SKEY", s(""));
|
||||
|
||||
let urop = Struct::new()
|
||||
.with("TMOP", i(0))
|
||||
.with("UID", i(cfg.identity.user_id));
|
||||
|
||||
Struct::new()
|
||||
.with("TELE", Value::Struct(tele))
|
||||
.with("TICK", Value::Struct(tick))
|
||||
.with("UROP", Value::Struct(urop))
|
||||
}
|
||||
|
||||
/// `Blaze::Authentication::Entitlement` @0x14487d490 — 16 members.
|
||||
///
|
||||
/// FUT's client-side filter (`onListEntitlements` @0x146f27440) keeps a record
|
||||
/// only if `GNAM` contains `"FIFA17PCBoxContent"` or `"FIFA16PC"`, `TAG` is
|
||||
/// non-empty, and `STAT == 1`. A plain `"FIFA17PC"` group matched neither
|
||||
/// needle and produced an empty store.
|
||||
///
|
||||
/// `PRID`/`GNAM`/`TAG` must contain no `'|'` and no `'/'`: the client
|
||||
/// re-serialises them as `PRID|GNAM|TAG|UCNT/`.
|
||||
pub fn entitlement(group: &str, tag: &str, eid: i64, cfg: &AdapterConfig) -> Struct {
|
||||
let id = &cfg.identity;
|
||||
Struct::new()
|
||||
.with("DEVI", s(""))
|
||||
.with("GDAY", s("2016-09-01T00:00:00Z"))
|
||||
.with("GNAM", s(group))
|
||||
.with("ID", i(eid))
|
||||
.with("ISCO", i(0))
|
||||
.with("PID", i(id.persona_id))
|
||||
.with("PJID", s(&id.content_id))
|
||||
.with("PRCA", i(2))
|
||||
.with("PRID", s(&id.content_id))
|
||||
.with("STAT", i(1)) // must be 1 or FUT drops it
|
||||
.with("STRC", i(0))
|
||||
.with("TAG", s(tag)) // must be non-empty
|
||||
.with("TDAY", s(""))
|
||||
.with("TYPE", i(1))
|
||||
.with("UCNT", i(0))
|
||||
.with("VER", i(1))
|
||||
}
|
||||
|
||||
/// `Blaze::Authentication::Entitlements` @0x14487d4e0 — single member `NLST`.
|
||||
///
|
||||
/// Emits BOTH accepted groups so the entitlement manager's "loaded" flag
|
||||
/// (`byte[entMgr+0x88]`) flips however the client asks.
|
||||
pub fn entitlements_response(cfg: &AdapterConfig) -> Struct {
|
||||
let tag = &cfg.identity.entitlement_tag;
|
||||
Struct::new().with(
|
||||
"NLST",
|
||||
Value::List {
|
||||
elem: TypeId::Struct,
|
||||
items: vec![
|
||||
Value::Struct(entitlement("FIFA17PCBoxContent", tag, 1, cfg)),
|
||||
Value::Struct(entitlement("FIFA16PC", tag, 2, cfg)),
|
||||
],
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// `Blaze::Authentication::GetAuthTokenResponse` @0x14487d080 — one member.
|
||||
pub fn get_auth_token_response(sess: &Session) -> Struct {
|
||||
Struct::new().with("AUTH", s(sess.auth_token()))
|
||||
}
|
||||
|
||||
/// `Util::userSettingsLoad` response.
|
||||
///
|
||||
/// TODO(verify): the response descriptor was never reflected. Both independent
|
||||
/// clean-room emulators use a single `DATA` string, and an unknown-tag payload
|
||||
/// is ignored rather than fatal, so an empty `DATA` is the safe minimum — the
|
||||
/// client falls back to its defaults.
|
||||
pub fn user_settings_response() -> Struct {
|
||||
Struct::new().with("DATA", s(""))
|
||||
}
|
||||
|
||||
/// `AssociationLists::getLists` response.
|
||||
///
|
||||
/// TODO(verify): FIFA's association-list names are NOT known — do not invent
|
||||
/// them. An empty list is well-formed and means "this user has no association
|
||||
/// lists", which is true offline.
|
||||
pub fn get_lists_response() -> Struct {
|
||||
Struct::new().with(
|
||||
"LMAP",
|
||||
Value::List {
|
||||
elem: TypeId::Struct,
|
||||
items: vec![],
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cfg() -> AdapterConfig {
|
||||
AdapterConfig::loopback()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso8601_matches_known_instants() {
|
||||
assert_eq!(iso8601_utc(0), "1970-01-01T00:00:00Z");
|
||||
assert_eq!(iso8601_utc(1_754_870_400), "2025-08-11T00:00:00Z");
|
||||
// A leap day, to exercise the civil-from-days branch.
|
||||
assert_eq!(iso8601_utc(1_709_164_800), "2024-02-29T00:00:00Z");
|
||||
assert_eq!(iso8601_utc(951_782_400), "2000-02-29T00:00:00Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_response_has_exactly_five_members() {
|
||||
let sess = Session::new("k", 0);
|
||||
assert_eq!(login_response(&sess, 0, &cfg()).len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_info_has_exactly_sixteen_members() {
|
||||
assert_eq!(account_info(0, &cfg()).len(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_key_appears_identically_in_all_three_places() {
|
||||
let sess = Session::new("THE-KEY", 0);
|
||||
let c = cfg();
|
||||
|
||||
let login = login_response(&sess, 1, &c);
|
||||
let in_login = login
|
||||
.get("SESS")
|
||||
.and_then(Value::as_struct)
|
||||
.and_then(|s| s.get("KEY"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap();
|
||||
|
||||
let notify = user_session_login_info(&sess, 1, &c);
|
||||
let in_notify = notify.get("KEY").and_then(Value::as_str).unwrap();
|
||||
|
||||
let post = post_auth_response(&sess, &c);
|
||||
let in_post = post
|
||||
.get("TELE")
|
||||
.and_then(Value::as_struct)
|
||||
.and_then(|s| s.get("SESS"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(in_login, "THE-KEY");
|
||||
assert_eq!(in_notify, "THE-KEY");
|
||||
assert_eq!(in_post, "THE-KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_is_consistent_between_login_and_account_info() {
|
||||
let sess = Session::new("k", 0);
|
||||
let c = cfg();
|
||||
let acct = account_info(0, &c);
|
||||
let sess_info = user_login_info(&sess, 0, &c);
|
||||
|
||||
assert_eq!(
|
||||
acct.get("MAIL").and_then(Value::as_str),
|
||||
sess_info.get("MAIL").and_then(Value::as_str)
|
||||
);
|
||||
assert_eq!(
|
||||
acct.get("UID").and_then(Value::as_int),
|
||||
sess_info.get("UID").and_then(Value::as_int)
|
||||
);
|
||||
assert_eq!(
|
||||
acct.get("ASRC").and_then(Value::as_str),
|
||||
Some(c.identity.namespace.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entitlement_groups_match_the_clients_needles() {
|
||||
let c = cfg();
|
||||
let list = entitlements_response(&c);
|
||||
let items = match list.get("NLST") {
|
||||
Some(Value::List { items, .. }) => items,
|
||||
_ => panic!("NLST is a list"),
|
||||
};
|
||||
assert_eq!(items.len(), 2);
|
||||
for item in items {
|
||||
let e = item.as_struct().unwrap();
|
||||
let gnam = e.get("GNAM").and_then(Value::as_str).unwrap();
|
||||
assert!(
|
||||
gnam.contains("FIFA17PCBoxContent") || gnam.contains("FIFA16PC"),
|
||||
"group {gnam} matches neither client needle"
|
||||
);
|
||||
assert_eq!(e.get("STAT").and_then(Value::as_int), Some(1));
|
||||
assert!(!e.get("TAG").and_then(Value::as_str).unwrap().is_empty());
|
||||
// The client re-serialises these delimited; a separator would corrupt it.
|
||||
for tag in ["PRID", "GNAM", "TAG"] {
|
||||
let v = e.get(tag).and_then(Value::as_str).unwrap();
|
||||
assert!(
|
||||
!v.contains('|') && !v.contains('/'),
|
||||
"{tag} has a separator"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn census_periods_are_non_zero() {
|
||||
// Zero here is the ~30/s storm that hangs the FUT loading screen.
|
||||
let r = census_subscribe_response();
|
||||
assert!(r.get("CNP").and_then(Value::as_int).unwrap() > 0);
|
||||
assert!(r.get("NTMT").and_then(Value::as_int).unwrap() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preauth_echoes_the_requested_service_name() {
|
||||
let p = preauth_response("fifa-2017-pc-de", &cfg());
|
||||
assert_eq!(
|
||||
p.get("INST").and_then(Value::as_str),
|
||||
Some("fifa-2017-pc-de")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extended_data_omits_the_unverified_members() {
|
||||
// Absent is safe; a wrong union/objid encoding breaks the whole parse.
|
||||
let d = user_session_extended_data();
|
||||
for absent in ["ADDR", "CVAR", "ULST"] {
|
||||
assert!(d.get(absent).is_none(), "{absent} must stay omitted");
|
||||
}
|
||||
assert!(
|
||||
d.get("PSLM").is_some(),
|
||||
"PSLM is FIFA-specific and required"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Per-connection Blaze session state.
|
||||
//!
|
||||
//! Note how little there is: a session key, the client's locale, the echoed
|
||||
//! service name, an auth code and a logged-in flag. That is the whole of it.
|
||||
//!
|
||||
//! This is the point of the adapter boundary. Blaze is an auth/session/config
|
||||
//! protocol — coins, packs, clubs, squads and the rest of the FUT domain never
|
||||
//! appear on this wire, so there is nothing here tempting the adapter into
|
||||
//! becoming a second backend. When UTAS is migrated that discipline will need
|
||||
//! actively defending; here it comes for free.
|
||||
|
||||
/// State carried across RPCs on one Blaze connection.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Session {
|
||||
/// Minted once per connection. Must appear byte-identically in
|
||||
/// `LoginResponse.SESS.KEY`, the `UserAuthenticated` push, and
|
||||
/// `PostAuthResponse.TELE.SESS`.
|
||||
pub session_key: String,
|
||||
/// Whatever `LoginRequest.AUTH` carried; echoed back by `getAuthToken`.
|
||||
pub auth_code: String,
|
||||
/// Packed four-char locale, seeded from config and overwritten by the
|
||||
/// client's own preAuth `LANG`/`LOC`.
|
||||
pub account_locale: i64,
|
||||
/// Echoed back as `PreAuthResponse.INST`.
|
||||
pub service_name: String,
|
||||
pub logged_in: bool,
|
||||
pub login_time: i64,
|
||||
}
|
||||
|
||||
/// The oracle's default service name when preAuth carries no `CDAT.SVCN`.
|
||||
pub const DEFAULT_SERVICE_NAME: &str = "fifa-2017-pc";
|
||||
|
||||
impl Session {
|
||||
/// Start a session with an explicit key.
|
||||
///
|
||||
/// The key is injected rather than generated internally so it can be made
|
||||
/// deterministic for differential tests — it appears verbatim in three
|
||||
/// different responses, so a self-generated one would make every login
|
||||
/// fixture unreproducible.
|
||||
pub fn new(session_key: impl Into<String>, account_locale: i64) -> Session {
|
||||
Session {
|
||||
session_key: session_key.into(),
|
||||
auth_code: String::new(),
|
||||
account_locale,
|
||||
service_name: DEFAULT_SERVICE_NAME.into(),
|
||||
logged_in: false,
|
||||
login_time: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The token `getAuthToken` returns.
|
||||
///
|
||||
/// Before login there is no auth code, so the oracle synthesises one from
|
||||
/// the session key. Reproduced exactly, including the 16-character slice.
|
||||
pub fn auth_token(&self) -> String {
|
||||
if !self.auth_code.is_empty() {
|
||||
return self.auth_code.clone();
|
||||
}
|
||||
// Byte slicing is safe here in practice (session keys are ASCII), but
|
||||
// char_indices keeps it correct for any injected key.
|
||||
let cut = self
|
||||
.session_key
|
||||
.char_indices()
|
||||
.nth(16)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(self.session_key.len());
|
||||
format!("OPENFUT-{}", &self.session_key[..cut])
|
||||
}
|
||||
}
|
||||
|
||||
/// Mint a Blaze-shaped session key: 16 hex, an underscore, then 44 alphanumerics.
|
||||
///
|
||||
/// The client never validates the format — one public emulator ships the
|
||||
/// literal `"0"` — so this only has to be stable within a connection. Callers
|
||||
/// supply the randomness so this crate needs no RNG dependency and stays
|
||||
/// deterministic under test.
|
||||
pub fn format_session_key(high_bits: u64, tail: &str) -> String {
|
||||
format!("{high_bits:016x}_{tail}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn synthesises_a_token_before_login() {
|
||||
let s = Session::new("0123456789abcdef_TAIL", 0);
|
||||
assert_eq!(s.auth_token(), "OPENFUT-0123456789abcdef");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn echoes_the_login_auth_code_afterwards() {
|
||||
let mut s = Session::new("0123456789abcdef_TAIL", 0);
|
||||
s.auth_code = "REAL-CODE".into();
|
||||
assert_eq!(s.auth_token(), "REAL-CODE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_session_keys_do_not_panic() {
|
||||
assert_eq!(Session::new("abc", 0).auth_token(), "OPENFUT-abc");
|
||||
assert_eq!(Session::new("", 0).auth_token(), "OPENFUT-");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_key_has_the_blaze_shape() {
|
||||
let k = format_session_key(0x0123456789abcdef, &"x".repeat(44));
|
||||
assert_eq!(k.len(), 16 + 1 + 44);
|
||||
assert!(k.starts_with("0123456789abcdef_"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
//! FIFA 17 **card-definition identity catalog** and owned-item **wire-id policy**.
|
||||
//!
|
||||
//! Two distinct identities (never conflate them):
|
||||
//!
|
||||
//! * **Card definition** — *what card is this?* A semantic OpenFUT
|
||||
//! `CardDefinitionId` maps to a FIFA 17 render identity here:
|
||||
//! `resource_id = (version << 24) | asset_id`. The client resolves
|
||||
//! `resource_id & 0xFFFFFF` (= `asset_id`) against its own local player DB;
|
||||
//! an invented id renders a blank card, so this catalog is authored from
|
||||
//! verified FIFA 17 data (`pool.json` player asset ids), never guessed.
|
||||
//! * **Owned-item instance** — *which exact copy?* A monotonic integer wire id,
|
||||
//! allocated per account by the generic external-identity store; this module
|
||||
//! only holds the FIFA 17 numeric **policy** ([`Fifa17WireItemIdPolicy`]).
|
||||
//!
|
||||
//! The catalog is game DATA (a versioned JSON file), not deployment config, and
|
||||
//! not a generic-Core concern. Unknown definitions resolve to `None` — callers
|
||||
//! drop them, never fabricate an asset id.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::fut::content_taxonomy::ContentKind;
|
||||
|
||||
/// The FIFA 17 render identity of a card definition. `version` is the high byte
|
||||
/// of `resource_id`; `asset_id` (the low 24 bits) is the real FIFA player id.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Fifa17CardIdentity {
|
||||
pub asset_id: u32,
|
||||
pub version: u8,
|
||||
pub resource_id: u32,
|
||||
/// FIFA wire `rareflag` (rare/special card TYPE). Carried so specials render
|
||||
/// as specials; observed metadata, not a guessed label.
|
||||
pub rareflag: i64,
|
||||
/// Content class of this definition. A catalog authored before this field
|
||||
/// existed defaults to [`ContentKind::Player`] (backward compatible).
|
||||
pub kind: ContentKind,
|
||||
/// FIFA `cardsubtypeid` for a non-player definition (consumable family /
|
||||
/// staff role), `0` for a player or when absent.
|
||||
pub subtype: i64,
|
||||
/// FIFA card-art class. Players default to `asset_id`; kit definitions carry
|
||||
/// the verified `fcc_kitcards.cardassetid` value (`35`).
|
||||
pub card_asset_id: u32,
|
||||
/// Source team id for a club kit, or a manager's real club. Zero for content
|
||||
/// kinds that do not use it.
|
||||
pub team_id: i64,
|
||||
/// Manager chemistry nation (`managercards.nation`), zero when unused.
|
||||
///
|
||||
/// The client NEVER supplies this: the managercards merge (`FUN_1801356c0`)
|
||||
/// leaves the manager-only record slot `rec+0xde` untouched, so the server is
|
||||
/// its only source. See `fifa17-recon/tools/fut_staff.py`.
|
||||
pub nation: i64,
|
||||
/// Manager chemistry league, zero when unused. Derived upstream through
|
||||
/// `manager.teamid` → `leagueteamlinks.leagueid`, because `managercards` has
|
||||
/// no league column. Lands in the equally untouched slot `rec+0xe0`.
|
||||
pub league_id: i64,
|
||||
/// EA's authored `rating` for a NON-PLAYER definition (`fcc_*.rating`), which
|
||||
/// Core does not model: an imported consumable's Core `overall` is 0, while
|
||||
/// the client's own copies carry 55..95 and the value drives the card level
|
||||
/// (`rec+0x54`) and therefore its quick-sell price. `None` → the caller falls
|
||||
/// back to Core's rating, which stays authoritative for players.
|
||||
pub rating: Option<u8>,
|
||||
/// `amount` (atom 0x1b) for a consumable definition — the bonus magnitude EA
|
||||
/// authored in the `fcc_*` row (+5 / +10 / +15 …). MANDATORY for the
|
||||
/// training, healing, fitness, play-style and manager-league families:
|
||||
/// omitting the key draws "-1" on the card, not "0".
|
||||
pub amount: Option<i64>,
|
||||
/// `contract` (atom 0xb8) for a contract-card definition (`cardsubtypeid`
|
||||
/// 201/202) — the number of matches the card grants. `fcc_contractcards` has
|
||||
/// no amount column, so this value comes from observed data; it is never
|
||||
/// defaulted here.
|
||||
pub contract: Option<i64>,
|
||||
}
|
||||
|
||||
/// The FIFA 17 numeric namespace policy for owned-item wire ids.
|
||||
///
|
||||
/// Owned-item ids are monotonic from `OWNED_ITEM_BASE + 1` (= 100_000_001,
|
||||
/// matching the oracle's `ITEM_ID_BASE = 100_000_000` and its first minted id),
|
||||
/// staying below the synthetic-overlay ranges the responder uses (≥ 9e8). The
|
||||
/// generic store enforces monotonicity/uniqueness; this type supplies the game,
|
||||
/// entity-kind and base floor.
|
||||
pub struct Fifa17WireItemIdPolicy;
|
||||
|
||||
impl Fifa17WireItemIdPolicy {
|
||||
pub const GAME: &'static str = "fifa17";
|
||||
pub const OWNED_ITEM_KIND: &'static str = "owned-item";
|
||||
pub const OWNED_ITEM_BASE: i64 = 100_000_000;
|
||||
|
||||
/// First owned-item wire id (`100_000_001`).
|
||||
pub fn owned_item_base_floor() -> i64 {
|
||||
Self::OWNED_ITEM_BASE + 1
|
||||
}
|
||||
|
||||
/// Identity scope for MATCH session ids.
|
||||
///
|
||||
/// A match id is deliberately NOT drawn from the owned-item scope. The
|
||||
/// oracle mints both from one counter, which is why an observed match id
|
||||
/// looks like an item id — but that is an artifact of a single-counter save
|
||||
/// file, not a client requirement. Here the identity store keeps a real
|
||||
/// reverse map, so an item-scoped match id would make
|
||||
/// `owned_id_for_wire` resolve a match to a bogus owned card and corrupt
|
||||
/// quick-sell and move. The store is generic over `(game, kind)`, so a
|
||||
/// separate scope costs one constant and cannot collide with, or advance,
|
||||
/// the owned-item watermark.
|
||||
pub const MATCH_KIND: &'static str = "match";
|
||||
|
||||
/// Base for match session ids. Clear of the owned-item range
|
||||
/// (`100_000_000+`) and of every synthetic overlay range the responder
|
||||
/// reserves (`≥ 9e8`). The client only requires a non-zero int.
|
||||
pub const MATCH_BASE: i64 = 200_000_000;
|
||||
|
||||
/// First match wire id (`200_000_001`).
|
||||
pub fn match_base_floor() -> i64 {
|
||||
Self::MATCH_BASE + 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Highest representable asset id (24 bits); above this `version` would be
|
||||
/// clobbered in `resource_id`.
|
||||
const MAX_ASSET_ID: u32 = 0x00FF_FFFF;
|
||||
const SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
/// Catalog load/validation errors — all explicit, no silent fallback.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum CatalogError {
|
||||
BadSchemaVersion(u32),
|
||||
WrongGame(String),
|
||||
AssetTooLarge {
|
||||
card_id: String,
|
||||
asset_id: u32,
|
||||
},
|
||||
DuplicateResource {
|
||||
resource_id: u32,
|
||||
first: String,
|
||||
second: String,
|
||||
},
|
||||
Parse(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CatalogError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CatalogError::BadSchemaVersion(v) => {
|
||||
write!(f, "unsupported catalog schema_version {v}")
|
||||
}
|
||||
CatalogError::WrongGame(g) => write!(f, "catalog game is '{g}', expected 'fifa17'"),
|
||||
CatalogError::AssetTooLarge { card_id, asset_id } => {
|
||||
write!(f, "card '{card_id}' asset_id {asset_id} exceeds 24 bits")
|
||||
}
|
||||
CatalogError::DuplicateResource {
|
||||
resource_id,
|
||||
first,
|
||||
second,
|
||||
} => write!(
|
||||
f,
|
||||
"resource_id {resource_id} claimed by both '{first}' and '{second}'"
|
||||
),
|
||||
CatalogError::Parse(e) => write!(f, "catalog parse error: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl std::error::Error for CatalogError {}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawCatalog {
|
||||
schema_version: u32,
|
||||
game: String,
|
||||
#[serde(default)]
|
||||
cards: std::collections::BTreeMap<String, RawCard>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawCard {
|
||||
asset_id: u32,
|
||||
#[serde(default)]
|
||||
version: u8,
|
||||
/// Absent in a base-only catalog → default 1 (rare), preserving prior wire
|
||||
/// behaviour; the production catalog carries the observed value.
|
||||
#[serde(default = "default_rareflag")]
|
||||
rareflag: i64,
|
||||
/// Content class token ("player"|"consumable"|"staff"). Absent → default
|
||||
/// (empty) → [`ContentKind::Player`], so existing player-only catalogs load
|
||||
/// unchanged.
|
||||
#[serde(default)]
|
||||
kind: String,
|
||||
/// FIFA `cardsubtypeid` for a non-player entry; absent → `0`.
|
||||
#[serde(default)]
|
||||
subtype: i64,
|
||||
/// Separate card-art id for non-player definitions; absent → `asset_id`.
|
||||
#[serde(default)]
|
||||
card_asset_id: Option<u32>,
|
||||
/// Source team id for a kit or manager definition; absent → `0`.
|
||||
#[serde(default)]
|
||||
team_id: Option<i64>,
|
||||
/// Manager chemistry nation; absent → `0`.
|
||||
#[serde(default)]
|
||||
nation: Option<i64>,
|
||||
/// Manager chemistry league; absent → `0`.
|
||||
#[serde(default)]
|
||||
league_id: Option<i64>,
|
||||
/// EA-authored rating for a non-player definition; absent → Core's rating.
|
||||
#[serde(default)]
|
||||
rating: Option<u8>,
|
||||
/// Consumable bonus magnitude (atom 0x1b); absent → key omitted.
|
||||
#[serde(default)]
|
||||
amount: Option<i64>,
|
||||
/// Contract-card grant (atom 0xb8); absent → key omitted.
|
||||
#[serde(default)]
|
||||
contract: Option<i64>,
|
||||
}
|
||||
|
||||
fn default_rareflag() -> i64 {
|
||||
1
|
||||
}
|
||||
|
||||
/// A loaded, validated FIFA 17 card-definition identity catalog.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Fifa17CardCatalog {
|
||||
by_card: HashMap<String, Fifa17CardIdentity>,
|
||||
/// Reverse index: full versioned `resource_id` → the card definition id. The
|
||||
/// wire carries a `resourceId`; the synthetic market must mint the
|
||||
/// authoritative Core `card_id`, never the raw FIFA number.
|
||||
by_resource: HashMap<u32, String>,
|
||||
}
|
||||
|
||||
impl Fifa17CardCatalog {
|
||||
/// Parse + validate a catalog document. Rejects wrong schema/game, an
|
||||
/// asset id that would overflow into the version byte, and two card ids
|
||||
/// claiming the same `resource_id` (a semantic-vs-FIFA identity conflict).
|
||||
pub fn from_json_str(s: &str) -> Result<Self, CatalogError> {
|
||||
let raw: RawCatalog =
|
||||
serde_json::from_str(s).map_err(|e| CatalogError::Parse(e.to_string()))?;
|
||||
if raw.schema_version != SCHEMA_VERSION {
|
||||
return Err(CatalogError::BadSchemaVersion(raw.schema_version));
|
||||
}
|
||||
if raw.game != Fifa17WireItemIdPolicy::GAME {
|
||||
return Err(CatalogError::WrongGame(raw.game));
|
||||
}
|
||||
let mut by_card = HashMap::new();
|
||||
let mut by_resource: HashMap<u32, String> = HashMap::new();
|
||||
for (card_id, rc) in raw.cards {
|
||||
if rc.asset_id > MAX_ASSET_ID {
|
||||
return Err(CatalogError::AssetTooLarge {
|
||||
card_id,
|
||||
asset_id: rc.asset_id,
|
||||
});
|
||||
}
|
||||
let resource_id = ((rc.version as u32) << 24) | rc.asset_id;
|
||||
if let Some(first) = by_resource.get(&resource_id) {
|
||||
return Err(CatalogError::DuplicateResource {
|
||||
resource_id,
|
||||
first: first.clone(),
|
||||
second: card_id,
|
||||
});
|
||||
}
|
||||
by_resource.insert(resource_id, card_id.clone());
|
||||
by_card.insert(
|
||||
card_id,
|
||||
Fifa17CardIdentity {
|
||||
asset_id: rc.asset_id,
|
||||
version: rc.version,
|
||||
resource_id,
|
||||
rareflag: rc.rareflag,
|
||||
kind: ContentKind::from_str(&rc.kind),
|
||||
subtype: rc.subtype,
|
||||
card_asset_id: rc.card_asset_id.unwrap_or(rc.asset_id),
|
||||
team_id: rc.team_id.unwrap_or(0),
|
||||
nation: rc.nation.unwrap_or(0),
|
||||
league_id: rc.league_id.unwrap_or(0),
|
||||
rating: rc.rating,
|
||||
amount: rc.amount,
|
||||
contract: rc.contract,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(Fifa17CardCatalog {
|
||||
by_card,
|
||||
by_resource,
|
||||
})
|
||||
}
|
||||
|
||||
/// Load a catalog from a JSON file.
|
||||
pub fn from_file(path: &std::path::Path) -> Result<Self, CatalogError> {
|
||||
let raw = std::fs::read_to_string(path)
|
||||
.map_err(|e| CatalogError::Parse(format!("reading {}: {e}", path.display())))?;
|
||||
Self::from_json_str(&raw)
|
||||
}
|
||||
|
||||
/// The FIFA 17 identity for a definition, or `None` (never a fabricated id).
|
||||
pub fn lookup(&self, card_id: &str) -> Option<Fifa17CardIdentity> {
|
||||
self.by_card.get(card_id).copied()
|
||||
}
|
||||
|
||||
/// Reverse a FIFA wire `resource_id` (full versioned id) to its authoritative
|
||||
/// Core `card_id`, or `None` (never a fabricated/heuristic id). Used by the
|
||||
/// synthetic transfer market so a purchase mints real Core content.
|
||||
pub fn card_id_for_resource(&self, resource_id: u32) -> Option<&str> {
|
||||
self.by_resource.get(&resource_id).map(String::as_str)
|
||||
}
|
||||
|
||||
/// Classify a `card_id` as player/consumable/staff. An unknown definition is
|
||||
/// [`ContentKind::Player`] — the neutral, backward-compatible default (an
|
||||
/// un-catalogued id was always treated as a player-shaped card).
|
||||
pub fn kind_of(&self, card_id: &str) -> ContentKind {
|
||||
self.by_card
|
||||
.get(card_id)
|
||||
.map(|c| c.kind)
|
||||
.unwrap_or(ContentKind::Player)
|
||||
}
|
||||
|
||||
/// The FIFA `cardsubtypeid` for a definition, or `0` if unknown / a player.
|
||||
pub fn subtype_of(&self, card_id: &str) -> i64 {
|
||||
self.by_card.get(card_id).map(|c| c.subtype).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.by_card.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.by_card.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resource_id_composition_base_and_special() {
|
||||
let cat = Fifa17CardCatalog::from_json_str(
|
||||
r#"{"schema_version":1,"game":"fifa17","cards":{
|
||||
"card_base":{"asset_id":20801},
|
||||
"card_totw":{"asset_id":20801,"version":3}
|
||||
}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
// version 0 -> resource_id == asset_id
|
||||
let base = cat.lookup("card_base").unwrap();
|
||||
assert_eq!(base.version, 0);
|
||||
assert_eq!(base.resource_id, 20801);
|
||||
assert_eq!(base.resource_id, base.asset_id);
|
||||
// version 3 -> high byte set; same base player, different FIFA card
|
||||
let totw = cat.lookup("card_totw").unwrap();
|
||||
assert_eq!(totw.asset_id, 20801, "asset_id (base player) unchanged");
|
||||
assert_eq!(totw.resource_id, (3u32 << 24) | 20801);
|
||||
assert_ne!(base.resource_id, totw.resource_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_card_is_none() {
|
||||
let cat = Fifa17CardCatalog::from_json_str(
|
||||
r#"{"schema_version":1,"game":"fifa17","cards":{"card_base":{"asset_id":1}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cat.lookup("card_missing"), None);
|
||||
}
|
||||
|
||||
/// The non-player definition fields a consumable needs, and the ABSENCE that
|
||||
/// must stay an absence: a defaulted `amount` would draw "-1" on the card and
|
||||
/// a defaulted `contract` would invent the number of matches a card grants.
|
||||
#[test]
|
||||
fn consumable_definition_fields_are_carried_and_never_defaulted() {
|
||||
let cat = Fifa17CardCatalog::from_json_str(
|
||||
r#"{"schema_version":1,"game":"fifa17","cards":{
|
||||
"fifa17_5003012":{"asset_id":5003012,"kind":"consumable","subtype":54,
|
||||
"card_asset_id":3,"rareflag":0,"rating":85,"amount":15},
|
||||
"fifa17_5001004":{"asset_id":5001004,"kind":"consumable","subtype":201,
|
||||
"card_asset_id":7,"rareflag":0,"rating":60,"contract":7},
|
||||
"fifa17_5003059":{"asset_id":5003059,"kind":"consumable","subtype":91,
|
||||
"card_asset_id":34,"rareflag":0,"rating":95},
|
||||
"fifa17_20801":{"asset_id":20801}
|
||||
}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
// A training card: art id 3 (NOT the carddbid), EA's rating, amount 15.
|
||||
let training = cat.lookup("fifa17_5003012").unwrap();
|
||||
assert_eq!(training.kind, ContentKind::Consumable);
|
||||
assert_eq!(training.subtype, 54);
|
||||
assert_eq!(training.card_asset_id, 3);
|
||||
assert_eq!(training.rating, Some(85));
|
||||
assert_eq!(training.amount, Some(15));
|
||||
assert_eq!(training.contract, None);
|
||||
// A contract card takes its number from `contract`, not `amount`.
|
||||
let contract = cat.lookup("fifa17_5001004").unwrap();
|
||||
assert_eq!(contract.contract, Some(7));
|
||||
assert_eq!(contract.amount, None);
|
||||
// A position modifier needs neither.
|
||||
let position = cat.lookup("fifa17_5003059").unwrap();
|
||||
assert_eq!(position.amount, None);
|
||||
assert_eq!(position.contract, None);
|
||||
assert_eq!(position.card_asset_id, 34);
|
||||
// A player carries none of them and keeps Core's authoritative rating.
|
||||
let player = cat.lookup("fifa17_20801").unwrap();
|
||||
assert_eq!(player.kind, ContentKind::Player);
|
||||
assert_eq!(player.rating, None);
|
||||
assert_eq!(player.amount, None);
|
||||
assert_eq!(player.contract, None);
|
||||
assert_eq!(
|
||||
player.card_asset_id, player.asset_id,
|
||||
"a player's card art IS its asset id"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_cards_same_resource_is_a_conflict() {
|
||||
let err = Fifa17CardCatalog::from_json_str(
|
||||
r#"{"schema_version":1,"game":"fifa17","cards":{
|
||||
"card_a":{"asset_id":20801},
|
||||
"card_b":{"asset_id":20801}
|
||||
}}"#,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
CatalogError::DuplicateResource {
|
||||
resource_id: 20801,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_and_game_are_validated() {
|
||||
assert_eq!(
|
||||
Fifa17CardCatalog::from_json_str(r#"{"schema_version":2,"game":"fifa17","cards":{}}"#)
|
||||
.unwrap_err(),
|
||||
CatalogError::BadSchemaVersion(2)
|
||||
);
|
||||
assert_eq!(
|
||||
Fifa17CardCatalog::from_json_str(r#"{"schema_version":1,"game":"fifa23","cards":{}}"#)
|
||||
.unwrap_err(),
|
||||
CatalogError::WrongGame("fifa23".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_exceeding_24_bits_is_rejected() {
|
||||
let err = Fifa17CardCatalog::from_json_str(
|
||||
r#"{"schema_version":1,"game":"fifa17","cards":{"c":{"asset_id":16777216}}}"#,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
CatalogError::AssetTooLarge {
|
||||
asset_id: 16_777_216,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_json_is_a_parse_error() {
|
||||
assert!(matches!(
|
||||
Fifa17CardCatalog::from_json_str("{not json").unwrap_err(),
|
||||
CatalogError::Parse(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_reload() {
|
||||
let doc = r#"{"schema_version":1,"game":"fifa17","cards":{"a":{"asset_id":10},"b":{"asset_id":20,"version":1}}}"#;
|
||||
let c1 = Fifa17CardCatalog::from_json_str(doc).unwrap();
|
||||
let c2 = Fifa17CardCatalog::from_json_str(doc).unwrap();
|
||||
assert_eq!(c1.lookup("a"), c2.lookup("a"));
|
||||
assert_eq!(c1.lookup("b"), c2.lookup("b"));
|
||||
assert_eq!(c1.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_id_policy_constants() {
|
||||
assert_eq!(Fifa17WireItemIdPolicy::GAME, "fifa17");
|
||||
assert_eq!(Fifa17WireItemIdPolicy::OWNED_ITEM_KIND, "owned-item");
|
||||
assert_eq!(Fifa17WireItemIdPolicy::owned_item_base_floor(), 100_000_001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_the_committed_generated_catalog() {
|
||||
// The generated base-card catalog (scripts/seed_fifa17_cards.py) must be
|
||||
// loadable by this adapter and carry real asset identities.
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("data/fifa17-card-identities.json");
|
||||
if !path.exists() {
|
||||
eprintln!("skip: {} not generated", path.display());
|
||||
return;
|
||||
}
|
||||
let cat = Fifa17CardCatalog::from_file(&path).expect("load committed catalog");
|
||||
assert!(
|
||||
cat.len() > 17_000,
|
||||
"full FIFA17 base pool, got {}",
|
||||
cat.len()
|
||||
);
|
||||
// Ronaldo (asset 20801), version 0 => resource_id == asset_id.
|
||||
let ron = cat
|
||||
.lookup("fifa17_20801")
|
||||
.expect("known base asset present");
|
||||
assert_eq!(ron.asset_id, 20801);
|
||||
assert_eq!(ron.version, 0);
|
||||
assert_eq!(ron.resource_id, 20801);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_catalog_without_kind_loads_as_player() {
|
||||
// A pre-taxonomy catalog (no `kind`/`subtype`) must load unchanged and
|
||||
// classify every entry as a player, with subtype 0.
|
||||
let cat = Fifa17CardCatalog::from_json_str(
|
||||
r#"{"schema_version":1,"game":"fifa17","cards":{
|
||||
"fifa17_20801":{"asset_id":20801},
|
||||
"fifa17_176580":{"asset_id":176580,"version":5,"rareflag":3}
|
||||
}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let base = cat.lookup("fifa17_20801").unwrap();
|
||||
assert_eq!(base.kind, ContentKind::Player);
|
||||
assert_eq!(base.subtype, 0);
|
||||
assert_eq!(base.rareflag, 1, "absent rareflag still defaults to 1");
|
||||
assert_eq!(cat.kind_of("fifa17_20801"), ContentKind::Player);
|
||||
assert_eq!(cat.kind_of("fifa17_176580"), ContentKind::Player);
|
||||
// Unknown id -> neutral Player default.
|
||||
assert_eq!(cat.kind_of("fifa17_missing"), ContentKind::Player);
|
||||
assert_eq!(cat.subtype_of("fifa17_missing"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_and_subtype_are_parsed_for_non_player_entries() {
|
||||
let cat = Fifa17CardCatalog::from_json_str(
|
||||
r#"{"schema_version":1,"game":"fifa17","cards":{
|
||||
"fifa17_20801":{"asset_id":20801,"kind":"player","subtype":0},
|
||||
"fifa17_5003012":{"asset_id":5003012,"kind":"consumable","subtype":54,"rareflag":0},
|
||||
"fifa17_3000083":{"asset_id":3000083,"kind":"staff","subtype":8,"rareflag":0},
|
||||
"fifa17_6300006":{"asset_id":6300006,"kind":"kit","subtype":9,
|
||||
"card_asset_id":35,"team_id":21,"rareflag":0}
|
||||
}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cat.kind_of("fifa17_20801"), ContentKind::Player);
|
||||
assert_eq!(cat.kind_of("fifa17_5003012"), ContentKind::Consumable);
|
||||
assert_eq!(cat.subtype_of("fifa17_5003012"), 54);
|
||||
assert_eq!(cat.kind_of("fifa17_3000083"), ContentKind::Staff);
|
||||
assert_eq!(cat.subtype_of("fifa17_3000083"), 8);
|
||||
assert_eq!(cat.lookup("fifa17_5003012").unwrap().rareflag, 0);
|
||||
let kit = cat.lookup("fifa17_6300006").unwrap();
|
||||
assert_eq!(kit.kind, ContentKind::Kit);
|
||||
assert_eq!(kit.subtype, 9);
|
||||
assert_eq!(kit.card_asset_id, 35);
|
||||
assert_eq!(kit.team_id, 21);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
//! Shape OpenFUT Core's semantic owned inventory into the FIFA 17 `/club`
|
||||
//! response envelope `{"itemData":[ <player item>, … ]}`.
|
||||
//!
|
||||
//! This module owns only the **`/club` envelope**; the per-item shape lives in
|
||||
//! the shared [`crate::fut::item`] primitive so `/club` and squad projection
|
||||
//! emit byte-identical items. Items whose real FIFA asset id is unknown are
|
||||
//! **dropped and counted** here (a collection may omit an unrenderable card);
|
||||
//! squad projection, which cannot omit a starter, refuses instead.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::content_taxonomy::ContentKind;
|
||||
use crate::fut::entities::ReverseEntityResolver;
|
||||
use crate::fut::item::{shape_item, shape_kit_item, shape_staff_item, STAFF_CONTRACT};
|
||||
use crate::fut::item_state;
|
||||
// Re-exported so existing `club_response::{…}` callers keep working; the types
|
||||
// are now defined once in `fut::item`.
|
||||
pub use crate::fut::item::{
|
||||
CoreOwnedItem, Fifa17ConsumableIdentity, Fifa17Identity, Fifa17KitIdentity,
|
||||
Fifa17StaffIdentity, ItemIdentityResolver, ShapeStats,
|
||||
};
|
||||
|
||||
/// Active club-level kit roles, keyed by Core owned-instance id.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct ActiveKitAssignments<'a> {
|
||||
pub home: Option<&'a str>,
|
||||
pub away: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// Shape the player portion of `/club` (the historical/default query).
|
||||
pub fn shape_club_response<I: ItemIdentityResolver + ?Sized>(
|
||||
items: &[CoreOwnedItem],
|
||||
ent: &impl ReverseEntityResolver,
|
||||
ident: &I,
|
||||
) -> (Value, ShapeStats) {
|
||||
shape_club_response_with_kits(items, ent, ident, ActiveKitAssignments::default())
|
||||
}
|
||||
|
||||
/// Shape `/club` items, including ownership-backed active kit designations.
|
||||
///
|
||||
/// This envelope carries the two families whose record shape it can carry:
|
||||
/// players and kits, plus the staff family (manager + the four coach families).
|
||||
/// Consumables have their own route and their own STACK envelope, and the
|
||||
/// club-customisation families are counted and withheld — see each arm.
|
||||
pub fn shape_club_response_with_kits<I: ItemIdentityResolver + ?Sized>(
|
||||
items: &[CoreOwnedItem],
|
||||
ent: &impl ReverseEntityResolver,
|
||||
ident: &I,
|
||||
active_kits: ActiveKitAssignments<'_>,
|
||||
) -> (Value, ShapeStats) {
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
let mut stats = ShapeStats::default();
|
||||
for item in items {
|
||||
match ident.kind_of(item) {
|
||||
ContentKind::Player => match ident.resolve(item) {
|
||||
Some(id) => {
|
||||
out.push(shape_item(item, id, ent));
|
||||
stats.emitted += 1;
|
||||
}
|
||||
None => stats.dropped_no_asset += 1,
|
||||
},
|
||||
ContentKind::Kit => match ident.resolve_kit(item) {
|
||||
Some(id) => {
|
||||
let state = if active_kits.home == Some(item.owned_card_id.as_str()) {
|
||||
item_state::ACTIVE_HOME_KIT
|
||||
} else if active_kits.away == Some(item.owned_card_id.as_str()) {
|
||||
item_state::ACTIVE_AWAY_KIT
|
||||
} else {
|
||||
item_state::FREE
|
||||
};
|
||||
out.push(shape_kit_item(id, state));
|
||||
stats.emitted += 1;
|
||||
}
|
||||
None => stats.dropped_no_asset += 1,
|
||||
},
|
||||
// A manager is a staff card: both Core kinds resolve through the one
|
||||
// staff record shape, discriminated on the wire by `cardsubtypeid`
|
||||
// (the same set as `ContentKind::is_staff_family`, spelled out here
|
||||
// because a guard arm would not prove exhaustiveness).
|
||||
ContentKind::Manager | ContentKind::Staff => match ident.resolve_staff(item) {
|
||||
Some(id) => {
|
||||
out.push(shape_staff_item(id, STAFF_CONTRACT));
|
||||
stats.emitted += 1;
|
||||
}
|
||||
None => stats.dropped_no_asset += 1,
|
||||
},
|
||||
// Consumables have their OWN route and their own envelope:
|
||||
// `GET club/consumables/<category>`, whose element is a stack
|
||||
// wrapper, not an item (see [`crate::fut::consumables`]). A bare
|
||||
// consumable item in THIS envelope is accepted by the client and
|
||||
// silently discarded, so emitting one here would be a 200 that does
|
||||
// nothing — the worst failure shape in this project. Counted.
|
||||
ContentKind::Consumable => {
|
||||
stats.excluded_non_player += 1;
|
||||
}
|
||||
// Club customisation. The SUBTYPES are settled (kit 9, stadium 10,
|
||||
// badge 11, ball 30, league logo 31), but the RECORD is not: a
|
||||
// badge/stadium still needs the narrow `teamid`/`assetId` test that
|
||||
// the 2026-08-05 crash denied us, and a ball has no display name at
|
||||
// all except `localizedName`, which this project's own rule scores as
|
||||
// "the parser reads it" and NOT "sending it is safe". Counted and
|
||||
// withheld rather than guessed — ownership stays authoritative in
|
||||
// Core either way, and club/stats still counts these families so the
|
||||
// screen's own numbers are right.
|
||||
ContentKind::Badge | ContentKind::Ball | ContentKind::Stadium | ContentKind::Misc => {
|
||||
stats.excluded_non_player += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
(json!({ "itemData": out }), stats)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::fut::entities::Fifa17Entities;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn entities() -> Fifa17Entities {
|
||||
Fifa17Entities::from_maps(
|
||||
HashMap::from([(13, "Premier League".to_string())]),
|
||||
HashMap::from([(52, "Argentina".to_string())]),
|
||||
HashMap::from([(5, "Chelsea".to_string())]),
|
||||
)
|
||||
}
|
||||
|
||||
fn item(
|
||||
owned: &str,
|
||||
card: &str,
|
||||
rating: u8,
|
||||
pos: &str,
|
||||
nation: &str,
|
||||
league: &str,
|
||||
club: &str,
|
||||
) -> CoreOwnedItem {
|
||||
CoreOwnedItem {
|
||||
owned_card_id: owned.into(),
|
||||
card_id: card.into(),
|
||||
rating,
|
||||
position: pos.into(),
|
||||
nation: nation.into(),
|
||||
league: league.into(),
|
||||
club: club.into(),
|
||||
attributes: [90, 88, 70, 85, 40, 78],
|
||||
}
|
||||
}
|
||||
|
||||
/// Test resolver: card_id -> real asset id, item_id from a table. Stands in
|
||||
/// for the (unresolved-in-production) Core-card→asset mapping.
|
||||
struct MapIdentity(HashMap<String, Fifa17Identity>);
|
||||
impl ItemIdentityResolver for MapIdentity {
|
||||
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
|
||||
self.0.get(&it.card_id).copied()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_item_with_full_field_set_and_reverse_ids() {
|
||||
let ent = entities();
|
||||
let ident = MapIdentity(HashMap::from([(
|
||||
"card_ch_1".to_string(),
|
||||
Fifa17Identity {
|
||||
item_id: 100000001,
|
||||
asset_id: 20801,
|
||||
resource_id: 20801,
|
||||
rareflag: 1,
|
||||
},
|
||||
)]));
|
||||
let items = vec![item(
|
||||
"oc1",
|
||||
"card_ch_1",
|
||||
86,
|
||||
"CDM",
|
||||
"Argentina",
|
||||
"Premier League",
|
||||
"Chelsea",
|
||||
)];
|
||||
let (body, stats) = shape_club_response(&items, &ent, &ident);
|
||||
assert_eq!(stats.emitted, 1);
|
||||
assert_eq!(stats.dropped_no_asset, 0);
|
||||
let it = &body["itemData"][0];
|
||||
assert_eq!(it["id"], 100000001);
|
||||
assert_eq!(it["resourceId"], 20801);
|
||||
assert_eq!(it["assetId"], 20801);
|
||||
assert_eq!(
|
||||
it["definitionId"], 20801,
|
||||
"version byte 0 => resourceId==assetId==definitionId"
|
||||
);
|
||||
assert_eq!(it["rating"], 86);
|
||||
assert_eq!(it["preferredPosition"], "CDM");
|
||||
assert_eq!(it["leagueId"], 13);
|
||||
assert_eq!(it["teamid"], 5);
|
||||
assert_eq!(it["nation"], 52);
|
||||
assert_eq!(it["itemType"], "player");
|
||||
assert_eq!(it["rareflag"], 1);
|
||||
assert_eq!(it["contract"], 7);
|
||||
assert_eq!(it["fitness"], 99);
|
||||
assert_eq!(it["attributeList"].as_array().unwrap().len(), 6);
|
||||
assert_eq!(it["attributeList"][0], json!({"index":0,"value":90}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_items_without_a_real_asset_id_never_faking() {
|
||||
let ent = entities();
|
||||
// Empty identity map == the current synthetic-catalogue reality.
|
||||
let ident = MapIdentity(HashMap::new());
|
||||
let items = vec![item(
|
||||
"oc1",
|
||||
"card_pl_001",
|
||||
84,
|
||||
"ST",
|
||||
"England",
|
||||
"Premier League",
|
||||
"Northgate United",
|
||||
)];
|
||||
let (body, stats) = shape_club_response(&items, &ent, &ident);
|
||||
assert_eq!(stats.emitted, 0);
|
||||
assert_eq!(stats.dropped_no_asset, 1);
|
||||
assert_eq!(
|
||||
body["itemData"].as_array().unwrap().len(),
|
||||
0,
|
||||
"no fabricated ids emitted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolved_entity_names_become_neutral_zero_not_dropped() {
|
||||
let ent = entities();
|
||||
let ident = MapIdentity(HashMap::from([(
|
||||
"card_x".to_string(),
|
||||
Fifa17Identity {
|
||||
item_id: 100000002,
|
||||
asset_id: 158023,
|
||||
resource_id: 158023,
|
||||
rareflag: 1,
|
||||
},
|
||||
)]));
|
||||
// Synthetic club "Northgate United" has no FIFA team id.
|
||||
let items = vec![item(
|
||||
"oc2",
|
||||
"card_x",
|
||||
84,
|
||||
"ST",
|
||||
"England",
|
||||
"Premier League",
|
||||
"Northgate United",
|
||||
)];
|
||||
let (body, _) = shape_club_response(&items, &ent, &ident);
|
||||
let it = &body["itemData"][0];
|
||||
assert_eq!(
|
||||
it["teamid"], 0,
|
||||
"unknown club -> neutral 0, item still emitted"
|
||||
);
|
||||
assert_eq!(it["leagueId"], 13);
|
||||
assert_eq!(it["nation"], 0, "England not in the test nation map -> 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_is_itemdata_object() {
|
||||
let ent = entities();
|
||||
let ident = MapIdentity(HashMap::new());
|
||||
let (body, _) = shape_club_response(&[], &ent, &ident);
|
||||
assert!(body.get("itemData").unwrap().is_array());
|
||||
assert_eq!(
|
||||
body.as_object().unwrap().len(),
|
||||
1,
|
||||
"only itemData at top level"
|
||||
);
|
||||
}
|
||||
|
||||
/// A resolver that resolves an asset id for EVERY item (so exclusion is not
|
||||
/// an artifact of a missing asset) but classifies some card_ids as non-player
|
||||
/// via an explicit kind table.
|
||||
struct KindMapIdentity {
|
||||
ids: HashMap<String, Fifa17Identity>,
|
||||
kinds: HashMap<String, ContentKind>,
|
||||
kits: HashMap<String, Fifa17KitIdentity>,
|
||||
staff: HashMap<String, Fifa17StaffIdentity>,
|
||||
}
|
||||
impl ItemIdentityResolver for KindMapIdentity {
|
||||
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
|
||||
self.ids.get(&it.card_id).copied()
|
||||
}
|
||||
fn resolve_kit(&self, it: &CoreOwnedItem) -> Option<Fifa17KitIdentity> {
|
||||
self.kits.get(&it.card_id).copied()
|
||||
}
|
||||
fn resolve_staff(&self, it: &CoreOwnedItem) -> Option<Fifa17StaffIdentity> {
|
||||
self.staff.get(&it.card_id).copied()
|
||||
}
|
||||
fn kind_of(&self, it: &CoreOwnedItem) -> ContentKind {
|
||||
self.kinds
|
||||
.get(&it.card_id)
|
||||
.copied()
|
||||
.unwrap_or(ContentKind::Player)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumables_are_excluded_but_staff_is_shaped() {
|
||||
let ent = entities();
|
||||
let id = |item_id: u32, asset: u32| Fifa17Identity {
|
||||
item_id,
|
||||
asset_id: asset,
|
||||
resource_id: asset,
|
||||
rareflag: 1,
|
||||
};
|
||||
let ident = KindMapIdentity {
|
||||
ids: HashMap::from([
|
||||
("card_player".to_string(), id(100000001, 20801)),
|
||||
("card_consumable".to_string(), id(100000002, 5003012)),
|
||||
]),
|
||||
kits: HashMap::new(),
|
||||
staff: HashMap::from([(
|
||||
"card_staff".to_string(),
|
||||
Fifa17StaffIdentity {
|
||||
item_id: 100000003,
|
||||
resource_id: 3000083,
|
||||
subtype: 8,
|
||||
nation: 0,
|
||||
league_id: 0,
|
||||
team_id: 0,
|
||||
},
|
||||
)]),
|
||||
kinds: HashMap::from([
|
||||
("card_consumable".to_string(), ContentKind::Consumable),
|
||||
("card_staff".to_string(), ContentKind::Staff),
|
||||
]),
|
||||
};
|
||||
let items = vec![
|
||||
item(
|
||||
"oc1",
|
||||
"card_player",
|
||||
86,
|
||||
"ST",
|
||||
"Argentina",
|
||||
"Premier League",
|
||||
"Chelsea",
|
||||
),
|
||||
item("oc2", "card_consumable", 0, "", "", "", ""),
|
||||
item("oc3", "card_staff", 0, "", "", "", ""),
|
||||
];
|
||||
let (body, stats) = shape_club_response(&items, &ent, &ident);
|
||||
assert_eq!(
|
||||
stats.emitted, 2,
|
||||
"the player and the staff card are emitted"
|
||||
);
|
||||
assert_eq!(
|
||||
stats.excluded_non_player, 1,
|
||||
"only the consumable is excluded; staff has a wire envelope of its own"
|
||||
);
|
||||
assert_eq!(stats.dropped_no_asset, 0);
|
||||
let arr = body["itemData"].as_array().unwrap();
|
||||
assert_eq!(arr.len(), 2);
|
||||
assert_eq!(arr[0]["id"], 100000001, "the player survives");
|
||||
assert_eq!(arr[0]["itemType"], "player");
|
||||
let coach = &arr[1];
|
||||
assert_eq!(coach["id"], 100000003);
|
||||
assert_eq!(coach["resourceId"], 3000083);
|
||||
assert_eq!(coach["cardsubtypeid"], 8);
|
||||
assert_eq!(coach["itemType"], "staff");
|
||||
assert_eq!(coach["contract"], STAFF_CONTRACT);
|
||||
assert!(
|
||||
coach.get("nation").is_none()
|
||||
&& coach.get("leagueId").is_none()
|
||||
&& coach.get("teamid").is_none(),
|
||||
"a COACH has no nation/league/team column in the client's tables, so \
|
||||
those keys must be absent rather than invented as zeroes"
|
||||
);
|
||||
assert!(
|
||||
coach.get("attributeList").is_none() && coach.get("preferredPosition").is_none(),
|
||||
"both survive the client's merge and are read by the card view-model"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_carries_the_chemistry_fields_only_the_server_can_supply() {
|
||||
let ent = entities();
|
||||
let ident = KindMapIdentity {
|
||||
ids: HashMap::new(),
|
||||
kits: HashMap::new(),
|
||||
staff: HashMap::from([(
|
||||
"card_manager".to_string(),
|
||||
Fifa17StaffIdentity {
|
||||
item_id: 100004871,
|
||||
resource_id: 1000509,
|
||||
subtype: 4,
|
||||
nation: 45,
|
||||
league_id: 53,
|
||||
team_id: 241,
|
||||
},
|
||||
)]),
|
||||
kinds: HashMap::from([("card_manager".to_string(), ContentKind::Staff)]),
|
||||
};
|
||||
let items = vec![item("oc-mgr", "card_manager", 0, "", "", "", "")];
|
||||
let (body, stats) = shape_club_response(&items, &ent, &ident);
|
||||
assert_eq!(stats.emitted, 1);
|
||||
let mgr = &body["itemData"][0];
|
||||
assert_eq!(
|
||||
mgr["cardsubtypeid"], 4,
|
||||
"subtype alone selects managercards"
|
||||
);
|
||||
assert_eq!(
|
||||
mgr["resourceId"], 1000509,
|
||||
"the merge key is read RAW: it must equal the carddbid with no version byte"
|
||||
);
|
||||
// rec+0xde / rec+0xe0 / rec+0x94 — the merge never writes these, so an
|
||||
// omission here is an unrecoverable blank flag and zero chemistry.
|
||||
assert_eq!(mgr["nation"], 45);
|
||||
assert_eq!(mgr["leagueId"], 53);
|
||||
assert_eq!(mgr["teamid"], 241);
|
||||
assert_eq!(mgr["contract"], STAFF_CONTRACT);
|
||||
assert_eq!(mgr["itemState"], "free");
|
||||
assert_eq!(mgr["owners"], 1);
|
||||
let keys: Vec<&String> = mgr.as_object().unwrap().keys().collect();
|
||||
assert_eq!(
|
||||
keys.len(),
|
||||
11,
|
||||
"exactly the 11 justified keys, no more: {keys:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kits_project_with_owned_active_home_and_away_states() {
|
||||
let ent = entities();
|
||||
let kit = |item_id, resource_id, team_id| Fifa17KitIdentity {
|
||||
item_id,
|
||||
asset_id: resource_id,
|
||||
resource_id,
|
||||
card_asset_id: 35,
|
||||
subtype: 9,
|
||||
team_id,
|
||||
};
|
||||
let ident = KindMapIdentity {
|
||||
ids: HashMap::new(),
|
||||
staff: HashMap::new(),
|
||||
kits: HashMap::from([
|
||||
("kit-home".into(), kit(100000010, 6300006, 21)),
|
||||
("kit-away".into(), kit(100000011, 6400003, 21)),
|
||||
]),
|
||||
kinds: HashMap::from([
|
||||
("kit-home".into(), ContentKind::Kit),
|
||||
("kit-away".into(), ContentKind::Kit),
|
||||
]),
|
||||
};
|
||||
let items = vec![
|
||||
item("owned-home", "kit-home", 0, "", "", "", ""),
|
||||
item("owned-away", "kit-away", 0, "", "", "", ""),
|
||||
];
|
||||
let (body, stats) = shape_club_response_with_kits(
|
||||
&items,
|
||||
&ent,
|
||||
&ident,
|
||||
ActiveKitAssignments {
|
||||
home: Some("owned-home"),
|
||||
away: Some("owned-away"),
|
||||
},
|
||||
);
|
||||
assert_eq!(stats.emitted, 2);
|
||||
assert_eq!(body["itemData"][0]["resourceId"], 6300006);
|
||||
assert_eq!(body["itemData"][0]["cardassetid"], 35);
|
||||
assert_eq!(body["itemData"][0]["cardsubtypeid"], 9);
|
||||
assert_eq!(body["itemData"][0]["teamid"], 21);
|
||||
assert_eq!(body["itemData"][0]["itemState"], "activeHomeKit");
|
||||
assert_eq!(body["itemData"][1]["itemState"], "activeAwayKit");
|
||||
assert!(body["itemData"][0].get("attributeList").is_none());
|
||||
assert!(body["itemData"][0].get("itemType").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
//! FIFA17 MY CLUB stat set (`GET …/club/stats/{year,consumables}`), computed
|
||||
//! from OpenFUT Core's authoritative owned inventory.
|
||||
//!
|
||||
//! Faithful port of the Python oracle's `fut_club_stats.py` (`global_counts` +
|
||||
//! `context_rows` + `stats_body`), which is itself censused from CardsDLL
|
||||
//! (`FUN_18012fd40` atom table). The body is `{"stat":[{contextId,contextValue,
|
||||
//! type,typeValue}, …]}`:
|
||||
//! * a GLOBAL bucket (contextId 1, contextValue 0) with player tier counts,
|
||||
//! staff/consumable families, owned-kit count, and honest zeros for other
|
||||
//! club-item families;
|
||||
//! * per-NATION buckets (contextId 3, contextValue = nation id) with the tier
|
||||
//! counts the MY CLUB summary panel sums into PLAYERS_EMPLOYED.
|
||||
//!
|
||||
//! Unlike the oracle (which counts its own stale profile + a synthetic consumable
|
||||
//! shelf), this counts Core — so staff and consumable families reflect the real
|
||||
//! imported content. Unrecognized atoms are inert in the client, so this is a
|
||||
//! low-risk cosmetic surface; the tier/staff/consumable atoms are the ones the
|
||||
//! screen reads and they are Core-accurate here.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::content_taxonomy::{consumable_family, ContentKind};
|
||||
|
||||
/// One owned item, already classified from the catalog + entity tables by the
|
||||
/// host. `subtype`/`rare`/`asset_id` come from the FIFA catalog; `nation_id`/
|
||||
/// `league_id`/`team_id` from the reverse entity resolver for players and from
|
||||
/// the kit table for kits (None = unresolved, bucket skipped).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClubStatInput {
|
||||
pub kind: ContentKind,
|
||||
pub subtype: i64,
|
||||
pub rating: i64,
|
||||
pub rare: bool,
|
||||
/// Base FIFA asset id. For a kit this is the `fcc_kitcards.assetid` family
|
||||
/// discriminator, which is what splits the home/away kit counters.
|
||||
pub asset_id: i64,
|
||||
pub nation_id: Option<i64>,
|
||||
pub league_id: Option<i64>,
|
||||
pub team_id: Option<i64>,
|
||||
}
|
||||
|
||||
/// Which entity the per-context (`contextId 3`) buckets are keyed by — the FIFA
|
||||
/// `MY CLUB` sub-screen selector (`fut_club_stats.py::context_rows`):
|
||||
/// * `Nation` — the default screen (year/consumables/club/newcards): nation buckets.
|
||||
/// * `League` — URL `club/stats/country/<id>`: league (leagueId) buckets, tier stats.
|
||||
/// * `Team` — URL `club/stats/league/<id>`: team (teamid) buckets, players/kits/badge.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ContextField {
|
||||
Nation,
|
||||
League,
|
||||
Team,
|
||||
}
|
||||
|
||||
// Stat ids (CardsDLL atom table, fut_club_stats.py VOCAB).
|
||||
const S_PLAYERS: i64 = 0x01;
|
||||
const S_BRONZE: i64 = 0x02;
|
||||
const S_SILVER: i64 = 0x03;
|
||||
const S_GOLD: i64 = 0x04;
|
||||
const S_RARE: i64 = 0x05;
|
||||
const S_STAFF: i64 = 0x0A;
|
||||
const S_CONSUMABLES: i64 = 0x3C;
|
||||
const S_KITS: i64 = 0x28;
|
||||
const S_KITS_HOME: i64 = 0x29;
|
||||
const S_KITS_AWAY: i64 = 0x2A;
|
||||
const S_BADGES: i64 = 0x2D;
|
||||
const S_STADIA: i64 = 0x14;
|
||||
const S_BALLS: i64 = 0x1E;
|
||||
|
||||
/// First `carddbid` of the AWAY kit family. `fcc_kitcards` is split into a
|
||||
/// `63xxxxx` home family and a `64xxxxx` away family, and the table's own
|
||||
/// `assetid` column agrees exactly: across all 1482 rows, assetid 14 covers
|
||||
/// precisely the 828 `63xxxxx` ids and assetid 15 precisely the 654 `64xxxxx`
|
||||
/// ids, with no exceptions either way. A kit's catalog `asset_id` IS its
|
||||
/// carddbid, so the id itself is the family key -- the `assetid` column is not
|
||||
/// carried on the wire and would be a second source of truth for the same fact.
|
||||
const KIT_AWAY_FLOOR: i64 = 6_400_000;
|
||||
|
||||
/// Which kit family an owned kit belongs to. Only meaningful for
|
||||
/// [`ContentKind::Kit`]; the caller filters first.
|
||||
fn is_home_kit(kit: &ClubStatInput) -> bool {
|
||||
kit.asset_id < KIT_AWAY_FLOOR
|
||||
}
|
||||
|
||||
/// cardsubtypeid (staff family) -> stat id (STAFF_SUBTYPE_STAT).
|
||||
fn staff_stat(subtype: i64) -> Option<i64> {
|
||||
match subtype {
|
||||
4 => Some(0x0B), // manager
|
||||
5 => Some(0x0C), // head coach
|
||||
6 => Some(0x0D), // GK coach
|
||||
7 => Some(0x0E), // physio
|
||||
8 => Some(0x0F), // fitness coach
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// consumable family `kind` -> stat id (CONSUMABLE_KIND_STAT).
|
||||
fn consumable_stat(kind: &str) -> Option<i64> {
|
||||
Some(match kind {
|
||||
"player_contract" => 0x42,
|
||||
"manager_contract" => 0x47,
|
||||
"healing" => 0x41,
|
||||
"player_fitness" => 0x44,
|
||||
"squad_fitness" => 0x4A,
|
||||
"gk_training" => 0x46,
|
||||
"player_training" => 0x43,
|
||||
"position_mod" => 0x45,
|
||||
"player_playstyle" => 0x4B,
|
||||
"gk_playstyle" => 0x4C,
|
||||
"manager_league" => 0x4D,
|
||||
"manager_formation_mod" | "formation_mod" => 0x48,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The JSON `type` atom name for a stat id (VOCAB). Only the ids this module
|
||||
/// emits are mapped; an unmapped id would panic (guards a transcription slip).
|
||||
fn vocab(stat_id: i64) -> &'static str {
|
||||
match stat_id {
|
||||
0x01 => "players",
|
||||
0x02 => "playersBronze",
|
||||
0x03 => "playersSilver",
|
||||
0x04 => "playersGold",
|
||||
0x05 => "rarePlayers",
|
||||
0x0A => "staff",
|
||||
0x0B => "staffManager",
|
||||
0x0C => "staffHeadCoach",
|
||||
0x0D => "staffGKCoach",
|
||||
0x0E => "staffPhysio",
|
||||
0x0F => "staffFitnessCoach",
|
||||
0x14 => "stadia",
|
||||
0x1E => "balls",
|
||||
0x28 => "kits",
|
||||
0x29 => "kitsHome",
|
||||
0x2A => "kitsAway",
|
||||
0x2D => "badges",
|
||||
0x2E => "badgeDBid",
|
||||
0x2F => "leagueLogos",
|
||||
0x32 => "trophies",
|
||||
0x33 => "trophiesOffline",
|
||||
0x34 => "trophiesOnline",
|
||||
0x35 => "trophiesFeaturedOffline",
|
||||
0x36 => "trophiesFeaturedOnline",
|
||||
0x37 => "trophiesSeasonOffline",
|
||||
0x38 => "trophiesSeasonOnline",
|
||||
0x3C => "consumables",
|
||||
0x41 => "consumablesHealing",
|
||||
0x42 => "consumablesContractPlayer",
|
||||
0x43 => "consumablesTrainingPlayer",
|
||||
0x44 => "consumablesFitnessPlayer",
|
||||
0x45 => "consumablesPosition",
|
||||
0x46 => "consumablesTrainingGk",
|
||||
0x47 => "consumablesContractManager",
|
||||
0x48 => "consumablesFormationManager",
|
||||
0x49 => "consumablesTrainingManager",
|
||||
0x4A => "consumablesFitnessTeam",
|
||||
0x4B => "consumablesTrainingPlayerPlayStyle",
|
||||
0x4C => "consumablesTrainingGkPlayStyle",
|
||||
0x4D => "consumablesTrainingManagerLeagueModifier",
|
||||
other => panic!("club_stats: unmapped stat id {other:#x}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn row(context_id: i64, context_value: i64, stat_id: i64, value: i64) -> Value {
|
||||
json!({
|
||||
"contextId": context_id,
|
||||
"contextValue": context_value,
|
||||
"type": vocab(stat_id),
|
||||
"typeValue": value,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_player(i: &ClubStatInput) -> bool {
|
||||
matches!(i.kind, ContentKind::Player)
|
||||
}
|
||||
|
||||
/// Build the full `{"stat":[…]}` body for a club/stats screen — the global bucket
|
||||
/// (identical for every mode) plus per-context buckets keyed by `ctx`
|
||||
/// (nation / league / team), mirroring `fut_club_stats.py::stats_body`.
|
||||
pub fn club_stats_body(items: &[ClubStatInput], ctx: ContextField) -> Value {
|
||||
// ---- global bucket (contextId 1, contextValue 0), sorted by stat id ----
|
||||
let mut g: BTreeMap<i64, i64> = BTreeMap::new();
|
||||
let players: Vec<&ClubStatInput> = items.iter().filter(|i| is_player(i)).collect();
|
||||
g.insert(S_PLAYERS, players.len() as i64);
|
||||
g.insert(
|
||||
S_GOLD,
|
||||
players.iter().filter(|i| i.rating >= 75).count() as i64,
|
||||
);
|
||||
g.insert(
|
||||
S_SILVER,
|
||||
players
|
||||
.iter()
|
||||
.filter(|i| (65..75).contains(&i.rating))
|
||||
.count() as i64,
|
||||
);
|
||||
g.insert(
|
||||
S_BRONZE,
|
||||
players
|
||||
.iter()
|
||||
.filter(|i| i.rating > 0 && i.rating < 65)
|
||||
.count() as i64,
|
||||
);
|
||||
g.insert(S_RARE, players.iter().filter(|i| i.rare).count() as i64);
|
||||
|
||||
// staff per family + total
|
||||
for sid in [0x0B, 0x0C, 0x0D, 0x0E, 0x0F] {
|
||||
g.insert(sid, 0);
|
||||
}
|
||||
let mut staff_total = 0i64;
|
||||
// A manager counts INSIDE the staff total (`staffManager` is a bucket within
|
||||
// it), so this selects the whole staff FAMILY, not `ContentKind::Staff`
|
||||
// alone — a `manager`-classified row would otherwise vanish from the panel.
|
||||
for it in items.iter().filter(|i| i.kind.is_staff_family()) {
|
||||
if let Some(sid) = staff_stat(it.subtype) {
|
||||
*g.get_mut(&sid).unwrap() += 1;
|
||||
staff_total += 1;
|
||||
}
|
||||
}
|
||||
g.insert(S_STAFF, staff_total);
|
||||
|
||||
// consumables per family + total
|
||||
for sid in [
|
||||
0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D,
|
||||
] {
|
||||
g.insert(sid, 0);
|
||||
}
|
||||
let mut cons_total = 0i64;
|
||||
for it in items
|
||||
.iter()
|
||||
.filter(|i| matches!(i.kind, ContentKind::Consumable))
|
||||
{
|
||||
cons_total += 1;
|
||||
if let Some((kind, _label)) = consumable_family(it.subtype) {
|
||||
if let Some(sid) = consumable_stat(kind) {
|
||||
*g.entry(sid).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
g.insert(S_CONSUMABLES, cons_total);
|
||||
|
||||
// Club items. THESE COUNTS ARE THE GATE: the client does not ask for a
|
||||
// family's items until club/stats reports a non-zero count for it (proven by
|
||||
// the consumables round, where two rounds of item work sat unrequested
|
||||
// because this panel answered zero). They are plain ints read by the same
|
||||
// getter/publisher shape as the live-proven PLAYERS_EMPLOYED rows, so every
|
||||
// family Core can own is counted here — including the ones whose ITEM record
|
||||
// shape is still withheld, because a count cannot desync a parser and a zero
|
||||
// guarantees the family is never even asked about. Unowned families stay
|
||||
// honest zeros.
|
||||
for sid in [0x2E, 0x2F, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38] {
|
||||
g.entry(sid).or_insert(0);
|
||||
}
|
||||
let count_kind =
|
||||
|want: ContentKind| items.iter().filter(|item| item.kind == want).count() as i64;
|
||||
g.insert(S_STADIA, count_kind(ContentKind::Stadium));
|
||||
g.insert(S_BALLS, count_kind(ContentKind::Ball));
|
||||
g.insert(S_BADGES, count_kind(ContentKind::Badge));
|
||||
let kits: Vec<&ClubStatInput> = items
|
||||
.iter()
|
||||
.filter(|item| matches!(item.kind, ContentKind::Kit))
|
||||
.collect();
|
||||
let home = kits.iter().filter(|kit| is_home_kit(kit)).count() as i64;
|
||||
g.insert(S_KITS, kits.len() as i64);
|
||||
g.insert(S_KITS_HOME, home);
|
||||
g.insert(S_KITS_AWAY, kits.len() as i64 - home);
|
||||
|
||||
let mut stat: Vec<Value> = g.iter().map(|(sid, v)| row(1, 0, *sid, *v)).collect();
|
||||
|
||||
// ---- per-context buckets (contextId 3, contextValue = entity id) ----
|
||||
// Nation/League read the tier set (gold/silver/bronze/rare/kits/badges);
|
||||
// Team (the league screen) reads players/kits/badgeDBid. Mirrors context_rows.
|
||||
let mut by_ctx: BTreeMap<i64, Vec<&ClubStatInput>> = BTreeMap::new();
|
||||
for p in &players {
|
||||
let id = match ctx {
|
||||
ContextField::Nation => p.nation_id,
|
||||
ContextField::League => p.league_id,
|
||||
ContextField::Team => p.team_id,
|
||||
};
|
||||
if let Some(id) = id {
|
||||
by_ctx.entry(id).or_default().push(p);
|
||||
}
|
||||
}
|
||||
|
||||
// A kit belongs to the team that wears it and has no nation/league of its
|
||||
// own, so it only buckets on the team screen -- and it buckets there even if
|
||||
// the club owns no player from that team, which is the normal case for a kit
|
||||
// won from a pack.
|
||||
let mut kits_by_team: BTreeMap<i64, i64> = BTreeMap::new();
|
||||
if ctx == ContextField::Team {
|
||||
for kit in &kits {
|
||||
if let Some(id) = kit.team_id {
|
||||
*kits_by_team.entry(id).or_insert(0) += 1;
|
||||
by_ctx.entry(id).or_default();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (cid, sel) in &by_ctx {
|
||||
if ctx == ContextField::Team {
|
||||
stat.push(row(3, *cid, S_PLAYERS, sel.len() as i64));
|
||||
stat.push(row(
|
||||
3,
|
||||
*cid,
|
||||
S_KITS,
|
||||
kits_by_team.get(cid).copied().unwrap_or(0),
|
||||
));
|
||||
stat.push(row(3, *cid, 0x2E, 0)); // badgeDBid
|
||||
} else {
|
||||
let gold = sel.iter().filter(|i| i.rating >= 75).count() as i64;
|
||||
let silver = sel.iter().filter(|i| (65..75).contains(&i.rating)).count() as i64;
|
||||
let bronze = sel.iter().filter(|i| i.rating > 0 && i.rating < 65).count() as i64;
|
||||
let rare = sel.iter().filter(|i| i.rare).count() as i64;
|
||||
stat.push(row(3, *cid, S_GOLD, gold));
|
||||
stat.push(row(3, *cid, S_SILVER, silver));
|
||||
stat.push(row(3, *cid, S_BRONZE, bronze));
|
||||
stat.push(row(3, *cid, S_RARE, rare));
|
||||
stat.push(row(3, *cid, S_KITS, 0));
|
||||
stat.push(row(3, *cid, S_BADGES, 0));
|
||||
}
|
||||
}
|
||||
|
||||
json!({ "stat": stat })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn player(rating: i64, rare: bool, nation: Option<i64>) -> ClubStatInput {
|
||||
ClubStatInput {
|
||||
kind: ContentKind::Player,
|
||||
subtype: 0,
|
||||
rating,
|
||||
rare,
|
||||
asset_id: 158023,
|
||||
nation_id: nation,
|
||||
league_id: None,
|
||||
team_id: None,
|
||||
}
|
||||
}
|
||||
fn staff(subtype: i64) -> ClubStatInput {
|
||||
ClubStatInput {
|
||||
kind: ContentKind::Staff,
|
||||
subtype,
|
||||
rating: 0,
|
||||
rare: false,
|
||||
asset_id: 0,
|
||||
nation_id: None,
|
||||
league_id: None,
|
||||
team_id: None,
|
||||
}
|
||||
}
|
||||
fn consumable(subtype: i64) -> ClubStatInput {
|
||||
ClubStatInput {
|
||||
kind: ContentKind::Consumable,
|
||||
subtype,
|
||||
rating: 0,
|
||||
rare: false,
|
||||
asset_id: 0,
|
||||
nation_id: None,
|
||||
league_id: None,
|
||||
team_id: None,
|
||||
}
|
||||
}
|
||||
/// A kit worn by `team`. `carddbid` is the real `fcc_kitcards` id, which is
|
||||
/// also the catalog `asset_id` and therefore the home/away family key.
|
||||
fn kit_of(carddbid: i64, team: i64) -> ClubStatInput {
|
||||
ClubStatInput {
|
||||
kind: ContentKind::Kit,
|
||||
subtype: 9,
|
||||
rating: 0,
|
||||
rare: false,
|
||||
asset_id: carddbid,
|
||||
nation_id: None,
|
||||
league_id: None,
|
||||
team_id: Some(team),
|
||||
}
|
||||
}
|
||||
/// Real team-21 kits from `fcc_kitcards`: 6300006 is its home kit and
|
||||
/// 6400003 its away kit.
|
||||
const HOME_KIT: i64 = 6_300_006;
|
||||
const AWAY_KIT: i64 = 6_400_003;
|
||||
|
||||
fn kit() -> ClubStatInput {
|
||||
kit_of(HOME_KIT, 21)
|
||||
}
|
||||
|
||||
fn global(body: &Value) -> std::collections::HashMap<String, i64> {
|
||||
body["stat"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|r| r["contextId"] == 1)
|
||||
.map(|r| {
|
||||
(
|
||||
r["type"].as_str().unwrap().to_string(),
|
||||
r["typeValue"].as_i64().unwrap(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiers_and_rare_counted() {
|
||||
let items = vec![
|
||||
player(90, true, Some(52)),
|
||||
player(70, true, Some(52)),
|
||||
player(60, false, Some(21)),
|
||||
];
|
||||
let g = global(&club_stats_body(&items, ContextField::Nation));
|
||||
assert_eq!(g["players"], 3);
|
||||
assert_eq!(g["playersGold"], 1);
|
||||
assert_eq!(g["playersSilver"], 1);
|
||||
assert_eq!(g["playersBronze"], 1);
|
||||
assert_eq!(g["rarePlayers"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staff_by_family() {
|
||||
let items = vec![staff(6), staff(8), staff(8)]; // 1 gk coach, 2 fitness
|
||||
let g = global(&club_stats_body(&items, ContextField::Nation));
|
||||
assert_eq!(g["staffGKCoach"], 1);
|
||||
assert_eq!(g["staffFitnessCoach"], 2);
|
||||
assert_eq!(g["staff"], 3);
|
||||
assert_eq!(g["staffManager"], 0);
|
||||
}
|
||||
|
||||
/// A row Core classifies as `manager` must still land in the STAFF bucket and
|
||||
/// in `staffManager`: the client's own model counts a manager inside its staff
|
||||
/// total, and the two encodings (`manager`, or `staff` + subtype 4) are the
|
||||
/// same card.
|
||||
#[test]
|
||||
fn a_manager_counts_inside_staff_under_either_kind_token() {
|
||||
for kind in [ContentKind::Manager, ContentKind::Staff] {
|
||||
let mut manager = staff(4);
|
||||
manager.kind = kind;
|
||||
let g = global(&club_stats_body(&[manager, staff(8)], ContextField::Nation));
|
||||
assert_eq!(g["staffManager"], 1, "kind={}", kind.as_str());
|
||||
assert_eq!(
|
||||
g["staff"],
|
||||
2,
|
||||
"the manager is INSIDE the staff total (kind={})",
|
||||
kind.as_str()
|
||||
);
|
||||
assert_eq!(g["staffFitnessCoach"], 1);
|
||||
assert_eq!(g["players"], 0, "a manager is not a player");
|
||||
}
|
||||
}
|
||||
|
||||
/// The count is the GATE: the client will not ask for a family's items until
|
||||
/// this panel reports a non-zero count for it, so an owned badge/ball/stadium
|
||||
/// must be counted even while its item record is withheld.
|
||||
#[test]
|
||||
fn owned_club_items_are_counted_per_family() {
|
||||
let club_item = |kind: ContentKind, subtype: i64| ClubStatInput {
|
||||
kind,
|
||||
subtype,
|
||||
rating: 0,
|
||||
rare: false,
|
||||
asset_id: 0,
|
||||
nation_id: None,
|
||||
league_id: None,
|
||||
team_id: None,
|
||||
};
|
||||
let items = vec![
|
||||
club_item(ContentKind::Badge, 11),
|
||||
club_item(ContentKind::Badge, 11),
|
||||
club_item(ContentKind::Ball, 30),
|
||||
club_item(ContentKind::Stadium, 10),
|
||||
kit(),
|
||||
];
|
||||
let g = global(&club_stats_body(&items, ContextField::Nation));
|
||||
assert_eq!(g["badges"], 2);
|
||||
assert_eq!(g["balls"], 1);
|
||||
assert_eq!(g["stadia"], 1);
|
||||
assert_eq!(g["kits"], 1);
|
||||
assert_eq!(g["players"], 0, "no club item is ever a player");
|
||||
assert_eq!(g["leagueLogos"], 0, "not an ownable Core kind: honest zero");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumables_by_family() {
|
||||
// 54 gk_training, 201 player_contract, 217 healing, 258 player_playstyle
|
||||
let items = vec![
|
||||
consumable(54),
|
||||
consumable(201),
|
||||
consumable(217),
|
||||
consumable(258),
|
||||
];
|
||||
let g = global(&club_stats_body(&items, ContextField::Nation));
|
||||
assert_eq!(g["consumables"], 4);
|
||||
assert_eq!(g["consumablesTrainingGk"], 1);
|
||||
assert_eq!(g["consumablesContractPlayer"], 1);
|
||||
assert_eq!(g["consumablesHealing"], 1);
|
||||
assert_eq!(g["consumablesTrainingPlayerPlayStyle"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owned_kits_increment_global_kit_count() {
|
||||
let items = vec![player(90, false, None), kit(), kit()];
|
||||
let g = global(&club_stats_body(&items, ContextField::Nation));
|
||||
assert_eq!(g["players"], 1);
|
||||
assert_eq!(g["kits"], 2);
|
||||
}
|
||||
|
||||
/// `kits` is the total and `kitsHome`/`kitsAway` are its family split, the
|
||||
/// same total/subset shape as players/playersGold and staff/staffManager.
|
||||
#[test]
|
||||
fn kit_counts_split_by_home_and_away_family() {
|
||||
let items = vec![
|
||||
kit_of(HOME_KIT, 21),
|
||||
kit_of(6_300_010, 38),
|
||||
kit_of(AWAY_KIT, 21),
|
||||
];
|
||||
let g = global(&club_stats_body(&items, ContextField::Nation));
|
||||
assert_eq!(g["kits"], 3);
|
||||
assert_eq!(g["kitsHome"], 2);
|
||||
assert_eq!(g["kitsAway"], 1);
|
||||
}
|
||||
|
||||
/// A kit buckets onto the team that wears it -- including a team the club
|
||||
/// owns no player from, which is the normal case for a kit won from a pack.
|
||||
#[test]
|
||||
fn kits_bucket_onto_their_own_team_on_the_team_screen() {
|
||||
let mut with_team = player(90, false, None);
|
||||
with_team.team_id = Some(21);
|
||||
let items = vec![
|
||||
with_team,
|
||||
kit_of(HOME_KIT, 21),
|
||||
kit_of(AWAY_KIT, 21),
|
||||
kit_of(6_300_010, 38),
|
||||
];
|
||||
let body = club_stats_body(&items, ContextField::Team);
|
||||
let kits_for = |team: i64| {
|
||||
body["stat"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|r| r["contextId"] == 3 && r["contextValue"] == team && r["type"] == "kits")
|
||||
.map(|r| r["typeValue"].as_i64().unwrap())
|
||||
};
|
||||
assert_eq!(kits_for(21), Some(2));
|
||||
// Team 38 has no players, so only the kit creates its bucket.
|
||||
assert_eq!(kits_for(38), Some(1));
|
||||
|
||||
// A nation/league screen has no team context, so kits stay out of it.
|
||||
let nation = club_stats_body(&items, ContextField::Nation);
|
||||
assert!(nation["stat"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|r| r["contextId"] == 3 && r["type"] == "kits")
|
||||
.all(|r| r["typeValue"] == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nation_buckets_emitted_and_players_excludes_nonplayers() {
|
||||
let items = vec![
|
||||
player(90, true, Some(52)),
|
||||
player(80, false, Some(52)),
|
||||
staff(8),
|
||||
];
|
||||
let body = club_stats_body(&items, ContextField::Nation);
|
||||
let g = global(&body);
|
||||
assert_eq!(g["players"], 2, "staff not counted as player");
|
||||
let buckets: Vec<&Value> = body["stat"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|r| r["contextId"] == 3 && r["contextValue"] == 52)
|
||||
.collect();
|
||||
// gold, silver, bronze, rare, kits, badges
|
||||
assert_eq!(buckets.len(), 6);
|
||||
let gold = buckets.iter().find(|r| r["type"] == "playersGold").unwrap();
|
||||
assert_eq!(gold["typeValue"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn honest_zero_club_items_present() {
|
||||
let g = global(&club_stats_body(
|
||||
&[player(90, false, None)],
|
||||
ContextField::Nation,
|
||||
));
|
||||
for atom in [
|
||||
"stadia",
|
||||
"balls",
|
||||
"kits",
|
||||
"badges",
|
||||
"trophies",
|
||||
"leagueLogos",
|
||||
] {
|
||||
assert_eq!(g[atom], 0, "{atom} present as honest zero");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn league_and_team_context_modes() {
|
||||
let mut a = player(90, true, Some(52));
|
||||
a.league_id = Some(13);
|
||||
a.team_id = Some(240);
|
||||
let mut b = player(60, false, Some(52));
|
||||
b.league_id = Some(13);
|
||||
b.team_id = Some(9);
|
||||
let items = vec![a, b];
|
||||
|
||||
// country screen -> league (leagueId) buckets, tier set (6 rows).
|
||||
let body = club_stats_body(&items, ContextField::League);
|
||||
let league_rows: Vec<&Value> = body["stat"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|r| r["contextId"] == 3 && r["contextValue"] == 13)
|
||||
.collect();
|
||||
assert_eq!(league_rows.len(), 6);
|
||||
let gold = league_rows
|
||||
.iter()
|
||||
.find(|r| r["type"] == "playersGold")
|
||||
.unwrap();
|
||||
assert_eq!(gold["typeValue"], 1);
|
||||
|
||||
// league screen -> team (teamid) buckets: players/kits/badgeDBid (3 rows).
|
||||
let body = club_stats_body(&items, ContextField::Team);
|
||||
let team_rows: Vec<&Value> = body["stat"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|r| r["contextId"] == 3 && r["contextValue"] == 240)
|
||||
.collect();
|
||||
assert_eq!(team_rows.len(), 3);
|
||||
let players = team_rows.iter().find(|r| r["type"] == "players").unwrap();
|
||||
assert_eq!(players["typeValue"], 1);
|
||||
assert!(team_rows.iter().any(|r| r["type"] == "badgeDBid"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! The FIFA 17 **consumables screen** response — `GET …/club/consumables/<category>`.
|
||||
//!
|
||||
//! Consumables are NOT a `club?type=` family. A previous round shipped four
|
||||
//! `?type=` arms for them and the screen stayed empty, because the client asks
|
||||
//! HERE — and it asks only once `club/stats/consumables` reports a non-zero count
|
||||
//! for the family, so the counter in [`crate::fut::club_stats`] is the gate and
|
||||
//! this route is the door. Before that was known, the path fell through the
|
||||
//! generic `/club` PREFIX and the consumables screen was answered with the club's
|
||||
//! player list.
|
||||
//!
|
||||
//! ## The element is a STACK WRAPPER, not an item
|
||||
//!
|
||||
//! Learned the hard way (live, 2026-08-05): bare items here were ACCEPTED and did
|
||||
//! nothing — the client's card map afterwards held only the squad, and the screen
|
||||
//! stayed empty with no error anywhere. `FutConsumablesSearchServerResponse`
|
||||
//! (RS4 literal `0x1802222f8`, factory `0x180130a10`, vtable `0x180222200`,
|
||||
//! deserializer `+0x08` = `0x180130d10`) reads `itemData` (atom 0x16b) at the root
|
||||
//! like the club list, but its ELEMENT is a five-atom wrapper of which exactly one
|
||||
//! atom carries the item:
|
||||
//!
|
||||
//! | atom | key | |
|
||||
//! |---|---|---|
|
||||
//! | 0xbc | `count` | copies in the stack |
|
||||
//! | 0xd7 | `discardValue` | |
|
||||
//! | 0x16a | `item` | → `FUN_18013fe00`, the item parser itself |
|
||||
//! | 0x287 | `resourceId` | the stack's identity |
|
||||
//! | 0x362 | `untradeableCount` | drives a UI flag as `untradeableCount < count` |
|
||||
//!
|
||||
//! Everything else falls to the value-SKIP handler, which is exactly why a bare
|
||||
//! item was silently discarded. It is also why FUT draws consumables as one card
|
||||
//! with a quantity badge rather than N cards: identical copies COLLAPSE by
|
||||
//! `resourceId` here.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::item::{shape_consumable_item, Fifa17ConsumableIdentity, ShapeStats};
|
||||
|
||||
/// Build the consumables-screen body from the club's owned consumable copies.
|
||||
///
|
||||
/// Copies are collapsed by `resourceId` into one stack each, in first-seen order
|
||||
/// (deterministic: Core's own owned order), with `count` and `untradeableCount`
|
||||
/// counted over the copies. A copy whose definition is incomplete is DROPPED and
|
||||
/// counted — see [`Fifa17ConsumableIdentity::is_renderable`]; drawing "-1" or a
|
||||
/// different item than the club owns is worse than omitting the stack.
|
||||
///
|
||||
/// `discardValue` is `0`: the client computes a card's own quick-sell price from
|
||||
/// `fcc_discardcoins` on `(cardtype 6, level, rare)`, and `0` is the value the
|
||||
/// live-proven oracle sends on this route. Inventing a price from the player
|
||||
/// quick-sell table would be a fabricated number the client does not need.
|
||||
///
|
||||
/// The stack's `item` is the FIRST copy, so its `id` is a real owned wire id — a
|
||||
/// later item operation on the stack therefore addresses a card the club really
|
||||
/// owns. (Which copy a quick-sell of a whole stack should consume is a lifecycle
|
||||
/// question, not a projection one, and is not decided here.)
|
||||
pub fn consumables_response(items: &[Fifa17ConsumableIdentity]) -> (Value, ShapeStats) {
|
||||
let mut stats = ShapeStats::default();
|
||||
// (resource_id, index into `stacks`) — a Vec keeps first-seen order without a
|
||||
// second sort, and a club holds tens of stacks, not thousands.
|
||||
let mut order: Vec<u32> = Vec::new();
|
||||
let mut stacks: Vec<Value> = Vec::new();
|
||||
for id in items {
|
||||
if !id.is_renderable() {
|
||||
stats.dropped_incomplete += 1;
|
||||
continue;
|
||||
}
|
||||
stats.emitted += 1;
|
||||
match order.iter().position(|r| *r == id.resource_id) {
|
||||
Some(i) => {
|
||||
let stack = stacks[i].as_object_mut().expect("stack is an object");
|
||||
let count = stack["count"].as_i64().unwrap_or(0) + 1;
|
||||
stack["count"] = json!(count);
|
||||
if id.untradeable {
|
||||
let untradeable = stack["untradeableCount"].as_i64().unwrap_or(0) + 1;
|
||||
stack["untradeableCount"] = json!(untradeable);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
order.push(id.resource_id);
|
||||
stacks.push(json!({
|
||||
"count": 1,
|
||||
"discardValue": 0,
|
||||
"item": shape_consumable_item(*id),
|
||||
"resourceId": id.resource_id,
|
||||
"untradeableCount": i64::from(id.untradeable),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
(json!({ "itemData": stacks }), stats)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A play-style card (category 9): `amount` mandatory, art id 50.
|
||||
fn playstyle(item_id: u32, resource_id: u32) -> Fifa17ConsumableIdentity {
|
||||
Fifa17ConsumableIdentity {
|
||||
item_id,
|
||||
resource_id,
|
||||
asset_id: resource_id,
|
||||
card_asset_id: 50,
|
||||
subtype: 258,
|
||||
rareflag: 0,
|
||||
rating: 95,
|
||||
amount: Some(2),
|
||||
contract: None,
|
||||
untradeable: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_copies_collapse_into_one_counted_stack() {
|
||||
// Two copies of 5003103 plus one of 5003112 → two stacks, counts 2 and 1.
|
||||
let items = vec![
|
||||
playstyle(100000293, 5_003_103),
|
||||
playstyle(100000326, 5_003_112),
|
||||
playstyle(100000294, 5_003_103),
|
||||
];
|
||||
let (body, stats) = consumables_response(&items);
|
||||
assert_eq!(stats.emitted, 3, "every copy is accounted for");
|
||||
let stacks = body["itemData"].as_array().unwrap();
|
||||
assert_eq!(stacks.len(), 2, "collapsed by resourceId");
|
||||
assert_eq!(stacks[0]["resourceId"], 5_003_103);
|
||||
assert_eq!(stacks[0]["count"], 2);
|
||||
assert_eq!(stacks[0]["untradeableCount"], 2);
|
||||
assert_eq!(stacks[1]["resourceId"], 5_003_112);
|
||||
assert_eq!(stacks[1]["count"], 1);
|
||||
// The five wrapper atoms and nothing else: anything extra falls to the
|
||||
// value-SKIP handler and only misleads the next reader.
|
||||
let mut keys: Vec<&str> = stacks[0]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
keys.sort_unstable();
|
||||
assert_eq!(
|
||||
keys,
|
||||
vec![
|
||||
"count",
|
||||
"discardValue",
|
||||
"item",
|
||||
"resourceId",
|
||||
"untradeableCount"
|
||||
]
|
||||
);
|
||||
// The item rides inside the wrapper, not beside it.
|
||||
assert_eq!(stacks[0]["item"]["id"], 100000293);
|
||||
assert_eq!(stacks[0]["item"]["cardsubtypeid"], 258);
|
||||
assert_eq!(stacks[0]["item"]["cardassetid"], 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tradeable_copy_lowers_untradeable_count_below_the_stack_count() {
|
||||
// The client's UI flag is `untradeableCount < count`, so the two numbers
|
||||
// must be counted over the same copies.
|
||||
let mut tradeable = playstyle(100000295, 5_003_103);
|
||||
tradeable.untradeable = false;
|
||||
let items = vec![playstyle(100000293, 5_003_103), tradeable];
|
||||
let (body, _) = consumables_response(&items);
|
||||
let stack = &body["itemData"][0];
|
||||
assert_eq!(stack["count"], 2);
|
||||
assert_eq!(stack["untradeableCount"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_definitions_are_dropped_and_counted_never_drawn_wrong() {
|
||||
// (a) a play style with no `amount` would draw "-1" on the card;
|
||||
let mut no_amount = playstyle(100000293, 5_003_103);
|
||||
no_amount.amount = None;
|
||||
// (b) rareflag on 219 turns Player Fitness into SQUAD Fitness;
|
||||
let trap = Fifa17ConsumableIdentity {
|
||||
item_id: 100000300,
|
||||
resource_id: 5_002_030,
|
||||
asset_id: 5_002_030,
|
||||
card_asset_id: 9,
|
||||
subtype: 219,
|
||||
rareflag: 1,
|
||||
rating: 70,
|
||||
amount: Some(10),
|
||||
contract: None,
|
||||
untradeable: true,
|
||||
};
|
||||
// (c) a subtype in no documented range renders as a plausible Squad
|
||||
// Training (Pace) card with amount 0.
|
||||
let mut dead_zone = playstyle(100000301, 5_003_999);
|
||||
dead_zone.subtype = 137;
|
||||
// (d) no `card_asset_id` in the catalog → the resolver defaulted it to
|
||||
// the asset id and the client would draw the notfound.swf green box.
|
||||
let mut no_art = playstyle(100000302, 5_003_104);
|
||||
no_art.card_asset_id = no_art.asset_id;
|
||||
let (body, stats) = consumables_response(&[no_amount, trap, dead_zone, no_art]);
|
||||
assert_eq!(stats.emitted, 0);
|
||||
assert_eq!(stats.dropped_incomplete, 4);
|
||||
assert_eq!(body["itemData"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_contract_card_carries_contract_and_no_amount() {
|
||||
let contract = Fifa17ConsumableIdentity {
|
||||
item_id: 100000294,
|
||||
resource_id: 5_001_004,
|
||||
asset_id: 5_001_004,
|
||||
card_asset_id: 7,
|
||||
subtype: 201,
|
||||
rareflag: 0,
|
||||
rating: 60,
|
||||
amount: None,
|
||||
contract: Some(7),
|
||||
untradeable: true,
|
||||
};
|
||||
let (body, stats) = consumables_response(&[contract]);
|
||||
assert_eq!(stats.emitted, 1);
|
||||
let item = &body["itemData"][0]["item"];
|
||||
assert_eq!(item["contract"], 7);
|
||||
assert!(
|
||||
item.get("amount").is_none(),
|
||||
"categories 2 and 3 ignore `amount` entirely"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_club_is_an_empty_itemdata_not_a_missing_key() {
|
||||
let (body, stats) = consumables_response(&[]);
|
||||
assert_eq!(stats.emitted, 0);
|
||||
assert!(body["itemData"].as_array().unwrap().is_empty());
|
||||
assert_eq!(body.as_object().unwrap().len(), 1, "only itemData at root");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
//! FIFA 17 **non-player content taxonomy** — the evidence-based map from a card
|
||||
//! `cardsubtypeid` to its functional family (consumables) or role (staff).
|
||||
//!
|
||||
//! This is the ONLY place the FIFA-specific `cardsubtypeid` vocabulary lives; it
|
||||
//! keeps that game concept out of generic Core, exactly as the player-side
|
||||
//! catalog keeps `resourceId`/`rareflag` out of Core. Nothing here is guessed:
|
||||
//!
|
||||
//! * Consumable families and their contiguous `cardsubtypeid` ranges are taken
|
||||
//! verbatim from `fifa17-recon/tools/fut_consumables.py`
|
||||
//! (`BY_SUBTYPE`/`CORE_KINDS`, Ghidra-derived from `FUN_18013f4d0` /
|
||||
//! `FUN_1801bfac0`) and `docs/CARD_TAXONOMY.md` (verified against the `.105`
|
||||
//! `fcc_*.json` tables).
|
||||
//! * Staff roles are the `FUN_1800d8330` family selector: 4=manager, 5=headcoach,
|
||||
//! 6=gkcoach, 7=physio, 8=fitnesscoach.
|
||||
//!
|
||||
//! Display **labels are functional, never marketing** (e.g. "Player Chemistry
|
||||
//! Style", not a promo name). A `cardsubtypeid` outside every documented range
|
||||
//! resolves to `None` — the caller DEFERS it (mirroring the player NoName gate),
|
||||
//! never fabricating a family.
|
||||
|
||||
/// The disjoint content classes a FIFA 17 owned item can belong to. Player is
|
||||
/// the default so a catalog authored before this taxonomy existed (no `kind`
|
||||
/// field) still classifies every entry as a player, unchanged.
|
||||
///
|
||||
/// The token set is OpenFUT Core's game-independent content vocabulary
|
||||
/// (`player | manager | staff | consumable | kit | badge | ball | stadium |
|
||||
/// misc`), so a Core owned row and a FIFA 17 catalog entry name the same class
|
||||
/// with the same string and the FIFA numerics (`cardsubtypeid`, resource ranges)
|
||||
/// never leak out of this crate.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ContentKind {
|
||||
#[default]
|
||||
Player,
|
||||
/// A MANAGER — its own Core kind, but on the FIFA 17 side it is a member of
|
||||
/// the STAFF family, never a class of its own: see
|
||||
/// [`ContentKind::is_staff_family`]. The wire discriminator is
|
||||
/// [`MANAGER_SUBTYPE`], not this token, so a catalog may classify a manager
|
||||
/// as either `manager` or `staff` + subtype 4 and every consumer here
|
||||
/// treats the two encodings identically.
|
||||
Manager,
|
||||
Staff,
|
||||
Consumable,
|
||||
Kit,
|
||||
Badge,
|
||||
Ball,
|
||||
Stadium,
|
||||
Misc,
|
||||
}
|
||||
|
||||
impl ContentKind {
|
||||
/// The stable wire/catalog token for this kind.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ContentKind::Player => "player",
|
||||
ContentKind::Manager => "manager",
|
||||
ContentKind::Staff => "staff",
|
||||
ContentKind::Consumable => "consumable",
|
||||
ContentKind::Kit => "kit",
|
||||
ContentKind::Badge => "badge",
|
||||
ContentKind::Ball => "ball",
|
||||
ContentKind::Stadium => "stadium",
|
||||
ContentKind::Misc => "misc",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a catalog `kind` token. Unknown or "player" (or an absent field that
|
||||
/// deserializes to the default) is `Player` — backward compatible.
|
||||
// Intentionally infallible (every input maps to a kind, unknown → Player), so
|
||||
// it is NOT `std::str::FromStr` (which is fallible); the name mirrors the
|
||||
// catalog token vocabulary.
|
||||
#[allow(clippy::should_implement_trait)]
|
||||
pub fn from_str(s: &str) -> ContentKind {
|
||||
match s {
|
||||
"manager" => ContentKind::Manager,
|
||||
"staff" => ContentKind::Staff,
|
||||
"consumable" => ContentKind::Consumable,
|
||||
"kit" => ContentKind::Kit,
|
||||
"badge" => ContentKind::Badge,
|
||||
"ball" => ContentKind::Ball,
|
||||
"stadium" => ContentKind::Stadium,
|
||||
"misc" => ContentKind::Misc,
|
||||
_ => ContentKind::Player,
|
||||
}
|
||||
}
|
||||
|
||||
/// True for the two kinds that make up the FIFA 17 STAFF family.
|
||||
///
|
||||
/// A manager IS a staff card: the client's own club-stats model counts it
|
||||
/// inside the `staff` total with `staffManager` as a bucket within it, its
|
||||
/// STAFF tab asks for the whole family with `type=manager`, and one record
|
||||
/// shape ([`crate::fut::item::shape_staff_item`]) serves all five families.
|
||||
/// Every staff consumer MUST use this predicate rather than matching
|
||||
/// `Staff` alone, or a `manager`-classified row silently leaves the staff
|
||||
/// bucket and the STAFF tab.
|
||||
pub fn is_staff_family(&self) -> bool {
|
||||
matches!(self, ContentKind::Manager | ContentKind::Staff)
|
||||
}
|
||||
}
|
||||
|
||||
/// The functional family + honest display label for a consumable `cardsubtypeid`,
|
||||
/// or `None` if the subtype is outside every documented range (→ DEFER).
|
||||
///
|
||||
/// Returns `(family, label)`, both `'static`. `family` is the neutral machine
|
||||
/// name stored as the CardDefinition family; `label` is the functional
|
||||
/// human-readable name.
|
||||
pub fn consumable_family(subtype: i64) -> Option<(&'static str, &'static str)> {
|
||||
let pair = match subtype {
|
||||
51..=57 => ("gk_training", "GK Training"),
|
||||
61..=67 => ("player_training", "Player Training"),
|
||||
71..=86 => ("manager_formation_mod", "Manager Formation"),
|
||||
91..=110 => ("position_mod", "Position Modifier"),
|
||||
121..=136 => ("formation_mod", "Formation Modifier"),
|
||||
201 => ("player_contract", "Player Contract"),
|
||||
202 => ("manager_contract", "Manager Contract"),
|
||||
211..=218 => ("healing", "Healing"),
|
||||
219 => ("player_fitness", "Player Fitness"),
|
||||
220 => ("squad_fitness", "Squad Fitness"),
|
||||
250..=268 => ("player_playstyle", "Player Chemistry Style"),
|
||||
269..=273 => ("gk_playstyle", "GK Chemistry Style"),
|
||||
300..=341 => ("manager_league", "Manager League Modifier"),
|
||||
_ => return None,
|
||||
};
|
||||
Some(pair)
|
||||
}
|
||||
|
||||
/// `cardsubtypeid` of a MANAGER staff card. This value alone selects the
|
||||
/// `managercards` merge in the client (`FUN_1800d8330` → cardtype 2 →
|
||||
/// `FUN_1801356c0`), and it is what distinguishes a manager from the four coach
|
||||
/// families inside [`ContentKind::Staff`].
|
||||
pub const MANAGER_SUBTYPE: i64 = 4;
|
||||
|
||||
/// The staff role + honest display label for a staff `cardsubtypeid` (4..=8), or
|
||||
/// `None` for any other subtype (→ DEFER). Grounded in the `FUN_1800d8330`
|
||||
/// family selector.
|
||||
pub fn staff_role(subtype: i64) -> Option<(&'static str, &'static str)> {
|
||||
let pair = match subtype {
|
||||
4 => ("manager", "Manager"),
|
||||
5 => ("headcoach", "Head Coach"),
|
||||
6 => ("gkcoach", "GK Coach"),
|
||||
7 => ("physio", "Physio"),
|
||||
8 => ("fitnesscoach", "Fitness Coach"),
|
||||
_ => return None,
|
||||
};
|
||||
Some(pair)
|
||||
}
|
||||
|
||||
/// The ONE extra wire key a consumable family needs, or [`ConsumableNeeds::None`].
|
||||
///
|
||||
/// Taken verbatim from `fifa17-recon/data/consumables.json`'s per-subtype `needs`
|
||||
/// (generated by `build_consumables.py` from `FUN_18013f4d0`), and independently
|
||||
/// confirmed by the real profile import, where `amount` is present on exactly the
|
||||
/// training/healing/fitness/play-style/league families and `contract` on exactly
|
||||
/// the two contract families.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ConsumableNeeds {
|
||||
/// `amount` (atom 0x1b → `rec+0xbf`, or `+0xbe` for a play style) is
|
||||
/// MANDATORY: the parser initialises its temp to -1 and both accessors read
|
||||
/// it SIGNED, so omitting the key draws "-1" on the card, not "0".
|
||||
Amount,
|
||||
/// `contract` (atom 0xb8 → `rec+0x8c`) carries the number the card grants;
|
||||
/// the two contract families IGNORE `amount` entirely.
|
||||
Contract,
|
||||
/// Nothing beyond the common key set — the card's whole meaning comes from
|
||||
/// `cardsubtypeid` (formation and position modifiers).
|
||||
None,
|
||||
}
|
||||
|
||||
/// Which extra key a consumable family requires. An unknown family name is
|
||||
/// [`ConsumableNeeds::None`]; callers get families from [`consumable_family`],
|
||||
/// so an unknown one cannot arrive from the wire.
|
||||
pub fn consumable_needs(family: &str) -> ConsumableNeeds {
|
||||
match family {
|
||||
"gk_training" | "player_training" | "healing" | "player_fitness" | "squad_fitness"
|
||||
| "player_playstyle" | "gk_playstyle" | "manager_league" => ConsumableNeeds::Amount,
|
||||
"player_contract" | "manager_contract" => ConsumableNeeds::Contract,
|
||||
_ => ConsumableNeeds::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The consumable families one `GET club/consumables/<category>` segment asks
|
||||
/// for, or `None` for a segment outside the client's own group table.
|
||||
///
|
||||
/// **This route, not `club?type=`.** Consumables are NOT a `?type=` family: a
|
||||
/// previous round shipped four `?type=` arms for them and the screen stayed
|
||||
/// empty, because the client asks here (and only once
|
||||
/// `club/stats/consumables` reports a non-zero count — the counter is the gate
|
||||
/// and this route is the door).
|
||||
///
|
||||
/// The segment names are the consumable UI group table at `0x180203260` (seven
|
||||
/// codes: `training`, `contracts`, `fitness`, `healing`, `playStyle`,
|
||||
/// `managerLeagueModifier`, `position`); `training` and `contracts` are CONFIRMED
|
||||
/// on the wire and the singular `contract` is accepted because the client has
|
||||
/// used both spellings. Segments are matched lower-cased.
|
||||
///
|
||||
/// The family sets are the `FUN_18013f4d0` categories those codes name, and the
|
||||
/// correspondence is checkable against the panel: training→42, contracts→13,
|
||||
/// healing→21, fitness→6, position→20, chemistry style→24 items in the oracle's
|
||||
/// own shelf. NOTE the two formation-modifier families (categories 6 and 7) have
|
||||
/// NO group code, so no segment can reach them — that is the client's own gap,
|
||||
/// not an omission here.
|
||||
pub fn consumable_families_for_category(segment: &str) -> Option<&'static [&'static str]> {
|
||||
Some(match segment {
|
||||
"training" => &["gk_training", "player_training"],
|
||||
"contracts" | "contract" => &["player_contract", "manager_contract"],
|
||||
"fitness" => &["player_fitness", "squad_fitness"],
|
||||
"healing" => &["healing"],
|
||||
"position" => &["position_mod"],
|
||||
"playstyle" => &["player_playstyle", "gk_playstyle"],
|
||||
"managerleaguemodifier" => &["manager_league"],
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The club-customisation `cardsubtypeid`s, SETTLED (supersedes
|
||||
/// `CARD_SYSTEM.md`'s "STILL UNKNOWN, AND NOT GUESSED" section, which is stale).
|
||||
///
|
||||
/// Kit 9, stadium 10 and badge 11 are cardtype **7** and resolve through
|
||||
/// `FUN_180119bd0` (the manager vtable slot `+0x498`, verified from disk and live
|
||||
/// memory); ball 30 (`0x1e`) and league logo 31 (`0x1f`) are cardtype 9, the
|
||||
/// latter by elimination over `FUN_1800d8330`'s cardtype-9 set. Four independent
|
||||
/// lines agree on kit = 9, including the deserializer's own `cardassetid` default
|
||||
/// of `0x23` = 35 for cardtype 7 / subtype 9 — exactly the `cardassetid` carried
|
||||
/// by all 1482 rows of `fcc_kitcards`.
|
||||
///
|
||||
/// `0x91..=0x96` are TROPHIES (tournament/season), not club items. The enum table
|
||||
/// at `0x180229ab0` (`badge=0xa kit=0xb leagueLogo=0xc … stadium=0x15 ball=0x16`)
|
||||
/// is the transfermarket `&cat=%s` vocabulary and NOT a subtype map: reading it as
|
||||
/// one swaps badge and kit and loses stadium.
|
||||
pub const KIT_SUBTYPE: i64 = 9;
|
||||
pub const STADIUM_SUBTYPE: i64 = 10;
|
||||
pub const BADGE_SUBTYPE: i64 = 11;
|
||||
pub const BALL_SUBTYPE: i64 = 30;
|
||||
pub const LEAGUE_LOGO_SUBTYPE: i64 = 31;
|
||||
|
||||
/// The club-customisation [`ContentKind`] for a `cardsubtypeid`, or `None` for a
|
||||
/// subtype outside the settled set above. A league logo has no Core kind of its
|
||||
/// own (it is not ownable club content in Core's vocabulary), so subtype 31
|
||||
/// deliberately maps to `None` rather than being folded into `Misc`.
|
||||
pub fn club_item_kind(subtype: i64) -> Option<ContentKind> {
|
||||
let kind = match subtype {
|
||||
KIT_SUBTYPE => ContentKind::Kit,
|
||||
STADIUM_SUBTYPE => ContentKind::Stadium,
|
||||
BADGE_SUBTYPE => ContentKind::Badge,
|
||||
BALL_SUBTYPE => ContentKind::Ball,
|
||||
_ => return None,
|
||||
};
|
||||
Some(kind)
|
||||
}
|
||||
|
||||
/// The three MY CLUB position tabs (`type=playerdefender|playermidfielder|
|
||||
/// playerforward`). `FUN_18012ddf0` remaps request field `*(req+0x14)` values
|
||||
/// `0x1c/0x1d/0x1e` onto type codes `0x1b/0x1c/0x1d` and SUPPRESSES `position=`,
|
||||
/// so a position tab arrives as one of those three tokens with no other filter.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PositionGroup {
|
||||
Defender,
|
||||
Midfielder,
|
||||
Forward,
|
||||
}
|
||||
|
||||
/// The FIFA 17 position ID for a FUT position label, from the client's OWN `pos`
|
||||
/// vocabulary — the NUL-terminated `{const char*, int}` table at `0x1802295c0`
|
||||
/// that it emits as the transfer-market `&pos=%s` parameter:
|
||||
/// `GK=0 RWB=2 RB=3 CB=5 LB=7 LWB=8 CDM=10 RM=12 CM=14 LM=16 CAM=18 RF=20 CF=21
|
||||
/// LF=22 RW=23 ST=25 LW=27`.
|
||||
///
|
||||
/// `None` = a label outside that table (never guessed): the item then belongs to
|
||||
/// no position tab rather than to an invented one.
|
||||
pub fn position_id(pos: &str) -> Option<i64> {
|
||||
let id = match pos {
|
||||
"GK" => 0,
|
||||
"RWB" => 2,
|
||||
"RB" => 3,
|
||||
"CB" => 5,
|
||||
"LB" => 7,
|
||||
"LWB" => 8,
|
||||
"CDM" => 10,
|
||||
"RM" => 12,
|
||||
"CM" => 14,
|
||||
"LM" => 16,
|
||||
"CAM" => 18,
|
||||
"RF" => 20,
|
||||
"CF" => 21,
|
||||
"LF" => 22,
|
||||
"RW" => 23,
|
||||
"ST" => 25,
|
||||
"LW" => 27,
|
||||
_ => return None,
|
||||
};
|
||||
Some(id)
|
||||
}
|
||||
|
||||
/// Which position tab a FUT position label belongs to, or `None` for a label
|
||||
/// outside the client's own `pos` table.
|
||||
///
|
||||
/// The ladder is the client's, not ours: `FUN_180135890` recomputes `rec+0x14c`
|
||||
/// from the position at `rec+0x146` as `0 → GK`, `1..=8 → DEF`, `9..=19 → MID`,
|
||||
/// `20..=27 → ATT`.
|
||||
///
|
||||
/// THE ONE GUESS, named: GK is folded into `Defender`, because the client has
|
||||
/// exactly three position tabs and no fourth, so a keeper must land in one of
|
||||
/// them or vanish from every drill-down. Falsifier: if the DEF tab renders
|
||||
/// without goalkeepers, move GK out (the group boundary becomes `1..=8`).
|
||||
pub fn position_group(pos: &str) -> Option<PositionGroup> {
|
||||
match position_id(pos)? {
|
||||
0..=8 => Some(PositionGroup::Defender),
|
||||
9..=19 => Some(PositionGroup::Midfielder),
|
||||
20..=27 => Some(PositionGroup::Forward),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn content_kind_round_trips_and_defaults_to_player() {
|
||||
assert_eq!(ContentKind::default(), ContentKind::Player);
|
||||
// The FULL Core content vocabulary, every token round-tripping.
|
||||
let all = [
|
||||
ContentKind::Player,
|
||||
ContentKind::Manager,
|
||||
ContentKind::Staff,
|
||||
ContentKind::Consumable,
|
||||
ContentKind::Kit,
|
||||
ContentKind::Badge,
|
||||
ContentKind::Ball,
|
||||
ContentKind::Stadium,
|
||||
ContentKind::Misc,
|
||||
];
|
||||
for k in all {
|
||||
assert_eq!(ContentKind::from_str(k.as_str()), k);
|
||||
}
|
||||
let tokens: Vec<&str> = all.iter().map(|k| k.as_str()).collect();
|
||||
assert_eq!(
|
||||
tokens,
|
||||
vec![
|
||||
"player",
|
||||
"manager",
|
||||
"staff",
|
||||
"consumable",
|
||||
"kit",
|
||||
"badge",
|
||||
"ball",
|
||||
"stadium",
|
||||
"misc"
|
||||
],
|
||||
"these exact strings are the cross-crate contract with Core"
|
||||
);
|
||||
// Unknown / absent tokens fall back to Player (backward compatible).
|
||||
assert_eq!(ContentKind::from_str(""), ContentKind::Player);
|
||||
assert_eq!(ContentKind::from_str("nonsense"), ContentKind::Player);
|
||||
assert_eq!(ContentKind::from_str("player"), ContentKind::Player);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_manager_and_staff_are_the_staff_family() {
|
||||
for k in [ContentKind::Manager, ContentKind::Staff] {
|
||||
assert!(k.is_staff_family(), "{} is a staff card", k.as_str());
|
||||
}
|
||||
for k in [
|
||||
ContentKind::Player,
|
||||
ContentKind::Consumable,
|
||||
ContentKind::Kit,
|
||||
ContentKind::Badge,
|
||||
ContentKind::Ball,
|
||||
ContentKind::Stadium,
|
||||
ContentKind::Misc,
|
||||
] {
|
||||
assert!(!k.is_staff_family(), "{} is not staff", k.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_consumable_family_needs_exactly_what_the_client_reads() {
|
||||
// Grouped from data/consumables.json's per-subtype `needs`, and observed
|
||||
// key-for-key in the real profile import.
|
||||
for f in [
|
||||
"gk_training",
|
||||
"player_training",
|
||||
"healing",
|
||||
"player_fitness",
|
||||
"squad_fitness",
|
||||
"player_playstyle",
|
||||
"gk_playstyle",
|
||||
"manager_league",
|
||||
] {
|
||||
assert_eq!(consumable_needs(f), ConsumableNeeds::Amount, "{f}");
|
||||
}
|
||||
for f in ["player_contract", "manager_contract"] {
|
||||
assert_eq!(consumable_needs(f), ConsumableNeeds::Contract, "{f}");
|
||||
}
|
||||
for f in ["manager_formation_mod", "formation_mod", "position_mod"] {
|
||||
assert_eq!(consumable_needs(f), ConsumableNeeds::None, "{f}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_route_categories_partition_the_reachable_families() {
|
||||
// The seven group codes, plus the singular `contract` spelling.
|
||||
let segments = [
|
||||
"training",
|
||||
"contracts",
|
||||
"fitness",
|
||||
"healing",
|
||||
"position",
|
||||
"playstyle",
|
||||
"managerleaguemodifier",
|
||||
];
|
||||
let mut seen: Vec<&str> = Vec::new();
|
||||
for seg in segments {
|
||||
for f in consumable_families_for_category(seg).unwrap() {
|
||||
assert!(!seen.contains(f), "{f} claimed by two categories");
|
||||
seen.push(f);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
consumable_families_for_category("contract"),
|
||||
consumable_families_for_category("contracts"),
|
||||
"both spellings the client has used mean the same set"
|
||||
);
|
||||
// Eleven of the thirteen families are reachable; the two formation
|
||||
// modifiers have no group code in the client's own table.
|
||||
assert_eq!(seen.len(), 11, "no duplicates: {seen:?}");
|
||||
for subtype in [51, 61, 91, 201, 202, 211, 219, 220, 250, 269, 300] {
|
||||
let (family, _) = consumable_family(subtype).unwrap();
|
||||
assert!(seen.contains(&family), "no category serves {family}");
|
||||
}
|
||||
for unreachable in [71, 121] {
|
||||
let (family, _) = consumable_family(unreachable).unwrap();
|
||||
assert!(
|
||||
!seen.contains(&family),
|
||||
"{family} has no group code; claiming it would invent a segment"
|
||||
);
|
||||
}
|
||||
// Not a consumables segment (and NOT a `?type=` token either).
|
||||
for s in ["", "player", "kit", "Training", "development"] {
|
||||
assert!(
|
||||
consumable_families_for_category(s).is_none(),
|
||||
"{s:?} is not a consumable category"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn club_item_subtypes_are_the_settled_five() {
|
||||
assert_eq!(club_item_kind(KIT_SUBTYPE), Some(ContentKind::Kit));
|
||||
assert_eq!(club_item_kind(STADIUM_SUBTYPE), Some(ContentKind::Stadium));
|
||||
assert_eq!(club_item_kind(BADGE_SUBTYPE), Some(ContentKind::Badge));
|
||||
assert_eq!(club_item_kind(BALL_SUBTYPE), Some(ContentKind::Ball));
|
||||
assert_eq!((KIT_SUBTYPE, STADIUM_SUBTYPE, BADGE_SUBTYPE), (9, 10, 11));
|
||||
assert_eq!((BALL_SUBTYPE, LEAGUE_LOGO_SUBTYPE), (30, 31));
|
||||
// A league logo is not ownable Core content, so it maps to no kind.
|
||||
assert_eq!(club_item_kind(LEAGUE_LOGO_SUBTYPE), None);
|
||||
// Trophies (0x91..0x96) are NOT club items, and staff/consumable
|
||||
// subtypes must never be mistaken for one.
|
||||
for s in [0, 4, 8, 0x91, 0x96, 201, 231] {
|
||||
assert_eq!(club_item_kind(s), None, "subtype {s} is not a club item");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_groups_follow_the_clients_own_ladder() {
|
||||
// Ids are the client's `pos` table; groups are its 0/1..8/9..19/20..27
|
||||
// recompute. GK folded into DEF is the one named guess.
|
||||
for p in ["GK", "CB", "LB", "RB", "LWB", "RWB"] {
|
||||
assert_eq!(position_group(p), Some(PositionGroup::Defender), "{p}");
|
||||
}
|
||||
for p in ["CDM", "CM", "CAM", "LM", "RM"] {
|
||||
assert_eq!(position_group(p), Some(PositionGroup::Midfielder), "{p}");
|
||||
}
|
||||
for p in ["RF", "CF", "LF", "RW", "ST", "LW"] {
|
||||
assert_eq!(position_group(p), Some(PositionGroup::Forward), "{p}");
|
||||
}
|
||||
assert_eq!(position_id("ST"), Some(25));
|
||||
assert_eq!(position_id("CDM"), Some(10));
|
||||
// Not in the client's table → no tab, never an invented one.
|
||||
for p in ["", "SW", "st", "MID", "SUB"] {
|
||||
assert_eq!(position_group(p), None, "{p:?}");
|
||||
assert_eq!(position_id(p), None, "{p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_family_range_boundaries() {
|
||||
// Each contiguous range: lower boundary, upper boundary, family + label.
|
||||
let cases: &[(i64, i64, &str, &str)] = &[
|
||||
(51, 57, "gk_training", "GK Training"),
|
||||
(61, 67, "player_training", "Player Training"),
|
||||
(71, 86, "manager_formation_mod", "Manager Formation"),
|
||||
(91, 110, "position_mod", "Position Modifier"),
|
||||
(121, 136, "formation_mod", "Formation Modifier"),
|
||||
(211, 218, "healing", "Healing"),
|
||||
(250, 268, "player_playstyle", "Player Chemistry Style"),
|
||||
(269, 273, "gk_playstyle", "GK Chemistry Style"),
|
||||
(300, 341, "manager_league", "Manager League Modifier"),
|
||||
];
|
||||
for &(lo, hi, family, label) in cases {
|
||||
assert_eq!(consumable_family(lo), Some((family, label)), "lo {lo}");
|
||||
assert_eq!(consumable_family(hi), Some((family, label)), "hi {hi}");
|
||||
}
|
||||
// Singleton subtypes.
|
||||
assert_eq!(
|
||||
consumable_family(201),
|
||||
Some(("player_contract", "Player Contract"))
|
||||
);
|
||||
assert_eq!(
|
||||
consumable_family(202),
|
||||
Some(("manager_contract", "Manager Contract"))
|
||||
);
|
||||
assert_eq!(
|
||||
consumable_family(219),
|
||||
Some(("player_fitness", "Player Fitness"))
|
||||
);
|
||||
assert_eq!(
|
||||
consumable_family(220),
|
||||
Some(("squad_fitness", "Squad Fitness"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_family_gaps_and_out_of_range_are_none() {
|
||||
// Just outside range edges, and in documented gaps between ranges.
|
||||
for s in [
|
||||
0, 50, 58, 60, 68, 70, 87, 90, 111, 120, 137, 200, 203, 210, 221, 249, 274, 299, 342,
|
||||
999,
|
||||
] {
|
||||
assert_eq!(consumable_family(s), None, "subtype {s} must be unknown");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staff_role_each_role_and_unknown_is_none() {
|
||||
assert_eq!(staff_role(4), Some(("manager", "Manager")));
|
||||
assert_eq!(staff_role(5), Some(("headcoach", "Head Coach")));
|
||||
assert_eq!(staff_role(6), Some(("gkcoach", "GK Coach")));
|
||||
assert_eq!(staff_role(7), Some(("physio", "Physio")));
|
||||
assert_eq!(staff_role(8), Some(("fitnesscoach", "Fitness Coach")));
|
||||
for s in [0, 1, 2, 3, 9, 10, 201, 300] {
|
||||
assert_eq!(staff_role(s), None, "staff subtype {s} must be unknown");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
//! FIFA 17 authoritative economy engine — coins + unopened-pack entitlements +
|
||||
//! owned inventory + stable item ids — with all-or-nothing transactional mutations.
|
||||
//!
|
||||
//! A faithful port of the Python oracle's `fut_store.Store` mutation primitives
|
||||
//! (`spend`/`grant_coins`/`quick_sell`/`record_match`/`grant_unopened_pack`/
|
||||
//! `consume_unopened_pack`/`open_pack`/`add_items`) — the single-writer engine the
|
||||
//! eventual economy cutover needs.
|
||||
//!
|
||||
//! ## Status / why not wired (R3)
|
||||
//!
|
||||
//! This engine is deliberately **not wired** into the live host. The live FIFA 17
|
||||
//! coin balance is one indivisible writer set — Store BUY (`spend`), pack-open,
|
||||
//! quick-sell, match rewards (`record_match`) AND the transfer-market buy-now
|
||||
//! (`spend`) all mutate the same `coins` + inventory in Python's `fut_profile.json`.
|
||||
//! No single route can become Rust-authoritative without migrating the whole
|
||||
//! cluster at once. The intended generic home (OpenFUT Core) is a preserved-dirty,
|
||||
//! frozen submodule, so the generic currency/inventory/transaction primitives can't
|
||||
//! land there yet. This engine + importer is therefore the coherent prerequisite:
|
||||
//! wiring it (and migrating every coin/inventory writer in one cut) is the R1 task.
|
||||
//!
|
||||
//! Generic concepts (balance/inventory/transaction) belong in Core once unfrozen;
|
||||
//! they are kept in the adapter meanwhile without distorting Core.
|
||||
//!
|
||||
//! ## Economy-parameter provenance
|
||||
//!
|
||||
//! Prices/odds/quick-sell values are current OpenFUT **PLACEHOLDER** economy, not
|
||||
//! EA-authentic. The mutation *invariants* (atomic debit, consume-once, no partial
|
||||
//! state, sentinel non-grantable) are the load-bearing contract this engine enforces.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::store_catalog::pack_by_id;
|
||||
use crate::fut::store_session::SENTINEL_PACK_ID;
|
||||
|
||||
/// A transactional failure. On any `Err`, the engine is left UNCHANGED (no partial
|
||||
/// mutation) — the caller may retry or surface a wire error.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum EconomyError {
|
||||
/// Debit rejected: balance would go negative.
|
||||
InsufficientFunds { balance: i64, needed: i64 },
|
||||
/// Pack id is not in the catalogue (includes the 65534 sentinel).
|
||||
UnknownPack(u64),
|
||||
/// No owned instance of this pack to consume.
|
||||
NotOwned(u64),
|
||||
/// The 65534 sentinel can never be bought/granted/opened.
|
||||
SentinelRejected,
|
||||
}
|
||||
|
||||
/// The authoritative per-profile economy state. Mirrors the load-bearing
|
||||
/// `fut_profile.json` fields. Wrap in a profile-scoped `Mutex`/DB transaction at the
|
||||
/// host boundary (as the eventual Core repository will); the methods here are the
|
||||
/// atomic units.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProfileEconomy {
|
||||
coins: i64,
|
||||
unopened_pack_ids: Vec<u64>,
|
||||
items: Vec<Value>,
|
||||
next_item_id: i64,
|
||||
}
|
||||
|
||||
impl ProfileEconomy {
|
||||
/// A fresh, empty economy (coins 0, no packs/items, ids from 1).
|
||||
pub fn new() -> Self {
|
||||
ProfileEconomy {
|
||||
coins: 0,
|
||||
unopened_pack_ids: Vec::new(),
|
||||
items: Vec::new(),
|
||||
next_item_id: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Import from a `fut_profile.json` object (the current authoritative store).
|
||||
/// Deterministic and idempotent on a fixture: reads coins, `unopenedPackIds`,
|
||||
/// `items`, `nextItemId`; missing fields take safe defaults. Never mutates the
|
||||
/// source. Production migration is NOT executed here.
|
||||
pub fn from_fut_profile(profile: &Value) -> Self {
|
||||
let coins = profile.get("coins").and_then(Value::as_i64).unwrap_or(0);
|
||||
let unopened_pack_ids = profile
|
||||
.get("unopenedPackIds")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(Value::as_u64).collect())
|
||||
.unwrap_or_default();
|
||||
let items = profile
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
// Continue item-id allocation past the highest existing id so re-import can
|
||||
// never mint a duplicate (Python persists nextItemId; we also floor by it).
|
||||
let max_item_id = items
|
||||
.iter()
|
||||
.filter_map(|it| it.get("id").and_then(Value::as_i64))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let next_item_id = profile
|
||||
.get("nextItemId")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(1)
|
||||
.max(max_item_id + 1);
|
||||
ProfileEconomy {
|
||||
coins,
|
||||
unopened_pack_ids,
|
||||
items,
|
||||
next_item_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write this economy back onto a `fut_profile.json` object (round-trip / export).
|
||||
/// Only the economy fields are touched; every other key is preserved.
|
||||
pub fn apply_to_fut_profile(&self, profile: &mut Value) {
|
||||
let obj = profile
|
||||
.as_object_mut()
|
||||
.expect("fut_profile is a JSON object");
|
||||
obj.insert("coins".into(), json!(self.coins));
|
||||
obj.insert("unopenedPackIds".into(), json!(self.unopened_pack_ids));
|
||||
obj.insert("items".into(), Value::Array(self.items.clone()));
|
||||
obj.insert("nextItemId".into(), json!(self.next_item_id));
|
||||
}
|
||||
|
||||
// ── reads ────────────────────────────────────────────────────────────────
|
||||
pub fn coins(&self) -> i64 {
|
||||
self.coins
|
||||
}
|
||||
pub fn unopened_pack_ids(&self) -> &[u64] {
|
||||
&self.unopened_pack_ids
|
||||
}
|
||||
pub fn next_item_id(&self) -> i64 {
|
||||
self.next_item_id
|
||||
}
|
||||
pub fn item_ids(&self) -> Vec<i64> {
|
||||
self.items
|
||||
.iter()
|
||||
.filter_map(|it| it.get("id").and_then(Value::as_i64))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── generic primitives (atomic building blocks) ───────────────────────────
|
||||
|
||||
/// Credit coins (reward/quick-sell proceeds). Non-negative by type.
|
||||
pub fn credit(&mut self, amount: u64) {
|
||||
self.coins += amount as i64;
|
||||
}
|
||||
|
||||
/// Debit coins. Fail-closed: insufficient balance leaves coins UNCHANGED.
|
||||
pub fn debit(&mut self, amount: u64) -> Result<(), EconomyError> {
|
||||
let needed = amount as i64;
|
||||
if self.coins < needed {
|
||||
return Err(EconomyError::InsufficientFunds {
|
||||
balance: self.coins,
|
||||
needed,
|
||||
});
|
||||
}
|
||||
self.coins -= needed;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Grant one owned instance of a catalogue pack (reward/purchase entitlement).
|
||||
/// Rejects the 65534 sentinel and any non-catalogue id (mirrors
|
||||
/// `grant_unopened_pack`, which returns False for `pack_by_id() is None`).
|
||||
pub fn grant_pack(&mut self, pack_id: u64) -> Result<(), EconomyError> {
|
||||
if pack_id == SENTINEL_PACK_ID {
|
||||
return Err(EconomyError::SentinelRejected);
|
||||
}
|
||||
if pack_by_id(pack_id).is_none() {
|
||||
return Err(EconomyError::UnknownPack(pack_id));
|
||||
}
|
||||
self.unopened_pack_ids.push(pack_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Consume exactly one owned instance of a pack. Fail-closed: not owned ⇒ Err,
|
||||
/// no mutation (consume-once; mirrors `consume_unopened_pack`).
|
||||
pub fn consume_pack(&mut self, pack_id: u64) -> Result<(), EconomyError> {
|
||||
match self.unopened_pack_ids.iter().position(|&p| p == pack_id) {
|
||||
Some(idx) => {
|
||||
self.unopened_pack_ids.remove(idx);
|
||||
Ok(())
|
||||
}
|
||||
None => Err(EconomyError::NotOwned(pack_id)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Allocate the next stable, monotonic, unique item id.
|
||||
pub fn allocate_item_id(&mut self) -> i64 {
|
||||
let id = self.next_item_id;
|
||||
self.next_item_id += 1;
|
||||
id
|
||||
}
|
||||
|
||||
/// Add an owned item, stamping a fresh unique id (overwriting any incoming id),
|
||||
/// and return the assigned id.
|
||||
pub fn add_item(&mut self, mut item: Value) -> i64 {
|
||||
let id = self.allocate_item_id();
|
||||
if let Some(obj) = item.as_object_mut() {
|
||||
obj.insert("id".into(), json!(id));
|
||||
}
|
||||
self.items.push(item);
|
||||
id
|
||||
}
|
||||
|
||||
// ── composed atomic transactions (validate-then-mutate) ────────────────────
|
||||
|
||||
/// Store BUY as an entitlement: validate the pack + funds FIRST, then debit and
|
||||
/// grant the unopened pack — all-or-nothing. Rejects the sentinel. (The Python
|
||||
/// oracle opens on buy; the authoritative model separates buy→entitlement→open,
|
||||
/// which is why `open_pack` exists — a DIFFERENT-BY-DESIGN improvement over the
|
||||
/// reference, preserving the coin/entitlement invariants.)
|
||||
pub fn buy_pack(&mut self, pack_id: u64, price: u64) -> Result<(), EconomyError> {
|
||||
if pack_id == SENTINEL_PACK_ID {
|
||||
return Err(EconomyError::SentinelRejected);
|
||||
}
|
||||
if pack_by_id(pack_id).is_none() {
|
||||
return Err(EconomyError::UnknownPack(pack_id));
|
||||
}
|
||||
if self.coins < price as i64 {
|
||||
return Err(EconomyError::InsufficientFunds {
|
||||
balance: self.coins,
|
||||
needed: price as i64,
|
||||
});
|
||||
}
|
||||
// Both preconditions hold: commit.
|
||||
self.coins -= price as i64;
|
||||
self.unopened_pack_ids.push(pack_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open an owned pack: consume exactly one entitlement FIRST (so a failed/absent
|
||||
/// entitlement grants nothing), then add the generated items with fresh ids.
|
||||
/// Returns the ids granted. Content generation/odds are the caller's concern and
|
||||
/// are current OpenFUT PLACEHOLDER.
|
||||
pub fn open_pack(&mut self, pack_id: u64, items: Vec<Value>) -> Result<Vec<i64>, EconomyError> {
|
||||
self.consume_pack(pack_id)?; // fail-closed: no items on a missing entitlement
|
||||
Ok(items.into_iter().map(|it| self.add_item(it)).collect())
|
||||
}
|
||||
|
||||
/// Quick-sell owned items by id: remove them and credit the caller-computed value
|
||||
/// total (values are FIFA17 policy, placeholder). Only ids actually owned are
|
||||
/// sold/credited (mirrors `quick_sell`). Returns `(sold_count, coins_credited)`.
|
||||
pub fn quick_sell(&mut self, ids: &[i64], value_of: impl Fn(&Value) -> u64) -> (u64, u64) {
|
||||
let want: std::collections::HashSet<i64> = ids.iter().copied().collect();
|
||||
let mut credited = 0u64;
|
||||
let mut sold = 0u64;
|
||||
let mut kept = Vec::with_capacity(self.items.len());
|
||||
for it in std::mem::take(&mut self.items) {
|
||||
let owned_id = it.get("id").and_then(Value::as_i64);
|
||||
if owned_id.is_some_and(|id| want.contains(&id)) {
|
||||
credited += value_of(&it);
|
||||
sold += 1;
|
||||
} else {
|
||||
kept.push(it);
|
||||
}
|
||||
}
|
||||
self.items = kept;
|
||||
if sold > 0 {
|
||||
self.credit(credited);
|
||||
}
|
||||
(sold, credited)
|
||||
}
|
||||
|
||||
/// Transfer-market buy-now: debit the price FIRST, then acquire the item — the
|
||||
/// market shares the SAME authoritative coin balance (that is why it is inside
|
||||
/// this engine's boundary). All-or-nothing.
|
||||
pub fn market_buy_now(&mut self, price: u64, item: Value) -> Result<i64, EconomyError> {
|
||||
self.debit(price)?;
|
||||
Ok(self.add_item(item))
|
||||
}
|
||||
|
||||
/// Match/reward coin credit (`record_match` coin part; SBC/objective grants).
|
||||
pub fn grant_reward(&mut self, coins: u64) {
|
||||
self.credit(coins);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ProfileEconomy {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn eco(coins: i64, unopened: &[u64]) -> ProfileEconomy {
|
||||
ProfileEconomy {
|
||||
coins,
|
||||
unopened_pack_ids: unopened.to_vec(),
|
||||
items: Vec::new(),
|
||||
next_item_id: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debit_insufficient_is_fail_closed() {
|
||||
let mut e = eco(100, &[]);
|
||||
assert_eq!(
|
||||
e.debit(101),
|
||||
Err(EconomyError::InsufficientFunds {
|
||||
balance: 100,
|
||||
needed: 101
|
||||
})
|
||||
);
|
||||
assert_eq!(e.coins(), 100, "no mutation on failure");
|
||||
assert_eq!(e.debit(100), Ok(()));
|
||||
assert_eq!(e.coins(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buy_pack_is_atomic() {
|
||||
// Insufficient funds: neither coins nor entitlements change.
|
||||
let mut poor = eco(399, &[]);
|
||||
assert!(matches!(
|
||||
poor.buy_pack(1, 400),
|
||||
Err(EconomyError::InsufficientFunds { .. })
|
||||
));
|
||||
assert_eq!(poor.coins(), 399);
|
||||
assert!(poor.unopened_pack_ids().is_empty());
|
||||
// Enough funds: debit + grant together.
|
||||
let mut ok = eco(1000, &[]);
|
||||
assert_eq!(ok.buy_pack(1, 400), Ok(()));
|
||||
assert_eq!(ok.coins(), 600);
|
||||
assert_eq!(ok.unopened_pack_ids(), &[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sentinel_can_never_be_bought_or_granted() {
|
||||
let mut e = eco(1_000_000, &[]);
|
||||
assert_eq!(
|
||||
e.buy_pack(SENTINEL_PACK_ID, 0),
|
||||
Err(EconomyError::SentinelRejected)
|
||||
);
|
||||
assert_eq!(
|
||||
e.grant_pack(SENTINEL_PACK_ID),
|
||||
Err(EconomyError::SentinelRejected)
|
||||
);
|
||||
// Never openable either (no entitlement can exist for it).
|
||||
assert_eq!(
|
||||
e.open_pack(SENTINEL_PACK_ID, vec![json!({})]),
|
||||
Err(EconomyError::NotOwned(SENTINEL_PACK_ID))
|
||||
);
|
||||
assert_eq!(e.coins(), 1_000_000, "sentinel ops never mutate economy");
|
||||
assert!(e.item_ids().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_pack_rejected() {
|
||||
let mut e = eco(1_000_000, &[]);
|
||||
assert_eq!(e.buy_pack(999, 0), Err(EconomyError::UnknownPack(999)));
|
||||
assert_eq!(e.grant_pack(999), Err(EconomyError::UnknownPack(999)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_pack_consumes_exactly_once() {
|
||||
let mut e = eco(0, &[70]);
|
||||
let granted = e.open_pack(70, vec![json!({"rating": 84}), json!({"rating": 90})]);
|
||||
assert_eq!(granted, Ok(vec![1, 2]));
|
||||
assert!(e.unopened_pack_ids().is_empty(), "entitlement consumed");
|
||||
assert_eq!(e.item_ids(), vec![1, 2], "unique ids assigned");
|
||||
// Second open of the same (now-absent) entitlement grants nothing.
|
||||
assert_eq!(
|
||||
e.open_pack(70, vec![json!({})]),
|
||||
Err(EconomyError::NotOwned(70))
|
||||
);
|
||||
assert_eq!(e.item_ids(), vec![1, 2], "no items added on failed open");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_sell_removes_owned_and_credits() {
|
||||
let mut e = eco(100, &[]);
|
||||
let a = e.add_item(json!({"rating": 84}));
|
||||
let b = e.add_item(json!({"rating": 90}));
|
||||
// sell only `a`; an unknown id is ignored (not owned).
|
||||
let (sold, credited) = e.quick_sell(&[a, 99999], |_| 300);
|
||||
assert_eq!((sold, credited), (1, 300));
|
||||
assert_eq!(e.coins(), 400);
|
||||
assert_eq!(e.item_ids(), vec![b], "only the sold item removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn market_buy_now_is_atomic() {
|
||||
let mut poor = eco(50, &[]);
|
||||
assert!(matches!(
|
||||
poor.market_buy_now(100, json!({"rating": 84})),
|
||||
Err(EconomyError::InsufficientFunds { .. })
|
||||
));
|
||||
assert_eq!(poor.coins(), 50);
|
||||
assert!(poor.item_ids().is_empty(), "no item acquired on failed buy");
|
||||
let mut ok = eco(500, &[]);
|
||||
let id = ok.market_buy_now(100, json!({"rating": 84})).unwrap();
|
||||
assert_eq!(ok.coins(), 400);
|
||||
assert_eq!(ok.item_ids(), vec![id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_ids_are_unique_and_monotonic() {
|
||||
let mut e = ProfileEconomy::new();
|
||||
let ids: Vec<i64> = (0..5).map(|_| e.allocate_item_id()).collect();
|
||||
assert_eq!(ids, vec![1, 2, 3, 4, 5]);
|
||||
assert_eq!(e.next_item_id(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fut_profile_import_export_round_trip() {
|
||||
let mut profile = json!({
|
||||
"personaId": 33068179,
|
||||
"coins": 29876776,
|
||||
"unopenedPackIds": [70],
|
||||
"items": [{"id": 41, "rating": 84}, {"id": 42, "rating": 90}],
|
||||
"nextItemId": 43,
|
||||
"clubName": "OpenFUT"
|
||||
});
|
||||
let e = ProfileEconomy::from_fut_profile(&profile);
|
||||
assert_eq!(e.coins(), 29876776);
|
||||
assert_eq!(e.unopened_pack_ids(), &[70]);
|
||||
assert_eq!(e.next_item_id(), 43, "past the highest existing id");
|
||||
// Export preserves unrelated keys and reflects the economy exactly.
|
||||
e.apply_to_fut_profile(&mut profile);
|
||||
assert_eq!(
|
||||
profile["clubName"],
|
||||
json!("OpenFUT"),
|
||||
"unrelated key preserved"
|
||||
);
|
||||
assert_eq!(profile["coins"], json!(29876776));
|
||||
assert_eq!(profile["nextItemId"], json!(43));
|
||||
// Re-import is stable.
|
||||
let e2 = ProfileEconomy::from_fut_profile(&profile);
|
||||
assert_eq!(e, e2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_floors_next_item_id_past_existing_ids() {
|
||||
// A stale/low nextItemId must never mint a duplicate id.
|
||||
let profile = json!({
|
||||
"coins": 0,
|
||||
"items": [{"id": 500}],
|
||||
"nextItemId": 10
|
||||
});
|
||||
let mut e = ProfileEconomy::from_fut_profile(&profile);
|
||||
assert_eq!(e.next_item_id(), 501);
|
||||
assert_eq!(e.allocate_item_id(), 501);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//! FIFA 17 economy POLICY mappers (pure, game-specific).
|
||||
//!
|
||||
//! These translate FIFA 17 wire semantics into the generic amounts the host
|
||||
//! feeds to Core economy authority. They own NO state — Core owns balances and
|
||||
//! inventory; these are the FIFA-specific numbers/derivations. Pack prices come
|
||||
//! from the Store catalogue; the transfer-market fee is the FUT-era 5%.
|
||||
//!
|
||||
//! Match result mapping + reward-body shaping live in [`crate::fut::match_wire`].
|
||||
|
||||
use crate::fut::store_catalog::pack_by_id;
|
||||
|
||||
/// The Store buy-now price for a pack id (`None` for unknown/owned-only packs,
|
||||
/// which are never purchasable).
|
||||
pub fn pack_price(pack_id: u64) -> Option<u64> {
|
||||
pack_by_id(pack_id)
|
||||
.filter(|p| !p.owned_only)
|
||||
.map(|p| p.price)
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pack_price_rejects_unknown_and_owned_only() {
|
||||
assert!(pack_price(1).is_some());
|
||||
assert_eq!(pack_price(65534), None); // sentinel absent from catalogue
|
||||
assert_eq!(pack_price(70), None); // owned-only reward pack, not purchasable
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
//! FIFA 17 entity id ⇄ name resolution, loaded from the committed game-DB
|
||||
//! tables (`leagues.json`, `nations.json`, `teams.json`, dumped from
|
||||
//! `FIFA17.exe`'s resident DB).
|
||||
//!
|
||||
//! Two directions, both FIFA-17-specific and therefore adapter-owned:
|
||||
//! * **forward** id → name — resolves a wire filter (`league=13`) to the
|
||||
//! semantic name Core filters on ("Premier League"). See [`EntityResolver`].
|
||||
//! * **reverse** name → id — shapes a Core item's names back into the numeric
|
||||
//! ids the FIFA `/club` response carries (`leagueId`/`teamid`/`nation`).
|
||||
//!
|
||||
//! Grounding (worker-verified against the tables): forward is 1:1 for all three
|
||||
//! (unique ids, no gaps). Reverse is clean for leagues (50 distinct names) and
|
||||
//! nations (221 distinct); **team names collide** (e.g. `Arsenal` ×3, plus the
|
||||
//! FUT "CHAMPIONS *" placeholder teams), so reverse team lookup is first-id-wins
|
||||
//! and a collision count is exposed for diagnostics. `teamid == assetid` on
|
||||
//! every row.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::fut::owned_query::EntityResolver;
|
||||
|
||||
/// Reverse (name → FIFA id) resolution, used when shaping a Core item back onto
|
||||
/// the FIFA wire. Unknown names return `None`; the shaper substitutes a neutral
|
||||
/// `0` (a valid, non-desyncing int) rather than dropping the item.
|
||||
pub trait ReverseEntityResolver {
|
||||
fn league_id(&self, name: &str) -> Option<u32>;
|
||||
fn nation_id(&self, name: &str) -> Option<u32>;
|
||||
fn team_id(&self, name: &str) -> Option<u32>;
|
||||
}
|
||||
|
||||
/// Forward + reverse FIFA 17 entity maps.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Fifa17Entities {
|
||||
league_by_id: HashMap<u32, String>,
|
||||
league_by_name: HashMap<String, u32>,
|
||||
nation_by_id: HashMap<u32, String>,
|
||||
nation_by_name: HashMap<String, u32>,
|
||||
team_by_id: HashMap<u32, String>,
|
||||
team_by_name: HashMap<String, u32>,
|
||||
/// name-collisions dropped from the reverse maps (diagnostics only).
|
||||
pub reverse_collisions: usize,
|
||||
}
|
||||
|
||||
/// Errors loading the entity tables.
|
||||
#[derive(Debug)]
|
||||
pub enum LoadError {
|
||||
Io(std::io::Error),
|
||||
Parse { file: String, detail: String },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LoadError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
LoadError::Io(e) => write!(f, "reading entity tables: {e}"),
|
||||
LoadError::Parse { file, detail } => write!(f, "parsing {file}: {detail}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for LoadError {}
|
||||
|
||||
impl Fifa17Entities {
|
||||
/// Load from a directory holding `leagues.json`, `nations.json`,
|
||||
/// `teams.json` in the FIFA17 `{schema, rows:[...]}` dump format.
|
||||
pub fn from_tables_dir(dir: &std::path::Path) -> Result<Self, LoadError> {
|
||||
let mut e = Fifa17Entities::default();
|
||||
e.load_table(
|
||||
&dir.join("leagues.json"),
|
||||
"leagueid",
|
||||
"leaguename",
|
||||
Entity::League,
|
||||
)?;
|
||||
e.load_table(
|
||||
&dir.join("nations.json"),
|
||||
"nationid",
|
||||
"nationname",
|
||||
Entity::Nation,
|
||||
)?;
|
||||
e.load_table(&dir.join("teams.json"), "teamid", "teamname", Entity::Team)?;
|
||||
Ok(e)
|
||||
}
|
||||
|
||||
fn load_table(
|
||||
&mut self,
|
||||
path: &std::path::Path,
|
||||
id_key: &str,
|
||||
name_key: &str,
|
||||
which: Entity,
|
||||
) -> Result<(), LoadError> {
|
||||
let raw = std::fs::read_to_string(path).map_err(LoadError::Io)?;
|
||||
let file = path.display().to_string();
|
||||
let doc: serde_json::Value = serde_json::from_str(&raw).map_err(|e| LoadError::Parse {
|
||||
file: file.clone(),
|
||||
detail: e.to_string(),
|
||||
})?;
|
||||
let rows = doc
|
||||
.get("rows")
|
||||
.and_then(|r| r.as_array())
|
||||
.ok_or_else(|| LoadError::Parse {
|
||||
file: file.clone(),
|
||||
detail: "missing `rows` array".into(),
|
||||
})?;
|
||||
for row in rows {
|
||||
let (Some(id), Some(name)) = (
|
||||
row.get(id_key).and_then(|v| v.as_u64()),
|
||||
row.get(name_key).and_then(|v| v.as_str()),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let id = id as u32;
|
||||
let (by_id, by_name) = match which {
|
||||
Entity::League => (&mut self.league_by_id, &mut self.league_by_name),
|
||||
Entity::Nation => (&mut self.nation_by_id, &mut self.nation_by_name),
|
||||
Entity::Team => (&mut self.team_by_id, &mut self.team_by_name),
|
||||
};
|
||||
by_id.insert(id, name.to_string());
|
||||
// Reverse: first id wins on a name collision; count the rest.
|
||||
if by_name.contains_key(name) {
|
||||
self.reverse_collisions += 1;
|
||||
} else {
|
||||
by_name.insert(name.to_string(), id);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build directly from maps (tests / small deployments).
|
||||
pub fn from_maps(
|
||||
leagues: HashMap<u32, String>,
|
||||
nations: HashMap<u32, String>,
|
||||
teams: HashMap<u32, String>,
|
||||
) -> Self {
|
||||
let invert = |m: &HashMap<u32, String>| {
|
||||
let mut out = HashMap::new();
|
||||
for (&id, name) in m {
|
||||
out.entry(name.clone()).or_insert(id);
|
||||
}
|
||||
out
|
||||
};
|
||||
Fifa17Entities {
|
||||
league_by_name: invert(&leagues),
|
||||
nation_by_name: invert(&nations),
|
||||
team_by_name: invert(&teams),
|
||||
league_by_id: leagues,
|
||||
nation_by_id: nations,
|
||||
team_by_id: teams,
|
||||
reverse_collisions: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn league_count(&self) -> usize {
|
||||
self.league_by_id.len()
|
||||
}
|
||||
pub fn nation_count(&self) -> usize {
|
||||
self.nation_by_id.len()
|
||||
}
|
||||
pub fn team_count(&self) -> usize {
|
||||
self.team_by_id.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Entity {
|
||||
League,
|
||||
Nation,
|
||||
Team,
|
||||
}
|
||||
|
||||
impl EntityResolver for Fifa17Entities {
|
||||
fn league_name(&self, id: u32) -> Option<String> {
|
||||
self.league_by_id.get(&id).cloned()
|
||||
}
|
||||
fn nation_name(&self, id: u32) -> Option<String> {
|
||||
self.nation_by_id.get(&id).cloned()
|
||||
}
|
||||
fn team_name(&self, id: u32) -> Option<String> {
|
||||
self.team_by_id.get(&id).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
impl ReverseEntityResolver for Fifa17Entities {
|
||||
fn league_id(&self, name: &str) -> Option<u32> {
|
||||
self.league_by_name.get(name).copied()
|
||||
}
|
||||
fn nation_id(&self, name: &str) -> Option<u32> {
|
||||
self.nation_by_name.get(name).copied()
|
||||
}
|
||||
fn team_id(&self, name: &str) -> Option<u32> {
|
||||
self.team_by_name.get(name).copied()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Path to the committed game-DB tables, relative to this crate.
|
||||
fn tables_dir() -> std::path::PathBuf {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../fifa17-recon/data/tables")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_committed_tables_and_resolves_both_directions() {
|
||||
let dir = tables_dir();
|
||||
if !dir.join("leagues.json").exists() {
|
||||
eprintln!(
|
||||
"skipping: committed tables not present at {}",
|
||||
dir.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
let e = Fifa17Entities::from_tables_dir(&dir).expect("load tables");
|
||||
assert_eq!(e.league_count(), 50);
|
||||
assert_eq!(e.nation_count(), 221);
|
||||
assert_eq!(e.team_count(), 750);
|
||||
// forward id -> name (grounding pinned in owned_query too)
|
||||
assert_eq!(e.league_name(13).as_deref(), Some("Premier League"));
|
||||
assert_eq!(e.nation_name(52).as_deref(), Some("Argentina"));
|
||||
assert_eq!(e.team_name(5).as_deref(), Some("Chelsea"));
|
||||
// reverse name -> id (clean for leagues/nations)
|
||||
assert_eq!(e.league_id("Premier League"), Some(13));
|
||||
assert_eq!(e.nation_id("Argentina"), Some(52));
|
||||
assert_eq!(e.team_id("Chelsea"), Some(5));
|
||||
// unknown -> None (never a raw-id fallback)
|
||||
assert_eq!(e.league_name(999_999), None);
|
||||
assert_eq!(e.team_id("Northgate United"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_maps_inverts() {
|
||||
let e = Fifa17Entities::from_maps(
|
||||
HashMap::from([(13, "Premier League".to_string())]),
|
||||
HashMap::from([(52, "Argentina".to_string())]),
|
||||
HashMap::from([(5, "Chelsea".to_string())]),
|
||||
);
|
||||
assert_eq!(e.league_id("Premier League"), Some(13));
|
||||
assert_eq!(e.team_name(5).as_deref(), Some("Chelsea"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,737 @@
|
||||
//! The shared FIFA 17 FUT **item-shaping primitive**.
|
||||
//!
|
||||
//! One function shapes one owned FUT item into the numeric card object the FIFA
|
||||
//! 17 client renders, and **every** route that emits a player item goes through
|
||||
//! it — `/club` (via [`crate::fut::club_response`]) and squad projection (via
|
||||
//! [`crate::fut::squad_projection`]) alike. There is deliberately no second copy
|
||||
//! of the field set: an item is shaped in exactly one place so the two routes
|
||||
//! can never drift.
|
||||
//!
|
||||
//! ## The asset-id boundary (load-bearing, evidence-grounded)
|
||||
//!
|
||||
//! FIFA renders a card by resolving `resourceId & 0xffffff` against the client's
|
||||
//! OWN local players table (proven live): a real id renders a real footballer,
|
||||
//! an **invented id renders a blank generic card**. OpenFUT Core's catalogue is
|
||||
//! synthetic string ids (`card_pl_001`) with no FIFA asset id. So an
|
||||
//! [`ItemIdentityResolver`] is injected; when it cannot supply a **real** FIFA
|
||||
//! asset id for an item, the item carries no fabricated identity — the caller
|
||||
//! decides what that means (`/club` drops and counts it; a squad refuses to
|
||||
//! project a starter it cannot render, never faking one).
|
||||
//!
|
||||
//! Entity ids (`leagueId`/`teamid`/`nation`) come from a reverse resolver; an
|
||||
//! unresolved name yields a neutral `0` (a valid int — non-fatal; it only means
|
||||
//! "no badge/flag"), because those are not the identity the renderer keys on.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::content_taxonomy::{
|
||||
consumable_family, consumable_needs, ConsumableNeeds, ContentKind, MANAGER_SUBTYPE,
|
||||
};
|
||||
use crate::fut::entities::ReverseEntityResolver;
|
||||
use crate::fut::item_state;
|
||||
|
||||
/// One owned item in game-independent terms, as read from Core's inventory.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoreOwnedItem {
|
||||
/// Core owned-instance id (string). The stable per-copy identity — two
|
||||
/// copies of the same card definition have distinct `owned_card_id`s.
|
||||
pub owned_card_id: String,
|
||||
/// Core card-definition id (string), used for asset resolution.
|
||||
pub card_id: String,
|
||||
/// Effective overall rating.
|
||||
pub rating: u8,
|
||||
/// Effective position, e.g. "ST".
|
||||
pub position: String,
|
||||
pub nation: String,
|
||||
pub league: String,
|
||||
pub club: String,
|
||||
/// [pace, shooting, passing, dribbling, defending, physical].
|
||||
pub attributes: [u8; 6],
|
||||
}
|
||||
|
||||
/// The FIFA-side numeric identity of an owned item. `asset_id` MUST be a real
|
||||
/// FIFA player asset (low 24 bits the client resolves); `item_id` is the wire
|
||||
/// instance id used for later item operations. Two owned copies of the same
|
||||
/// definition share an `asset_id` but MUST have distinct `item_id`s.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Fifa17Identity {
|
||||
pub item_id: u32,
|
||||
/// Base FIFA player asset (low 24 bits the client resolves art/name from).
|
||||
pub asset_id: u32,
|
||||
/// Full versioned resource id = `(version << 24) | asset_id`. Equals
|
||||
/// `asset_id` for a version-0 base card. This is the wire
|
||||
/// `resourceId`/`definitionId`, kept DISTINCT from `asset_id` so a versioned
|
||||
/// (special) card never collapses onto its base on the wire.
|
||||
pub resource_id: u32,
|
||||
/// FIFA wire `rareflag` — the card's rare/special TYPE (e.g. 3=inform,
|
||||
/// 21..=24 = special programmes). Drives the client's special-card art;
|
||||
/// carried from the catalog, never hardcoded, so specials render as specials.
|
||||
pub rareflag: i64,
|
||||
}
|
||||
|
||||
/// FIFA-side identity fields needed to render an owned club kit. Unlike player
|
||||
/// items, kit art and source-team metadata come from `fcc_kitcards`, not Core.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Fifa17KitIdentity {
|
||||
pub item_id: u32,
|
||||
pub asset_id: u32,
|
||||
pub resource_id: u32,
|
||||
pub card_asset_id: u32,
|
||||
pub subtype: i64,
|
||||
pub team_id: i64,
|
||||
}
|
||||
|
||||
/// FIFA-side identity fields needed to render an owned staff card (manager or
|
||||
/// coach). Unlike a player, a staff record carries NO attributes, rating,
|
||||
/// position or rareflag: the client merges all of those from its own
|
||||
/// `managercards`/`*coachcards` tables keyed on `resource_id`.
|
||||
///
|
||||
/// `nation`/`league_id`/`team_id` are meaningful for a MANAGER only
|
||||
/// (`subtype == MANAGER_SUBTYPE`) and are zero for the four coach families,
|
||||
/// whose tables carry no nation/league/team column.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Fifa17StaffIdentity {
|
||||
pub item_id: u32,
|
||||
/// THE merge key, read RAW as a u32 by `FUN_1801356c0` with NO `& 0xffffff`
|
||||
/// mask (players are the only family that is masked). It must equal the
|
||||
/// table `carddbid` exactly — a non-zero version byte silently breaks the
|
||||
/// lookup, and the manager branch has no else-arm to report the miss.
|
||||
pub resource_id: u32,
|
||||
/// `cardsubtypeid`. This ALONE selects which staff table the client merges.
|
||||
pub subtype: i64,
|
||||
pub nation: i64,
|
||||
pub league_id: i64,
|
||||
pub team_id: i64,
|
||||
}
|
||||
|
||||
/// FIFA-side identity + definition facts needed to render an owned consumable.
|
||||
///
|
||||
/// A consumable carries NO id space to discover: `FUN_18013f4d0` never touches a
|
||||
/// DB handle, and category, artwork, name and both stat bytes all derive from
|
||||
/// `cardsubtypeid` alone. What it does need is the fcc_* row's ART id and the one
|
||||
/// extra key its family reads — see [`Fifa17ConsumableIdentity::is_renderable`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Fifa17ConsumableIdentity {
|
||||
pub item_id: u32,
|
||||
/// `rec+0x18`. Bookkeeping only for a consumable (artwork is a client-side
|
||||
/// constant, so this never reaches the screen), but kept as EA's own
|
||||
/// `carddbid` so nothing drifts out of their space.
|
||||
pub resource_id: u32,
|
||||
pub asset_id: u32,
|
||||
/// The fcc_* `cardassetid` — the ART id, NOT a copy of `resource_id`.
|
||||
/// Observed values in the real profile: 3 (training), 7/8 (contracts),
|
||||
/// 9 (healing), 34 (position), 50/51 (play style). Copying `resource_id`
|
||||
/// here is right for players and wrong for every other family: the client
|
||||
/// looks up art `5003001`, finds none, and draws the `notfound.swf` green
|
||||
/// "NOT FOUND" box.
|
||||
pub card_asset_id: u32,
|
||||
/// `rec+0x50`. THE ONLY selector: category, artwork, name and both stat
|
||||
/// bytes derive from it.
|
||||
pub subtype: i64,
|
||||
/// `rec+0x58`. Observed 0 on every owned consumable in the real profile.
|
||||
pub rareflag: i64,
|
||||
/// `rec+0xb4`. Drives the card level (`rec+0x54`) and therefore the
|
||||
/// `fcc_discardcoins` price. Definition-level EA data (55..95 observed).
|
||||
pub rating: u8,
|
||||
/// `amount` (atom 0x1b) → `rec+0xbf`, or `+0xbe` for a play style.
|
||||
/// `Some` exactly for the families [`ConsumableNeeds::Amount`] names.
|
||||
pub amount: Option<i64>,
|
||||
/// `contract` (atom 0xb8) → `rec+0x8c`. `Some` for the two contract
|
||||
/// families only; they ignore `amount` entirely.
|
||||
pub contract: Option<i64>,
|
||||
/// `rec+0x49`. Per-INSTANCE in FIFA, unmodelled by Core, so the host passes
|
||||
/// the observed constant [`CONSUMABLE_UNTRADEABLE`]. Carried per copy rather
|
||||
/// than baked into the shaper because the consumables route's stack wrapper
|
||||
/// reports `untradeableCount` over the copies in the stack.
|
||||
pub untradeable: bool,
|
||||
}
|
||||
|
||||
impl Fifa17ConsumableIdentity {
|
||||
/// Whether this definition can be drawn HONESTLY. Three refusals, every one a
|
||||
/// silent-failure guard rather than taste:
|
||||
///
|
||||
/// * the family's mandatory extra key is missing — the parser initialises
|
||||
/// its `amount` temp to `-1` and both accessors read the byte SIGNED, so
|
||||
/// an omission draws "-1" on the card, not "0" (and a contract card with
|
||||
/// no `contract` grants nothing);
|
||||
/// * `rareflag != 0` on subtype 219 — `FUN_1801bfac0` case 5 renders a RARE
|
||||
/// Player Fitness card as a SQUAD Fitness card, i.e. a different item
|
||||
/// entirely, with no error anywhere;
|
||||
/// * `card_asset_id == asset_id` — a consumable's art id is a SMALL `fcc_`
|
||||
/// art id (3, 7, 8, 9, 34, 50, 51 observed) and never its own `carddbid`,
|
||||
/// so this means the catalog carried no `card_asset_id` and the client
|
||||
/// would draw `notfound.swf`, the green "NOT FOUND" box.
|
||||
///
|
||||
/// A subtype outside every documented range is also refused: it falls to
|
||||
/// `FUN_18013f4d0`'s bottom default and renders as a perfectly ordinary
|
||||
/// Squad Training (Pace) card with amount 0 — plausible and wrong.
|
||||
pub fn is_renderable(&self) -> bool {
|
||||
if self.subtype == SQUAD_FITNESS_TRAP_SUBTYPE && self.rareflag != 0 {
|
||||
return false;
|
||||
}
|
||||
if self.card_asset_id == self.asset_id {
|
||||
return false;
|
||||
}
|
||||
match consumable_family(self.subtype) {
|
||||
None => false,
|
||||
Some((family, _)) => match consumable_needs(family) {
|
||||
ConsumableNeeds::Amount => self.amount.is_some(),
|
||||
ConsumableNeeds::Contract => self.contract.is_some(),
|
||||
ConsumableNeeds::None => true,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Supplies the FIFA numeric identity for a Core item. Returning `None` means
|
||||
/// "no real FIFA asset id known" → the caller must not fabricate one.
|
||||
pub trait ItemIdentityResolver {
|
||||
fn resolve(&self, item: &CoreOwnedItem) -> Option<Fifa17Identity>;
|
||||
|
||||
/// Resolve one owned kit definition. Default `None` preserves existing
|
||||
/// player-only resolvers; the catalog-backed FIFA17 resolver overrides it.
|
||||
fn resolve_kit(&self, _item: &CoreOwnedItem) -> Option<Fifa17KitIdentity> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolve one owned staff definition (manager or coach). Default `None`
|
||||
/// preserves existing resolvers; the catalog-backed FIFA17 resolver
|
||||
/// overrides it.
|
||||
fn resolve_staff(&self, _item: &CoreOwnedItem) -> Option<Fifa17StaffIdentity> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Resolve one owned consumable definition. Default `None` preserves
|
||||
/// existing resolvers; the catalog-backed FIFA17 resolver overrides it.
|
||||
fn resolve_consumable(&self, _item: &CoreOwnedItem) -> Option<Fifa17ConsumableIdentity> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The FIFA `cardsubtypeid` of a Core item's definition, or `0` when unknown
|
||||
/// or a player. NON-MINTING by contract: `/club`'s per-family filters call it
|
||||
/// for every owned row, so allocating a wire id here would pollute the
|
||||
/// identity store on a read.
|
||||
fn subtype_of(&self, _item: &CoreOwnedItem) -> i64 {
|
||||
0
|
||||
}
|
||||
|
||||
/// Classify a Core item's definition into the content vocabulary. Defaults to
|
||||
/// [`ContentKind::Player`] so existing resolvers keep their behaviour; a
|
||||
/// catalog-backed resolver overrides this to consult its `kind_of`, letting
|
||||
/// `/club` exclude non-player content (which must never render as a
|
||||
/// 0-rated player).
|
||||
fn kind_of(&self, _item: &CoreOwnedItem) -> ContentKind {
|
||||
ContentKind::Player
|
||||
}
|
||||
}
|
||||
|
||||
/// Diagnostics from shaping (safe to log — counts only).
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ShapeStats {
|
||||
pub emitted: usize,
|
||||
/// No real FIFA asset id for this definition — dropped, never faked.
|
||||
pub dropped_no_asset: usize,
|
||||
/// The definition resolved but is INCOMPLETE or self-contradictory, so
|
||||
/// drawing it would be a lie the client cannot detect (a consumable missing
|
||||
/// the mandatory `amount`/`contract`, or the subtype-219 rareflag trap).
|
||||
/// Dropped and counted separately, because the fix is a catalog re-emit, not
|
||||
/// an identity mapping.
|
||||
pub dropped_incomplete: usize,
|
||||
/// Owned content this envelope deliberately does not carry: a CONSUMABLE
|
||||
/// (its own route serves it as a stack), or a club-customisation family
|
||||
/// whose record shape is not yet verified (badge, ball, stadium, misc).
|
||||
/// Core owns the row; the projection is withheld, never guessed.
|
||||
pub excluded_non_player: usize,
|
||||
}
|
||||
|
||||
/// Quick-sell / discard value by rating tier (mirrors Core's quick-sell table;
|
||||
/// non-fatal display field).
|
||||
fn discard_value(rating: u8) -> i64 {
|
||||
match rating {
|
||||
r if r >= 85 => 1500,
|
||||
r if r >= 80 => 900,
|
||||
r if r >= 75 => 600,
|
||||
r if r >= 65 => 300,
|
||||
_ => 150,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build one FIFA `_item` object. `resourceId`/`definitionId` carry the full
|
||||
/// versioned resource id; `assetId`/`cardassetid` carry the base asset. For a
|
||||
/// version-0 base card these coincide; for a special they differ and MUST NOT
|
||||
/// be collapsed.
|
||||
///
|
||||
/// This is the single source of truth for a player item's on-wire shape; the
|
||||
/// `/club` envelope and squad projection both call it, so their items are
|
||||
/// identical by construction. `id` is the owned instance's resolved FIFA
|
||||
/// identity — pass the resolver's answer for *this* owned copy so two copies of
|
||||
/// one definition stay distinct on the wire.
|
||||
pub fn shape_item(
|
||||
item: &CoreOwnedItem,
|
||||
id: Fifa17Identity,
|
||||
ent: &impl ReverseEntityResolver,
|
||||
) -> Value {
|
||||
let asset = id.asset_id;
|
||||
let league_id = ent.league_id(&item.league).unwrap_or(0);
|
||||
let team_id = ent.team_id(&item.club).unwrap_or(0);
|
||||
let nation_id = ent.nation_id(&item.nation).unwrap_or(0);
|
||||
let attribute_list: Vec<Value> = item
|
||||
.attributes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| json!({ "index": i, "value": v }))
|
||||
.collect();
|
||||
json!({
|
||||
"id": id.item_id,
|
||||
"resourceId": id.resource_id,
|
||||
"assetId": asset,
|
||||
"cardassetid": asset,
|
||||
"definitionId": id.resource_id,
|
||||
"cardsubtypeid": 0,
|
||||
"itemType": "player",
|
||||
"rareflag": id.rareflag,
|
||||
"rating": item.rating,
|
||||
"preferredPosition": item.position,
|
||||
"nation": nation_id,
|
||||
"teamid": team_id,
|
||||
"leagueId": league_id,
|
||||
"playStyle": 250,
|
||||
"attributeList": attribute_list,
|
||||
"itemState": item_state::FREE,
|
||||
"owners": 1,
|
||||
// Owned/pack-pulled cards are TRADEABLE in FIFA 17 (untradeable is the
|
||||
// exception for SBC/promo rewards, which Core does not model). Emitting
|
||||
// `true` greyed out "Place on Transfer Market" for every card — the same
|
||||
// "our own data showing through" bug the Python oracle fixed by forcing
|
||||
// this off for owned copies (item_def keeps `true`; instances do not).
|
||||
"untradeable": false,
|
||||
"contract": 7,
|
||||
"fitness": 99,
|
||||
"discardValue": discard_value(item.rating),
|
||||
})
|
||||
}
|
||||
|
||||
/// Build one FIFA 17 club-kit item. `item_state` is the proven wire enum token:
|
||||
/// `free`, `activeHomeKit`, or `activeAwayKit`; the client deserializes the
|
||||
/// latter two to runtime values 101 and 102.
|
||||
pub fn shape_kit_item(id: Fifa17KitIdentity, item_state: &str) -> Value {
|
||||
json!({
|
||||
"id": id.item_id,
|
||||
"resourceId": id.resource_id,
|
||||
"assetId": id.asset_id,
|
||||
"cardassetid": id.card_asset_id,
|
||||
"cardsubtypeid": id.subtype,
|
||||
"itemState": item_state,
|
||||
"owners": 1,
|
||||
"untradeable": false,
|
||||
"teamid": id.team_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Contracts remaining on an owned staff card.
|
||||
///
|
||||
/// Staff consume contracts exactly as players do (`rec+0x8c`), and the client
|
||||
/// refuses to start a match when the manager's has run out. Core does not model
|
||||
/// staff contracts, so this mirrors the constant [`shape_item`] already emits
|
||||
/// for players rather than inventing a second, different default.
|
||||
pub const STAFF_CONTRACT: i64 = 7;
|
||||
|
||||
/// Build one FIFA 17 staff item (manager or coach).
|
||||
///
|
||||
/// The key set is deliberately minimal and is taken field-by-field from the
|
||||
/// instruction-level reversal in `fifa17-recon/tools/fut_staff.py`, where every
|
||||
/// key is justified by its CardsDLL record offset:
|
||||
///
|
||||
/// * `id` → `rec+0x08`, `resourceId` → `rec+0x18` (the RAW merge key),
|
||||
/// `cardsubtypeid` → `rec+0x50` (alone selects which staff table is merged),
|
||||
/// `contract` → `rec+0x8c`, `itemState` → `rec+0x5c`, `owners` → `rec+0x48`,
|
||||
/// `untradeable` → `rec+0x49`.
|
||||
/// * `nation` → `rec+0xde` and `leagueId` → `rec+0xe0` are MANAGER-ONLY record
|
||||
/// slots the client's merge never writes, so the server is their only source;
|
||||
/// they drive the manager's flag, league badge and both halves of manager
|
||||
/// chemistry. `teamid` → `rec+0x94` is read by the card view-model.
|
||||
///
|
||||
/// Everything else is omitted on purpose, because each is either overwritten by
|
||||
/// the merge from the client's own table (`assetId`/`cardassetid` at `rec+0x20`,
|
||||
/// `rating` at `rec+0xb4`, `rareflag` at `rec+0x58`), skipped by the parser
|
||||
/// (`definitionId`), or — worse — SURVIVES the merge and is then read by the
|
||||
/// view-model, which would hang a position label or an attribute row on a
|
||||
/// manager (`preferredPosition` at `rec+0x146`, `attributeList` at `rec+0x98`).
|
||||
/// A staff card must therefore never be routed through [`shape_item`].
|
||||
///
|
||||
/// The four coach families carry no nation/league/team columns in the client's
|
||||
/// tables, so those three keys are emitted for a manager only rather than being
|
||||
/// invented as zeroes for a coach.
|
||||
pub fn shape_staff_item(id: Fifa17StaffIdentity, contract: i64) -> Value {
|
||||
let mut item = json!({
|
||||
"id": id.item_id,
|
||||
"resourceId": id.resource_id,
|
||||
"cardsubtypeid": id.subtype,
|
||||
// Inert on the wire (the parser reads atom 0x173 into a stack string and
|
||||
// frees it), but it is what every staff family reports, and our own
|
||||
// readers use it to tell a staff card from a footballer at a glance.
|
||||
"itemType": "staff",
|
||||
"contract": contract,
|
||||
"itemState": item_state::FREE,
|
||||
"owners": 1,
|
||||
"untradeable": false,
|
||||
});
|
||||
if id.subtype == MANAGER_SUBTYPE {
|
||||
let obj = item.as_object_mut().expect("json! built an object");
|
||||
obj.insert("nation".to_string(), json!(id.nation));
|
||||
obj.insert("leagueId".to_string(), json!(id.league_id));
|
||||
obj.insert("teamid".to_string(), json!(id.team_id));
|
||||
}
|
||||
item
|
||||
}
|
||||
|
||||
/// Build one FIFA 17 consumable item.
|
||||
///
|
||||
/// The key set is EXACTLY what the real profile import holds for its 17 owned
|
||||
/// consumables — i.e. what the client itself stored — and every key is a key the
|
||||
/// live player path already proves, so this introduces NO new wire shape:
|
||||
///
|
||||
/// * `id` → `rec+0x08`, `resourceId` → `rec+0x18`, `assetId`, `cardassetid` (the
|
||||
/// ART id, see [`Fifa17ConsumableIdentity::card_asset_id`]),
|
||||
/// `cardsubtypeid` → `rec+0x50`, `rareflag` → `rec+0x58`,
|
||||
/// `rating` → `rec+0xb4`, `itemState` → `rec+0x5c`, `owners` → `rec+0x48`,
|
||||
/// `untradeable` → `rec+0x49`.
|
||||
/// * `amount` → `rec+0xbf` / `+0xbe` and `contract` → `rec+0x8c`, each emitted
|
||||
/// only for the families that read it (the caller has already gated on
|
||||
/// [`Fifa17ConsumableIdentity::is_renderable`]).
|
||||
///
|
||||
/// `itemType` is `"player"`, which is not a mislabel: it is the ONLY value this
|
||||
/// client has ever been sent, it is what the real profile stores on all 17, and
|
||||
/// `cardtype` is derived from `cardsubtypeid` alone (`FUN_18013fe00`), so the
|
||||
/// string cannot affect the render. A consumable is discriminated by its subtype
|
||||
/// plus the ABSENCE of `attributeList`; inventing `"consumable"` here would be a
|
||||
/// fabricated token.
|
||||
///
|
||||
/// `untradeable` is carried per copy from
|
||||
/// [`Fifa17ConsumableIdentity::untradeable`] (the host supplies the observed
|
||||
/// [`CONSUMABLE_UNTRADEABLE`]), because the consumables route reports
|
||||
/// `untradeableCount` over a stack and the two must agree.
|
||||
///
|
||||
/// DELIBERATELY ABSENT, each for a named reason:
|
||||
/// * `teamid`, `leagueid` and `value` — the three "extras" copied out of an fcc
|
||||
/// row that CRASHED the client on 2026-08-05. `value` is the established
|
||||
/// culprit (it is an OBJECT member elsewhere, and a scalar where an object is
|
||||
/// expected is the type-desync busy loop at `0x1801c7f1a`); none of the three
|
||||
/// is needed to draw a card.
|
||||
/// * `preferredPosition`, `nation`, `playStyle`, `attributeList`, `fitness` —
|
||||
/// player-only, and `attributeList` is the very thing that distinguishes a
|
||||
/// footballer from a consumable.
|
||||
/// * `definitionId` — not an atom at all; the parser has always skipped it.
|
||||
/// * `discardValue` — the client computes it from `fcc_discardcoins` on
|
||||
/// `(cardtype 6, level, rare)`, and real rows exist for both rare values.
|
||||
/// * `pile` — Core/host state (the transfer pile), not a wire atom: the
|
||||
/// live-proven player path does not send it either.
|
||||
pub fn shape_consumable_item(id: Fifa17ConsumableIdentity) -> Value {
|
||||
let mut item = json!({
|
||||
"id": id.item_id,
|
||||
"resourceId": id.resource_id,
|
||||
"assetId": id.asset_id,
|
||||
"cardassetid": id.card_asset_id,
|
||||
"cardsubtypeid": id.subtype,
|
||||
"itemType": "player",
|
||||
"rareflag": id.rareflag,
|
||||
"rating": id.rating,
|
||||
"itemState": item_state::FREE,
|
||||
"owners": 1,
|
||||
"untradeable": id.untradeable,
|
||||
});
|
||||
let obj = item.as_object_mut().expect("json! built an object");
|
||||
if let Some(amount) = id.amount {
|
||||
obj.insert("amount".to_string(), json!(amount));
|
||||
}
|
||||
if let Some(contract) = id.contract {
|
||||
obj.insert("contract".to_string(), json!(contract));
|
||||
}
|
||||
item
|
||||
}
|
||||
|
||||
/// `cardsubtypeid` of the PLAYER FITNESS card, and the one subtype where
|
||||
/// `rareflag` is load-bearing rather than cosmetic: `FUN_1801bfac0` case 5 reads
|
||||
/// it as the squad-fitness selector, so a rare Player Fitness card silently
|
||||
/// becomes a SQUAD Fitness card — a different item, with no error anywhere.
|
||||
pub const SQUAD_FITNESS_TRAP_SUBTYPE: i64 = 219;
|
||||
|
||||
/// Tradeability of an owned consumable.
|
||||
///
|
||||
/// FIFA models this per INSTANCE (`rec+0x49`) and Core does not model it at all,
|
||||
/// so this is the observed value, not a policy: all 17 owned consumables in the
|
||||
/// real profile import carry `untradeable: true`, and it is also the oracle's own
|
||||
/// default for the family. When Core models per-instance tradeability, this
|
||||
/// constant is what it replaces.
|
||||
///
|
||||
/// Note the lever it controls on screen: the consumables deserializer sets a UI
|
||||
/// flag from `untradeableCount < count`, so an all-untradeable stack draws the
|
||||
/// untradeable badge. That is correct for genuinely untradeable copies; it was
|
||||
/// only wrong for the oracle's SYNTHETIC shelf, where the badge was its own data
|
||||
/// showing through.
|
||||
pub const CONSUMABLE_UNTRADEABLE: bool = true;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::fut::entities::Fifa17Entities;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn entities() -> Fifa17Entities {
|
||||
Fifa17Entities::from_maps(
|
||||
HashMap::from([(13, "Premier League".to_string())]),
|
||||
HashMap::from([(52, "Argentina".to_string())]),
|
||||
HashMap::from([(5, "Chelsea".to_string())]),
|
||||
)
|
||||
}
|
||||
|
||||
fn item(owned: &str, card: &str, rating: u8, pos: &str) -> CoreOwnedItem {
|
||||
CoreOwnedItem {
|
||||
owned_card_id: owned.into(),
|
||||
card_id: card.into(),
|
||||
rating,
|
||||
position: pos.into(),
|
||||
nation: "Argentina".into(),
|
||||
league: "Premier League".into(),
|
||||
club: "Chelsea".into(),
|
||||
attributes: [90, 88, 70, 85, 40, 78],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_real_identity_and_reverse_entity_ids() {
|
||||
let ent = entities();
|
||||
let it = shape_item(
|
||||
&item("oc1", "card_ch_1", 86, "CDM"),
|
||||
Fifa17Identity {
|
||||
item_id: 100000001,
|
||||
asset_id: 20801,
|
||||
resource_id: 20801,
|
||||
rareflag: 1,
|
||||
},
|
||||
&ent,
|
||||
);
|
||||
assert_eq!(it["id"], 100000001, "wire instance id");
|
||||
assert_eq!(it["resourceId"], 20801);
|
||||
assert_eq!(it["assetId"], 20801);
|
||||
assert_eq!(
|
||||
it["definitionId"], 20801,
|
||||
"version byte 0 => resourceId==assetId==definitionId"
|
||||
);
|
||||
assert_eq!(it["rating"], 86);
|
||||
assert_eq!(it["preferredPosition"], "CDM");
|
||||
assert_eq!(it["leagueId"], 13);
|
||||
assert_eq!(it["teamid"], 5);
|
||||
assert_eq!(it["nation"], 52);
|
||||
assert_eq!(it["itemType"], "player");
|
||||
assert_eq!(it["attributeList"].as_array().unwrap().len(), 6);
|
||||
assert_eq!(it["attributeList"][0], json!({"index":0,"value":90}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_owned_copies_of_one_definition_stay_distinct_on_the_wire() {
|
||||
// fifa17_101490 has two owned instances: same definition/asset, two
|
||||
// distinct owned ids and two distinct wire ids. Shaping each from its
|
||||
// own identity must NEVER collapse them.
|
||||
let ent = entities();
|
||||
let a = shape_item(
|
||||
&item("oc-a", "fifa17_101490", 84, "ST"),
|
||||
Fifa17Identity {
|
||||
item_id: 100000030,
|
||||
asset_id: 101490,
|
||||
resource_id: 101490,
|
||||
rareflag: 1,
|
||||
},
|
||||
&ent,
|
||||
);
|
||||
let b = shape_item(
|
||||
&item("oc-b", "fifa17_101490", 84, "ST"),
|
||||
Fifa17Identity {
|
||||
item_id: 100000031,
|
||||
asset_id: 101490,
|
||||
resource_id: 101490,
|
||||
rareflag: 1,
|
||||
},
|
||||
&ent,
|
||||
);
|
||||
assert_eq!(
|
||||
a["resourceId"], b["resourceId"],
|
||||
"same definition => same asset"
|
||||
);
|
||||
assert_ne!(
|
||||
a["id"], b["id"],
|
||||
"distinct owned copies keep distinct wire ids"
|
||||
);
|
||||
assert_eq!(a["id"], 100000030);
|
||||
assert_eq!(b["id"], 100000031);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versioned_special_keeps_resourceid_distinct_from_assetid() {
|
||||
// A versioned (special) card: resourceId/definitionId carry the full
|
||||
// versioned id; assetId/cardassetid stay the base asset. They MUST NOT
|
||||
// collapse. (resource 117617092 = version 7 of asset 176580.)
|
||||
let ent = entities();
|
||||
let it = shape_item(
|
||||
&item("oc-v", "fifa17_117617092", 92, "ST"),
|
||||
Fifa17Identity {
|
||||
item_id: 100000384,
|
||||
asset_id: 176580,
|
||||
resource_id: 117617092,
|
||||
rareflag: 3,
|
||||
},
|
||||
&ent,
|
||||
);
|
||||
assert_eq!(
|
||||
it["resourceId"], 117617092,
|
||||
"versioned resource id on the wire"
|
||||
);
|
||||
assert_eq!(it["definitionId"], 117617092);
|
||||
assert_eq!(it["assetId"], 176580, "base asset id preserved");
|
||||
assert_eq!(it["cardassetid"], 176580);
|
||||
assert_eq!(
|
||||
it["rareflag"], 3,
|
||||
"special rareflag carried, not hardcoded 1"
|
||||
);
|
||||
}
|
||||
|
||||
/// The GK-training card the real profile owns: `5003012`, art 3, subtype 54,
|
||||
/// rating 85, amount 15. Its key set is the acceptance criterion.
|
||||
fn training_consumable() -> Fifa17ConsumableIdentity {
|
||||
Fifa17ConsumableIdentity {
|
||||
item_id: 100000239,
|
||||
resource_id: 5_003_012,
|
||||
asset_id: 5_003_012,
|
||||
card_asset_id: 3,
|
||||
subtype: 54,
|
||||
rareflag: 0,
|
||||
rating: 85,
|
||||
amount: Some(15),
|
||||
contract: None,
|
||||
untradeable: CONSUMABLE_UNTRADEABLE,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_emits_exactly_the_keys_the_client_itself_stored() {
|
||||
let it = shape_consumable_item(training_consumable());
|
||||
// Verbatim from the real profile import (persona 33068179):
|
||||
// {"id":100000239,"resourceId":5003012,"assetId":5003012,"cardassetid":3,
|
||||
// "cardsubtypeid":54,"itemType":"player","rareflag":0,"rating":85,
|
||||
// "itemState":"free","owners":1,"untradeable":true,"amount":15}
|
||||
assert_eq!(
|
||||
it,
|
||||
json!({
|
||||
"id": 100000239,
|
||||
"resourceId": 5_003_012,
|
||||
"assetId": 5_003_012,
|
||||
"cardassetid": 3,
|
||||
"cardsubtypeid": 54,
|
||||
"itemType": "player",
|
||||
"rareflag": 0,
|
||||
"rating": 85,
|
||||
"itemState": "free",
|
||||
"owners": 1,
|
||||
"untradeable": true,
|
||||
"amount": 15,
|
||||
})
|
||||
);
|
||||
// The three "extras" that crashed the client on 2026-08-05, and the
|
||||
// player-only keys that would make a consumable look like a footballer.
|
||||
for forbidden in [
|
||||
"teamid",
|
||||
"leagueid",
|
||||
"leagueId",
|
||||
"value",
|
||||
"attributeList",
|
||||
"preferredPosition",
|
||||
"nation",
|
||||
"playStyle",
|
||||
"fitness",
|
||||
"definitionId",
|
||||
"discardValue",
|
||||
"pile",
|
||||
] {
|
||||
assert!(
|
||||
it.get(forbidden).is_none(),
|
||||
"a consumable must not carry `{forbidden}`"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_art_id_is_never_the_resource_id() {
|
||||
// The green "NOT FOUND" box: the client resolves artwork by cardassetid,
|
||||
// which is a SMALL fcc_ art id, not the carddbid.
|
||||
let it = shape_consumable_item(training_consumable());
|
||||
assert_eq!(it["cardassetid"], 3);
|
||||
assert_ne!(it["cardassetid"], it["resourceId"]);
|
||||
}
|
||||
|
||||
/// EVERY `itemState` this crate can put on the wire must be one of the twelve
|
||||
/// tokens recovered from the client's own table. An unrecovered token decodes
|
||||
/// to `0xffffffff` through `FUN_180166660` and the client then acts on an
|
||||
/// unrecognised state.
|
||||
#[test]
|
||||
fn every_emitted_item_state_is_in_the_recovered_table() {
|
||||
let ent = entities();
|
||||
let mut emitted: Vec<String> = Vec::new();
|
||||
let player = shape_item(
|
||||
&item("oc1", "card_ch_1", 86, "CDM"),
|
||||
Fifa17Identity {
|
||||
item_id: 1,
|
||||
asset_id: 20801,
|
||||
resource_id: 20801,
|
||||
rareflag: 1,
|
||||
},
|
||||
&ent,
|
||||
);
|
||||
emitted.push(player["itemState"].as_str().unwrap().to_string());
|
||||
let staff = shape_staff_item(
|
||||
Fifa17StaffIdentity {
|
||||
item_id: 2,
|
||||
resource_id: 1_000_509,
|
||||
subtype: MANAGER_SUBTYPE,
|
||||
nation: 45,
|
||||
league_id: 53,
|
||||
team_id: 241,
|
||||
},
|
||||
STAFF_CONTRACT,
|
||||
);
|
||||
emitted.push(staff["itemState"].as_str().unwrap().to_string());
|
||||
emitted.push(
|
||||
shape_consumable_item(training_consumable())["itemState"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
);
|
||||
// Every state `/club` can hand a kit, including both equipped roles.
|
||||
let kit = Fifa17KitIdentity {
|
||||
item_id: 3,
|
||||
asset_id: 6_300_006,
|
||||
resource_id: 6_300_006,
|
||||
card_asset_id: 35,
|
||||
subtype: 9,
|
||||
team_id: 21,
|
||||
};
|
||||
for state in [
|
||||
item_state::FREE,
|
||||
item_state::ACTIVE_HOME_KIT,
|
||||
item_state::ACTIVE_AWAY_KIT,
|
||||
] {
|
||||
let it = shape_kit_item(kit, state);
|
||||
emitted.push(it["itemState"].as_str().unwrap().to_string());
|
||||
}
|
||||
for state in &emitted {
|
||||
assert!(
|
||||
item_state::is_recovered(state),
|
||||
"{state:?} is not one of the twelve recovered itemState tokens"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!emitted.iter().any(|s| s == item_state::INVALID),
|
||||
"omitting itemState yields `invalid` (0) and fails the squad builder; \
|
||||
no shaper may emit it deliberately either"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! The FIFA 17 **`itemState` vocabulary** — the complete recovered set, and the
|
||||
//! only place these strings are written down.
|
||||
//!
|
||||
//! Twelve entries in one NUL-terminated `{const char* name, u32 value}` table at
|
||||
//! `0x180229cc0` (stride 0x10), walked in full from both disk and live memory.
|
||||
//! `FUN_180166660` is a linear walk over that table and returns `0xffffffff` for
|
||||
//! anything not in it, so an invented token is not a cosmetic slip: it decodes to
|
||||
//! "unrecognised state" and the client acts on garbage. Every shaper in this
|
||||
//! crate therefore takes its `itemState` from a constant here, and
|
||||
//! [`is_recovered`] is asserted over every emitted value by the tests.
|
||||
//!
|
||||
//! **Omitting `itemState` is NOT the same as sending [`FREE`].** The record
|
||||
//! constructor zero-initialises `+0x50..+0x5f` from `_DAT_1801f66a0`, so an
|
||||
//! absent key leaves `0` = [`INVALID`], and an item left at `0` fails the squad
|
||||
//! builder's `state == 1 || state == 2` acceptance test. Always send it.
|
||||
//!
|
||||
//! (Source: `fifa17-recon/docs/plan-2026-08-06-card-subsystem.md` §4, which also
|
||||
//! corrects `CARD_SYSTEM.md`'s earlier ten-row reading — that one started at
|
||||
//! `0x180229d20`, the MIDDLE of the table, and so missed `invalid`, `free`,
|
||||
//! `WAITING_FOR_GAME`, `inGame`, `forSale` and `offered`.)
|
||||
|
||||
/// `0` — what an item gets when `itemState` is OMITTED. No consumer found; it
|
||||
/// fails the squad builder. Never emit it deliberately.
|
||||
pub const INVALID: &str = "invalid";
|
||||
/// `1` — the normal owned state: accepted by the squad builder, and what the
|
||||
/// unequip path writes back.
|
||||
pub const FREE: &str = "free";
|
||||
/// `2` — alias of [`IN_GAME`] (both decode to 2).
|
||||
pub const WAITING_FOR_GAME: &str = "WAITING_FOR_GAME";
|
||||
/// `2` — accepted by the squad builder.
|
||||
pub const IN_GAME: &str = "inGame";
|
||||
/// `5` — an item offered for sale. Never TESTED anywhere in CardsDLL, but it is
|
||||
/// in the table, so it decodes; the transfer market emits it.
|
||||
pub const FOR_SALE: &str = "forSale";
|
||||
/// `6` — never tested anywhere in CardsDLL.
|
||||
pub const OFFERED: &str = "offered";
|
||||
/// `100` — equipped badge; drives the `IS_ACTIVE` tick.
|
||||
pub const ACTIVE_BADGE: &str = "activeBadge";
|
||||
/// `101` — equipped home kit.
|
||||
pub const ACTIVE_HOME_KIT: &str = "activeHomeKit";
|
||||
/// `102` — equipped away kit.
|
||||
pub const ACTIVE_AWAY_KIT: &str = "activeAwayKit";
|
||||
/// `103` — equipped ball; the unequip path writes [`FREE`] back over it.
|
||||
pub const ACTIVE_BALL: &str = "activeBall";
|
||||
/// `104` — equipped stadium.
|
||||
pub const ACTIVE_STADIUM: &str = "activeStadium";
|
||||
/// `255` — no consumer found.
|
||||
pub const ACTIVE: &str = "active";
|
||||
|
||||
/// The complete recovered vocabulary, in table order.
|
||||
pub const ALL: [&str; 12] = [
|
||||
INVALID,
|
||||
FREE,
|
||||
WAITING_FOR_GAME,
|
||||
IN_GAME,
|
||||
FOR_SALE,
|
||||
OFFERED,
|
||||
ACTIVE_BADGE,
|
||||
ACTIVE_HOME_KIT,
|
||||
ACTIVE_AWAY_KIT,
|
||||
ACTIVE_BALL,
|
||||
ACTIVE_STADIUM,
|
||||
ACTIVE,
|
||||
];
|
||||
|
||||
/// Whether `state` is one of the twelve recovered tokens. Case-sensitive, as the
|
||||
/// client's own lookup is a `strcmp` walk.
|
||||
pub fn is_recovered(state: &str) -> bool {
|
||||
ALL.contains(&state)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_table_is_the_twelve_recovered_rows_and_nothing_else() {
|
||||
assert_eq!(ALL.len(), 12);
|
||||
for s in ALL {
|
||||
assert!(is_recovered(s), "{s} must be in its own table");
|
||||
}
|
||||
// Tokens this project has actually seen invented or mis-cased. `listFS`
|
||||
// in particular is the Python oracle's own token and appears NOWHERE in
|
||||
// the client (zero occurrences in the DLL and in 4.26 GiB of live
|
||||
// process memory), so it decodes to -1.
|
||||
for s in [
|
||||
"listFS",
|
||||
"free ",
|
||||
"Free",
|
||||
"activehomekit",
|
||||
"sold",
|
||||
"won",
|
||||
"equipped",
|
||||
"",
|
||||
] {
|
||||
assert!(!is_recovered(s), "{s:?} is not a FIFA 17 itemState");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
//! FIFA 17 `/match` + `/match/end` wire ↔ Core match-economy mapping.
|
||||
//!
|
||||
//! This module owns the FIFA 17-specific match protocol: the `endReason` enum
|
||||
//! (wire atom 260), the `PUT …/match/end` payload shape, and the reward-response
|
||||
//! body. It is pure — no state, no Core calls. The host wires it to OpenFUT
|
||||
//! Core's authoritative `complete_match` transaction:
|
||||
//!
|
||||
//! 1. [`parse_match_end`] turns the client payload into a [`MatchEnd`].
|
||||
//! 2. [`MatchResult::core_token`] gives Core the canonical, game-independent
|
||||
//! result string — Core never sees a FIFA `endReason`.
|
||||
//! 3. Core applies the economy exactly once and returns the authoritative coin
|
||||
//! numbers, which the host renders back through [`reward_response`].
|
||||
//!
|
||||
//! Keeping every FIFA 17 constant here (never in Core) is the layering contract:
|
||||
//! a second title's adapter maps its own wire onto the same canonical tokens.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Participation award added to every match reward. FIFA 17 economy parameter
|
||||
/// (oracle `MATCH_PARTICIPATION`, production default `0`). Core owns the coin
|
||||
/// balance; this is only the wire body's cosmetic `participationAward` field.
|
||||
pub const MATCH_PARTICIPATION: i64 = 0;
|
||||
|
||||
/// Canonical match result. The FIFA 17 `endReason` enum (atom 260) is the
|
||||
/// AUTHORITATIVE source [STATIC_REVERSED]; this is the normalized shape the host
|
||||
/// forwards to Core.
|
||||
///
|
||||
/// * `Win` / `Draw` / `Loss` — a decided match.
|
||||
/// * `Dnf` — the reporting player abandoned/quit (`DNF`/`QUIT`). Economically a
|
||||
/// loss (LIVE_PROVEN: `endReason=DNF` → loss reward), tracked in its own Core
|
||||
/// statistics bucket.
|
||||
/// * `NoContest` — a voided match; zero economic effect.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MatchResult {
|
||||
Win,
|
||||
Draw,
|
||||
Loss,
|
||||
Dnf,
|
||||
NoContest,
|
||||
}
|
||||
|
||||
impl MatchResult {
|
||||
/// The canonical Core result token — the ONLY match datum the adapter hands
|
||||
/// Core. Matches `openfut_core::models::match_result::MatchResultKind`'s serde
|
||||
/// representation exactly (`win`/`draw`/`loss`/`dnf`/`no_contest`).
|
||||
pub fn core_token(self) -> &'static str {
|
||||
match self {
|
||||
MatchResult::Win => "win",
|
||||
MatchResult::Draw => "draw",
|
||||
MatchResult::Loss => "loss",
|
||||
MatchResult::Dnf => "dnf",
|
||||
MatchResult::NoContest => "no_contest",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the FIFA 17 `endReason` enum onto a canonical [`MatchResult`].
|
||||
///
|
||||
/// The enum is authoritative when present [STATIC_REVERSED]. `DNF`/`QUIT` are the
|
||||
/// reporting player's abandon (→ `Dnf`, loss economics, LIVE_PROVEN); the
|
||||
/// `DNF_WIN`/`DNF_DRAW`/`DNF_LOSS` variants carry a decided outcome (the opponent
|
||||
/// abandoned) and map to that outcome. `NO_CONTEST` voids the match. An
|
||||
/// absent/unknown reason is a conservative `Draw`, matching the oracle default.
|
||||
pub fn result_from_end_reason(end_reason: Option<&str>) -> MatchResult {
|
||||
match end_reason.unwrap_or("").to_ascii_uppercase().as_str() {
|
||||
"WIN" => MatchResult::Win,
|
||||
"DRAW" => MatchResult::Draw,
|
||||
"LOSS" => MatchResult::Loss,
|
||||
"DNF" | "QUIT" => MatchResult::Dnf,
|
||||
"DNF_WIN" => MatchResult::Win,
|
||||
"DNF_DRAW" => MatchResult::Draw,
|
||||
"DNF_LOSS" => MatchResult::Loss,
|
||||
"NO_CONTEST" => MatchResult::NoContest,
|
||||
_ => MatchResult::Draw,
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed `PUT …/match/end` payload: the fields the host needs to drive Core.
|
||||
/// Unknown fields are ignored.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MatchEnd {
|
||||
/// The raw `endReason` string, if the client sent one.
|
||||
pub end_reason: Option<String>,
|
||||
/// Canonical result derived from `end_reason`.
|
||||
pub result: MatchResult,
|
||||
/// `matchReportId` from the payload (observed `0` on the live path).
|
||||
pub match_report_id: i64,
|
||||
/// Goals scored by the reporting player — `myMatchStats[0]` (goals is the
|
||||
/// first of the 15 ints). Absent on `DNF`/`QUIT` (stats omitted) → `0`.
|
||||
pub goals_for: i64,
|
||||
/// Opponent goals — `opponentMatchStats[0]`. Absent on `DNF`/`QUIT` → `0`.
|
||||
pub goals_against: i64,
|
||||
}
|
||||
|
||||
/// Parse the FIFA 17 match-end body. Returns `None` for a body that is not a
|
||||
/// JSON object (malformed). A well-formed object with a missing/unknown
|
||||
/// `endReason` still parses — the result defaults to `Draw`.
|
||||
pub fn parse_match_end(body: &[u8]) -> Option<MatchEnd> {
|
||||
let v: Value = serde_json::from_slice(body).ok()?;
|
||||
if !v.is_object() {
|
||||
return None;
|
||||
}
|
||||
let end_reason = v
|
||||
.get("endReason")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
let result = result_from_end_reason(end_reason.as_deref());
|
||||
let match_report_id = v.get("matchReportId").and_then(Value::as_i64).unwrap_or(0);
|
||||
Some(MatchEnd {
|
||||
end_reason,
|
||||
result,
|
||||
match_report_id,
|
||||
goals_for: first_stat(&v, "myMatchStats"),
|
||||
goals_against: first_stat(&v, "opponentMatchStats"),
|
||||
})
|
||||
}
|
||||
|
||||
/// `myMatchStats`/`opponentMatchStats` are 15 ints with goals first
|
||||
/// [STATIC_REVERSED]; the arrays are OMITTED on DNF/QUIT, so a missing array is
|
||||
/// `0` goals, not an error.
|
||||
fn first_stat(v: &Value, key: &str) -> i64 {
|
||||
v.get(key)
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|a| a.first())
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Build the FIFA 17 match-reward response body (the `destroy_match_body` shape,
|
||||
/// [STATIC_REVERSED]).
|
||||
///
|
||||
/// `all_coins` is Core's AUTHORITATIVE post-credit balance; `match_coins` is the
|
||||
/// amount Core granted for THIS match (mirrored into `gameModeAward.coins`, where
|
||||
/// the client reads it). Emits ONLY the reversed fields — it NEVER emits
|
||||
/// `bidTokens` or `qualifiedChampionEventId`, which are client freeze traps.
|
||||
pub fn reward_response(all_coins: i64, match_coins: i64) -> Value {
|
||||
json!({
|
||||
"allCoins": all_coins,
|
||||
"matchCoins": match_coins,
|
||||
"seasonCoins": 0,
|
||||
"tournamentCoins": 0,
|
||||
"boostConis": 0, // EA's misspelling (atom 96), preserved on the wire.
|
||||
"participationAward": MATCH_PARTICIPATION,
|
||||
"teamOfTournamentWinner": false,
|
||||
"gameModeAward": { "coins": match_coins },
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the FIFA 17 `POST …/match` create ack. Zero economic effect: it only
|
||||
/// hands the client a match id + start time. `id` doubles as the per-match
|
||||
/// identity the host later keys Core's exactly-once completion on.
|
||||
pub fn create_response(id: i64, start_epoch: i64) -> Value {
|
||||
json!({
|
||||
"startDateTime": start_epoch,
|
||||
"reportIdEnabled": false,
|
||||
"id": id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the FIFA 17 `…/match/ready` ack (FutMatchReady). Zero economic effect.
|
||||
///
|
||||
/// Two scalars only. The response type also has an optional nested item list,
|
||||
/// which is deliberately omitted: a nested value the client half-reads is the
|
||||
/// documented freeze mode, and nothing needs it here. `opponent_persona_id` is
|
||||
/// echoed from the request when the client supplies one and is otherwise `0` —
|
||||
/// an offline AI opponent has no persona, and it must NEVER default to the
|
||||
/// player's own persona, which would claim the user is their own opponent.
|
||||
pub fn ready_response(match_id: i64, opponent_persona_id: i64) -> Value {
|
||||
json!({
|
||||
"matchId": match_id,
|
||||
"opponentPersonaId": opponent_persona_id,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_end_reason_maps_to_canonical_result() {
|
||||
assert_eq!(result_from_end_reason(Some("WIN")), MatchResult::Win);
|
||||
assert_eq!(result_from_end_reason(Some("DRAW")), MatchResult::Draw);
|
||||
assert_eq!(result_from_end_reason(Some("LOSS")), MatchResult::Loss);
|
||||
assert_eq!(result_from_end_reason(Some("DNF")), MatchResult::Dnf);
|
||||
assert_eq!(result_from_end_reason(Some("QUIT")), MatchResult::Dnf);
|
||||
assert_eq!(
|
||||
result_from_end_reason(Some("NO_CONTEST")),
|
||||
MatchResult::NoContest
|
||||
);
|
||||
assert_eq!(result_from_end_reason(Some("DNF_WIN")), MatchResult::Win);
|
||||
assert_eq!(result_from_end_reason(Some("DNF_DRAW")), MatchResult::Draw);
|
||||
assert_eq!(result_from_end_reason(Some("DNF_LOSS")), MatchResult::Loss);
|
||||
// Case-insensitive.
|
||||
assert_eq!(result_from_end_reason(Some("dnf_win")), MatchResult::Win);
|
||||
// Unknown / absent → conservative draw.
|
||||
assert_eq!(result_from_end_reason(Some("weird")), MatchResult::Draw);
|
||||
assert_eq!(result_from_end_reason(None), MatchResult::Draw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_tokens_match_core_serde() {
|
||||
assert_eq!(MatchResult::Win.core_token(), "win");
|
||||
assert_eq!(MatchResult::Draw.core_token(), "draw");
|
||||
assert_eq!(MatchResult::Loss.core_token(), "loss");
|
||||
assert_eq!(MatchResult::Dnf.core_token(), "dnf");
|
||||
assert_eq!(MatchResult::NoContest.core_token(), "no_contest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_match_end_reads_reason_and_goals() {
|
||||
let body = br#"{"matchReportId":7,"endReason":"WIN","myMatchStats":[3,1,2,0,0,0,0,0,0,0,0,0,0,0,0],"opponentMatchStats":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}"#;
|
||||
let end = parse_match_end(body).expect("parses");
|
||||
assert_eq!(end.result, MatchResult::Win);
|
||||
assert_eq!(end.match_report_id, 7);
|
||||
assert_eq!(end.goals_for, 3);
|
||||
assert_eq!(end.goals_against, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_match_end_dnf_omits_stats_as_zero() {
|
||||
// The live DNF payload: stats arrays omitted entirely.
|
||||
let body = br#"{"matchReportId":0,"endReason":"DNF","items":[],"matchData":"","matchStatusFlags":0}"#;
|
||||
let end = parse_match_end(body).expect("parses");
|
||||
assert_eq!(end.result, MatchResult::Dnf);
|
||||
assert_eq!(end.goals_for, 0);
|
||||
assert_eq!(end.goals_against, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_match_end_unknown_reason_defaults_draw() {
|
||||
let end = parse_match_end(br#"{"endReason":"BANANA"}"#).expect("parses");
|
||||
assert_eq!(end.result, MatchResult::Draw);
|
||||
assert_eq!(end.end_reason.as_deref(), Some("BANANA"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_match_end_rejects_malformed() {
|
||||
assert!(parse_match_end(b"not json").is_none());
|
||||
assert!(parse_match_end(b"[]").is_none());
|
||||
assert!(parse_match_end(b"42").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reward_response_has_only_reversed_fields() {
|
||||
let body = reward_response(29_876_876, 100);
|
||||
assert_eq!(body["allCoins"], 29_876_876);
|
||||
assert_eq!(body["matchCoins"], 100);
|
||||
assert_eq!(body["gameModeAward"]["coins"], 100);
|
||||
assert_eq!(body["seasonCoins"], 0);
|
||||
assert_eq!(body["tournamentCoins"], 0);
|
||||
assert_eq!(body["boostConis"], 0);
|
||||
assert_eq!(body["participationAward"], 0);
|
||||
assert_eq!(body["teamOfTournamentWinner"], false);
|
||||
// The freeze traps must never appear.
|
||||
assert!(body.get("bidTokens").is_none());
|
||||
assert!(body.get("qualifiedChampionEventId").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_response_shape() {
|
||||
let body = create_response(100_004_838, 1_700_000_000);
|
||||
assert_eq!(body["id"], 100_004_838);
|
||||
assert_eq!(body["reportIdEnabled"], false);
|
||||
assert_eq!(body["startDateTime"], 1_700_000_000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! FIFA 17 FUT (UTAS/RS4) wire → OpenFUT Core semantic mappings.
|
||||
//!
|
||||
//! Unlike [`crate::blaze`] (binary Blaze RPC), this is the JSON/HTTP FUT surface.
|
||||
//! It currently holds the owned-player ("My Squad") search mapping; more UTAS
|
||||
//! routes join it as the UTAS→Core migration proceeds. Nothing here opens a
|
||||
//! socket — a Rust UTAS host wires it to Core later.
|
||||
pub mod catalog;
|
||||
pub mod club_response;
|
||||
pub mod club_stats;
|
||||
pub mod consumables;
|
||||
pub mod content_taxonomy;
|
||||
pub mod economy;
|
||||
pub mod economy_policy;
|
||||
pub mod entities;
|
||||
pub mod item;
|
||||
pub mod item_state;
|
||||
pub mod match_wire;
|
||||
pub mod non_economy;
|
||||
pub mod owned_query;
|
||||
pub mod pack_content;
|
||||
pub mod sbc;
|
||||
pub mod season_wire;
|
||||
pub mod squad;
|
||||
pub mod squad_ext;
|
||||
pub mod squad_projection;
|
||||
pub mod store_catalog;
|
||||
pub mod store_session;
|
||||
@@ -0,0 +1,701 @@
|
||||
//! FIFA17 non-economy presentation routes, migrated from the Python oracle.
|
||||
//!
|
||||
//! Pure, IO-free shapers that reproduce the **observed production** oracle
|
||||
//! contract (`fifa17-recon/tools/utas_server.py`) for the non-economy UTAS
|
||||
//! routes a Rust host can own without any Core or account state:
|
||||
//!
|
||||
//! * `GET …/user/accountinfo` → `{}` (FUT_ACCOUNTINFO off)
|
||||
//! * `GET …/settings` → `{"configs":[]}` (FUT_SETTINGS off)
|
||||
//! * `GET …/leaderboards/options` → `{}` (FUT_MODES off)
|
||||
//! * `PUT …/match/reset` → `{}` (Tier-B no-op ack)
|
||||
//! * `GET/POST/PUT …/phishing/{trusteddevice,question,validate}` — the retired
|
||||
//! FUT security-question service, a stateless acknowledgement.
|
||||
//!
|
||||
//! These match the bodies the live prod oracle actually returns under the
|
||||
//! production environment (`FUT_TRADING=1 FUT_PILESIZES=1 FUT_TRADEABLE=1`, none
|
||||
//! of the feature flags set). They carry no persisted state: the security
|
||||
//! record the oracle seeds (`{version:1,verified:true}`) is log-only — the
|
||||
//! response is invariant — so a faithful Rust owner needs no persistence.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// `GET …/user/accountinfo` — production oracle returns an empty object.
|
||||
pub fn accountinfo_body() -> Value {
|
||||
json!({})
|
||||
}
|
||||
|
||||
/// `GET …/settings` — production oracle returns an empty config list.
|
||||
pub fn settings_body() -> Value {
|
||||
json!({ "configs": [] })
|
||||
}
|
||||
|
||||
/// `GET …/leaderboards/options` — production oracle (FUT_MODES off) returns an
|
||||
/// empty object; the retail client ignores the body.
|
||||
pub fn leaderboard_options_body() -> Value {
|
||||
json!({})
|
||||
}
|
||||
|
||||
/// `PUT …/match/reset` — Tier-B no-op acknowledgement.
|
||||
pub fn match_reset_body() -> Value {
|
||||
json!({})
|
||||
}
|
||||
|
||||
/// `GET …/club/stats/staff` — production oracle returns an empty object (FIFA's
|
||||
/// staff-bonus stat set is deliberately empty; the client tolerates `{}`).
|
||||
pub fn club_stats_staff_body() -> Value {
|
||||
json!({})
|
||||
}
|
||||
|
||||
/// The FUT modes (Seasons / Tournaments / FUT Champions) and the club-identity
|
||||
/// service are disabled in this emulator, so their read routes (`season`,
|
||||
/// `tournament`, `champion`, `clubUser`, `user/list`) return an empty object —
|
||||
/// byte-identical to the Python oracle with `FUT_MODES` / `FUT_CLUB_IDENTITY`
|
||||
/// off. Enabling a mode later requires a real Rust implementation here, never a
|
||||
/// Python fallback (which would reintroduce split authority).
|
||||
pub fn feature_off_body() -> Value {
|
||||
json!({})
|
||||
}
|
||||
|
||||
/// One FUT item-definition for a requested `resource_id`, replicating the Python
|
||||
/// oracle's `item_def`: `assetId = resource_id & 0xffffff`; a single hardcoded
|
||||
/// card (Ronaldo, asset 20801) and a generic placeholder (`"Player"`, 75, CM,
|
||||
/// attrs 70) for every other asset. The FIFA client renders the real card from
|
||||
/// its LOCAL DB from the `(rareflag, resourceId)` pair, so this route only needs
|
||||
/// a valid-shaped record — the placeholder is exactly what the oracle itself
|
||||
/// returns for all but the one hardcoded asset. Key order is irrelevant (the
|
||||
/// client's deserializer is key-addressed and skip-safe).
|
||||
pub fn item_def(resource_id: i64) -> Value {
|
||||
let asset = resource_id & 0xff_ffff;
|
||||
// (name, rating, position, nation, leagueId, teamid, [6 attrs])
|
||||
let (name, rating, pos, nation, league, team, attrs): (
|
||||
&str,
|
||||
i64,
|
||||
&str,
|
||||
i64,
|
||||
i64,
|
||||
i64,
|
||||
[i64; 6],
|
||||
) = if asset == 20801 {
|
||||
("Ronaldo", 94, "ST", 38, 53, 243, [90, 93, 82, 91, 33, 80])
|
||||
} else {
|
||||
("Player", 75, "CM", 0, 0, 0, [70, 70, 70, 70, 70, 70])
|
||||
};
|
||||
let attribute_list: Vec<Value> = attrs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| json!({ "index": i, "value": v }))
|
||||
.collect();
|
||||
json!({
|
||||
"id": resource_id,
|
||||
"resourceId": resource_id,
|
||||
"definitionId": resource_id,
|
||||
"assetId": asset,
|
||||
"cardassetid": asset,
|
||||
"commodityId": asset,
|
||||
"cardsubtypeid": 0,
|
||||
"cardType": 0,
|
||||
"itemType": "player",
|
||||
"rareflag": 1,
|
||||
"rating": rating,
|
||||
"preferredPosition": pos,
|
||||
"nation": nation,
|
||||
"leagueId": league,
|
||||
"teamid": team,
|
||||
"playStyle": 250,
|
||||
"attributeList": attribute_list,
|
||||
"name": name,
|
||||
"commonName": name,
|
||||
"lastName": name,
|
||||
"itemState": "free",
|
||||
"untradeable": true,
|
||||
})
|
||||
}
|
||||
|
||||
/// `GET …/item/resource`, `…/defid` — `{itemData:[…]}` with one [`item_def`] per
|
||||
/// requested id (mirrors the oracle's `defs_route`). No ids → an empty list.
|
||||
pub fn item_defs_body(ids: &[i64]) -> Value {
|
||||
json!({ "itemData": ids.iter().map(|&id| item_def(id)).collect::<Vec<_>>() })
|
||||
}
|
||||
|
||||
/// `GET …/marketdata/pricelimits?defId=a,b,c` — FutGetSuggestedPricing. The root
|
||||
/// MUST be a BARE ARRAY (one element per defId): returning an object here froze a
|
||||
/// live client (object-where-array busy loop at the listing screen). Constant
|
||||
/// band 150..15000 (placeholder pricing; not a freeze concern).
|
||||
pub fn marketdata_pricelimits_body(def_ids: &[i64]) -> Value {
|
||||
json!(def_ids
|
||||
.iter()
|
||||
.map(|&d| json!({ "defId": d, "minPrice": 150, "maxPrice": 15000 }))
|
||||
.collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// `GET …/marketdata` (NOT `/pricelimits`) — the price-comparison endpoint, which
|
||||
/// takes an OBJECT `{minPrice,maxPrice}`. Array-where-object would be the same
|
||||
/// freeze in reverse, so the container type is load-bearing. Constant band.
|
||||
pub fn marketdata_object_body() -> Value {
|
||||
json!({ "minPrice": 150, "maxPrice": 15000 })
|
||||
}
|
||||
|
||||
/// The phishing/security-question action, parsed from the URL tail.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SecurityAction {
|
||||
TrustedDevice,
|
||||
Question,
|
||||
Validate,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// The `phishing/<action>` selector from a `…/phishing/<action>` path tail.
|
||||
pub fn parse_security_action(tail: &str) -> SecurityAction {
|
||||
let last = tail.trim_end_matches('/').rsplit('/').next().unwrap_or("");
|
||||
match last {
|
||||
"trusteddevice" => SecurityAction::TrustedDevice,
|
||||
"question" => SecurityAction::Question,
|
||||
"validate" => SecurityAction::Validate,
|
||||
_ => SecurityAction::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// A well-formed FUT phishing token is an opaque 32-char lowercase/uppercase hex
|
||||
/// value (`_PHISHING_HEX32.fullmatch` in the oracle).
|
||||
fn is_hex32(s: &str) -> bool {
|
||||
s.len() == 32 && s.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// Reproduce `utas_server.py::security_question_route` verbatim.
|
||||
///
|
||||
/// The retired FUT security-question service is a stateless acknowledgement: it
|
||||
/// validates request shape (session, 32-hex device id, 32-hex answer, numeric
|
||||
/// question) and returns fixed bodies. The answer is a client-transformed opaque
|
||||
/// value that is **never stored or compared**; the trusted-device response is an
|
||||
/// invariant `verified/trusted` constant.
|
||||
///
|
||||
/// * `session_known` — whether `X-UT-SID` maps to an open Rust session.
|
||||
/// * `device_id` / `question` / `answer` — decoded query parameters (`""`/`None`
|
||||
/// when absent).
|
||||
///
|
||||
/// Returns `(http_status, body)`.
|
||||
pub fn security_question_response(
|
||||
method: &str,
|
||||
action: SecurityAction,
|
||||
session_known: bool,
|
||||
device_id: &str,
|
||||
question: Option<&str>,
|
||||
answer: Option<&str>,
|
||||
) -> (u16, Value) {
|
||||
let is = |m: &str| method.eq_ignore_ascii_case(m);
|
||||
if !session_known {
|
||||
return (400, json!({ "reason": "invalid_session" }));
|
||||
}
|
||||
if !is_hex32(device_id) {
|
||||
return (400, json!({ "reason": "malformed_request" }));
|
||||
}
|
||||
match action {
|
||||
SecurityAction::TrustedDevice => {
|
||||
if !is("GET") {
|
||||
return (405, json!({ "reason": "method_not_allowed" }));
|
||||
}
|
||||
(
|
||||
200,
|
||||
json!({ "changed": false, "exists": true, "locked": false, "trusted": true }),
|
||||
)
|
||||
}
|
||||
SecurityAction::Question if is("GET") => (
|
||||
200,
|
||||
json!({ "question": 0, "attempts": 5, "recoverAttempts": 0 }),
|
||||
),
|
||||
SecurityAction::Question if is("POST") || is("PUT") => {
|
||||
let q = question.unwrap_or("");
|
||||
let a = answer.unwrap_or("");
|
||||
if q.is_empty() || !q.bytes().all(|b| b.is_ascii_digit()) || !is_hex32(a) {
|
||||
return (400, json!({ "reason": "malformed_request" }));
|
||||
}
|
||||
(200, json!({}))
|
||||
}
|
||||
SecurityAction::Validate if is("POST") => {
|
||||
let a = answer.unwrap_or("");
|
||||
if !is_hex32(a) {
|
||||
return (400, json!({ "reason": "malformed_request" }));
|
||||
}
|
||||
(200, json!({}))
|
||||
}
|
||||
_ => (405, json!({ "reason": "method_not_allowed" })),
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────── POST /openfut/account/sync (Rust-owned) ─────────────────
|
||||
|
||||
/// The launcher `account/sync` request fields, with production defaults already
|
||||
/// applied. Everything is optional in the wire body; missing fields fall back to
|
||||
/// the fixed defaults the launcher expects. `personaId` defaults to the host's
|
||||
/// configured persona (passed in), never a baked-in constant.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountSyncRequest {
|
||||
pub persona_id: i64,
|
||||
pub persona_name: String,
|
||||
pub level: i64,
|
||||
pub experience: i64,
|
||||
pub experience_max: i64,
|
||||
pub account_funds: i64,
|
||||
pub account_funds_cap: i64,
|
||||
}
|
||||
|
||||
/// The FIFA persona display name for this emulator's single account.
|
||||
///
|
||||
/// The oracle sources this from the shared account (`fut_account.py`, default
|
||||
/// `"CAGE"`) and documents the property as "Blaze PDTL.DSNM / LSX
|
||||
/// GetProfileResponse Persona / **UTAS sellerName**". That last role is
|
||||
/// load-bearing: the client decides whether a transfer-market listing is the
|
||||
/// player's OWN — and therefore whether to offer Remove / Re-list at all — from
|
||||
/// the seller identity on the auction record. Stamping EA's house name there
|
||||
/// makes the player's own listing un-actionable (pressing it opens no dialog).
|
||||
pub const PERSONA_DISPLAY_NAME: &str = "CAGE";
|
||||
|
||||
/// Parse the `account/sync` request body, applying every default. `default_persona`
|
||||
/// is the host's configured persona id (used when `personaId` is absent).
|
||||
pub fn parse_account_sync(body: &[u8], default_persona: i64) -> AccountSyncRequest {
|
||||
let v: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
|
||||
let int = |key: &str, dflt: i64| v.get(key).and_then(Value::as_i64).unwrap_or(dflt);
|
||||
let persona_name = v
|
||||
.get("personaName")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(PERSONA_DISPLAY_NAME)
|
||||
.to_string();
|
||||
AccountSyncRequest {
|
||||
persona_id: int("personaId", default_persona),
|
||||
persona_name,
|
||||
level: int("level", 1),
|
||||
experience: int("experience", 0),
|
||||
experience_max: int("experienceMax", 1000),
|
||||
account_funds: int("accountFunds", 0),
|
||||
account_funds_cap: int("accountFundsCap", 100000),
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /openfut/account/sync` — the launcher control-plane account summary.
|
||||
/// `coins`/`unopened_packs` are the AUTHORITATIVE Core values (balance +
|
||||
/// entitlement count), never Python's stale profile funds.
|
||||
pub fn account_sync_body(
|
||||
req: &AccountSyncRequest,
|
||||
coins: i64,
|
||||
unopened_packs: usize,
|
||||
club_name: &str,
|
||||
club_abbr: &str,
|
||||
) -> Value {
|
||||
json!({
|
||||
"account": {
|
||||
"personaId": req.persona_id,
|
||||
"personaName": req.persona_name,
|
||||
"clubName": club_name,
|
||||
"clubAbbr": club_abbr,
|
||||
"level": req.level,
|
||||
"experience": req.experience,
|
||||
"experienceMax": req.experience_max,
|
||||
"accountFunds": req.account_funds,
|
||||
"accountFundsCap": req.account_funds_cap,
|
||||
"profilePath": "accounts/33068179/fifa17_profile.json",
|
||||
"coins": coins,
|
||||
"unopenedPacks": unopened_packs,
|
||||
},
|
||||
"status": "OK",
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────── GET …/userMassInfo (Rust-owned) ─────────────────────
|
||||
|
||||
/// Build the full `GET …/userMassInfo` body entirely in Rust (no Python).
|
||||
///
|
||||
/// `squad` is the Core-projected active squad (`user_mass_info_squad` output), so
|
||||
/// it is byte-for-byte the object `GET …/squad/active` embeds. `coins` and
|
||||
/// `unopened_packs` are the authoritative Core economy values. `userInfo.actives`
|
||||
/// mirrors the squad's `actives` (capped at 5), and `userInfo.squadList` is the
|
||||
/// summary of the current squad.
|
||||
pub fn user_mass_info_body(
|
||||
squad: Value,
|
||||
coins: i64,
|
||||
unopened_packs: usize,
|
||||
persona_id: i64,
|
||||
club_name: &str,
|
||||
club_abbr: &str,
|
||||
established: &str,
|
||||
) -> Value {
|
||||
let actives: Vec<Value> = squad
|
||||
.get("actives")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().take(5).cloned().collect())
|
||||
.unwrap_or_default();
|
||||
let squad_list = crate::fut::squad_projection::squad_list(&squad);
|
||||
let mut user_info = json!({
|
||||
"personaId": persona_id,
|
||||
"clubName": club_name,
|
||||
"clubAbbr": club_abbr,
|
||||
"established": established,
|
||||
"accountCreatedPlatformName": "pc",
|
||||
"currencies": [
|
||||
{"name": "coins", "funds": coins, "finalFunds": coins, "active": true},
|
||||
{"name": "points", "funds": 0, "finalFunds": 0, "active": true},
|
||||
],
|
||||
"won": 0,
|
||||
"draw": 0,
|
||||
"loss": 0,
|
||||
"clubNameChangeAllowed": false,
|
||||
"divisionOffline": 10,
|
||||
"divisionOnline": 10,
|
||||
"purchased": false,
|
||||
"feature": {},
|
||||
"reliability": {"reliability": 100, "matchUnfinishedTime": 0},
|
||||
"bidTokens": {"count": 0, "updateTime": 0},
|
||||
"trophies": 0,
|
||||
"sessionCoinsBankBalance": 0,
|
||||
"actives": actives,
|
||||
"squadList": squad_list,
|
||||
});
|
||||
if unopened_packs > 0 {
|
||||
user_info.as_object_mut().unwrap().insert(
|
||||
"unopenedPacks".into(),
|
||||
json!({"preOrderPacks": 0, "recoveredPacks": unopened_packs}),
|
||||
);
|
||||
}
|
||||
json!({
|
||||
"pileSizeClientData": {"entries": [{"key": 2, "value": 100}, {"key": 4, "value": 50}]},
|
||||
"settings": {"configs": []},
|
||||
"userData": {},
|
||||
"squad": squad,
|
||||
"userInfo": user_info,
|
||||
})
|
||||
}
|
||||
|
||||
// ───────────────────────────── POST /ut/auth (Rust) ──────────────────────────
|
||||
|
||||
/// Format `epoch_secs` (seconds since the Unix epoch) as UTC
|
||||
/// `YYYY-MM-DD HH:MM:SS`. Pure civil-date arithmetic (Howard Hinnant's
|
||||
/// `civil_from_days`), so no `time`/`chrono` dependency is needed.
|
||||
pub fn format_utc_datetime(epoch_secs: i64) -> String {
|
||||
let days = epoch_secs.div_euclid(86_400);
|
||||
let secs_of_day = epoch_secs.rem_euclid(86_400);
|
||||
let (hour, min, sec) = (
|
||||
secs_of_day / 3600,
|
||||
(secs_of_day % 3600) / 60,
|
||||
secs_of_day % 60,
|
||||
);
|
||||
// civil_from_days: days is a count of days since 1970-01-01.
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = z - era * 146_097; // [0, 146096]
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
|
||||
let year = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
|
||||
let mp = (5 * doy + 2) / 153; // [0, 11]
|
||||
let day = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
|
||||
let year = if month <= 2 { year + 1 } else { year };
|
||||
format!("{year:04}-{month:02}-{day:02} {hour:02}:{min:02}:{sec:02}")
|
||||
}
|
||||
|
||||
/// The persona a `/ut/auth` request adopts: `nucleusPersonaId` or `nuc` from the
|
||||
/// body (numeric or numeric string), else `None` (the host substitutes its
|
||||
/// configured persona). The client is never refused.
|
||||
pub fn parse_auth_persona(body: &[u8]) -> Option<i64> {
|
||||
let v: Value = serde_json::from_slice(body).ok()?;
|
||||
let field = |key: &str| {
|
||||
v.get(key).and_then(|x| {
|
||||
x.as_i64()
|
||||
.or_else(|| x.as_str().and_then(|s| s.parse::<i64>().ok()))
|
||||
})
|
||||
};
|
||||
field("nucleusPersonaId").or_else(|| field("nuc"))
|
||||
}
|
||||
|
||||
/// `POST /ut/auth` response body. `sid` is the freshly minted Rust session id;
|
||||
/// `server_time` is UTC `YYYY-MM-DD HH:MM:SS` (also used for `lastOnlineTime`).
|
||||
pub fn auth_body(sid: &str, server_time: &str) -> Value {
|
||||
json!({
|
||||
"protocol": 1,
|
||||
"sid": sid,
|
||||
"serverTime": server_time,
|
||||
"lastOnlineTime": server_time,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const DEV: &str = "6236375476659cd0f6c780e728774b71"; // 32-hex (live deviceId)
|
||||
const ANS: &str = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
#[test]
|
||||
fn static_bodies_match_oracle() {
|
||||
assert_eq!(accountinfo_body(), json!({}));
|
||||
assert_eq!(settings_body(), json!({ "configs": [] }));
|
||||
assert_eq!(leaderboard_options_body(), json!({}));
|
||||
assert_eq!(match_reset_body(), json!({}));
|
||||
assert_eq!(club_stats_staff_body(), json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_parse() {
|
||||
assert_eq!(
|
||||
parse_security_action("phishing/trusteddevice"),
|
||||
SecurityAction::TrustedDevice
|
||||
);
|
||||
assert_eq!(
|
||||
parse_security_action("phishing/question/"),
|
||||
SecurityAction::Question
|
||||
);
|
||||
assert_eq!(
|
||||
parse_security_action("phishing/validate"),
|
||||
SecurityAction::Validate
|
||||
);
|
||||
assert_eq!(
|
||||
parse_security_action("phishing/other"),
|
||||
SecurityAction::Unknown
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_device_verified_constant() {
|
||||
let (s, b) =
|
||||
security_question_response("GET", SecurityAction::TrustedDevice, true, DEV, None, None);
|
||||
assert_eq!(s, 200);
|
||||
assert_eq!(
|
||||
b,
|
||||
json!({ "changed": false, "exists": true, "locked": false, "trusted": true })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_session_is_400_invalid_session() {
|
||||
let (s, b) = security_question_response(
|
||||
"GET",
|
||||
SecurityAction::TrustedDevice,
|
||||
false,
|
||||
DEV,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(s, 400);
|
||||
assert_eq!(b, json!({ "reason": "invalid_session" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_device_id_is_malformed() {
|
||||
for bad in [
|
||||
"",
|
||||
"xyz",
|
||||
"6236375476659cd0f6c780e728774b7",
|
||||
"not-hex-not-hex-not-hex-not-hexx",
|
||||
] {
|
||||
let (s, b) = security_question_response(
|
||||
"GET",
|
||||
SecurityAction::TrustedDevice,
|
||||
true,
|
||||
bad,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(s, 400, "device {bad:?}");
|
||||
assert_eq!(b, json!({ "reason": "malformed_request" }));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_device_wrong_method_405() {
|
||||
let (s, _) = security_question_response(
|
||||
"POST",
|
||||
SecurityAction::TrustedDevice,
|
||||
true,
|
||||
DEV,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(s, 405);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn question_get_returns_prompt() {
|
||||
let (s, b) =
|
||||
security_question_response("GET", SecurityAction::Question, true, DEV, None, None);
|
||||
assert_eq!(s, 200);
|
||||
assert_eq!(
|
||||
b,
|
||||
json!({ "question": 0, "attempts": 5, "recoverAttempts": 0 })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn question_setup_validates_shape() {
|
||||
// valid numeric question + 32-hex answer
|
||||
let (s, b) = security_question_response(
|
||||
"POST",
|
||||
SecurityAction::Question,
|
||||
true,
|
||||
DEV,
|
||||
Some("0"),
|
||||
Some(ANS),
|
||||
);
|
||||
assert_eq!(s, 200);
|
||||
assert_eq!(b, json!({}));
|
||||
// empty question -> malformed
|
||||
let (s, _) = security_question_response(
|
||||
"POST",
|
||||
SecurityAction::Question,
|
||||
true,
|
||||
DEV,
|
||||
Some(""),
|
||||
Some(ANS),
|
||||
);
|
||||
assert_eq!(s, 400);
|
||||
// non-digit question -> malformed
|
||||
let (s, _) = security_question_response(
|
||||
"PUT",
|
||||
SecurityAction::Question,
|
||||
true,
|
||||
DEV,
|
||||
Some("x"),
|
||||
Some(ANS),
|
||||
);
|
||||
assert_eq!(s, 400);
|
||||
// bad answer -> malformed
|
||||
let (s, _) = security_question_response(
|
||||
"POST",
|
||||
SecurityAction::Question,
|
||||
true,
|
||||
DEV,
|
||||
Some("0"),
|
||||
Some("short"),
|
||||
);
|
||||
assert_eq!(s, 400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_checks_answer() {
|
||||
let (s, b) = security_question_response(
|
||||
"POST",
|
||||
SecurityAction::Validate,
|
||||
true,
|
||||
DEV,
|
||||
None,
|
||||
Some(ANS),
|
||||
);
|
||||
assert_eq!(s, 200);
|
||||
assert_eq!(b, json!({}));
|
||||
let (s, _) = security_question_response(
|
||||
"POST",
|
||||
SecurityAction::Validate,
|
||||
true,
|
||||
DEV,
|
||||
None,
|
||||
Some("nope"),
|
||||
);
|
||||
assert_eq!(s, 400);
|
||||
// wrong method for validate
|
||||
let (s, _) =
|
||||
security_question_response("GET", SecurityAction::Validate, true, DEV, None, Some(ANS));
|
||||
assert_eq!(s, 405);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_sync_defaults_and_core_economy() {
|
||||
// Empty body -> every default applied; persona falls back to the host's.
|
||||
let req = parse_account_sync(b"", 33_068_179);
|
||||
assert_eq!(req.persona_id, 33_068_179);
|
||||
assert_eq!(req.persona_name, "CAGE");
|
||||
assert_eq!(req.level, 1);
|
||||
assert_eq!(req.experience_max, 1000);
|
||||
assert_eq!(req.account_funds_cap, 100_000);
|
||||
let body = account_sync_body(&req, 29_859_876, 2, "Real FUT", "RF");
|
||||
let acc = &body["account"];
|
||||
assert_eq!(acc["clubName"], "Real FUT");
|
||||
assert_eq!(acc["clubAbbr"], "RF");
|
||||
assert_eq!(acc["profilePath"], "accounts/33068179/fifa17_profile.json");
|
||||
assert_eq!(acc["coins"], 29_859_876);
|
||||
assert_eq!(acc["unopenedPacks"], 2);
|
||||
assert_eq!(body["status"], "OK");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_sync_honours_request_overrides() {
|
||||
let req = parse_account_sync(
|
||||
br#"{"personaId":42,"personaName":"X","level":9,"accountFunds":500}"#,
|
||||
33_068_179,
|
||||
);
|
||||
assert_eq!(req.persona_id, 42);
|
||||
assert_eq!(req.persona_name, "X");
|
||||
assert_eq!(req.level, 9);
|
||||
assert_eq!(req.account_funds, 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_mass_info_flat_shape() {
|
||||
let squad = json!({
|
||||
"id": 0,
|
||||
"squadName": "OpenFUT",
|
||||
"formation": "f433",
|
||||
"squadType": "REGULAR_SQUAD",
|
||||
"rating": 90,
|
||||
"chemistry": 49,
|
||||
"actives": [],
|
||||
"players": [],
|
||||
});
|
||||
let body = user_mass_info_body(squad, 29_859_876, 0, 33_068_179, "Real FUT", "RF", "2016");
|
||||
// Flat top-level envelope.
|
||||
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["settings"], json!({"configs": []}));
|
||||
assert_eq!(body["userData"], json!({}));
|
||||
// userInfo economy + club identity.
|
||||
let ui = &body["userInfo"];
|
||||
assert_eq!(ui["personaId"], 33_068_179);
|
||||
assert_eq!(ui["clubName"], "Real FUT");
|
||||
assert_eq!(ui["clubAbbr"], "RF");
|
||||
assert_eq!(ui["established"], "2016"); // string, not number
|
||||
assert_eq!(ui["accountCreatedPlatformName"], "pc");
|
||||
assert_eq!(ui["currencies"][0]["name"], "coins");
|
||||
assert_eq!(ui["currencies"][0]["funds"], 29_859_876);
|
||||
assert_eq!(ui["currencies"][0]["finalFunds"], 29_859_876);
|
||||
assert_eq!(ui["currencies"][1]["name"], "points");
|
||||
assert_eq!(ui["reliability"]["reliability"], 100);
|
||||
assert_eq!(ui["divisionOnline"], 10);
|
||||
assert!(ui.get("unopenedPacks").is_none(), "no packs -> key omitted");
|
||||
assert_eq!(ui["squadList"]["squad"][0]["squadName"], "OpenFUT");
|
||||
// Squad object embedded flat under top-level `squad`.
|
||||
assert_eq!(body["squad"]["squadName"], "OpenFUT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_mass_info_includes_unopened_packs_when_present() {
|
||||
let squad = json!({"id": 0, "actives": [], "players": []});
|
||||
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"]["preOrderPacks"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utc_datetime_formats_known_epochs() {
|
||||
// 2026-08-17 03:54:47 UTC == 1_786_938_887.
|
||||
assert_eq!(format_utc_datetime(1_786_938_887), "2026-08-17 03:54:47");
|
||||
// Unix epoch.
|
||||
assert_eq!(format_utc_datetime(0), "1970-01-01 00:00:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_persona_and_body() {
|
||||
assert_eq!(
|
||||
parse_auth_persona(br#"{"nucleusPersonaId":33068179}"#),
|
||||
Some(33_068_179)
|
||||
);
|
||||
assert_eq!(parse_auth_persona(br#"{"nuc":"42"}"#), Some(42));
|
||||
assert_eq!(parse_auth_persona(b"{}"), None);
|
||||
let b = auth_body("OPENFUT-SID-DEADBEEF", "2026-08-17 03:54:47");
|
||||
assert_eq!(b["protocol"], 1);
|
||||
assert_eq!(b["sid"], "OPENFUT-SID-DEADBEEF");
|
||||
assert_eq!(b["serverTime"], "2026-08-17 03:54:47");
|
||||
assert_eq!(b["lastOnlineTime"], "2026-08-17 03:54:47");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
//! FIFA 17 "My Squad" owned-player search: parse the RS4 `club` query and map
|
||||
//! FIFA 17 wire encodings to the **game-independent** semantic values OpenFUT
|
||||
//! Core understands.
|
||||
//!
|
||||
//! ## The boundary this enforces
|
||||
//!
|
||||
//! The FIFA 17 client sends its owned-player search as query params on
|
||||
//! `GET /ut/game/fifa17/club`, e.g.
|
||||
//! `?year=2017&type=player&count=11&level=gold&position=ST&nation=52&league=13&team=5&sort=desc&start=10`.
|
||||
//! Two encoding families appear: **string enums** (`level`, `rare`, `position`)
|
||||
//! and **numeric FIFA entity ids** (`nation`, `league`, `team`).
|
||||
//!
|
||||
//! **Numeric FIFA ids must never reach Core.** Core filters on semantic names
|
||||
//! ("Premier League", "Chelsea", "Argentina"), so this adapter resolves each id
|
||||
//! to a name via an injected [`EntityResolver`]. An id the resolver cannot map is
|
||||
//! a hard [`MapError`] — never a silent passthrough of the raw number, which is
|
||||
//! exactly how a game-specific id would leak into the generic layer.
|
||||
//!
|
||||
//! ## Evidence-grounded semantics (see vault Protocol Findings / Endpoint Map)
|
||||
//!
|
||||
//! * `level=gold` → semantic quality tier. Grounded in FIFA 17's own convention
|
||||
//! (`fut_cards.py::tier`, gold ≥ 75). `level=any` (the always-present default)
|
||||
//! → no quality constraint.
|
||||
//! * `position` / `nation` / `league` / `team` → applied. The Python oracle
|
||||
//! applied only `league`+`team`; applying the rest is a deliberate correction
|
||||
//! of a proven bug, not a guess (each maps to a card attribute Core already
|
||||
//! stores). FIFA `team` is Core `club`.
|
||||
//! * `start` / `count` → semantic `offset` / `limit`. The oracle ignored both and
|
||||
//! re-served page one forever; Core paginates for real. The client's 11-count /
|
||||
//! 10-step windowing is a UI convention and stays out of Core.
|
||||
//! * `sort=desc` → **dropped**. No sort key was ever proven (the oracle does not
|
||||
//! sort); Core imposes its own deterministic order. We do not invent a named
|
||||
//! FIFA sort mode.
|
||||
//! * `rare=SP` ("Special") → **UNKNOWN and unsupported.** The oracle never reads
|
||||
//! it and no committed metadata grounds "SP" to a card set. It is recorded in
|
||||
//! [`CoreOwnedQuery::unsupported`] and deliberately produces **no** Core filter.
|
||||
//!
|
||||
//! ## Decoupling
|
||||
//!
|
||||
//! This module does not depend on `openfut-core`. The contract between the two is
|
||||
//! the set of Core `/collection` query-parameter *names* emitted by
|
||||
//! [`CoreOwnedQuery::to_query_pairs`]; they mirror Core's `OwnedItemQuery` fields
|
||||
//! and are pinned by a test so drift is caught.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// The FIFA 17 club-search query exactly as it arrives on the wire. Numeric
|
||||
/// fields are FIFA entity ids that MUST be resolved before reaching Core.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct Fifa17ClubQuery {
|
||||
/// Requested FIFA item family (`player`, `kit`, …). Adapter-owned: Core has
|
||||
/// no FIFA content taxonomy, so the host applies this filter locally.
|
||||
pub item_type: Option<String>,
|
||||
/// Quality filter: `any` (default, always present) or `gold`.
|
||||
pub level: Option<String>,
|
||||
/// "Special" filter (`SP`). Semantics UNKNOWN — never applied.
|
||||
pub rare: Option<String>,
|
||||
/// Playing position, e.g. `ST`.
|
||||
pub position: Option<String>,
|
||||
/// FIFA nation id (e.g. 52 = Argentina).
|
||||
pub nation: Option<u32>,
|
||||
/// FIFA league id (e.g. 13 = Premier League).
|
||||
pub league: Option<u32>,
|
||||
/// FIFA team id (e.g. 5 = Chelsea). Core calls this "club".
|
||||
pub team: Option<u32>,
|
||||
/// Client sort token (`desc`). No proven key; dropped.
|
||||
pub sort: Option<String>,
|
||||
/// Pagination offset.
|
||||
pub start: Option<u32>,
|
||||
/// Pagination page size.
|
||||
pub count: Option<u32>,
|
||||
}
|
||||
|
||||
/// Minimal percent/`+` decoding, dependency-free. FIFA sends bare tokens and
|
||||
/// numeric ids, but names in general may be percent-encoded.
|
||||
fn percent_decode(s: &str) -> String {
|
||||
let b = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(b.len());
|
||||
let hex = |c: u8| (c as char).to_digit(16);
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
match b[i] {
|
||||
b'+' => {
|
||||
out.push(b' ');
|
||||
i += 1;
|
||||
}
|
||||
b'%' if i + 2 < b.len() => match (hex(b[i + 1]), hex(b[i + 2])) {
|
||||
(Some(hi), Some(lo)) => {
|
||||
out.push((hi * 16 + lo) as u8);
|
||||
i += 3;
|
||||
}
|
||||
_ => {
|
||||
out.push(b'%');
|
||||
i += 1;
|
||||
}
|
||||
},
|
||||
c => {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
/// Parse the raw query string into a [`Fifa17ClubQuery`].
|
||||
///
|
||||
/// Order-independent by construction (each key sets its own field), so HTTP
|
||||
/// parameter order can never change the result. Unknown keys (`year`, `type`, …)
|
||||
/// are ignored. A present-but-unparseable numeric id is treated as absent (the
|
||||
/// retail client never sends one; absent is the safe, non-amplifying choice).
|
||||
pub fn parse_club_query(query: &str) -> Fifa17ClubQuery {
|
||||
let q = query.strip_prefix('?').unwrap_or(query);
|
||||
let mut out = Fifa17ClubQuery::default();
|
||||
for pair in q.split('&').filter(|p| !p.is_empty()) {
|
||||
let (k, v) = match pair.split_once('=') {
|
||||
Some((k, v)) => (k, percent_decode(v)),
|
||||
None => (pair, String::new()),
|
||||
};
|
||||
match k {
|
||||
"type" => out.item_type = Some(v),
|
||||
"level" => out.level = Some(v),
|
||||
"rare" => out.rare = Some(v),
|
||||
"position" => out.position = Some(v),
|
||||
"nation" => out.nation = v.parse().ok(),
|
||||
"league" => out.league = v.parse().ok(),
|
||||
"team" => out.team = v.parse().ok(),
|
||||
"sort" => out.sort = Some(v),
|
||||
"start" => out.start = v.parse().ok(),
|
||||
"count" => out.count = v.parse().ok(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Resolves FIFA 17 numeric entity ids to their semantic names. A real
|
||||
/// implementation reads the game's `leagues`/`teams`/`nations` tables; tests use
|
||||
/// [`StaticResolver`]. Returning `None` means "unknown id" and is fatal, by
|
||||
/// design — the raw id must not flow onward.
|
||||
pub trait EntityResolver {
|
||||
fn league_name(&self, id: u32) -> Option<String>;
|
||||
fn nation_name(&self, id: u32) -> Option<String>;
|
||||
fn team_name(&self, id: u32) -> Option<String>;
|
||||
}
|
||||
|
||||
/// A map-backed [`EntityResolver`] for tests and small deployments.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct StaticResolver {
|
||||
pub leagues: HashMap<u32, String>,
|
||||
pub nations: HashMap<u32, String>,
|
||||
pub teams: HashMap<u32, String>,
|
||||
}
|
||||
|
||||
impl EntityResolver for StaticResolver {
|
||||
fn league_name(&self, id: u32) -> Option<String> {
|
||||
self.leagues.get(&id).cloned()
|
||||
}
|
||||
fn nation_name(&self, id: u32) -> Option<String> {
|
||||
self.nations.get(&id).cloned()
|
||||
}
|
||||
fn team_name(&self, id: u32) -> Option<String> {
|
||||
self.teams.get(&id).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
/// A FIFA id that no resolver could map. Fatal on purpose: never fall back to
|
||||
/// the raw id.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MapError {
|
||||
UnknownLeague(u32),
|
||||
UnknownNation(u32),
|
||||
UnknownTeam(u32),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MapError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
MapError::UnknownLeague(id) => write!(f, "unknown FIFA league id {id}"),
|
||||
MapError::UnknownNation(id) => write!(f, "unknown FIFA nation id {id}"),
|
||||
MapError::UnknownTeam(id) => write!(f, "unknown FIFA team id {id}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for MapError {}
|
||||
|
||||
/// The semantic query handed to OpenFUT Core. Contains only game-independent
|
||||
/// values: a quality tier string, entity **names**, and semantic offset/limit.
|
||||
/// No FIFA ids.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct CoreOwnedQuery {
|
||||
pub quality: Option<String>,
|
||||
pub position: Option<String>,
|
||||
pub nation: Option<String>,
|
||||
pub league: Option<String>,
|
||||
pub club: Option<String>,
|
||||
pub offset: Option<i64>,
|
||||
pub limit: Option<i64>,
|
||||
/// "Special" filter (`rare=SP`): keep only special cards. Applied by the
|
||||
/// HOST via the FIFA `rareflag` (which lives in the catalog, not Core) — so
|
||||
/// it is NEVER a Core `/collection` param. See [`is_special_rareflag`].
|
||||
pub special: bool,
|
||||
/// Wire filters that were parsed but deliberately NOT applied because their
|
||||
/// semantics are unproven (currently: `rare`/Special). Recorded, never guessed.
|
||||
pub unsupported: Vec<&'static str>,
|
||||
}
|
||||
|
||||
impl CoreOwnedQuery {
|
||||
/// Core `/collection` query parameters. Param **names mirror**
|
||||
/// `openfut_core::services::inventory::OwnedItemQuery` and are the wire
|
||||
/// contract between this adapter and Core (pinned by test). `unsupported`
|
||||
/// filters are intentionally absent.
|
||||
pub fn to_query_pairs(&self) -> Vec<(&'static str, String)> {
|
||||
let mut p = Vec::new();
|
||||
if let Some(q) = &self.quality {
|
||||
p.push(("quality", q.clone()));
|
||||
}
|
||||
if let Some(x) = &self.position {
|
||||
p.push(("position", x.clone()));
|
||||
}
|
||||
if let Some(x) = &self.nation {
|
||||
p.push(("nation", x.clone()));
|
||||
}
|
||||
if let Some(x) = &self.league {
|
||||
p.push(("league", x.clone()));
|
||||
}
|
||||
if let Some(x) = &self.club {
|
||||
p.push(("club", x.clone()));
|
||||
}
|
||||
if let Some(x) = self.offset {
|
||||
p.push(("offset", x.to_string()));
|
||||
}
|
||||
if let Some(x) = self.limit {
|
||||
p.push(("limit", x.to_string()));
|
||||
}
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a parsed FIFA 17 query to the semantic Core query, resolving every
|
||||
/// numeric id to a name. Any unknown id is a hard error — the raw id never flows
|
||||
/// through.
|
||||
pub fn map_to_core(
|
||||
q: &Fifa17ClubQuery,
|
||||
resolver: &impl EntityResolver,
|
||||
) -> Result<CoreOwnedQuery, MapError> {
|
||||
// level: only the proven quality tiers map; "any"/absent → no constraint.
|
||||
let quality = match q.level.as_deref() {
|
||||
Some("gold") => Some("gold".to_string()),
|
||||
Some("silver") => Some("silver".to_string()),
|
||||
Some("bronze") => Some("bronze".to_string()),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// rare=SP → "Special" quality. Now GROUNDED via the observed FIFA rareflag
|
||||
// (carried in the catalog): a special is rareflag > 1 (base rare = 1). The
|
||||
// host applies it post-shape; Core never sees it. Any OTHER `rare` value
|
||||
// stays genuinely unsupported (recorded, never guessed).
|
||||
let special = matches!(q.rare.as_deref(), Some(s) if s.eq_ignore_ascii_case("SP"));
|
||||
let mut unsupported = Vec::new();
|
||||
if q.rare.is_some() && !special {
|
||||
unsupported.push("rare");
|
||||
}
|
||||
|
||||
let position = q.position.as_ref().map(|p| p.to_uppercase());
|
||||
|
||||
let nation = match q.nation {
|
||||
Some(id) => Some(
|
||||
resolver
|
||||
.nation_name(id)
|
||||
.ok_or(MapError::UnknownNation(id))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
let league = match q.league {
|
||||
Some(id) => Some(
|
||||
resolver
|
||||
.league_name(id)
|
||||
.ok_or(MapError::UnknownLeague(id))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
// FIFA "team" is Core "club".
|
||||
let club = match q.team {
|
||||
Some(id) => Some(resolver.team_name(id).ok_or(MapError::UnknownTeam(id))?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(CoreOwnedQuery {
|
||||
quality,
|
||||
position,
|
||||
nation,
|
||||
league,
|
||||
club,
|
||||
offset: q.start.map(|s| s as i64),
|
||||
limit: q.count.map(|c| c as i64),
|
||||
special,
|
||||
unsupported,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a FIFA `rareflag` denotes a SPECIAL card (in-form/programme), as
|
||||
/// opposed to a base card. Grounded in the observed profile + the FIFA 17
|
||||
/// taxonomy: 0 = common, 1 = rare (both BASE gold/silver/bronze); every value
|
||||
/// above 1 is a special programme (3 = TOTW, 21..=24 = programmes, etc.).
|
||||
pub fn is_special_rareflag(rareflag: i64) -> bool {
|
||||
rareflag > 1
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn resolver() -> StaticResolver {
|
||||
// Confirmed against fifa17-recon/data/tables/{leagues,teams,nations}.json.
|
||||
StaticResolver {
|
||||
leagues: HashMap::from([(13, "Premier League".to_string())]),
|
||||
nations: HashMap::from([(52, "Argentina".to_string())]),
|
||||
teams: HashMap::from([(5, "Chelsea".to_string())]),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_full_query() {
|
||||
let q = parse_club_query(
|
||||
"year=2017&type=player&count=11&level=gold&position=ST&nation=52&league=13&team=5&sort=desc&start=10",
|
||||
);
|
||||
assert_eq!(
|
||||
q,
|
||||
Fifa17ClubQuery {
|
||||
item_type: Some("player".into()),
|
||||
level: Some("gold".into()),
|
||||
rare: None,
|
||||
position: Some("ST".into()),
|
||||
nation: Some(52),
|
||||
league: Some(13),
|
||||
team: Some(5),
|
||||
sort: Some("desc".into()),
|
||||
start: Some(10),
|
||||
count: Some(11),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_is_parameter_order_independent() {
|
||||
let a = parse_club_query("level=gold&league=13&position=ST&start=10&count=11");
|
||||
let b = parse_club_query("count=11&start=10&position=ST&league=13&level=gold");
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ignores_unknown_keys_and_omitted_optionals() {
|
||||
let q = parse_club_query("year=2017&type=player&level=any&sort=desc");
|
||||
assert_eq!(q.level.as_deref(), Some("any"));
|
||||
assert!(q.rare.is_none() && q.position.is_none() && q.nation.is_none());
|
||||
assert!(q.league.is_none() && q.team.is_none() && q.start.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_percent_encoded_value() {
|
||||
let q = parse_club_query("position=ST&rare=SP");
|
||||
assert_eq!(q.position.as_deref(), Some("ST"));
|
||||
assert_eq!(q.rare.as_deref(), Some("SP"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_malformed_numeric_is_absent() {
|
||||
let q = parse_club_query("league=notanumber");
|
||||
assert!(
|
||||
q.league.is_none(),
|
||||
"malformed id treated as absent, not applied"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_level_gold_to_quality() {
|
||||
let core = map_to_core(&parse_club_query("level=gold"), &resolver()).unwrap();
|
||||
assert_eq!(core.quality.as_deref(), Some("gold"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_level_any_has_no_quality_filter() {
|
||||
let core = map_to_core(&parse_club_query("level=any"), &resolver()).unwrap();
|
||||
assert_eq!(core.quality, None, "'any' must NOT become a quality filter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_rare_sp_sets_special_and_never_a_core_param() {
|
||||
let core = map_to_core(&parse_club_query("level=any&rare=SP"), &resolver()).unwrap();
|
||||
// rare=SP is now GROUNDED: a host-applied special flag, not "unsupported".
|
||||
assert!(core.special, "rare=SP must set the special flag");
|
||||
assert!(!core.unsupported.contains(&"rare"));
|
||||
// still NEVER a Core predicate (Core has no rareflag) and no quality guess.
|
||||
assert_eq!(core.quality, None);
|
||||
let keys: Vec<&str> = core.to_query_pairs().into_iter().map(|(k, _)| k).collect();
|
||||
assert!(!keys.contains(&"rare") && !keys.contains(&"special"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_unknown_rare_value_stays_unsupported() {
|
||||
let core = map_to_core(&parse_club_query("rare=WAT"), &resolver()).unwrap();
|
||||
assert!(!core.special);
|
||||
assert!(core.unsupported.contains(&"rare"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn special_predicate_base_vs_special() {
|
||||
assert!(!is_special_rareflag(0)); // common
|
||||
assert!(!is_special_rareflag(1)); // rare gold (base)
|
||||
assert!(is_special_rareflag(3)); // TOTW
|
||||
assert!(is_special_rareflag(24)); // programme
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_resolves_ids_to_semantic_names() {
|
||||
let core =
|
||||
map_to_core(&parse_club_query("nation=52&league=13&team=5"), &resolver()).unwrap();
|
||||
assert_eq!(core.nation.as_deref(), Some("Argentina"));
|
||||
assert_eq!(core.league.as_deref(), Some("Premier League"));
|
||||
assert_eq!(
|
||||
core.club.as_deref(),
|
||||
Some("Chelsea"),
|
||||
"FIFA team -> Core club"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_unknown_id_is_a_hard_error_not_passthrough() {
|
||||
assert_eq!(
|
||||
map_to_core(&parse_club_query("league=9999"), &resolver()),
|
||||
Err(MapError::UnknownLeague(9999))
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_core(&parse_club_query("nation=9999"), &resolver()),
|
||||
Err(MapError::UnknownNation(9999))
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_core(&parse_club_query("team=9999"), &resolver()),
|
||||
Err(MapError::UnknownTeam(9999))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_raw_fifa_id_ever_reaches_core() {
|
||||
// Every resolvable id becomes a name; a numeric string must never appear
|
||||
// as a nation/league/club value in the Core-bound pairs.
|
||||
let core =
|
||||
map_to_core(&parse_club_query("nation=52&league=13&team=5"), &resolver()).unwrap();
|
||||
for (k, v) in core.to_query_pairs() {
|
||||
if matches!(k, "nation" | "league" | "club") {
|
||||
assert!(
|
||||
v.parse::<u32>().is_err(),
|
||||
"{k}={v} looks like a raw FIFA id leaking into Core"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_start_count_to_offset_limit() {
|
||||
let core = map_to_core(&parse_club_query("start=20&count=11"), &resolver()).unwrap();
|
||||
assert_eq!(core.offset, Some(20));
|
||||
assert_eq!(core.limit, Some(11));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_position_uppercased() {
|
||||
let core = map_to_core(&parse_club_query("position=st"), &resolver()).unwrap();
|
||||
assert_eq!(core.position.as_deref(), Some("ST"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_is_dropped_no_core_param() {
|
||||
let core = map_to_core(&parse_club_query("sort=desc"), &resolver()).unwrap();
|
||||
let keys: Vec<&str> = core.to_query_pairs().into_iter().map(|(k, _)| k).collect();
|
||||
assert!(
|
||||
!keys.contains(&"sort"),
|
||||
"no proven FIFA sort key; must not emit one"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_query_param_names_mirror_core_contract() {
|
||||
// Pins the wire contract with openfut-core's OwnedItemQuery field names.
|
||||
let core = CoreOwnedQuery {
|
||||
quality: Some("gold".into()),
|
||||
position: Some("ST".into()),
|
||||
nation: Some("Argentina".into()),
|
||||
league: Some("Premier League".into()),
|
||||
club: Some("Chelsea".into()),
|
||||
offset: Some(10),
|
||||
limit: Some(11),
|
||||
special: false,
|
||||
unsupported: vec![],
|
||||
};
|
||||
let keys: Vec<&str> = core.to_query_pairs().into_iter().map(|(k, _)| k).collect();
|
||||
assert_eq!(
|
||||
keys,
|
||||
["quality", "position", "nation", "league", "club", "offset", "limit"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_to_end_capture_shaped_query() {
|
||||
// Mirrors the retail PAGINATION capture: PL + Chelsea, page 2.
|
||||
let core = map_to_core(
|
||||
&parse_club_query(
|
||||
"year=2017&type=player&count=11&level=gold&nation=52&league=13&team=5&sort=desc&start=10",
|
||||
),
|
||||
&resolver(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
core,
|
||||
CoreOwnedQuery {
|
||||
quality: Some("gold".into()),
|
||||
position: None,
|
||||
nation: Some("Argentina".into()),
|
||||
league: Some("Premier League".into()),
|
||||
club: Some("Chelsea".into()),
|
||||
offset: Some(10),
|
||||
limit: Some(11),
|
||||
special: false,
|
||||
unsupported: vec![],
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
//! FIFA 17 Store pack-content generator (pure, seeded).
|
||||
//!
|
||||
//! Draws the cards a Store pack awards. It is a **pure function** of
|
||||
//! `(pack definition, RNG, candidate pool)` — no IO, no Core, no catalogue
|
||||
//! lookup — so it is deterministic under a seeded [`rand::Rng`] and trivially
|
||||
//! unit-tested. The host owns the impure parts: it builds the candidate pool
|
||||
//! (only card ids that resolve in BOTH the FIFA catalogue and Core content),
|
||||
//! mints the drawn cards into Core, and shapes them onto the wire.
|
||||
//!
|
||||
//! ## Policy (real FUT 17 composition, DESIGNED odds)
|
||||
//!
|
||||
//! A pack draws [`PackDef::count`] cards split across rating tiers by the pack's
|
||||
//! `n_bronze`/`n_silver`/`n_gold` composition (the same numbers the tile shows in
|
||||
//! `packContentInfo`), drawing with replacement from the candidate pool. Each pick
|
||||
//! is biased toward a special version with probability `special_chance` (a DESIGNED
|
||||
//! placeholder — FUT 17 pack odds are unrecoverable). An empty tier falls back to
|
||||
//! the whole pool so a draw is always possible even when the pool lacks that tier.
|
||||
//! `FUT_PACK_MIX` (consumable/staff extras) is deliberately OMITTED — Core
|
||||
//! candidates are player defs.
|
||||
|
||||
use rand::Rng;
|
||||
|
||||
use crate::fut::store_catalog::PackDef;
|
||||
|
||||
/// A candidate the host has already verified resolves in BOTH the FIFA catalogue
|
||||
/// and Core content. Carries the full Core definition the shaper needs plus the
|
||||
/// two draw-policy annotations (`gold` tier, `special` version) the host derives
|
||||
/// from the catalogue (keeping this generator pure — it never reads a catalogue).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GeneratedCandidate {
|
||||
/// Core card-definition id (resolves in the FIFA catalogue and Core content).
|
||||
pub card_id: String,
|
||||
pub rating: u8,
|
||||
pub position: String,
|
||||
pub nation: String,
|
||||
pub league: String,
|
||||
pub club: String,
|
||||
/// [pace, shooting, passing, dribbling, defending, physical].
|
||||
pub attributes: [u8; 6],
|
||||
/// Gold tier (host derives this as `rating >= 75`, the oracle's split point).
|
||||
pub gold: bool,
|
||||
/// Special version available (host derives this from the catalogue rareflag
|
||||
/// `> 1`); gated by [`PackDef::special_chance`].
|
||||
pub special: bool,
|
||||
}
|
||||
|
||||
impl GeneratedCandidate {
|
||||
fn to_card(&self) -> GeneratedCard {
|
||||
GeneratedCard {
|
||||
card_id: self.card_id.clone(),
|
||||
rating: self.rating,
|
||||
position: self.position.clone(),
|
||||
nation: self.nation.clone(),
|
||||
league: self.league.clone(),
|
||||
club: self.club.clone(),
|
||||
attributes: self.attributes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One card a pack awarded. Carries `card_id` (the Core definition the host mints
|
||||
/// and shapes) plus the definition fields the shared item shaper needs. It is
|
||||
/// NOT an owned instance yet — the host mints the Core instance id and allocates
|
||||
/// the numeric wire id.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GeneratedCard {
|
||||
pub card_id: String,
|
||||
pub rating: u8,
|
||||
pub position: String,
|
||||
pub nation: String,
|
||||
pub league: String,
|
||||
pub club: String,
|
||||
pub attributes: [u8; 6],
|
||||
}
|
||||
|
||||
/// Draw a pack's cards from `pool` with the injected RNG. Pure and deterministic
|
||||
/// under a seeded RNG. Returns an empty `Vec` (fail-closed) when the pool is empty
|
||||
/// or the pack awards no cards.
|
||||
///
|
||||
/// Draws the pack's per-tier composition (`n_gold` gold-tier, `n_silver` silver,
|
||||
/// `n_bronze` bronze), biasing each pick toward a special with `special_chance`.
|
||||
/// An empty tier falls back to the whole pool (so a draw is always possible).
|
||||
pub fn generate_pack_contents(
|
||||
pack: &PackDef,
|
||||
rng: &mut impl Rng,
|
||||
pool: &[GeneratedCandidate],
|
||||
) -> Vec<GeneratedCard> {
|
||||
if pool.is_empty() || pack.count() == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let all: Vec<&GeneratedCandidate> = pool.iter().collect();
|
||||
let gold: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.rating >= 75).collect();
|
||||
let silver: Vec<&GeneratedCandidate> = pool
|
||||
.iter()
|
||||
.filter(|c| (65..75).contains(&c.rating))
|
||||
.collect();
|
||||
let bronze: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.rating < 65).collect();
|
||||
|
||||
let mut out = Vec::with_capacity(pack.count() as usize);
|
||||
draw_tier(&mut out, &gold, &all, pack.n_gold, pack.special_chance, rng);
|
||||
draw_tier(
|
||||
&mut out,
|
||||
&silver,
|
||||
&all,
|
||||
pack.n_silver,
|
||||
pack.special_chance,
|
||||
rng,
|
||||
);
|
||||
draw_tier(
|
||||
&mut out,
|
||||
&bronze,
|
||||
&all,
|
||||
pack.n_bronze,
|
||||
pack.special_chance,
|
||||
rng,
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/// Draw `n` cards from `tier` — or the whole-pool `fallback` when `tier` is empty —
|
||||
/// biasing each pick toward a special version with probability `chance`. Either the
|
||||
/// special or normal partition falls back to the tier when empty.
|
||||
fn draw_tier(
|
||||
out: &mut Vec<GeneratedCard>,
|
||||
tier: &[&GeneratedCandidate],
|
||||
fallback: &[&GeneratedCandidate],
|
||||
n: u64,
|
||||
chance: f64,
|
||||
rng: &mut impl Rng,
|
||||
) {
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
let src: &[&GeneratedCandidate] = if tier.is_empty() { fallback } else { tier };
|
||||
if src.is_empty() {
|
||||
return;
|
||||
}
|
||||
let special: Vec<&GeneratedCandidate> = src.iter().copied().filter(|c| c.special).collect();
|
||||
let normal: Vec<&GeneratedCandidate> = src.iter().copied().filter(|c| !c.special).collect();
|
||||
let chance = chance.clamp(0.0, 1.0);
|
||||
for _ in 0..n {
|
||||
let want_special = chance > 0.0 && rng.gen_bool(chance);
|
||||
let sub: &[&GeneratedCandidate] = if want_special && !special.is_empty() {
|
||||
&special
|
||||
} else if !want_special && !normal.is_empty() {
|
||||
&normal
|
||||
} else {
|
||||
src
|
||||
};
|
||||
let pick = sub[rng.gen_range(0..sub.len())];
|
||||
out.push(pick.to_card());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rand::rngs::StdRng;
|
||||
use rand::SeedableRng;
|
||||
|
||||
fn cand(card: &str, rating: u8, gold: bool, special: bool) -> GeneratedCandidate {
|
||||
GeneratedCandidate {
|
||||
card_id: card.into(),
|
||||
rating,
|
||||
position: "ST".into(),
|
||||
nation: "Brazil".into(),
|
||||
league: "Premier League".into(),
|
||||
club: "Arsenal".into(),
|
||||
attributes: [rating; 6],
|
||||
gold,
|
||||
special,
|
||||
}
|
||||
}
|
||||
|
||||
/// A mixed pool: gold specials, gold normals, and a bronze tier.
|
||||
fn pool() -> Vec<GeneratedCandidate> {
|
||||
vec![
|
||||
cand("g-sp-1", 90, true, true),
|
||||
cand("g-sp-2", 88, true, true),
|
||||
cand("g-1", 84, true, false),
|
||||
cand("g-2", 82, true, false),
|
||||
cand("g-3", 79, true, false),
|
||||
cand("b-1", 64, false, false),
|
||||
cand("b-2", 62, false, false),
|
||||
]
|
||||
}
|
||||
|
||||
fn pack(id: u64, n_bronze: u64, n_silver: u64, n_gold: u64, special_chance: f64) -> PackDef {
|
||||
PackDef {
|
||||
id,
|
||||
name: "Test Pack",
|
||||
price: 1000,
|
||||
n_bronze,
|
||||
n_silver,
|
||||
n_gold,
|
||||
rares: 0,
|
||||
category: if n_gold > 0 {
|
||||
"gold"
|
||||
} else if n_silver > 0 {
|
||||
"silver"
|
||||
} else {
|
||||
"bronze"
|
||||
},
|
||||
special_chance,
|
||||
owned_only: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_seed_same_output() {
|
||||
let pool = pool();
|
||||
let p = pack(5, 0, 0, 7, 0.3);
|
||||
let mut a = StdRng::seed_from_u64(42);
|
||||
let mut b = StdRng::seed_from_u64(42);
|
||||
assert_eq!(
|
||||
generate_pack_contents(&p, &mut a, &pool),
|
||||
generate_pack_contents(&p, &mut b, &pool)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_seeds_can_diverge() {
|
||||
let pool = pool();
|
||||
let p = pack(5, 0, 0, 7, 0.3);
|
||||
let a = generate_pack_contents(&p, &mut StdRng::seed_from_u64(1), &pool);
|
||||
let b = generate_pack_contents(&p, &mut StdRng::seed_from_u64(999), &pool);
|
||||
// Not a hard guarantee, but with this pool/count the two seeds differ.
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_is_exact_and_all_cards_from_pool() {
|
||||
let pool = pool();
|
||||
let ids: std::collections::HashSet<&str> =
|
||||
pool.iter().map(|c| c.card_id.as_str()).collect();
|
||||
for &n in &[1u64, 5, 7, 11] {
|
||||
let p = pack(6, 0, 0, n, 0.08);
|
||||
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(n), &pool);
|
||||
assert_eq!(cards.len() as u64, n);
|
||||
for c in &cards {
|
||||
assert!(
|
||||
ids.contains(c.card_id.as_str()),
|
||||
"drew unknown card {}",
|
||||
c.card_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gold_pack_draws_only_gold_tier() {
|
||||
let pool = pool();
|
||||
let p = pack(5, 0, 0, 20, 0.03);
|
||||
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(7), &pool);
|
||||
assert!(
|
||||
cards.iter().all(|c| c.rating >= 75),
|
||||
"gold pack drew a bronze card"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bronze_pack_draws_only_bronze_tier() {
|
||||
let pool = pool();
|
||||
let p = pack(1, 20, 0, 0, 0.005);
|
||||
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(7), &pool);
|
||||
assert!(
|
||||
cards.iter().all(|c| c.rating < 75),
|
||||
"bronze pack drew a gold card"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn special_chance_one_draws_only_specials() {
|
||||
let pool = pool();
|
||||
let special_ids: std::collections::HashSet<&str> = pool
|
||||
.iter()
|
||||
.filter(|c| c.special)
|
||||
.map(|c| c.card_id.as_str())
|
||||
.collect();
|
||||
let p = pack(7, 0, 0, 11, 1.0);
|
||||
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(3), &pool);
|
||||
assert!(cards
|
||||
.iter()
|
||||
.all(|c| special_ids.contains(c.card_id.as_str())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_pool_fails_closed() {
|
||||
let p = pack(5, 0, 0, 7, 0.03);
|
||||
assert!(generate_pack_contents(&p, &mut StdRng::seed_from_u64(1), &[]).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
//! FIFA 17 Squad Building Challenge wire shapes.
|
||||
//!
|
||||
//! Numeric category/set/challenge ids and container types mirror the reversed FIFA 17
|
||||
//! `/sbs/*` family. Core ids remain opaque strings and are mapped here, never in Core.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub const CATEGORY_ID: i64 = 1;
|
||||
pub const CATEGORY_NAME: &str = "Foundations";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ChallengeIdentity {
|
||||
pub core_id: &'static str,
|
||||
pub set_id: i64,
|
||||
pub challenge_id: i64,
|
||||
pub priority: i64,
|
||||
}
|
||||
|
||||
pub const CHALLENGES: [ChallengeIdentity; 2] = [
|
||||
ChallengeIdentity {
|
||||
core_id: "sbc_bronze_upgrade",
|
||||
set_id: 1,
|
||||
challenge_id: 101,
|
||||
priority: 1,
|
||||
},
|
||||
ChallengeIdentity {
|
||||
core_id: "sbc_hybrid_nations",
|
||||
set_id: 2,
|
||||
challenge_id: 201,
|
||||
priority: 2,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ChallengeView {
|
||||
pub identity: ChallengeIdentity,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub repeatable: bool,
|
||||
pub times_completed: i64,
|
||||
}
|
||||
|
||||
pub fn identity_for_core(core_id: &str) -> Option<ChallengeIdentity> {
|
||||
CHALLENGES
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|entry| entry.core_id == core_id)
|
||||
}
|
||||
|
||||
pub fn identity_for_challenge(challenge_id: i64) -> Option<ChallengeIdentity> {
|
||||
CHALLENGES
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|entry| entry.challenge_id == challenge_id)
|
||||
}
|
||||
|
||||
/// `GET sbs/sets`: object root; categories, sets and awards are always arrays.
|
||||
pub fn sets_body(challenges: &[ChallengeView]) -> Value {
|
||||
let sets: Vec<Value> = challenges
|
||||
.iter()
|
||||
.map(|challenge| {
|
||||
json!({
|
||||
"setId": challenge.identity.set_id,
|
||||
"categoryId": CATEGORY_ID,
|
||||
"name": challenge.name,
|
||||
"description": challenge.description,
|
||||
"priority": challenge.identity.priority,
|
||||
"challengesCount": 1,
|
||||
"challengesCompletedCount": i64::from(!challenge.repeatable && challenge.times_completed > 0),
|
||||
"awards": [],
|
||||
"hidden": false,
|
||||
"endTime": 4_102_444_800_i64
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"categories": [{
|
||||
"categoryId": CATEGORY_ID,
|
||||
"name": CATEGORY_NAME,
|
||||
"priority": 1,
|
||||
"sets": sets
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
/// `GET sbs/setId/{id}/challenges`: object root with a challenge array.
|
||||
pub fn challenges_body(set_id: i64, challenges: &[ChallengeView]) -> Value {
|
||||
let records: Vec<Value> = challenges
|
||||
.iter()
|
||||
.filter(|challenge| challenge.identity.set_id == set_id)
|
||||
.map(|challenge| {
|
||||
// A repeatable challenge is always available to enter again. The FIFA 17
|
||||
// client gates challenge re-entry on `timesCompleted` (not on `repeatable`):
|
||||
// a nonzero count renders the tile COMPLETED and refuses re-entry. So a
|
||||
// repeatable challenge never reports itself as terminally completed here.
|
||||
// Core keeps the true completion record (economy authority); this is
|
||||
// presentation only. Live-proven on the retail client 2026-08-18.
|
||||
let times_completed = if challenge.repeatable {
|
||||
0
|
||||
} else {
|
||||
challenge.times_completed
|
||||
};
|
||||
json!({
|
||||
"challengeId": challenge.identity.challenge_id,
|
||||
"setId": challenge.identity.set_id,
|
||||
"categoryId": CATEGORY_ID,
|
||||
"index": 0,
|
||||
"type": "OPEN_CHALLENGE",
|
||||
"name": challenge.name,
|
||||
"description": challenge.description,
|
||||
"challengeImageId": "",
|
||||
"formation": "f442",
|
||||
"endTime": 0,
|
||||
"repeatable": challenge.repeatable,
|
||||
"trophyId": 0,
|
||||
"status": "OPEN",
|
||||
"timesCompleted": times_completed,
|
||||
"awards": [],
|
||||
// `elgReq` stays empty deliberately. Reversed from the pinned CardsDLL
|
||||
// (2026-08-19, see ENDPOINT_MAP.md "Shared record shapes"): the client
|
||||
// consumes `eligibilityKey`/`eligibilityOperation` only as ordinals that
|
||||
// index the packed locale (`LOC_SBC_ELG_KEY_%d`) to render requirement
|
||||
// text — it does NOT validate on them. The ordinal->string map is not
|
||||
// recoverable from any asset we have, so any value we emit would show the
|
||||
// WRONG requirement to the player. Submission is validated server-side by
|
||||
// Core regardless; leaving this empty is display-only, never a correctness
|
||||
// gap. Populate ONLY once the locale ordinal map is captured.
|
||||
"elgReq": []
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({ "challenges": records })
|
||||
}
|
||||
|
||||
/// Empty-body POST starts a challenge. `squad` is object-root on this response class.
|
||||
pub fn start_body(challenge_id: i64) -> Value {
|
||||
json!({
|
||||
"challengeId": challenge_id,
|
||||
"squad": {},
|
||||
"playerRequirements": []
|
||||
})
|
||||
}
|
||||
|
||||
/// GET challenge squad. The reversed response requires array containers.
|
||||
pub fn squad_body(challenge_id: i64, wire_item_ids: &[i64]) -> Value {
|
||||
let squad: Vec<Value> = wire_item_ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, id)| {
|
||||
json!({
|
||||
"index": index,
|
||||
"itemData": { "id": id },
|
||||
"kitNumber": 0
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"id": challenge_id,
|
||||
"squad": squad,
|
||||
"playerRequirements": []
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save_body(challenge_id: i64) -> Value {
|
||||
json!({ "id": challenge_id })
|
||||
}
|
||||
|
||||
pub fn submit_body(challenge_id: i64, set_id: i64, credits: i64, unopened_packs: i64) -> Value {
|
||||
json!({
|
||||
"challengeId": challenge_id,
|
||||
"setId": set_id,
|
||||
"credits": credits,
|
||||
"preOrderPacks": 0,
|
||||
"recoveredPacks": unopened_packs,
|
||||
"grantedChallengeAwards": [],
|
||||
"grantedSetAwards": []
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum SbcWireError {
|
||||
Json(String),
|
||||
MissingSquad,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SbcWireError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Json(error) => write!(formatter, "invalid SBC squad JSON: {error}"),
|
||||
Self::MissingSquad => {
|
||||
formatter.write_str("SBC squad body contains no supported squad container")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SbcWireError {}
|
||||
|
||||
/// Extract FIFA wire item ids from a saved/submitted challenge squad.
|
||||
///
|
||||
/// The exact retail challenge-squad body is now captured (2026-08-18 Gate C,
|
||||
/// `PUT /ut/game/fifa17/sbs/challenge/101/squad`): a fixed 23-entry `players`
|
||||
/// array of `{index, itemData:{id, dream}}` (empty slots carry `id == 0`),
|
||||
/// alongside sibling `chemistry`, `rating`, `formation`, and a `manager` array
|
||||
/// of `{id, dream}`. Only `players[].itemData.id` selects the consumed cards;
|
||||
/// the manager, chemistry, rating, formation, dream, and index fields are
|
||||
/// presentation/validation hints and are never consumed. The `squad` array
|
||||
/// container remains accepted for the alternate proven shape. Within either,
|
||||
/// only non-zero `itemData.id` values are interpreted.
|
||||
pub fn parse_wire_item_ids(body: &[u8]) -> Result<Vec<i64>, SbcWireError> {
|
||||
let root: Value =
|
||||
serde_json::from_slice(body).map_err(|error| SbcWireError::Json(error.to_string()))?;
|
||||
let entries = root
|
||||
.get("players")
|
||||
.and_then(Value::as_array)
|
||||
.or_else(|| root.get("squad").and_then(Value::as_array))
|
||||
.ok_or(SbcWireError::MissingSquad)?;
|
||||
|
||||
let mut ids = Vec::new();
|
||||
for entry in entries {
|
||||
if let Some(id) = entry
|
||||
.get("itemData")
|
||||
.and_then(|item| item.get("id"))
|
||||
.and_then(Value::as_i64)
|
||||
.filter(|id| *id != 0)
|
||||
{
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn challenge() -> ChallengeView {
|
||||
ChallengeView {
|
||||
identity: CHALLENGES[0],
|
||||
name: "Bronze Upgrade".into(),
|
||||
description: "Submit players".into(),
|
||||
repeatable: true,
|
||||
times_completed: 2,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_containers_match_reversed_fifa17_shapes() {
|
||||
let sets = sets_body(&[challenge()]);
|
||||
assert!(sets.is_object());
|
||||
assert!(sets["categories"].is_array());
|
||||
assert!(sets["categories"][0]["sets"].is_array());
|
||||
assert!(sets["categories"][0]["sets"][0]["awards"].is_array());
|
||||
|
||||
let challenges = challenges_body(1, &[challenge()]);
|
||||
assert!(challenges.is_object());
|
||||
assert!(challenges["challenges"].is_array());
|
||||
assert!(challenges["challenges"][0]["awards"].is_array());
|
||||
assert!(challenges["challenges"][0]["elgReq"].is_array());
|
||||
|
||||
assert!(start_body(101)["squad"].is_object());
|
||||
assert!(start_body(101)["playerRequirements"].is_array());
|
||||
assert!(squad_body(101, &[100_000_001])["squad"].is_array());
|
||||
let submit = submit_body(101, 1, 1234, 1);
|
||||
assert!(submit["grantedChallengeAwards"].is_array());
|
||||
assert!(submit["grantedSetAwards"].is_array());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeatable_completed_challenge_stays_enterable() {
|
||||
// The FIFA 17 client refuses challenge re-entry when timesCompleted > 0, so
|
||||
// a repeatable challenge must always project as not-yet-completed while a
|
||||
// non-repeatable one keeps its true count. Core holds the real record.
|
||||
let repeatable = ChallengeView {
|
||||
identity: CHALLENGES[0],
|
||||
name: "Bronze Upgrade".into(),
|
||||
description: "Submit players".into(),
|
||||
repeatable: true,
|
||||
times_completed: 3,
|
||||
};
|
||||
let once = ChallengeView {
|
||||
identity: CHALLENGES[1],
|
||||
name: "Hybrid Nations".into(),
|
||||
description: "Submit a hybrid squad".into(),
|
||||
repeatable: false,
|
||||
times_completed: 1,
|
||||
};
|
||||
|
||||
let repeatable_view =
|
||||
challenges_body(CHALLENGES[0].set_id, std::slice::from_ref(&repeatable));
|
||||
assert_eq!(repeatable_view["challenges"][0]["timesCompleted"], 0);
|
||||
assert_eq!(repeatable_view["challenges"][0]["status"], "OPEN");
|
||||
let once_view = challenges_body(CHALLENGES[1].set_id, std::slice::from_ref(&once));
|
||||
assert_eq!(once_view["challenges"][0]["timesCompleted"], 1);
|
||||
|
||||
let sets = sets_body(&[repeatable, once]);
|
||||
let sets_arr = sets["categories"][0]["sets"].as_array().unwrap();
|
||||
let repeatable_set = sets_arr
|
||||
.iter()
|
||||
.find(|set| set["setId"] == CHALLENGES[0].set_id)
|
||||
.unwrap();
|
||||
let once_set = sets_arr
|
||||
.iter()
|
||||
.find(|set| set["setId"] == CHALLENGES[1].set_id)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
repeatable_set["challengesCompletedCount"], 0,
|
||||
"a repeatable set never reports itself terminally completed"
|
||||
);
|
||||
assert_eq!(
|
||||
once_set["challengesCompletedCount"], 1,
|
||||
"a one-shot set counts its single completion"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_accepts_only_known_squad_containers_and_item_ids() {
|
||||
let normal = br#"{"players":[{"index":0,"itemData":{"id":100000001}},{"index":1,"itemData":{"id":0}}]}"#;
|
||||
assert_eq!(parse_wire_item_ids(normal).unwrap(), [100_000_001]);
|
||||
let sbc = br#"{"squad":[{"itemData":{"id":100000002}}]}"#;
|
||||
assert_eq!(parse_wire_item_ids(sbc).unwrap(), [100_000_002]);
|
||||
assert_eq!(
|
||||
parse_wire_item_ids(br#"{"challengeId":101}"#),
|
||||
Err(SbcWireError::MissingSquad)
|
||||
);
|
||||
assert!(matches!(
|
||||
parse_wire_item_ids(br#"{"squad":"not-an-array"}"#),
|
||||
Err(SbcWireError::MissingSquad)
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_wire_item_ids(br#"{"squad":["#),
|
||||
Err(SbcWireError::Json(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_matches_captured_retail_challenge_squad_body() {
|
||||
// Verbatim shape from the 2026-08-18 Gate C retail capture of
|
||||
// PUT /ut/game/fifa17/sbs/challenge/101/squad (11 filled + 12 empty
|
||||
// slots, plus manager/chemistry/rating/formation siblings). Only the 11
|
||||
// non-zero player itemData.id values are consumed, in wire order; the
|
||||
// manager and all presentation fields are ignored.
|
||||
let retail = br#"{"chemistry":21,"rating":86,"formation":"f433",
|
||||
"manager":[{"id":100000427,"dream":false}],
|
||||
"players":[
|
||||
{"index":0,"itemData":{"id":100004227,"dream":false}},
|
||||
{"index":1,"itemData":{"id":100004233,"dream":false}},
|
||||
{"index":2,"itemData":{"id":100001317,"dream":false}},
|
||||
{"index":3,"itemData":{"id":100001531,"dream":false}},
|
||||
{"index":4,"itemData":{"id":100000966,"dream":false}},
|
||||
{"index":5,"itemData":{"id":100001947,"dream":false}},
|
||||
{"index":6,"itemData":{"id":100000169,"dream":false}},
|
||||
{"index":7,"itemData":{"id":100002017,"dream":false}},
|
||||
{"index":8,"itemData":{"id":100002765,"dream":false}},
|
||||
{"index":9,"itemData":{"id":100000147,"dream":false}},
|
||||
{"index":10,"itemData":{"id":100000311,"dream":false}},
|
||||
{"index":11,"itemData":{"id":0,"dream":false}},
|
||||
{"index":12,"itemData":{"id":0,"dream":false}},
|
||||
{"index":13,"itemData":{"id":0,"dream":false}},
|
||||
{"index":14,"itemData":{"id":0,"dream":false}},
|
||||
{"index":15,"itemData":{"id":0,"dream":false}},
|
||||
{"index":16,"itemData":{"id":0,"dream":false}},
|
||||
{"index":17,"itemData":{"id":0,"dream":false}},
|
||||
{"index":18,"itemData":{"id":0,"dream":false}},
|
||||
{"index":19,"itemData":{"id":0,"dream":false}},
|
||||
{"index":20,"itemData":{"id":0,"dream":false}},
|
||||
{"index":21,"itemData":{"id":0,"dream":false}},
|
||||
{"index":22,"itemData":{"id":0,"dream":false}}
|
||||
]}"#;
|
||||
assert_eq!(
|
||||
parse_wire_item_ids(retail).unwrap(),
|
||||
[
|
||||
100_004_227,
|
||||
100_004_233,
|
||||
100_001_317,
|
||||
100_001_531,
|
||||
100_000_966,
|
||||
100_001_947,
|
||||
100_000_169,
|
||||
100_002_017,
|
||||
100_002_765,
|
||||
100_000_147,
|
||||
100_000_311
|
||||
],
|
||||
"exactly the 11 non-zero players in wire order; manager and empty slots ignored"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//! FIFA 17 offline-Seasons wire shapes.
|
||||
//!
|
||||
//! Reversed from `CardsDLL_Win64_retail.dll`, not guessed. The `season/list`
|
||||
//! per-element parser is `FUN_180167740` (element stride 0x318) and the response
|
||||
//! deserialiser root is `FUN_1801683f0` (an object with the single key
|
||||
//! `seasons`). Element fields land at:
|
||||
//!
|
||||
//! | wire key | atom | element offset |
|
||||
//! |--------------|-------|--------------------------------|
|
||||
//! | `id` | 0x15c | +0x1b0 |
|
||||
//! | `divisionId` | 0x0dc | +0x1f8, as `(0xb - value)` |
|
||||
//! | `type` | — | +0x1b4 (int, switch) |
|
||||
//! | `matches` | 0x1b8 | vector at +0x2e8/+0x2f0/+0x2f8 |
|
||||
//!
|
||||
//! Each `matches` element is 16 bytes, parsed by `FUN_180167fb0`:
|
||||
//! `teamId`(0x305) int@+0x0, `difficulty`(0xd4) byte@+0x4, `roundId`(0x291)
|
||||
//! byte@+0x5, `rewardMult`(0x28b) int@+0x8, `coins`(0x95) int@+0xc.
|
||||
//!
|
||||
//! WHY `matches` MUST BE NON-EMPTY: `StartSeason` (`FUN_1800fc500`) reads
|
||||
//! `matches[*(x+0x70)].teamId` through `*(elem+0x2e8 + index*0x10)`. With an
|
||||
//! empty vector `elem+0x2e8` is NULL and the client dereferences address 0 —
|
||||
//! a hard crash at `CardsDLL+0xfc5b5`. Emitting a full round set is therefore a
|
||||
//! correctness requirement, not a nicety.
|
||||
//!
|
||||
//! WHY STRUCTS AND NOT `json!`: `type` must precede `divisionId` — the element
|
||||
//! parser binds the competition type before it maps the division. `serde_json`'s
|
||||
//! `Value` is a `BTreeMap` without the `preserve_order` feature, so `json!`
|
||||
//! silently reorders keys ALPHABETICALLY and would emit `divisionId` first.
|
||||
//! A `#[derive(Serialize)]` struct serialises in declaration order, so these
|
||||
//! types ARE the wire contract. For the same reason every body here is rendered
|
||||
//! straight to a `String` and never round-tripped through `Value`.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Rounds in one FIFA 17 offline season. The division ladder is ten matches.
|
||||
pub const SEASON_ROUNDS: i64 = 10;
|
||||
|
||||
/// Opponent team ids used for the round schedule.
|
||||
///
|
||||
/// These are real team ids observed in this client's own database (they appear
|
||||
/// as the `teamid` of club players in the live kit-item trace), so every round
|
||||
/// resolves to a team the client can actually render. They are cycled rather
|
||||
/// than randomised so a season's schedule is stable across reloads — the client
|
||||
/// re-reads `season/list` and a shifting schedule would renumber fixtures.
|
||||
const OPPONENT_TEAM_IDS: &[i64] = &[21, 73, 240, 241, 243];
|
||||
|
||||
/// One scheduled offline-season round.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SeasonMatch {
|
||||
#[serde(rename = "teamId")]
|
||||
pub team_id: i64,
|
||||
pub difficulty: i64,
|
||||
#[serde(rename = "roundId")]
|
||||
pub round_id: i64,
|
||||
#[serde(rename = "rewardMult")]
|
||||
pub reward_mult: i64,
|
||||
pub coins: i64,
|
||||
}
|
||||
|
||||
/// One offline competition. FIELD ORDER IS THE WIRE CONTRACT — `type` first.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SeasonElement {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: &'static str,
|
||||
pub id: i64,
|
||||
#[serde(rename = "divisionId")]
|
||||
pub division_id: i64,
|
||||
pub matches: Vec<SeasonMatch>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SeasonList {
|
||||
pub seasons: Vec<SeasonElement>,
|
||||
}
|
||||
|
||||
/// The club's position in its current season.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SeasonUser {
|
||||
#[serde(rename = "seasonId")]
|
||||
pub season_id: i64,
|
||||
#[serde(rename = "divisionId")]
|
||||
pub division_id: i64,
|
||||
pub round: i64,
|
||||
#[serde(rename = "userPoints")]
|
||||
pub user_points: i64,
|
||||
/// Opaque client blob; the client round-trips it and never requires server
|
||||
/// interpretation.
|
||||
#[serde(rename = "dataVersion")]
|
||||
pub data_version: &'static str,
|
||||
pub data: &'static str,
|
||||
}
|
||||
|
||||
fn round(index: i64) -> SeasonMatch {
|
||||
SeasonMatch {
|
||||
team_id: OPPONENT_TEAM_IDS[(index as usize) % OPPONENT_TEAM_IDS.len()],
|
||||
// Difficulty and reward multiplier are per-round bytes; a flat schedule
|
||||
// is the honest default until the retail ladder is captured.
|
||||
difficulty: 1,
|
||||
round_id: index,
|
||||
reward_mult: 1,
|
||||
coins: 400,
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET …/season/list` — the offline competitions the club can enter, as wire
|
||||
/// text (see the module note on key order).
|
||||
pub fn season_list_body(season_id: i64, division_id: i64) -> String {
|
||||
let list = SeasonList {
|
||||
seasons: vec![SeasonElement {
|
||||
kind: "OFFLINE",
|
||||
id: season_id,
|
||||
division_id,
|
||||
matches: (0..SEASON_ROUNDS).map(round).collect(),
|
||||
}],
|
||||
};
|
||||
serde_json::to_string(&list).expect("season list serialises")
|
||||
}
|
||||
|
||||
/// `GET …/season/user` — where the club currently is in its season.
|
||||
pub fn season_user_body(season_id: i64, division_id: i64, round: i64, user_points: i64) -> String {
|
||||
let user = SeasonUser {
|
||||
season_id,
|
||||
division_id,
|
||||
round,
|
||||
user_points,
|
||||
data_version: "1",
|
||||
data: "",
|
||||
};
|
||||
serde_json::to_string(&user).expect("season user serialises")
|
||||
}
|
||||
|
||||
/// `GET …/season/user/history` — completed seasons. Empty until a season ends;
|
||||
/// the client renders an empty history without complaint.
|
||||
pub fn season_history_body() -> String {
|
||||
String::from(r#"{"seasons":[]}"#)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::Value;
|
||||
|
||||
fn parsed(text: &str) -> Value {
|
||||
serde_json::from_str(text).expect("valid json")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_emits_a_full_round_schedule() {
|
||||
let body = parsed(&season_list_body(1, 10));
|
||||
let season = &body["seasons"][0];
|
||||
assert_eq!(season["type"], "OFFLINE");
|
||||
assert_eq!(season["id"], 1);
|
||||
assert_eq!(season["divisionId"], 10);
|
||||
assert_eq!(
|
||||
season["matches"].as_array().unwrap().len(),
|
||||
SEASON_ROUNDS as usize
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty `matches` vector makes StartSeason dereference NULL
|
||||
/// (CardsDLL+0xfc5b5), so the schedule can never be empty.
|
||||
#[test]
|
||||
fn matches_are_never_empty_and_every_round_has_a_team() {
|
||||
let body = parsed(&season_list_body(3, 7));
|
||||
let matches = body["seasons"][0]["matches"].as_array().unwrap();
|
||||
assert!(!matches.is_empty());
|
||||
for (i, m) in matches.iter().enumerate() {
|
||||
assert_eq!(m["roundId"], i as i64, "rounds are 0..n and in order");
|
||||
assert!(
|
||||
m["teamId"].as_i64().is_some_and(|t| t > 0),
|
||||
"round {i} must name a real opponent team: {m}"
|
||||
);
|
||||
assert!(m["coins"].as_i64().is_some());
|
||||
assert!(m["rewardMult"].as_i64().is_some());
|
||||
assert!(m["difficulty"].as_i64().is_some());
|
||||
}
|
||||
}
|
||||
|
||||
/// The element parser binds the competition type before mapping the
|
||||
/// division, so `type` MUST serialise before `divisionId`. `json!` would
|
||||
/// order them alphabetically and break this.
|
||||
#[test]
|
||||
fn type_is_serialised_before_division_id() {
|
||||
let text = season_list_body(1, 10);
|
||||
let type_at = text.find("\"type\"").expect("type key");
|
||||
let division_at = text.find("\"divisionId\"").expect("divisionId key");
|
||||
assert!(
|
||||
type_at < division_at,
|
||||
"type must precede divisionId on the wire: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_state_carries_the_season_position() {
|
||||
let body = parsed(&season_user_body(1, 10, 3, 6));
|
||||
assert_eq!(body["seasonId"], 1);
|
||||
assert_eq!(body["divisionId"], 10);
|
||||
assert_eq!(body["round"], 3);
|
||||
assert_eq!(body["userPoints"], 6);
|
||||
assert_eq!(body["dataVersion"], "1");
|
||||
assert_eq!(body["data"], "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_is_an_empty_season_list() {
|
||||
let body = parsed(&season_history_body());
|
||||
assert_eq!(body["seasons"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
//! FIFA 17 squad **full-replacement** wire (`PUT /ut/game/fifa17/squad/<id>`) →
|
||||
//! a game-independent proposed replacement OpenFUT Core can apply.
|
||||
//!
|
||||
//! ## Scope (deliberately preparatory — nothing is routed yet)
|
||||
//!
|
||||
//! This module parses the captured squad-save wire and reverse-maps each slot's
|
||||
//! FIFA wire item id to a Core owned-instance id, producing a [`ProposedSquad`].
|
||||
//! It does **not** open a socket, call Core, or mutate state — squad is a
|
||||
//! *stateful* slice and its GET (retrieval) and PUT (save) must migrate together
|
||||
//! as one authority, which needs more captured evidence first. Until then this is
|
||||
//! pure, unit-tested scaffolding.
|
||||
//!
|
||||
//! ## What the wire proves (2 captured retail saves, `fixtures/utas/`)
|
||||
//!
|
||||
//! * The client sends the **whole** squad on every save — a fixed 23-slot array
|
||||
//! plus `formation`, `captain`, `kicktakers`, a 33-int `custom` string, and
|
||||
//! client-reported `chemistry`/`rating`/`starRating`. A two-player edit changed
|
||||
//! nine slots, so slot deltas never describe intent: the only honest operation
|
||||
//! is *this is the squad now*.
|
||||
//! * Each occupied slot carries its FIFA **wire item id** (`itemData.id`, the same
|
||||
//! namespace as `/club` and the identity store); an **empty** slot is `id == 0`.
|
||||
//! * `captain` and `kicktakers` reference the same wire item id space.
|
||||
//!
|
||||
//! ## What it does NOT prove (kept UNKNOWN, never invented)
|
||||
//!
|
||||
//! * The `index → (slot, bench)` layout for formations other than **f442**, and
|
||||
//! the meaning/stability of the `custom` 33-int array. `custom` is preserved
|
||||
//! **opaquely** so it can round-trip unchanged; its integers are not decoded.
|
||||
//! * FIFA's chemistry/rating algorithm — client-reported values are carried in
|
||||
//! [`ClientReportedSquadEval`] and never reconciled with Core's own evaluation.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A `{ "id": <wire item id>, "dream": bool }` reference (player, manager, …).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SquadEntityRef {
|
||||
pub id: i64,
|
||||
#[serde(default)]
|
||||
pub dream: bool,
|
||||
}
|
||||
|
||||
/// One entry of the fixed-length `players` array.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SquadSlotWire {
|
||||
pub index: i64,
|
||||
pub item_data: SquadEntityRef,
|
||||
#[serde(default)]
|
||||
pub kit_number: i64,
|
||||
}
|
||||
|
||||
/// A `kicktakers` entry (penalty/corner/free-kick roles). Carries a wire item id;
|
||||
/// role semantics are UNKNOWN and not modelled.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SquadKicktaker {
|
||||
pub index: i64,
|
||||
pub id: i64,
|
||||
#[serde(default)]
|
||||
pub dream: bool,
|
||||
}
|
||||
|
||||
/// The squad-save body exactly as FIFA 17 sends it. FIFA-only fields
|
||||
/// (`custom`, `kicktakers`, `kit_number`, `manager`, `squad_type`) are captured
|
||||
/// verbatim; their persistence/reconstruction rules await more evidence.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Fifa17SquadPut {
|
||||
/// Squad id (the path `…/squad/0` and the body agree; `0` = active).
|
||||
#[serde(default)]
|
||||
pub id: i64,
|
||||
#[serde(default)]
|
||||
pub squad_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub formation: Option<String>,
|
||||
#[serde(default)]
|
||||
pub squad_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub chemistry: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub rating: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub star_rating: Option<i64>,
|
||||
/// Wire item id of the captain (must be one of the occupied slots).
|
||||
#[serde(default)]
|
||||
pub captain: Option<i64>,
|
||||
/// Opaque 33-int array as a JSON-encoded string. Semantics UNKNOWN — carried
|
||||
/// verbatim, never parsed or interpreted.
|
||||
#[serde(default)]
|
||||
pub custom: Option<String>,
|
||||
#[serde(default)]
|
||||
pub manager: Vec<SquadEntityRef>,
|
||||
#[serde(default)]
|
||||
pub players: Vec<SquadSlotWire>,
|
||||
#[serde(default)]
|
||||
pub kicktakers: Vec<SquadKicktaker>,
|
||||
}
|
||||
|
||||
/// Reverse-maps a FIFA wire item id to a Core owned-instance id. Implemented by
|
||||
/// the host over `Fifa17IdentityResolver`; `None` = unknown id (never guessed).
|
||||
pub trait SquadWireResolver {
|
||||
fn owned_id_for_wire(&self, wire: i64) -> Option<String>;
|
||||
}
|
||||
|
||||
/// Client-reported squad evaluation. Kept DISTINCT from Core's authoritative
|
||||
/// evaluation and never reconciled — FIFA's chemistry/rating algorithm is UNKNOWN.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ClientReportedSquadEval {
|
||||
pub chemistry: Option<i64>,
|
||||
pub rating: Option<i64>,
|
||||
pub star_rating: Option<i64>,
|
||||
}
|
||||
|
||||
/// One resolved slot of a proposed replacement (game-independent).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProposedSlot {
|
||||
pub owned_card_id: String,
|
||||
/// FIFA player-array index (0-based). Core imposes its own slot numbering;
|
||||
/// the adapter carries the index plus a bench flag from the f442 convention.
|
||||
pub index: i64,
|
||||
pub kit_number: i64,
|
||||
pub is_captain: bool,
|
||||
pub is_on_bench: bool,
|
||||
}
|
||||
|
||||
/// A full-squad replacement in **canonical** (game-independent) terms, ready for
|
||||
/// the host to map onto Core's `SquadReplacement`/`SaveSquadRequest`. Carries no
|
||||
/// FIFA-only state (that is [`crate::fut::squad_ext::Fifa17SquadExtensionV1`])
|
||||
/// and no `openfut-core` dependency. No FIFA wire integer survives into a slot —
|
||||
/// every player is a Core `owned_card_id`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProposedSquad {
|
||||
/// The FIFA wire squad id the PUT targeted (`0` = the active squad). Routing
|
||||
/// only — it selects which Core squad to replace; it is never a Core field.
|
||||
pub squad_id: i64,
|
||||
pub name: Option<String>,
|
||||
/// The FIFA formation token exactly as sent (e.g. `"f442"`, `"f433"`). Core
|
||||
/// stores it verbatim as its opaque formation token and never interprets it,
|
||||
/// so it round-trips exactly — never mapped to a second representation and
|
||||
/// never used to derive slot layout.
|
||||
pub formation: Option<String>,
|
||||
pub slots: Vec<ProposedSlot>,
|
||||
/// The owned instance assigned as the squad's **manager**, reverse-resolved
|
||||
/// from the wire `manager` ref to a Core `owned_card_id` (so the assignment
|
||||
/// is ownership-backed, never a dangling wire id). `None` when the save
|
||||
/// carries no manager, or when its manager ref does not resolve — see
|
||||
/// [`Self::unresolved_manager_wire_id`].
|
||||
pub manager_owned_card_id: Option<String>,
|
||||
/// A non-zero manager ref the resolver could not map, if any.
|
||||
///
|
||||
/// This does NOT refuse the save. FIFA 17 sends a manager ref that is not an
|
||||
/// owned club item: production's own squad points at instance 100000427,
|
||||
/// which is absent from production's `/club/staff` listing (1975 items,
|
||||
/// 100000001..100004826), and the client accepts that squad back unchanged —
|
||||
/// so the client does not validate the manager against the club, and refusing
|
||||
/// the save would break EVERY real squad save for a field that was not even
|
||||
/// ownership-backed before migration 0023.
|
||||
///
|
||||
/// An unresolvable ref therefore means "no ownership-backed manager": the
|
||||
/// assignment is cleared, exactly as a full replacement should, and the id is
|
||||
/// reported so the host can log what it could not map. An occupied PLAYER
|
||||
/// slot is different and still refuses the save — dropping one would silently
|
||||
/// lose an owned card from the club.
|
||||
pub unresolved_manager_wire_id: Option<i64>,
|
||||
/// Occupied PLAYER wire item ids the resolver could not map. A caller MUST
|
||||
/// refuse the replacement if this is non-empty — a save must never silently
|
||||
/// drop an owned player it failed to identify.
|
||||
pub unresolved_wire_ids: Vec<i64>,
|
||||
}
|
||||
|
||||
/// Parse errors — explicit, never a silent empty squad.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum SquadError {
|
||||
Parse(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SquadError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SquadError::Parse(e) => write!(f, "squad wire parse error: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl std::error::Error for SquadError {}
|
||||
|
||||
/// The FIFA 17 squad wire is a fixed 23-slot array: indices `0..=10` are the
|
||||
/// pitch (the 11 starters), `11..=22` are bench/reserves. This layout is a
|
||||
/// property of the array, not of the formation — the captured f442 and f433
|
||||
/// saves both place their 11 starters at `0..=10`. Bench membership is therefore
|
||||
/// derived from the index alone, NEVER from the formation token.
|
||||
pub const FIFA17_STARTER_SLOTS: i64 = 11;
|
||||
|
||||
/// Length of the fixed FIFA 17 squad slot array (evidence: every captured save
|
||||
/// and read carries exactly 23 slots).
|
||||
pub const FIFA17_SQUAD_SLOTS: i64 = 23;
|
||||
|
||||
/// Parse a squad-save body into the typed wire form. Structural only.
|
||||
pub fn parse_squad_put(body: &[u8]) -> Result<Fifa17SquadPut, SquadError> {
|
||||
serde_json::from_slice(body).map_err(|e| SquadError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
/// Resolve a parsed save into a **canonical** [`ProposedSquad`]: drop empty
|
||||
/// (`id == 0`) slots, reverse-map each occupied slot's wire id to a Core
|
||||
/// `owned_card_id`, flag the captain, derive the bench split from the fixed
|
||||
/// 23-slot array, and reverse-resolve the manager ref to an owned instance
|
||||
/// (the manager assignment is ownership-backed canonical state, migration 0023).
|
||||
/// The remaining FIFA-only state (`custom`, kicktakers, kit numbers, squadType)
|
||||
/// and client-reported evaluation are NOT canonical — they are built separately
|
||||
/// into [`crate::fut::squad_ext::Fifa17SquadExtensionV1`]. The formation token is
|
||||
/// carried verbatim (never mapped). Unresolvable occupied ids are reported,
|
||||
/// never guessed or dropped.
|
||||
pub fn to_proposed(put: &Fifa17SquadPut, resolver: &dyn SquadWireResolver) -> ProposedSquad {
|
||||
let captain = put.captain.unwrap_or(0);
|
||||
let mut slots = Vec::new();
|
||||
let mut unresolved = Vec::new();
|
||||
for p in &put.players {
|
||||
if p.item_data.id == 0 {
|
||||
continue; // empty slot — never a Core player
|
||||
}
|
||||
match resolver.owned_id_for_wire(p.item_data.id) {
|
||||
Some(owned_card_id) => slots.push(ProposedSlot {
|
||||
owned_card_id,
|
||||
index: p.index,
|
||||
kit_number: p.kit_number,
|
||||
is_captain: captain != 0 && p.item_data.id == captain,
|
||||
is_on_bench: p.index >= FIFA17_STARTER_SLOTS,
|
||||
}),
|
||||
None => unresolved.push(p.item_data.id),
|
||||
}
|
||||
}
|
||||
// Manager: the first non-zero manager ref, reverse-resolved to an owned
|
||||
// instance. Unresolvable is NOT fatal (see `unresolved_manager_wire_id`) --
|
||||
// the real client always sends a dangling ref, so refusing would break every
|
||||
// squad save.
|
||||
let mut manager_owned_card_id = None;
|
||||
let mut unresolved_manager_wire_id = None;
|
||||
if let Some(wire) = put.manager.iter().map(|m| m.id).find(|&id| id != 0) {
|
||||
match resolver.owned_id_for_wire(wire) {
|
||||
Some(owned) => manager_owned_card_id = Some(owned),
|
||||
None => unresolved_manager_wire_id = Some(wire),
|
||||
}
|
||||
}
|
||||
ProposedSquad {
|
||||
squad_id: put.id,
|
||||
name: put.squad_name.clone(),
|
||||
formation: put.formation.clone(),
|
||||
slots,
|
||||
manager_owned_card_id,
|
||||
unresolved_manager_wire_id,
|
||||
unresolved_wire_ids: unresolved,
|
||||
}
|
||||
}
|
||||
|
||||
/// The save acknowledgement FIFA expects: just the squad id (matches the oracle's
|
||||
/// `{"id": <n>}`, 9 bytes — it does NOT echo the squad).
|
||||
pub fn save_ack(squad_id: i64) -> serde_json::Value {
|
||||
serde_json::json!({ "id": squad_id })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// The real captured f442 save body (decoded from `session-001.jsonl:21`).
|
||||
const PUT_F442: &str = include_str!("../../fixtures/utas/squad_put_f442.json");
|
||||
|
||||
/// A resolver mapping every occupied wire id in the fixture to a Core id.
|
||||
struct MapResolver(HashMap<i64, String>);
|
||||
impl SquadWireResolver for MapResolver {
|
||||
fn owned_id_for_wire(&self, wire: i64) -> Option<String> {
|
||||
self.0.get(&wire).cloned()
|
||||
}
|
||||
}
|
||||
fn full_resolver() -> MapResolver {
|
||||
// indices 0..=10 (11 starters); the rest of the 23 slots are id==0 (empty).
|
||||
// 100000427 is the fixture's manager ref — the host resolves it like any
|
||||
// other owned instance, so the manager assignment is ownership-backed.
|
||||
let ids = [
|
||||
100000003, 100000010, 100000005, 100000008, 100000007, 100000006, 100000004, 100000009,
|
||||
100000001, 100000002, 100000025, 100000427,
|
||||
];
|
||||
MapResolver(ids.iter().map(|&w| (w, format!("oc-{w}"))).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_the_captured_full_squad_wire() {
|
||||
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
|
||||
assert_eq!(put.id, 0, "path/body squad id 0 = active");
|
||||
assert_eq!(put.formation.as_deref(), Some("f442"));
|
||||
assert_eq!(put.squad_type.as_deref(), Some("REGULAR_SQUAD"));
|
||||
assert_eq!(put.chemistry, Some(52));
|
||||
assert_eq!(put.rating, Some(90));
|
||||
assert_eq!(put.star_rating, Some(90));
|
||||
assert_eq!(put.captain, Some(100000001));
|
||||
assert_eq!(put.players.len(), 23, "fixed 23-slot array");
|
||||
assert_eq!(put.kicktakers.len(), 5);
|
||||
// custom is carried opaquely, never parsed.
|
||||
assert!(put.custom.as_deref().unwrap().starts_with("[0,0,0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_occupied_slots_drops_empties_and_flags_captain() {
|
||||
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
|
||||
let sq = to_proposed(&put, &full_resolver());
|
||||
|
||||
assert_eq!(sq.slots.len(), 11, "11 occupied; 12 empty (id==0) dropped");
|
||||
assert!(
|
||||
sq.unresolved_wire_ids.is_empty(),
|
||||
"all occupied ids resolved"
|
||||
);
|
||||
assert_eq!(
|
||||
sq.formation.as_deref(),
|
||||
Some("f442"),
|
||||
"formation token carried verbatim, never mapped"
|
||||
);
|
||||
// Every occupied f442 slot is a starter (indices 0..=10).
|
||||
assert!(sq.slots.iter().all(|s| !s.is_on_bench));
|
||||
// The captain flag lands on exactly the captain's slot (id 100000001 @ index 8).
|
||||
let caps: Vec<_> = sq.slots.iter().filter(|s| s.is_captain).collect();
|
||||
assert_eq!(caps.len(), 1);
|
||||
assert_eq!(caps[0].owned_card_id, "oc-100000001");
|
||||
assert_eq!(caps[0].index, 8);
|
||||
assert_eq!(caps[0].kit_number, 8);
|
||||
// The manager ref is reverse-resolved to an owned instance (canonical).
|
||||
assert_eq!(sq.manager_owned_card_id.as_deref(), Some("oc-100000427"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolved_occupied_id_is_reported_never_dropped_silently() {
|
||||
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
|
||||
// Resolver missing one occupied id (100000025).
|
||||
let mut ids = full_resolver().0;
|
||||
ids.remove(&100000025);
|
||||
let sq = to_proposed(&put, &MapResolver(ids));
|
||||
assert_eq!(
|
||||
sq.slots.len(),
|
||||
10,
|
||||
"the unresolved slot is not emitted as a player"
|
||||
);
|
||||
assert_eq!(
|
||||
sq.unresolved_wire_ids,
|
||||
vec![100000025],
|
||||
"reported for the caller to refuse"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_slot_is_dropped_not_resolved() {
|
||||
// A minimal body: one occupied + one empty slot.
|
||||
let body = br#"{"id":0,"formation":"f442","captain":0,"players":[
|
||||
{"index":0,"itemData":{"id":100000003},"kitNumber":1},
|
||||
{"index":11,"itemData":{"id":0},"kitNumber":0}]}"#;
|
||||
let put = parse_squad_put(body).unwrap();
|
||||
let sq = to_proposed(&put, &full_resolver());
|
||||
assert_eq!(sq.slots.len(), 1);
|
||||
assert_eq!(sq.slots[0].index, 0);
|
||||
assert!(sq.unresolved_wire_ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formation_token_round_trips_verbatim() {
|
||||
// Canonical formation is the FIFA token as-sent; f433 is preserved as
|
||||
// readily as f442 (the old lossy f442->"4-4-2" map is gone).
|
||||
for tok in ["f442", "f433"] {
|
||||
let body = format!(
|
||||
r#"{{"id":0,"formation":"{tok}","captain":0,"players":[{{"index":0,"itemData":{{"id":100000003}},"kitNumber":1}}]}}"#
|
||||
);
|
||||
let sq = to_proposed(&parse_squad_put(body.as_bytes()).unwrap(), &full_resolver());
|
||||
assert_eq!(sq.formation.as_deref(), Some(tok));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_ack_is_the_bare_id() {
|
||||
assert_eq!(save_ack(0), serde_json::json!({ "id": 0 }));
|
||||
assert_eq!(save_ack(7), serde_json::json!({ "id": 7 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_body_errors_never_empty_squad() {
|
||||
assert!(matches!(
|
||||
parse_squad_put(b"not json"),
|
||||
Err(SquadError::Parse(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
//! FIFA 17 **Squad Extension v1** — the adapter-owned, versioned payload that
|
||||
//! carries the squad's FIFA-only wire state that OpenFUT Core does not model.
|
||||
//!
|
||||
//! Core stores this serialized payload **opaquely** (never interpreting it) next
|
||||
//! to the canonical squad, anchored by a server fingerprint. This module owns its
|
||||
//! schema and meaning; Core must never import this type.
|
||||
//!
|
||||
//! ## What lives here (and why it is not canonical)
|
||||
//!
|
||||
//! | field | ownership rationale |
|
||||
//! |-------|---------------------|
|
||||
//! | `custom` | opaque 33-int string; meaning UNKNOWN, round-tripped verbatim |
|
||||
//! | `squad_type` | an observed FIFA wire token; no matching generic Core concept |
|
||||
//! | `kit_numbers` | keyed by **`owned_card_id`** — evidence: kit follows the player |
|
||||
//! | ~~manager~~ | MOVED to ownership-backed canonical Core state (migration 0023 `squad_managers`); resolved to an `owned_card_id`, no longer opaque here |
|
||||
//! | `kicktakers` | role→item refs; relationship to captain UNKNOWN, kept opaque |
|
||||
//! | `client_reported` | chemistry/rating/starRating — client shadow, NOT authority |
|
||||
//!
|
||||
//! ## Versioning
|
||||
//!
|
||||
//! Two independent version numbers must not be confused:
|
||||
//! * [`EXT_SCHEMA_VERSION`] — the version of **this** payload schema. Stored in
|
||||
//! Core's `game_entity_ext.schema_version` and re-checked on read
|
||||
//! ([`Fifa17SquadExtensionV1::from_payload`] rejects any other version).
|
||||
//! * Core's own DB storage schema version — a Core concern, unrelated to this.
|
||||
//!
|
||||
//! [`EXT_NAMESPACE`] is the opaque scope key Core files the row under.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::fut::squad::{ClientReportedSquadEval, Fifa17SquadPut, ProposedSquad};
|
||||
|
||||
/// Opaque scope key Core files this extension under (`game_entity_ext.namespace`).
|
||||
pub const EXT_NAMESPACE: &str = "fifa17.squad";
|
||||
|
||||
/// The version of THIS payload schema (goes into `OpaqueExtensionWrite.schema_version`).
|
||||
/// Distinct from Core's DB storage schema version.
|
||||
pub const EXT_SCHEMA_VERSION: i64 = 1;
|
||||
|
||||
/// A FIFA item reference `{ id, dream }` preserved verbatim from the wire. `id`
|
||||
/// is the FIFA wire item id (opaque to this extension — used for manager and
|
||||
/// kicktaker refs whose semantics are not modelled).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WireItemRef {
|
||||
pub id: i64,
|
||||
#[serde(default)]
|
||||
pub dream: bool,
|
||||
}
|
||||
|
||||
/// A kicktaker slot preserved verbatim. `index` is the role slot (0..=4 observed);
|
||||
/// `item` is the referenced FIFA wire item. The role→player meaning and any
|
||||
/// relationship to the captain are UNKNOWN, so this is stored opaquely and never
|
||||
/// normalized to the captain or to a Core `owned_card_id`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct KicktakerRef {
|
||||
pub index: i64,
|
||||
#[serde(flatten)]
|
||||
pub item: WireItemRef,
|
||||
}
|
||||
|
||||
/// FIFA 17 Squad Extension, version 1. Serialized to the opaque payload Core stores.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Fifa17SquadExtensionV1 {
|
||||
/// Opaque 33-int array as a JSON-encoded string, verbatim. Never decoded.
|
||||
#[serde(default)]
|
||||
pub custom: Option<String>,
|
||||
/// FIFA squad-type wire token (e.g. `"REGULAR_SQUAD"`).
|
||||
#[serde(default)]
|
||||
pub squad_type: Option<String>,
|
||||
/// kit number per player, keyed by Core `owned_card_id`. Keyed by the player
|
||||
/// instance — NEVER by slot/index or by card definition — because the wire
|
||||
/// proves the kit number follows the player across swaps and formation change.
|
||||
#[serde(default)]
|
||||
pub kit_numbers: BTreeMap<String, i64>,
|
||||
// NOTE: the squad manager is NO LONGER carried here. It is ownership-backed
|
||||
// canonical Core state (migration 0023 `squad_managers`), resolved to an
|
||||
// `owned_card_id` on the ProposedSquad — never a dangling opaque wire ref.
|
||||
/// Kicktaker role refs, opaque (see [`KicktakerRef`]).
|
||||
#[serde(default)]
|
||||
pub kicktakers: Vec<KicktakerRef>,
|
||||
/// Client-reported evaluation (shadow state). NEVER Core's authoritative
|
||||
/// evaluation and never reconciled with it.
|
||||
#[serde(default)]
|
||||
pub client_reported: ClientReportedSquadEval,
|
||||
}
|
||||
|
||||
/// Errors reading a stored extension payload.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum ExtError {
|
||||
/// The stored `schema_version` is not one this adapter understands. Never
|
||||
/// silently coerced — the caller decides (e.g. treat as unreadable).
|
||||
UnsupportedSchemaVersion(i64),
|
||||
/// The payload bytes did not deserialize as this schema.
|
||||
Parse(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ExtError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ExtError::UnsupportedSchemaVersion(v) => {
|
||||
write!(f, "unsupported fifa17 squad extension schema version {v} (want {EXT_SCHEMA_VERSION})")
|
||||
}
|
||||
ExtError::Parse(e) => write!(f, "fifa17 squad extension parse error: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl std::error::Error for ExtError {}
|
||||
|
||||
impl Fifa17SquadExtensionV1 {
|
||||
/// Build the extension from the parsed PUT plus the resolved canonical squad.
|
||||
/// `custom`/`squad_type`/manager/kicktakers/client eval come straight off the
|
||||
/// wire; kit numbers are re-keyed from slot index onto the resolved
|
||||
/// `owned_card_id` so they stay bound to the player, not the slot.
|
||||
pub fn from_put(put: &Fifa17SquadPut, canonical: &ProposedSquad) -> Self {
|
||||
let kit_numbers = canonical
|
||||
.slots
|
||||
.iter()
|
||||
.map(|s| (s.owned_card_id.clone(), s.kit_number))
|
||||
.collect();
|
||||
Fifa17SquadExtensionV1 {
|
||||
custom: put.custom.clone(),
|
||||
squad_type: put.squad_type.clone(),
|
||||
kit_numbers,
|
||||
kicktakers: put
|
||||
.kicktakers
|
||||
.iter()
|
||||
.map(|k| KicktakerRef {
|
||||
index: k.index,
|
||||
item: WireItemRef {
|
||||
id: k.id,
|
||||
dream: k.dream,
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
client_reported: ClientReportedSquadEval {
|
||||
chemistry: put.chemistry,
|
||||
rating: put.rating,
|
||||
star_rating: put.star_rating,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize to the opaque payload string Core stores.
|
||||
pub fn to_payload(&self) -> String {
|
||||
// Infallible for this type (no maps with non-string keys, no floats).
|
||||
serde_json::to_string(self).expect("fifa17 squad extension serializes")
|
||||
}
|
||||
|
||||
/// Parse a stored payload, enforcing the schema version FIRST. A version this
|
||||
/// adapter does not understand is rejected — never coerced or ignored.
|
||||
pub fn from_payload(schema_version: i64, payload: &str) -> Result<Self, ExtError> {
|
||||
if schema_version != EXT_SCHEMA_VERSION {
|
||||
return Err(ExtError::UnsupportedSchemaVersion(schema_version));
|
||||
}
|
||||
serde_json::from_str(payload).map_err(|e| ExtError::Parse(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors building a squad write from a PUT. A save is refused, never silently
|
||||
/// degraded, when it cannot be expressed faithfully as a canonical replacement.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum SquadBuildError {
|
||||
/// One or more occupied wire ids did not reverse-map to a Core owned item.
|
||||
/// Saving would silently drop an owned player — refused.
|
||||
UnresolvedWireIds(Vec<i64>),
|
||||
/// The same Core owned item appears in two slots. A full replacement cannot
|
||||
/// place one instance twice (two *copies* of a definition are distinct owned
|
||||
/// items and are fine — this is the same `owned_card_id` twice).
|
||||
DuplicateOwnedItem(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SquadBuildError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SquadBuildError::UnresolvedWireIds(ids) => {
|
||||
write!(f, "unresolved FIFA wire item ids (save refused): {ids:?}")
|
||||
}
|
||||
SquadBuildError::DuplicateOwnedItem(id) => {
|
||||
write!(f, "owned item {id} placed in two slots (save refused)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl std::error::Error for SquadBuildError {}
|
||||
|
||||
/// The adapter's PUT-build output: a canonical replacement plus the FIFA-only
|
||||
/// extension. The host maps `canonical` onto Core's `SquadReplacement` and
|
||||
/// serializes `extension` into an `OpaqueExtensionWrite`
|
||||
/// (`namespace = EXT_NAMESPACE`, `schema_version = EXT_SCHEMA_VERSION`,
|
||||
/// `payload = extension.to_payload()`) so both commit in one Core transaction.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SquadWriteBuild {
|
||||
pub canonical: ProposedSquad,
|
||||
pub extension: Fifa17SquadExtensionV1,
|
||||
}
|
||||
|
||||
/// Build a full-replacement squad write from a parsed PUT and a host-supplied
|
||||
/// wire→owned resolver. Refuses (never degrades) on any unresolved occupied id
|
||||
/// or a duplicate owned item.
|
||||
///
|
||||
/// The resolver only maps identity; it is NOT authorization. The host still
|
||||
/// verifies every resolved `owned_card_id` belongs to the active FIFA 17
|
||||
/// profile/club before committing — a resolvable id is not proof of ownership.
|
||||
pub fn build_squad_write(
|
||||
put: &Fifa17SquadPut,
|
||||
resolver: &dyn crate::fut::squad::SquadWireResolver,
|
||||
) -> Result<SquadWriteBuild, SquadBuildError> {
|
||||
let canonical = crate::fut::squad::to_proposed(put, resolver);
|
||||
if !canonical.unresolved_wire_ids.is_empty() {
|
||||
return Err(SquadBuildError::UnresolvedWireIds(
|
||||
canonical.unresolved_wire_ids.clone(),
|
||||
));
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for slot in &canonical.slots {
|
||||
if !seen.insert(slot.owned_card_id.as_str()) {
|
||||
return Err(SquadBuildError::DuplicateOwnedItem(
|
||||
slot.owned_card_id.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let extension = Fifa17SquadExtensionV1::from_put(put, &canonical);
|
||||
Ok(SquadWriteBuild {
|
||||
canonical,
|
||||
extension,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::fut::squad::{parse_squad_put, SquadWireResolver};
|
||||
use std::collections::HashMap;
|
||||
|
||||
const PUT_F442: &str = include_str!("../../fixtures/utas/squad_put_f442.json");
|
||||
|
||||
struct MapResolver(HashMap<i64, String>);
|
||||
impl SquadWireResolver for MapResolver {
|
||||
fn owned_id_for_wire(&self, wire: i64) -> Option<String> {
|
||||
self.0.get(&wire).cloned()
|
||||
}
|
||||
}
|
||||
fn full_resolver() -> MapResolver {
|
||||
let ids = [
|
||||
100000003, 100000010, 100000005, 100000008, 100000007, 100000006, 100000004, 100000009,
|
||||
100000001, 100000002, 100000025,
|
||||
// The f442 fixture's manager ref — the host resolves it like any other
|
||||
// owned instance, so the ownership-backed manager assignment is present.
|
||||
100000427,
|
||||
];
|
||||
MapResolver(ids.iter().map(|&w| (w, format!("oc-{w}"))).collect())
|
||||
}
|
||||
|
||||
fn built() -> SquadWriteBuild {
|
||||
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
|
||||
build_squad_write(&put, &full_resolver()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trips_the_whole_extension() {
|
||||
let ext = built().extension;
|
||||
let payload = ext.to_payload();
|
||||
let back = Fifa17SquadExtensionV1::from_payload(EXT_SCHEMA_VERSION, &payload).unwrap();
|
||||
assert_eq!(ext, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_is_preserved_byte_for_byte() {
|
||||
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
|
||||
let ext = built().extension;
|
||||
assert_eq!(ext.custom, put.custom, "opaque custom carried verbatim");
|
||||
// survives a serialize/parse cycle unchanged.
|
||||
let back =
|
||||
Fifa17SquadExtensionV1::from_payload(EXT_SCHEMA_VERSION, &ext.to_payload()).unwrap();
|
||||
assert_eq!(back.custom, put.custom);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kit_number_is_keyed_by_owned_item_not_slot() {
|
||||
let ext = built().extension;
|
||||
// In the f442 fixture, owned oc-100000001 (captain) wears kit 8 at index 8;
|
||||
// oc-100000025 wears kit 11 at index 10. Keyed by owned id, not index.
|
||||
assert_eq!(ext.kit_numbers.get("oc-100000001"), Some(&8));
|
||||
assert_eq!(ext.kit_numbers.get("oc-100000025"), Some(&11));
|
||||
assert_eq!(ext.kit_numbers.len(), 11, "one per occupied slot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_reported_eval_is_carried_as_shadow() {
|
||||
let ext = built().extension;
|
||||
assert_eq!(
|
||||
ext.client_reported,
|
||||
ClientReportedSquadEval {
|
||||
chemistry: Some(52),
|
||||
rating: Some(90),
|
||||
star_rating: Some(90)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_is_canonical_and_kicktakers_stay_opaque() {
|
||||
let build = built();
|
||||
// Manager is now ownership-backed canonical state: the wire ref resolved
|
||||
// to a Core owned_card_id on the ProposedSquad, not an opaque ext blob.
|
||||
assert_eq!(
|
||||
build.canonical.manager_owned_card_id.as_deref(),
|
||||
Some("oc-100000427")
|
||||
);
|
||||
// Kicktakers remain opaque in the extension.
|
||||
let ext = build.extension;
|
||||
assert_eq!(ext.kicktakers.len(), 5);
|
||||
assert!(ext.kicktakers.iter().all(|k| k.item.id == 100000001));
|
||||
assert_eq!(ext.kicktakers[0].index, 0);
|
||||
}
|
||||
|
||||
/// A manager ref that does not resolve must NOT refuse the save: FIFA always
|
||||
/// sends one, and on a real profile it is dangling (production points at
|
||||
/// 100000427, absent from its own /club/staff). The save commits with no
|
||||
/// ownership-backed manager and reports the id it could not map.
|
||||
#[test]
|
||||
fn an_unresolvable_manager_ref_clears_the_assignment_without_refusing() {
|
||||
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
|
||||
let mut ids = full_resolver().0;
|
||||
ids.remove(&100000427);
|
||||
let build = build_squad_write(&put, &MapResolver(ids)).expect("save must still commit");
|
||||
assert_eq!(build.canonical.manager_owned_card_id, None);
|
||||
assert_eq!(
|
||||
build.canonical.unresolved_manager_wire_id,
|
||||
Some(100000427),
|
||||
"the ref we could not map is reported, not swallowed"
|
||||
);
|
||||
// The starting XI is untouched -- only the manager assignment is dropped.
|
||||
assert_eq!(build.canonical.slots.len(), 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_schema_version_is_rejected_not_coerced() {
|
||||
let payload = built().extension.to_payload();
|
||||
assert_eq!(
|
||||
Fifa17SquadExtensionV1::from_payload(2, &payload),
|
||||
Err(ExtError::UnsupportedSchemaVersion(2))
|
||||
);
|
||||
assert_eq!(
|
||||
Fifa17SquadExtensionV1::from_payload(0, &payload),
|
||||
Err(ExtError::UnsupportedSchemaVersion(0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_payload_errors() {
|
||||
assert!(matches!(
|
||||
Fifa17SquadExtensionV1::from_payload(EXT_SCHEMA_VERSION, "not json"),
|
||||
Err(ExtError::Parse(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_refuses_unresolved_wire_ids() {
|
||||
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
|
||||
let mut ids = full_resolver().0;
|
||||
ids.remove(&100000025);
|
||||
let err = build_squad_write(&put, &MapResolver(ids)).unwrap_err();
|
||||
assert_eq!(err, SquadBuildError::UnresolvedWireIds(vec![100000025]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_refuses_duplicate_owned_item() {
|
||||
// Two occupied slots resolving to the SAME owned id (one instance twice).
|
||||
let body = br#"{"id":0,"formation":"f442","captain":100000003,"players":[
|
||||
{"index":0,"itemData":{"id":100000003},"kitNumber":1},
|
||||
{"index":1,"itemData":{"id":100000004},"kitNumber":2}]}"#;
|
||||
let put = parse_squad_put(body).unwrap();
|
||||
let mut m = HashMap::new();
|
||||
m.insert(100000003, "oc-dup".to_string());
|
||||
m.insert(100000004, "oc-dup".to_string()); // collide onto same owned id
|
||||
let err = build_squad_write(&put, &MapResolver(m)).unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
SquadBuildError::DuplicateOwnedItem("oc-dup".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_replacement_carries_every_occupied_slot_no_diff() {
|
||||
let build = built();
|
||||
assert_eq!(build.canonical.slots.len(), 11, "whole squad, not a diff");
|
||||
assert_eq!(build.canonical.formation.as_deref(), Some("f442"));
|
||||
// No FIFA wire integer survives into the canonical slots.
|
||||
assert!(build
|
||||
.canonical
|
||||
.slots
|
||||
.iter()
|
||||
.all(|s| s.owned_card_id.starts_with("oc-")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
//! The **single** FIFA 17 squad projector: canonical Core squad + Fresh FIFA
|
||||
//! extension → the FIFA 17 squad wire object.
|
||||
//!
|
||||
//! One projector serves every squad read shape. `userMassInfo.squad` embeds the
|
||||
//! full object; `GET /squad/list` is a summary *subset* of it; a future
|
||||
//! `/squad/active` is the same object again. Endpoint wrappers ([`user_mass_info_squad`],
|
||||
//! [`squad_list`]) only shape the outer envelope — there is deliberately no
|
||||
//! second squad domain model per endpoint.
|
||||
//!
|
||||
//! ## Purity / no N+1
|
||||
//!
|
||||
//! The projector touches no database, socket, or Core API. It consumes a
|
||||
//! [`SquadProjectionInput`] the host assembles from ONE bounded batch — Core's
|
||||
//! `read_squad_with_ext` (canonical squad + players + extension freshness) plus a
|
||||
//! single "all owned cards for this club" fetch joined against the in-memory card
|
||||
//! definitions. There is no per-slot lookup here or above.
|
||||
//!
|
||||
//! ## Item identity is shared, never reconstructed
|
||||
//!
|
||||
//! Each occupied slot is shaped by the shared [`crate::fut::item::shape_item`],
|
||||
//! the same primitive `/club` uses — the projector never rebuilds the card shape
|
||||
//! itself, so squad items and `/club` items cannot drift, and two owned copies of
|
||||
//! one definition stay distinct (each carries its own resolved wire id).
|
||||
//!
|
||||
//! ## Fresh / Stale / Missing
|
||||
//!
|
||||
//! Freshness comes from Core and is surfaced, never buried in a default:
|
||||
//! * **Fresh** → project the full object.
|
||||
//! * **Stale** → NEVER overlay the stale extension on the newer canonical squad;
|
||||
//! return [`SquadProjection::Stale`] for the host to act on (e.g. fall back).
|
||||
//! * **Missing** → return [`SquadProjection::Missing`]; the projector does NOT
|
||||
//! fabricate a manager/custom/kicktakers/kit numbers just to emit a response.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::entities::ReverseEntityResolver;
|
||||
use crate::fut::item::{
|
||||
shape_item, shape_staff_item, CoreOwnedItem, ItemIdentityResolver, STAFF_CONTRACT,
|
||||
};
|
||||
use crate::fut::squad::FIFA17_SQUAD_SLOTS;
|
||||
use crate::fut::squad_ext::Fifa17SquadExtensionV1;
|
||||
|
||||
/// Freshness of the FIFA 17 extension relative to the current canonical squad,
|
||||
/// as reported by Core's `read_squad_with_ext`. This is a game-independent mirror
|
||||
/// the host populates from Core's `SquadExtState`; the adapter never computes the
|
||||
/// canonical fingerprint itself (that is Core's server-side job).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SquadExtInput {
|
||||
Fresh(Fifa17SquadExtensionV1),
|
||||
/// The stored extension whose fingerprint no longer matches the canonical
|
||||
/// squad. Carried so the host can log/inspect it, but the projector NEVER
|
||||
/// applies it over the newer canonical squad (mirrors Core's
|
||||
/// `SquadExtState::Stale { stored, .. }`).
|
||||
Stale(Fifa17SquadExtensionV1),
|
||||
Missing,
|
||||
}
|
||||
|
||||
/// One canonical slot as read back from Core. `is_on_bench` is carried through
|
||||
/// from Core, never re-derived from the formation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProjectionSlot {
|
||||
pub owned_card_id: String,
|
||||
pub index: i64,
|
||||
pub is_captain: bool,
|
||||
pub is_on_bench: bool,
|
||||
}
|
||||
|
||||
/// Everything the pure projector needs to render one full squad.
|
||||
pub struct SquadProjectionInput<'a> {
|
||||
/// FIFA wire squad id (`0` = active).
|
||||
pub fifa_squad_id: i64,
|
||||
pub name: String,
|
||||
/// FIFA formation token, verbatim from the canonical squad (never mapped).
|
||||
pub formation: String,
|
||||
pub slots: Vec<ProjectionSlot>,
|
||||
pub ext: SquadExtInput,
|
||||
/// Every owned item a slot references, keyed by `owned_card_id`. Assembled by
|
||||
/// the host in one batch — the projector only reads from it.
|
||||
pub owned: &'a HashMap<String, CoreOwnedItem>,
|
||||
/// The owned instance assigned as this squad's **manager** (Core's
|
||||
/// ownership-backed `squad_managers` assignment, migration 0023), or `None`.
|
||||
/// Projected as the FIFA `manager` wire ref resolved from ownership — never a
|
||||
/// dangling wire id, and never fabricated when absent.
|
||||
pub manager: Option<CoreOwnedItem>,
|
||||
}
|
||||
|
||||
/// Result of a projection, with the extension-freshness verdict surfaced.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SquadProjection {
|
||||
/// A fully projected FIFA 17 squad object (the `userMassInfo.squad` shape,
|
||||
/// minus session envelope fields the endpoint wrapper adds).
|
||||
Projected(Value),
|
||||
/// The stored extension is stale vs the canonical squad — not applied.
|
||||
Stale,
|
||||
/// No extension stored — nothing fabricated.
|
||||
Missing,
|
||||
}
|
||||
|
||||
/// Hard projection failures — a squad cannot be rendered faithfully. Never
|
||||
/// silently degraded (a squad cannot drop a starter the way `/club` drops a card).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SquadProjectError {
|
||||
/// A slot references an `owned_card_id` absent from the projection input.
|
||||
MissingOwnedItem(String),
|
||||
/// An occupied slot's owned item has no real FIFA asset identity — it cannot
|
||||
/// be rendered and MUST NOT be faked.
|
||||
NoFifaIdentity(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SquadProjectError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SquadProjectError::MissingOwnedItem(id) => {
|
||||
write!(f, "projection input missing owned item {id}")
|
||||
}
|
||||
SquadProjectError::NoFifaIdentity(id) => {
|
||||
write!(
|
||||
f,
|
||||
"owned item {id} has no real FIFA asset identity (cannot render)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl std::error::Error for SquadProjectError {}
|
||||
|
||||
/// Project a squad. On `Fresh`, returns the full FIFA squad object; on
|
||||
/// `Stale`/`Missing`, returns that verdict without fabricating anything.
|
||||
pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
|
||||
input: &SquadProjectionInput<'_>,
|
||||
ident: &I,
|
||||
ent: &impl ReverseEntityResolver,
|
||||
) -> Result<SquadProjection, SquadProjectError> {
|
||||
let ext = match &input.ext {
|
||||
SquadExtInput::Stale(_) => return Ok(SquadProjection::Stale),
|
||||
SquadExtInput::Missing => return Ok(SquadProjection::Missing),
|
||||
SquadExtInput::Fresh(ext) => ext,
|
||||
};
|
||||
|
||||
// Index occupied slots by their FIFA array index for O(1) fill.
|
||||
let by_index: HashMap<i64, &ProjectionSlot> =
|
||||
input.slots.iter().map(|s| (s.index, s)).collect();
|
||||
|
||||
let mut players = Vec::with_capacity(FIFA17_SQUAD_SLOTS as usize);
|
||||
let mut captain_wire: i64 = 0;
|
||||
|
||||
for index in 0..FIFA17_SQUAD_SLOTS {
|
||||
match by_index.get(&index) {
|
||||
Some(slot) => {
|
||||
let item = input.owned.get(&slot.owned_card_id).ok_or_else(|| {
|
||||
SquadProjectError::MissingOwnedItem(slot.owned_card_id.clone())
|
||||
})?;
|
||||
let id = ident
|
||||
.resolve(item)
|
||||
.ok_or_else(|| SquadProjectError::NoFifaIdentity(slot.owned_card_id.clone()))?;
|
||||
if slot.is_captain {
|
||||
captain_wire = id.item_id as i64;
|
||||
}
|
||||
// kit follows the player: look it up by owned id, never by index.
|
||||
let kit = ext
|
||||
.kit_numbers
|
||||
.get(&slot.owned_card_id)
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
players.push(json!({
|
||||
"index": index,
|
||||
"itemData": shape_item(item, id, ent),
|
||||
"kitNumber": kit,
|
||||
}));
|
||||
}
|
||||
None => players.push(json!({
|
||||
"index": index,
|
||||
"itemData": { "id": 0, "dream": false },
|
||||
"kitNumber": 0,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
// Manager: the ownership-backed assignment, resolved to its FIFA wire ref
|
||||
// AND carrying its item, as `[{id, itemData, dream}]`.
|
||||
//
|
||||
// The bare `[{id, dream}]` form is NOT sufficient, which cost a real
|
||||
// debugging round: the operator picked a manager in the hub, the save
|
||||
// persisted (Core `squad_managers` row written, `outcome=ok`, no unresolved
|
||||
// ref), and the pre-match squad still showed no manager. Every retail
|
||||
// capture that shows the bare form has `id: 0` — an EMPTY manager — so none
|
||||
// of them ever demonstrated that a POPULATED ref resolves without its item.
|
||||
//
|
||||
// The squad response is self-contained for players: `players[].itemData`
|
||||
// carries the whole card rather than an id the client resolves out of band.
|
||||
// The manager is the same kind of slot in the same object, and the one
|
||||
// implementation that ever drove a working manager (the Python oracle's
|
||||
// squad) emits `id` BESIDE `itemData` exactly like this. Note the element
|
||||
// shape differs from a player slot: `{index, itemData, kitNumber}` there,
|
||||
// `{id, itemData, dream}` here.
|
||||
//
|
||||
// An owned manager with no resolvable FIFA staff identity is omitted
|
||||
// (non-fatal, like /club dropping an unrenderable card) rather than emitted
|
||||
// with a fabricated id.
|
||||
let manager = match input.manager.as_ref().and_then(|m| ident.resolve_staff(m)) {
|
||||
Some(id) => json!([{
|
||||
"id": id.item_id,
|
||||
"itemData": shape_staff_item(id, STAFF_CONTRACT),
|
||||
"dream": false,
|
||||
}]),
|
||||
None => json!([]),
|
||||
};
|
||||
let squad = json!({
|
||||
"id": input.fifa_squad_id,
|
||||
"squadName": input.name,
|
||||
"formation": input.formation,
|
||||
"squadType": ext.squad_type,
|
||||
"chemistry": ext.client_reported.chemistry,
|
||||
"starRating": ext.client_reported.star_rating,
|
||||
"rating": ext.client_reported.rating,
|
||||
"captain": captain_wire,
|
||||
"manager": manager,
|
||||
"custom": ext.custom,
|
||||
"players": players,
|
||||
"kicktakers": ext.kicktakers,
|
||||
});
|
||||
Ok(SquadProjection::Projected(squad))
|
||||
}
|
||||
|
||||
/// Wrap a projected squad object into the `userMassInfo.squad` shape, injecting
|
||||
/// the session-envelope fields the projector does not own (`personaId`, plus the
|
||||
/// observed constants `changed: 0`, `actives: []`).
|
||||
pub fn user_mass_info_squad(projected: Value, persona_id: i64) -> Value {
|
||||
let mut obj = projected;
|
||||
if let Value::Object(map) = &mut obj {
|
||||
map.insert("personaId".into(), json!(persona_id));
|
||||
map.insert("changed".into(), json!(0));
|
||||
map.insert("actives".into(), json!([]));
|
||||
}
|
||||
obj
|
||||
}
|
||||
|
||||
/// The `GET /squad/list` summary response — a subset of the SAME projected
|
||||
/// object, wrapped in `{"squad":[ … ]}`. Not a separate domain projection.
|
||||
pub fn squad_list(projected: &Value) -> Value {
|
||||
let summary = json!({
|
||||
"id": projected.get("id").cloned().unwrap_or(Value::Null),
|
||||
"squadName": projected.get("squadName").cloned().unwrap_or(Value::Null),
|
||||
"formation": projected.get("formation").cloned().unwrap_or(Value::Null),
|
||||
"squadType": projected.get("squadType").cloned().unwrap_or(Value::Null),
|
||||
"rating": projected.get("rating").cloned().unwrap_or(Value::Null),
|
||||
"chemistry": projected.get("chemistry").cloned().unwrap_or(Value::Null),
|
||||
});
|
||||
json!({ "squad": [summary] })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::fut::entities::Fifa17Entities;
|
||||
use crate::fut::item::{Fifa17Identity, Fifa17StaffIdentity};
|
||||
|
||||
// A resolver that mints a distinct wire id per owned item and a fixed asset.
|
||||
// `staff` is separate because a manager resolves through the STAFF identity,
|
||||
// which carries the chemistry fields a player identity has no room for.
|
||||
struct TableIdentity(
|
||||
HashMap<String, Fifa17Identity>,
|
||||
HashMap<String, Fifa17StaffIdentity>,
|
||||
);
|
||||
impl ItemIdentityResolver for TableIdentity {
|
||||
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
|
||||
self.0.get(&it.owned_card_id).copied()
|
||||
}
|
||||
fn resolve_staff(&self, it: &CoreOwnedItem) -> Option<Fifa17StaffIdentity> {
|
||||
self.1.get(&it.owned_card_id).copied()
|
||||
}
|
||||
}
|
||||
|
||||
fn ent() -> Fifa17Entities {
|
||||
Fifa17Entities::from_maps(HashMap::new(), HashMap::new(), HashMap::new())
|
||||
}
|
||||
|
||||
fn owned_item(id: &str, card: &str) -> CoreOwnedItem {
|
||||
CoreOwnedItem {
|
||||
owned_card_id: id.into(),
|
||||
card_id: card.into(),
|
||||
rating: 84,
|
||||
position: "ST".into(),
|
||||
nation: "n".into(),
|
||||
league: "l".into(),
|
||||
club: "c".into(),
|
||||
attributes: [80, 80, 80, 80, 40, 80],
|
||||
}
|
||||
}
|
||||
|
||||
fn one_slot_input<'a>(
|
||||
owned: &'a HashMap<String, CoreOwnedItem>,
|
||||
ext: SquadExtInput,
|
||||
) -> SquadProjectionInput<'a> {
|
||||
SquadProjectionInput {
|
||||
fifa_squad_id: 0,
|
||||
name: "OpenFUT".into(),
|
||||
formation: "f442".into(),
|
||||
slots: vec![ProjectionSlot {
|
||||
owned_card_id: "oc1".into(),
|
||||
index: 0,
|
||||
is_captain: true,
|
||||
is_on_bench: false,
|
||||
}],
|
||||
ext,
|
||||
owned,
|
||||
manager: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn fresh_ext() -> Fifa17SquadExtensionV1 {
|
||||
let mut kit = std::collections::BTreeMap::new();
|
||||
kit.insert("oc1".to_string(), 9);
|
||||
Fifa17SquadExtensionV1 {
|
||||
custom: Some("[1,2,3]".into()),
|
||||
squad_type: Some("REGULAR_SQUAD".into()),
|
||||
kit_numbers: kit,
|
||||
kicktakers: vec![],
|
||||
client_reported: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_projects_full_23_slot_array_with_captain_wire_id() {
|
||||
let mut owned = HashMap::new();
|
||||
owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
|
||||
let ident = TableIdentity(
|
||||
HashMap::from([(
|
||||
"oc1".to_string(),
|
||||
Fifa17Identity {
|
||||
item_id: 100000042,
|
||||
asset_id: 20801,
|
||||
resource_id: 20801,
|
||||
rareflag: 1,
|
||||
},
|
||||
)]),
|
||||
HashMap::new(),
|
||||
);
|
||||
let input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
|
||||
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
|
||||
panic!("expected Projected");
|
||||
};
|
||||
assert_eq!(
|
||||
v["players"].as_array().unwrap().len(),
|
||||
23,
|
||||
"fixed 23-slot array"
|
||||
);
|
||||
assert_eq!(
|
||||
v["players"][0]["itemData"]["id"], 100000042,
|
||||
"wire id, not resourceId"
|
||||
);
|
||||
assert_eq!(v["players"][0]["itemData"]["resourceId"], 20801);
|
||||
assert_eq!(v["players"][0]["kitNumber"], 9, "kit from ext by owned id");
|
||||
assert_eq!(v["players"][1]["itemData"]["id"], 0, "empty slot");
|
||||
assert_eq!(v["captain"], 100000042, "captain is the resolved WIRE id");
|
||||
assert_ne!(v["captain"], 20801, "captain must NOT be the resourceId");
|
||||
assert_eq!(v["custom"], "[1,2,3]");
|
||||
assert_eq!(v["formation"], "f442");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_is_never_applied() {
|
||||
let owned = HashMap::new();
|
||||
// A stale extension IS carried (host may log it) but must not be applied.
|
||||
let input = one_slot_input(&owned, SquadExtInput::Stale(fresh_ext()));
|
||||
let ident = TableIdentity(HashMap::new(), HashMap::new());
|
||||
assert_eq!(
|
||||
project_squad(&input, &ident, &ent()).unwrap(),
|
||||
SquadProjection::Stale
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_is_explicit_never_fabricated() {
|
||||
let owned = HashMap::new();
|
||||
let input = one_slot_input(&owned, SquadExtInput::Missing);
|
||||
let ident = TableIdentity(HashMap::new(), HashMap::new());
|
||||
assert_eq!(
|
||||
project_squad(&input, &ident, &ent()).unwrap(),
|
||||
SquadProjection::Missing
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occupied_starter_without_asset_identity_is_refused_not_faked() {
|
||||
let mut owned = HashMap::new();
|
||||
owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
|
||||
let ident = TableIdentity(HashMap::new(), HashMap::new()); // resolves nothing
|
||||
let input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
|
||||
assert_eq!(
|
||||
project_squad(&input, &ident, &ent()),
|
||||
Err(SquadProjectError::NoFifaIdentity("oc1".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_owned_copies_of_one_definition_project_as_distinct_players() {
|
||||
// Same card definition -> same resourceId; two distinct owned instances
|
||||
// in two slots with distinct kits must stay distinct on the wire.
|
||||
let mut owned = HashMap::new();
|
||||
owned.insert("oc-a".to_string(), owned_item("oc-a", "fifa17_101490"));
|
||||
owned.insert("oc-b".to_string(), owned_item("oc-b", "fifa17_101490"));
|
||||
let ident = TableIdentity(
|
||||
HashMap::from([
|
||||
(
|
||||
"oc-a".to_string(),
|
||||
Fifa17Identity {
|
||||
item_id: 100000030,
|
||||
asset_id: 101490,
|
||||
resource_id: 101490,
|
||||
rareflag: 1,
|
||||
},
|
||||
),
|
||||
(
|
||||
"oc-b".to_string(),
|
||||
Fifa17Identity {
|
||||
item_id: 100000031,
|
||||
asset_id: 101490,
|
||||
resource_id: 101490,
|
||||
rareflag: 1,
|
||||
},
|
||||
),
|
||||
]),
|
||||
HashMap::new(),
|
||||
);
|
||||
let mut kit = std::collections::BTreeMap::new();
|
||||
kit.insert("oc-a".to_string(), 7);
|
||||
kit.insert("oc-b".to_string(), 19);
|
||||
let ext = Fifa17SquadExtensionV1 {
|
||||
kit_numbers: kit,
|
||||
..fresh_ext()
|
||||
};
|
||||
let owned_ref = &owned;
|
||||
let input = SquadProjectionInput {
|
||||
fifa_squad_id: 0,
|
||||
name: "OpenFUT".into(),
|
||||
formation: "f442".into(),
|
||||
slots: vec![
|
||||
ProjectionSlot {
|
||||
owned_card_id: "oc-a".into(),
|
||||
index: 0,
|
||||
is_captain: false,
|
||||
is_on_bench: false,
|
||||
},
|
||||
ProjectionSlot {
|
||||
owned_card_id: "oc-b".into(),
|
||||
index: 1,
|
||||
is_captain: false,
|
||||
is_on_bench: false,
|
||||
},
|
||||
],
|
||||
ext: SquadExtInput::Fresh(ext),
|
||||
owned: owned_ref,
|
||||
manager: None,
|
||||
};
|
||||
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
|
||||
panic!();
|
||||
};
|
||||
let a = &v["players"][0]["itemData"];
|
||||
let b = &v["players"][1]["itemData"];
|
||||
assert_eq!(
|
||||
a["resourceId"], b["resourceId"],
|
||||
"same definition => same asset"
|
||||
);
|
||||
assert_ne!(
|
||||
a["id"], b["id"],
|
||||
"distinct owned copies keep distinct wire ids"
|
||||
);
|
||||
assert_eq!(v["players"][0]["kitNumber"], 7);
|
||||
assert_eq!(
|
||||
v["players"][1]["kitNumber"], 19,
|
||||
"kit stays with the instance"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_projected_from_ownership_as_wire_ref() {
|
||||
let mut owned = HashMap::new();
|
||||
owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
|
||||
let ident = TableIdentity(
|
||||
HashMap::from([(
|
||||
"oc1".to_string(),
|
||||
Fifa17Identity {
|
||||
item_id: 100000042,
|
||||
asset_id: 20801,
|
||||
resource_id: 20801,
|
||||
rareflag: 1,
|
||||
},
|
||||
)]),
|
||||
HashMap::from([(
|
||||
"oc-mgr".to_string(),
|
||||
Fifa17StaffIdentity {
|
||||
item_id: 100000427,
|
||||
resource_id: 1_000_509,
|
||||
subtype: 4,
|
||||
nation: 45,
|
||||
league_id: 53,
|
||||
team_id: 241,
|
||||
},
|
||||
)]),
|
||||
);
|
||||
let mut input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
|
||||
input.manager = Some(owned_item("oc-mgr", "fifa17_mgr"));
|
||||
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
|
||||
panic!("expected Projected");
|
||||
};
|
||||
// The item must ride ALONG with the ref: a bare `{id, dream}` left the
|
||||
// pre-match squad with no manager even though the assignment had been
|
||||
// saved, because nothing in the response described the card.
|
||||
assert_eq!(
|
||||
v["manager"],
|
||||
json!([{
|
||||
"id": 100000427,
|
||||
"itemData": {
|
||||
"id": 100000427,
|
||||
"resourceId": 1_000_509,
|
||||
"cardsubtypeid": 4,
|
||||
"itemType": "staff",
|
||||
"nation": 45,
|
||||
"leagueId": 53,
|
||||
"teamid": 241,
|
||||
"contract": STAFF_CONTRACT,
|
||||
"itemState": "free",
|
||||
"owners": 1,
|
||||
"untradeable": false,
|
||||
},
|
||||
"dream": false,
|
||||
}]),
|
||||
"manager is the ownership-backed wire ref WITH its item"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_manager_projects_empty_array_never_fabricated() {
|
||||
let mut owned = HashMap::new();
|
||||
owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
|
||||
let ident = TableIdentity(
|
||||
HashMap::from([(
|
||||
"oc1".to_string(),
|
||||
Fifa17Identity {
|
||||
item_id: 100000042,
|
||||
asset_id: 20801,
|
||||
resource_id: 20801,
|
||||
rareflag: 1,
|
||||
},
|
||||
)]),
|
||||
HashMap::new(),
|
||||
);
|
||||
// one_slot_input leaves manager: None.
|
||||
let input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
|
||||
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
|
||||
panic!("expected Projected");
|
||||
};
|
||||
assert_eq!(
|
||||
v["manager"],
|
||||
json!([]),
|
||||
"no manager assignment => empty array, nothing fabricated"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
//! FIFA 17 Store pack catalogue + `/store/purchasegroup` wire shaping.
|
||||
//!
|
||||
//! Rust is the authoritative store owner: [`build_purchasegroup`] is served live
|
||||
//! by the host via `EconomyRoute::PurchaseGroup` over Core economy authority (Core
|
||||
//! owns coins + unopened packs), so there is no Python dependency and no
|
||||
//! dual-write/split-brain.
|
||||
//!
|
||||
//! ## Relationship to the Python oracle
|
||||
//!
|
||||
//! The wire *shape* was RE'd from the client and cross-checked against the Python
|
||||
//! oracle's `_pack_body`/`store_catalog`
|
||||
//! (`fifa17-recon/tools/{fut_store,utas_server}.py`). Rust now diverges from the
|
||||
//! oracle where the RE proved the oracle wrong: it does NOT emit `extPrice`, whose
|
||||
//! parser side-effect creates an `"mtx"` currency row and switches on the broken
|
||||
//! `or %1s` FIFA-Points tile line (plan-2026-08-05-store-subsystem.md §3.4). The
|
||||
//! Python oracle stays the rollback baseline and is never modified; the
|
||||
//! `tests/fixtures/purchasegroup_*.json` goldens pin Rust's authoritative output.
|
||||
//!
|
||||
//! ## Economy-parameter provenance
|
||||
//!
|
||||
//! Pack prices and tier composition are the real always-available FUT 17
|
||||
//! regular-store packs (community-documented on fifauteam). Pack ODDS
|
||||
//! (`special_chance`) are DESIGNED placeholders, NOT EA-authentic — EA never
|
||||
//! published FUT 17 pack probabilities. The wire *shape* is EA-observed/RE-verified.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::store_session::{StoreMode, SENTINEL_PACK_ID};
|
||||
|
||||
/// A FIFA 17 Store pack definition. The wire *shape* is RE-verified; the price and
|
||||
/// per-tier composition are the real always-available FUT 17 regular-store packs,
|
||||
/// with DESIGNED (not EA-authentic) `special_chance` odds.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct PackDef {
|
||||
pub id: u64,
|
||||
pub name: &'static str,
|
||||
pub price: u64,
|
||||
/// Cards awarded per rating-tier band: bronze `< 65`, silver `65..=74`, gold
|
||||
/// `>= 75`. These are ALSO the wire `packContentInfo` per-tier quantities, so a
|
||||
/// pack's displayed composition matches what its generator draws.
|
||||
pub n_bronze: u64,
|
||||
pub n_silver: u64,
|
||||
pub n_gold: u64,
|
||||
/// `rareQuantity` shown on the tile (wire display only).
|
||||
pub rares: u64,
|
||||
/// StoreFront category token (`displayGroup.value`): one of the six hard-coded
|
||||
/// client tokens — here `"bronze"`, `"silver"` or `"gold"`.
|
||||
pub category: &'static str,
|
||||
/// Per-draw probability the awarded card is a special version (DESIGNED
|
||||
/// placeholder; FUT 17 odds are unrecoverable).
|
||||
pub special_chance: f64,
|
||||
/// Reward-only pack (no purchase path): excluded from the normal catalogue,
|
||||
/// rendered only when owned (in `unopenedPackIds`).
|
||||
pub owned_only: bool,
|
||||
}
|
||||
|
||||
impl PackDef {
|
||||
/// Total cards awarded / wire `itemQuantity` — the sum of the per-tier counts.
|
||||
pub fn count(&self) -> u64 {
|
||||
self.n_bronze + self.n_silver + self.n_gold
|
||||
}
|
||||
}
|
||||
|
||||
/// The always-available FIFA 17 FUT regular-store packs (real fifauteam-documented
|
||||
/// prices + tier composition), plus the OpenFUT reward pack. Two packs per client
|
||||
/// category (bronze/silver/gold), which the client renders as separate buyable tiles
|
||||
/// on drill-in. The 65534 sentinel is deliberately ABSENT — a compatibility shim,
|
||||
/// never purchasable/openable.
|
||||
pub const PACK_CATALOG: &[PackDef] = &[
|
||||
// ── Bronze category ──
|
||||
PackDef {
|
||||
id: 1,
|
||||
name: "Bronze Pack",
|
||||
price: 400,
|
||||
n_bronze: 10,
|
||||
n_silver: 2,
|
||||
n_gold: 0,
|
||||
rares: 1,
|
||||
category: "bronze",
|
||||
special_chance: 0.01,
|
||||
owned_only: false,
|
||||
},
|
||||
PackDef {
|
||||
id: 2,
|
||||
name: "Premium Bronze Pack",
|
||||
price: 750,
|
||||
n_bronze: 10,
|
||||
n_silver: 2,
|
||||
n_gold: 0,
|
||||
rares: 3,
|
||||
category: "bronze",
|
||||
special_chance: 0.02,
|
||||
owned_only: false,
|
||||
},
|
||||
// ── Silver category ──
|
||||
PackDef {
|
||||
id: 3,
|
||||
name: "Silver Pack",
|
||||
price: 2500,
|
||||
n_bronze: 1,
|
||||
n_silver: 11,
|
||||
n_gold: 0,
|
||||
rares: 1,
|
||||
category: "silver",
|
||||
special_chance: 0.015,
|
||||
owned_only: false,
|
||||
},
|
||||
PackDef {
|
||||
id: 4,
|
||||
name: "Premium Silver Pack",
|
||||
price: 3750,
|
||||
n_bronze: 1,
|
||||
n_silver: 11,
|
||||
n_gold: 0,
|
||||
rares: 3,
|
||||
category: "silver",
|
||||
special_chance: 0.03,
|
||||
owned_only: false,
|
||||
},
|
||||
// ── Gold category ──
|
||||
PackDef {
|
||||
id: 5,
|
||||
name: "Gold Pack",
|
||||
price: 5000,
|
||||
n_bronze: 0,
|
||||
n_silver: 2,
|
||||
n_gold: 10,
|
||||
rares: 1,
|
||||
category: "gold",
|
||||
special_chance: 0.04,
|
||||
owned_only: false,
|
||||
},
|
||||
PackDef {
|
||||
id: 6,
|
||||
name: "Premium Gold Pack",
|
||||
price: 7500,
|
||||
n_bronze: 0,
|
||||
n_silver: 2,
|
||||
n_gold: 10,
|
||||
rares: 3,
|
||||
category: "gold",
|
||||
special_chance: 0.06,
|
||||
owned_only: false,
|
||||
},
|
||||
// ── Reward (owned-only; opened from My Packs, never coin-purchasable) ──
|
||||
PackDef {
|
||||
id: 70,
|
||||
name: "Reward Gold Pack",
|
||||
price: 0,
|
||||
n_bronze: 0,
|
||||
n_silver: 0,
|
||||
n_gold: 11,
|
||||
rares: 11,
|
||||
category: "gold",
|
||||
special_chance: 1.0,
|
||||
owned_only: true,
|
||||
},
|
||||
PackDef {
|
||||
id: 71,
|
||||
name: "Bronze Pack",
|
||||
price: 0,
|
||||
n_bronze: 10,
|
||||
n_silver: 2,
|
||||
n_gold: 0,
|
||||
rares: 1,
|
||||
category: "bronze",
|
||||
special_chance: 0.01,
|
||||
owned_only: true,
|
||||
},
|
||||
PackDef {
|
||||
id: 72,
|
||||
name: "Silver Pack",
|
||||
price: 0,
|
||||
n_bronze: 1,
|
||||
n_silver: 11,
|
||||
n_gold: 0,
|
||||
rares: 1,
|
||||
category: "silver",
|
||||
special_chance: 0.02,
|
||||
owned_only: true,
|
||||
},
|
||||
PackDef {
|
||||
id: 73,
|
||||
name: "Gold Pack",
|
||||
price: 0,
|
||||
n_bronze: 0,
|
||||
n_silver: 2,
|
||||
n_gold: 10,
|
||||
rares: 1,
|
||||
category: "gold",
|
||||
special_chance: 0.05,
|
||||
owned_only: true,
|
||||
},
|
||||
PackDef {
|
||||
id: 74,
|
||||
name: "Rare Gold Pack",
|
||||
price: 0,
|
||||
n_bronze: 0,
|
||||
n_silver: 2,
|
||||
n_gold: 10,
|
||||
rares: 3,
|
||||
category: "gold",
|
||||
special_chance: 0.10,
|
||||
owned_only: true,
|
||||
},
|
||||
PackDef {
|
||||
id: 75,
|
||||
name: "Icon Pack",
|
||||
price: 0,
|
||||
n_bronze: 0,
|
||||
n_silver: 0,
|
||||
n_gold: 12,
|
||||
rares: 12,
|
||||
category: "gold",
|
||||
special_chance: 1.0,
|
||||
owned_only: true,
|
||||
},
|
||||
];
|
||||
|
||||
/// Look up a catalogue pack by id (the 65534 sentinel is never present).
|
||||
pub fn pack_by_id(id: u64) -> Option<&'static PackDef> {
|
||||
PACK_CATALOG.iter().find(|p| p.id == id)
|
||||
}
|
||||
|
||||
/// Resolve a Core entitlement's opaque `definition_id` to the FIFA 17 numeric
|
||||
/// owned-only pack it renders and opens as. Accepts a numeric id (imported
|
||||
/// entitlements, e.g. `"70"`) or one of the symbolic reward-pack names Core's
|
||||
/// reward services grant (SBC, draft, season, check-in, FUT Champions). Only
|
||||
/// owned-only packs qualify, so an unknown or non-reward entitlement resolves to
|
||||
/// `None` and is simply not shown as an openable pack rather than faked.
|
||||
pub fn owned_pack_id_for_definition(definition_id: &str) -> Option<u64> {
|
||||
if let Ok(numeric) = definition_id.parse::<u64>() {
|
||||
return pack_by_id(numeric)
|
||||
.filter(|pack| pack.owned_only)
|
||||
.map(|pack| pack.id);
|
||||
}
|
||||
let id = match definition_id {
|
||||
"bronze_pack" => 71,
|
||||
"silver_pack" => 72,
|
||||
"gold_pack" => 73,
|
||||
"rare_gold_pack" => 74,
|
||||
"icon_pack" => 75,
|
||||
_ => return None,
|
||||
};
|
||||
Some(id)
|
||||
}
|
||||
|
||||
/// The FIFA 17 StoreFront category token for a pack tile (`displayGroup.value`):
|
||||
/// one of the six hard-coded tokens the client resolves. Each catalogue pack
|
||||
/// carries its own token; owned/reward packs take `mypacks` instead (see
|
||||
/// [`pack_body`]).
|
||||
fn category(p: &PackDef) -> &'static str {
|
||||
p.category
|
||||
}
|
||||
|
||||
/// One `purchase[]` entry — the faithful `_pack_body` port (`utas_server.py:3474`) at
|
||||
/// production flag defaults. `owned` packs (My Packs / reward / sentinel) drop the
|
||||
/// purchase fields and take the `mypacks` display group.
|
||||
pub fn pack_body(p: &PackDef, idx: u64, owned: bool) -> Value {
|
||||
let pack_type = match p.category {
|
||||
"gold" => "GOLD",
|
||||
"silver" => "SILVER",
|
||||
_ => "BRONZE",
|
||||
};
|
||||
// Group background texture: FIFA 17's all-groups landing (shown on first store
|
||||
// entry) renders one tile per group whose art is the client-bundled
|
||||
// `packs_backgrounds_%d.dds` selected by this field. LIVE-PROBED 2026-08-18:
|
||||
// index 0 is blank; 1/2/3 render real pack art — so assign non-zero per
|
||||
// category and that landing shows native art instead of blank shields. The
|
||||
// persistent tabbed store draws its pack art from the packs themselves and
|
||||
// does not depend on this.
|
||||
let group_bg: u64 = if owned {
|
||||
3
|
||||
} else {
|
||||
match p.category {
|
||||
"gold" => 3,
|
||||
"silver" => 2,
|
||||
_ => 1,
|
||||
}
|
||||
};
|
||||
// My Packs cover art. LIVE-MAPPED on the retail client 2026-08-18 across all
|
||||
// three store tabs and two reward tiles:
|
||||
// * `assetId` only gates whether art renders AT ALL. A reward pack's own id
|
||||
// (70-75) is not a known client asset, so its tile renders BLANK; any valid
|
||||
// catalogue asset (1-6) makes art appear.
|
||||
// * WHICH art is drawn comes from `packType` + `packContentInfo.rareQuantity`,
|
||||
// not from `assetId`: BRONZE+1rare -> bronze card, BRONZE+3 -> silver,
|
||||
// SILVER+1 -> gold, SILVER+3 -> silver trio, GOLD+1 -> blue special,
|
||||
// GOLD+3 -> red inform. (Remapping assetId 5->3 and 3->2 left both frames
|
||||
// unchanged and only rotated the featured player, which proves this.)
|
||||
// So a reward tile automatically shows the same art as the equivalent
|
||||
// purchasable pack; we only need a valid asset, and we use the tier's own store
|
||||
// pack for clarity. `id` stays the pack's own id (the open packId / SERVER_ID).
|
||||
let art_asset: u64 = if owned {
|
||||
match p.category {
|
||||
"gold" => 5,
|
||||
"silver" => 3,
|
||||
_ => 1,
|
||||
}
|
||||
} else {
|
||||
p.id
|
||||
};
|
||||
let mut body = json!({
|
||||
"assetId": art_asset,
|
||||
"id": p.id,
|
||||
"packType": pack_type,
|
||||
"description": p.name,
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": idx,
|
||||
"displayGroupAssetId": group_bg,
|
||||
// The tile renders `finalFunds` as its coin price; the HUD balance reads
|
||||
// `funds` from the /credits currencies array. We keep the pair equal.
|
||||
//
|
||||
// NO `extPrice`. Its parser has a SIDE EFFECT: `finalPrice`/`originalPrice`
|
||||
// both CREATE an `"mtx"` currency row, which the tile adapter reads as "has
|
||||
// a real-money price" and switches on the broken `or %1s` label — the
|
||||
// Origin/Dime commerce catalogue that would fill it no longer exists
|
||||
// offline, so every string stays at its constructor default. Omitting the
|
||||
// key is the documented fix (plan-2026-08-05-store-subsystem.md §3.4 /
|
||||
// experiment #4): it strictly reduces executed client code and leaves every
|
||||
// tile buyable. LIVE-observed `or %1s` on the Store tiles, 2026-08-18.
|
||||
"currencies": [{ "name": "coins", "funds": p.price, "finalFunds": p.price }],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": p.n_bronze,
|
||||
"silverQuantity": p.n_silver,
|
||||
"goldQuantity": p.n_gold,
|
||||
"rareQuantity": p.rares,
|
||||
"itemQuantity": p.count(),
|
||||
},
|
||||
"unopened": owned,
|
||||
});
|
||||
let obj = body.as_object_mut().expect("pack body is a JSON object");
|
||||
if owned {
|
||||
// Reward/My-Packs tiles KEEP the coins currency at the pack price (0 for
|
||||
// reward packs). My Packs opens through the store purchase flow, so a tile
|
||||
// with no currency row is not actionable — clicking navigates instead of
|
||||
// opening. With a free coin row the client sends POST /purchased and the
|
||||
// server (owned-only) opens it for free. No extPrice (that is the `or %1s`
|
||||
// mtx bug), so the free coin row formats as "0", not an unavailable label.
|
||||
// LIVE-PROVEN 2026-08-18: a reward Silver Pack opened and revealed cards.
|
||||
obj.insert(
|
||||
"displayGroup".into(),
|
||||
json!({ "value": "mypacks", "priority": idx }),
|
||||
);
|
||||
} else {
|
||||
obj.insert("displayGroup".into(), json!({ "value": category(p) }));
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
/// The synthetic empty-My-Packs sentinel `purchase[]` entry (id 65534): an owned-style
|
||||
/// body forced to `state:"active"`, `unopened:false`. Compatibility shim ONLY — 65534
|
||||
/// is absent from [`PACK_CATALOG`], so it can never be bought/opened/granted.
|
||||
pub fn sentinel_body(idx: u64) -> Value {
|
||||
let sentinel = PackDef {
|
||||
id: SENTINEL_PACK_ID,
|
||||
name: "",
|
||||
price: 0,
|
||||
n_bronze: 0,
|
||||
n_silver: 0,
|
||||
n_gold: 0,
|
||||
rares: 0,
|
||||
category: "gold",
|
||||
special_chance: 0.0,
|
||||
owned_only: true,
|
||||
};
|
||||
let mut body = pack_body(&sentinel, idx, true);
|
||||
let obj = body
|
||||
.as_object_mut()
|
||||
.expect("sentinel body is a JSON object");
|
||||
obj.insert("state".into(), json!("active"));
|
||||
obj.insert("unopened".into(), json!(false));
|
||||
// The sentinel must stay non-openable (it is only a resolve-without-crash shim
|
||||
// for an empty My Packs), so it keeps no purchase path.
|
||||
obj.remove("currencies");
|
||||
// Keep the sentinel's own id as its asset: it must render as an inert blank
|
||||
// placeholder, never borrow a real pack's cover.
|
||||
obj.insert("assetId".into(), json!(SENTINEL_PACK_ID));
|
||||
body
|
||||
}
|
||||
|
||||
/// Build the full `/store/purchasegroup` body from the authoritative unopened-pack
|
||||
/// ids and the frozen empty-My-Packs mode. Pure: the six regular packs (ids 1–6)
|
||||
/// first, then any owned packs, then the empty-My-Packs shim (sentinel for
|
||||
/// [`StoreMode::Sentinel`], nothing for [`StoreMode::CleanV1`]).
|
||||
pub fn build_purchasegroup(unopened_ids: &[u64], mode: StoreMode) -> Value {
|
||||
let mut packs: Vec<Value> = PACK_CATALOG
|
||||
.iter()
|
||||
.filter(|p| !p.owned_only)
|
||||
.enumerate()
|
||||
.map(|(i, p)| pack_body(p, i as u64 + 1, false))
|
||||
.collect();
|
||||
for (i, &pid) in unopened_ids.iter().enumerate() {
|
||||
if let Some(owned) = pack_by_id(pid) {
|
||||
packs.push(pack_body(owned, i as u64 + 1, true));
|
||||
}
|
||||
}
|
||||
if unopened_ids.is_empty() && mode == StoreMode::Sentinel {
|
||||
packs.push(sentinel_body(1));
|
||||
}
|
||||
json!({ "purchase": packs, "timestamp": 1596326400i64 })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Golden tests pinning the authoritative Rust `/store/purchasegroup` body. The
|
||||
//! fixtures under `tests/fixtures/purchasegroup_*.json` are Rust's own output
|
||||
//! (object key order is irrelevant to `serde_json::Value` eq). They track the
|
||||
//! RE-driven divergence from the Python oracle — notably no `extPrice` (see the
|
||||
//! module header).
|
||||
use super::*;
|
||||
|
||||
fn parse(s: &str) -> Value {
|
||||
serde_json::from_str(s).expect("fixture parses")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purchasegroup_zero_sentinel_matches_golden() {
|
||||
let got = build_purchasegroup(&[], StoreMode::Sentinel);
|
||||
let want = parse(include_str!(
|
||||
"../../tests/fixtures/purchasegroup_zero_sentinel.json"
|
||||
));
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purchasegroup_zero_clean_matches_golden() {
|
||||
let got = build_purchasegroup(&[], StoreMode::CleanV1);
|
||||
let want = parse(include_str!(
|
||||
"../../tests/fixtures/purchasegroup_zero_clean.json"
|
||||
));
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purchasegroup_pack70_matches_golden() {
|
||||
// Owned pack present -> no sentinel regardless of mode.
|
||||
let got = build_purchasegroup(&[70], StoreMode::Sentinel);
|
||||
let want = parse(include_str!(
|
||||
"../../tests/fixtures/purchasegroup_pack70.json"
|
||||
));
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sentinel_absent_from_catalog() {
|
||||
assert!(pack_by_id(SENTINEL_PACK_ID).is_none());
|
||||
assert!(PACK_CATALOG.iter().all(|p| p.id != SENTINEL_PACK_ID));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reward_pack_definitions_resolve_to_openable_owned_packs() {
|
||||
// Core reward services grant symbolic pack names; each must resolve to an
|
||||
// owned-only catalogue pack so it renders as an openable My Packs tile.
|
||||
for (def, want) in [
|
||||
("bronze_pack", 71),
|
||||
("silver_pack", 72),
|
||||
("gold_pack", 73),
|
||||
("rare_gold_pack", 74),
|
||||
("icon_pack", 75),
|
||||
] {
|
||||
let id = owned_pack_id_for_definition(def).expect("reward def resolves");
|
||||
assert_eq!(id, want);
|
||||
assert!(
|
||||
pack_by_id(id).unwrap().owned_only,
|
||||
"a reward pack must be owned-only"
|
||||
);
|
||||
}
|
||||
// Imported numeric owned-pack ids resolve to themselves.
|
||||
assert_eq!(owned_pack_id_for_definition("70"), Some(70));
|
||||
// Purchasable (non-owned) numeric ids and unknown names never resolve.
|
||||
assert_eq!(owned_pack_id_for_definition("5"), None);
|
||||
assert_eq!(owned_pack_id_for_definition("mystery_pack"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reward_tiles_carry_a_renderable_cover_asset() {
|
||||
// A reward pack's own id is not a client art asset, so its My Packs tile
|
||||
// renders blank. Each reward tile must therefore carry a valid catalogue
|
||||
// assetId (its tier's store pack) while `id` stays the open packId.
|
||||
for (reward_id, want_asset) in [(71, 1), (72, 3), (73, 5), (74, 5), (75, 5)] {
|
||||
let pack = pack_by_id(reward_id).expect("reward pack in catalogue");
|
||||
let tile = pack_body(pack, 1, true);
|
||||
assert_eq!(tile["id"], reward_id, "open packId stays the pack's own id");
|
||||
assert_eq!(
|
||||
tile["assetId"], want_asset,
|
||||
"reward tile borrows its tier's store-pack cover asset"
|
||||
);
|
||||
assert!(
|
||||
pack_by_id(tile["assetId"].as_u64().unwrap()).is_some_and(|p| !p.owned_only),
|
||||
"the cover asset must be a real purchasable catalogue pack"
|
||||
);
|
||||
}
|
||||
// The sentinel must NOT borrow a real cover — it stays an inert placeholder.
|
||||
assert_eq!(sentinel_body(1)["assetId"], SENTINEL_PACK_ID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbolic_reward_pack_renders_as_openable_my_packs_tile() {
|
||||
// A granted silver reward pack (resolved to id 72) must appear as an owned
|
||||
// My Packs tile, not the non-openable sentinel shim.
|
||||
let got = build_purchasegroup(&[72], StoreMode::Sentinel);
|
||||
let packs = got["purchase"].as_array().unwrap();
|
||||
let reward = packs
|
||||
.iter()
|
||||
.find(|p| p["id"] == 72)
|
||||
.expect("reward pack tile present");
|
||||
assert_eq!(reward["displayGroup"]["value"], "mypacks");
|
||||
assert!(reward["unopened"].as_bool().unwrap());
|
||||
assert!(packs.iter().all(|p| p["id"] != SENTINEL_PACK_ID));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_v1_empty_emits_no_mypacks_group() {
|
||||
let got = build_purchasegroup(&[], StoreMode::CleanV1);
|
||||
let ids: Vec<u64> = got["purchase"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|e| e["id"].as_u64().unwrap())
|
||||
.collect();
|
||||
assert_eq!(ids, vec![1, 2, 3, 4, 5, 6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_tokens_are_canonical() {
|
||||
assert_eq!(category(pack_by_id(1).unwrap()), "bronze");
|
||||
assert_eq!(category(pack_by_id(3).unwrap()), "silver");
|
||||
assert_eq!(category(pack_by_id(5).unwrap()), "gold");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,726 @@
|
||||
//! FIFA 17 empty-My-Packs capability negotiation — per-session state machine.
|
||||
//!
|
||||
//! This is the Rust production port of the *novel* session/capability logic that
|
||||
//! was proven live on staging and currently lives in the Python oracle
|
||||
//! (`fifa17-recon/tools/utas_server.py`, "FIFA17 empty-My-Packs capability
|
||||
//! negotiation"). Python remains the behavioural **reference/oracle**; this module
|
||||
//! is the intended production **authority** for the machinery so new FUT session
|
||||
//! behaviour stops accumulating in Python.
|
||||
//!
|
||||
//! ## Scope (deliberately bounded)
|
||||
//!
|
||||
//! This module owns the pure state machine only:
|
||||
//! * per-login session table keyed by the unique UTAS session id (`X-UT-SID`),
|
||||
//! * the single-use `(ip, persona)` launcher→session capability hand-off (pending),
|
||||
//! * capability binding (`bound` / `pending` / `ignored-late`),
|
||||
//! * the once-per-session empty-My-Packs freeze (`clean-v1` vs `sentinel`),
|
||||
//! * TTL reaping and fail-closed rules (unknown / expired / ambiguous / late /
|
||||
//! cross-session / sid-ip-mismatch ⇒ sentinel).
|
||||
//!
|
||||
//! It has **no** HTTP, Core, or IO dependencies, and it does **not** reproduce the
|
||||
//! delicate `_pack_body` UTAS wire shaping (utas_server.py:3474 — a type-sensitive,
|
||||
//! reverse-engineered body where a wrong scalar type silently breaks pack buying).
|
||||
//! That shaping, the `/ut/auth` persona-adoption, `/store/purchasegroup` catalogue
|
||||
//! assembly, and the store BUY path remain Python-owned until a separate, carefully
|
||||
//! differential-tested slice ports them. Wiring these three routes
|
||||
//! (`/ut/auth` SID, `/openfut/fifa17/capability`, `/store/purchasegroup`) into
|
||||
//! `openfut-utas-host` against this state machine — without dividing store authority
|
||||
//! (i.e. porting BUY too, so purchasegroup display and buy validation don't split) —
|
||||
//! is the remaining bounded gap toward full Rust authority.
|
||||
//!
|
||||
//! ## Purity / testability
|
||||
//!
|
||||
//! The monotonic clock is injected (`now: f64` seconds) rather than read from a
|
||||
//! global, so every A–R matrix scenario is a deterministic unit test. SID entropy
|
||||
//! is likewise injected ([`format_sid`]) — the host supplies a unique 64-bit value;
|
||||
//! the Python oracle notes uniqueness (not unpredictability) is the requirement.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// The single capability this negotiation understands.
|
||||
pub const CAPABILITY_NAME: &str = "empty_mypacks_resolver";
|
||||
/// The only accepted resolver version (FIFA17 CardsDLL guard at RVA `0x14858`).
|
||||
pub const EMPTY_MYPACKS_RESOLVER_VERSION: u32 = 1;
|
||||
/// Synthetic non-openable pack id — the universal P2 sentinel. Deliberately ABSENT
|
||||
/// from the store `PACK_CATALOG`, so it can never be bought/opened/granted.
|
||||
pub const SENTINEL_PACK_ID: u64 = 65534;
|
||||
/// A FIFA session is reaped after this many idle seconds.
|
||||
pub const SESSION_TTL_SECS: f64 = 3600.0;
|
||||
/// A launcher capability may await its session for this long before expiring.
|
||||
pub const PENDING_TTL_SECS: f64 = 120.0;
|
||||
|
||||
/// The empty-My-Packs store topology, frozen once per session at its first store
|
||||
/// request and immutable thereafter.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StoreMode {
|
||||
/// Universal fallback: emit the synthetic non-openable [`SENTINEL_PACK_ID`] pack
|
||||
/// so the client's `mypacks` category resolves and does not crash.
|
||||
Sentinel,
|
||||
/// Verified patched client: emit NO `mypacks` group; the CardsDLL resolver guard
|
||||
/// routes the absent category to Browse.
|
||||
CleanV1,
|
||||
}
|
||||
|
||||
impl StoreMode {
|
||||
/// The wire token the Python oracle logs/uses (`"sentinel"` / `"clean-v1"`).
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
StoreMode::Sentinel => "sentinel",
|
||||
StoreMode::CleanV1 => "clean-v1",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of a launcher capability registration.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RegisterOutcome {
|
||||
/// Exactly one live, unfrozen, unbound session for `(ip, persona)` existed and
|
||||
/// was bound now (the common post-login case).
|
||||
Bound,
|
||||
/// No session for `(ip, persona)` yet (registration before login): staged as a
|
||||
/// single-use pending hand-off.
|
||||
Pending,
|
||||
/// A session for `(ip, persona)` exists but is frozen or ambiguous (>1 unbound):
|
||||
/// NOT staged, so no later/unverified process can inherit it. Fail-closed.
|
||||
IgnoredLate,
|
||||
}
|
||||
|
||||
/// Rejection reason for a capability POST body (maps to HTTP 400 at the route).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CapabilityError {
|
||||
/// Capability name or version is not the supported `empty_mypacks_resolver` v1.
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/// Validate a capability registration request body. Anything but the supported
|
||||
/// `empty_mypacks_resolver` at the current version is rejected — the session then
|
||||
/// simply stays on the sentinel fallback (the route records nothing).
|
||||
pub fn validate_capability(name: &str, version: u32) -> Result<(), CapabilityError> {
|
||||
if name == CAPABILITY_NAME && version == EMPTY_MYPACKS_RESOLVER_VERSION {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CapabilityError::Unsupported)
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a unique per-login UTAS session id from a caller-supplied 64-bit value.
|
||||
/// Same shape/length as the legacy constant; uniqueness — not unpredictability — is
|
||||
/// what the binding needs, so the host may use any unique source (random or counter).
|
||||
pub fn format_sid(bits: u64) -> String {
|
||||
format!("OPENFUT-SID-{bits:016X}")
|
||||
}
|
||||
|
||||
/// The identity of the synthetic empty-My-Packs sentinel pack (utas_server.py:3671).
|
||||
/// This is the *identity* only; the full UTAS `purchase[]` entry is produced by the
|
||||
/// Python `_pack_body` shaping, which is intentionally not ported here.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct SentinelPack {
|
||||
pub id: u64,
|
||||
pub price: u64,
|
||||
pub count: u64,
|
||||
pub gold: bool,
|
||||
pub special_chance: f64,
|
||||
}
|
||||
|
||||
impl SentinelPack {
|
||||
/// The v1 sentinel: empty, free, non-openable. `_pack_body` additionally forces
|
||||
/// `state="active"` and `unopened=false` (owned) — documented here, applied by
|
||||
/// the shaping layer, not this module.
|
||||
pub fn v1() -> Self {
|
||||
SentinelPack {
|
||||
id: SENTINEL_PACK_ID,
|
||||
price: 0,
|
||||
count: 0,
|
||||
gold: true,
|
||||
special_chance: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Session {
|
||||
ip: Option<String>,
|
||||
persona: i64,
|
||||
resolver: Option<u32>,
|
||||
mode: Option<StoreMode>,
|
||||
last_seen: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Pending {
|
||||
resolver: u32,
|
||||
ts: f64,
|
||||
}
|
||||
|
||||
/// The per-session capability/store-mode authority. Not `Sync` itself; the host
|
||||
/// wraps it in a `Mutex` exactly as the Python oracle guards its tables with a lock.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SessionStore {
|
||||
sessions: HashMap<String, Session>,
|
||||
pending: HashMap<(Option<String>, i64), Pending>,
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
/// A fresh, empty store.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Reap idle sessions (> [`SESSION_TTL_SECS`]) and expired pendings
|
||||
/// (> [`PENDING_TTL_SECS`]). Called at the start of every mutating operation,
|
||||
/// mirroring the oracle's lazy reap.
|
||||
fn reap(&mut self, now: f64) {
|
||||
self.sessions
|
||||
.retain(|_, r| now - r.last_seen <= SESSION_TTL_SECS);
|
||||
self.pending.retain(|_, p| now - p.ts <= PENDING_TTL_SECS);
|
||||
}
|
||||
|
||||
/// Single-use: remove and return a fresh pending resolver for `(ip, persona)`.
|
||||
fn take_pending(&mut self, ip: &Option<String>, persona: i64, now: f64) -> Option<u32> {
|
||||
let key = (ip.clone(), persona);
|
||||
if let Some(p) = self.pending.get(&key) {
|
||||
if now - p.ts <= PENDING_TTL_SECS {
|
||||
let resolver = p.resolver;
|
||||
self.pending.remove(&key);
|
||||
return Some(resolver);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `/ut/auth`: open a per-login session and bind any pending launcher capability
|
||||
/// for `(ip, persona)` that arrived before login. An empty `sid` is a no-op.
|
||||
pub fn open_session(&mut self, sid: &str, ip: Option<String>, persona: i64, now: f64) {
|
||||
if sid.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.reap(now);
|
||||
let resolver = self.take_pending(&ip, persona, now);
|
||||
self.sessions.insert(
|
||||
sid.to_string(),
|
||||
Session {
|
||||
ip,
|
||||
persona,
|
||||
resolver,
|
||||
mode: None,
|
||||
last_seen: now,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// `/openfut/account/sync` hygiene: drop any stale pending for this machine so a
|
||||
/// new launch's unverified session cannot inherit a leftover capability.
|
||||
pub fn clear_pending(&mut self, ip: &str, now: f64) {
|
||||
self.reap(now);
|
||||
let ip_key = Some(ip.to_string());
|
||||
self.pending.retain(|(k_ip, _), _| *k_ip != ip_key);
|
||||
}
|
||||
|
||||
/// Launcher capability registration. Never authorizes more than one session:
|
||||
/// binds iff exactly one live, unfrozen, unbound session for `(ip, persona)`
|
||||
/// exists; otherwise stages a single-use pending (no session yet) or fails
|
||||
/// closed as ignored-late (a session exists but is frozen/ambiguous).
|
||||
pub fn register_capability(
|
||||
&mut self,
|
||||
ip: Option<String>,
|
||||
persona: i64,
|
||||
version: u32,
|
||||
now: f64,
|
||||
) -> RegisterOutcome {
|
||||
self.reap(now);
|
||||
let mut any_for_key = false;
|
||||
let mut candidate: Option<String> = None;
|
||||
let mut candidate_count = 0usize;
|
||||
for (sid, r) in &self.sessions {
|
||||
if r.ip == ip && r.persona == persona {
|
||||
any_for_key = true;
|
||||
if r.mode.is_none() && r.resolver.is_none() {
|
||||
candidate = Some(sid.clone());
|
||||
candidate_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if candidate_count == 1 {
|
||||
let sid = candidate.expect("exactly one candidate");
|
||||
self.sessions
|
||||
.get_mut(&sid)
|
||||
.expect("candidate session present")
|
||||
.resolver = Some(version);
|
||||
return RegisterOutcome::Bound;
|
||||
}
|
||||
if any_for_key {
|
||||
return RegisterOutcome::IgnoredLate;
|
||||
}
|
||||
self.pending.insert(
|
||||
(ip, persona),
|
||||
Pending {
|
||||
resolver: version,
|
||||
ts: now,
|
||||
},
|
||||
);
|
||||
RegisterOutcome::Pending
|
||||
}
|
||||
|
||||
/// True if `sid` is a live session. (The legacy constant SID is accepted only by
|
||||
/// the retired security-question gate in Python — never used to grant clean
|
||||
/// mode — and is intentionally not modelled here.)
|
||||
pub fn session_known(&self, sid: &str) -> bool {
|
||||
self.sessions.contains_key(sid)
|
||||
}
|
||||
|
||||
/// Freeze (once) and return the empty-My-Packs mode for FIFA session `sid`.
|
||||
/// Freeze point = the first `/store/purchasegroup` of the session. Fail-closed:
|
||||
/// an unknown session, or a `sid` presented from a different IP than it was
|
||||
/// opened on, resolves to [`StoreMode::Sentinel`] (and a mismatch does NOT freeze
|
||||
/// the real session, so a later correct-IP request can still freeze it).
|
||||
pub fn empty_mypacks_mode(&mut self, sid: &str, ip: Option<&str>, now: f64) -> StoreMode {
|
||||
self.reap(now);
|
||||
|
||||
// Resolve/take pending without holding a mutable borrow across the call.
|
||||
let (session_ip, persona, already_frozen, resolver) = match self.sessions.get(sid) {
|
||||
None => return StoreMode::Sentinel,
|
||||
Some(r) => (r.ip.clone(), r.persona, r.mode, r.resolver),
|
||||
};
|
||||
|
||||
if let Some(r) = self.sessions.get_mut(sid) {
|
||||
r.last_seen = now;
|
||||
}
|
||||
|
||||
// Fail-closed sid/ip sanity check (does not freeze).
|
||||
if let (Some(sess_ip), Some(req_ip)) = (session_ip.as_deref(), ip) {
|
||||
if sess_ip != req_ip {
|
||||
return StoreMode::Sentinel;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(mode) = already_frozen {
|
||||
return mode; // immutable per SID
|
||||
}
|
||||
|
||||
// First store request for this session: consume a still-pending capability
|
||||
// if the session was opened before the launcher registered, then freeze.
|
||||
let resolver = match resolver {
|
||||
Some(v) => Some(v),
|
||||
None => self.take_pending(&session_ip, persona, now),
|
||||
};
|
||||
let mode = if resolver == Some(EMPTY_MYPACKS_RESOLVER_VERSION) {
|
||||
StoreMode::CleanV1
|
||||
} else {
|
||||
StoreMode::Sentinel
|
||||
};
|
||||
if let Some(r) = self.sessions.get_mut(sid) {
|
||||
r.resolver = resolver;
|
||||
r.mode = Some(mode);
|
||||
}
|
||||
mode
|
||||
}
|
||||
|
||||
/// Store-catalogue decision for a `/store/purchasegroup` request. Mirrors the
|
||||
/// oracle's `if not owned_ids:` gate: with owned unopened packs the real
|
||||
/// `mypacks` group is served and no freeze occurs (`None`); only an *empty*
|
||||
/// My Packs freezes and returns the topology mode.
|
||||
pub fn store_empty_mypacks(
|
||||
&mut self,
|
||||
sid: &str,
|
||||
ip: Option<&str>,
|
||||
has_unopened_packs: bool,
|
||||
now: f64,
|
||||
) -> Option<StoreMode> {
|
||||
if has_unopened_packs {
|
||||
return None;
|
||||
}
|
||||
Some(self.empty_mypacks_mode(sid, ip, now))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Ports the Python capability-negotiation matrix A–R
|
||||
//! (`fifa17-recon/tools/test_capability_negotiation.py`). Python is the oracle;
|
||||
//! these assertions must stay in lockstep with it.
|
||||
use super::*;
|
||||
|
||||
const IP1: &str = "10.10.0.105";
|
||||
const IP2: &str = "10.10.0.106";
|
||||
const PERSONA: i64 = 111001;
|
||||
|
||||
fn ip(s: &str) -> Option<String> {
|
||||
Some(s.to_string())
|
||||
}
|
||||
|
||||
// A: no-capability, zero packs -> sentinel
|
||||
#[test]
|
||||
fn a_no_capability_zero_packs_sentinel() {
|
||||
let mut s = SessionStore::new();
|
||||
s.open_session("sidA", ip(IP1), PERSONA, 0.0);
|
||||
assert_eq!(
|
||||
s.store_empty_mypacks("sidA", Some(IP1), false, 1.0),
|
||||
Some(StoreMode::Sentinel)
|
||||
);
|
||||
}
|
||||
|
||||
// B: verified v1, zero packs -> clean
|
||||
#[test]
|
||||
fn b_verified_v1_zero_packs_clean() {
|
||||
let mut s = SessionStore::new();
|
||||
s.open_session("sidB", ip(IP1), PERSONA, 0.0);
|
||||
assert_eq!(
|
||||
s.register_capability(ip(IP1), PERSONA, 1, 1.0),
|
||||
RegisterOutcome::Bound
|
||||
);
|
||||
assert_eq!(
|
||||
s.store_empty_mypacks("sidB", Some(IP1), false, 2.0),
|
||||
Some(StoreMode::CleanV1)
|
||||
);
|
||||
}
|
||||
|
||||
// C: real unopened pack + no capability -> genuine packs (no freeze/sentinel)
|
||||
#[test]
|
||||
fn c_real_pack_no_capability_genuine() {
|
||||
let mut s = SessionStore::new();
|
||||
s.open_session("sidC", ip(IP1), PERSONA, 0.0);
|
||||
assert_eq!(s.store_empty_mypacks("sidC", Some(IP1), true, 1.0), None);
|
||||
}
|
||||
|
||||
// D: real unopened pack + capability -> genuine packs (no freeze)
|
||||
#[test]
|
||||
fn d_real_pack_with_capability_genuine() {
|
||||
let mut s = SessionStore::new();
|
||||
s.open_session("sidD", ip(IP1), PERSONA, 0.0);
|
||||
assert_eq!(
|
||||
s.register_capability(ip(IP1), PERSONA, 1, 1.0),
|
||||
RegisterOutcome::Bound
|
||||
);
|
||||
assert_eq!(s.store_empty_mypacks("sidD", Some(IP1), true, 2.0), None);
|
||||
}
|
||||
|
||||
// E: unsupported version / capability -> endpoint rejects AND mode sentinel
|
||||
#[test]
|
||||
fn e_unsupported_capability_rejected_and_sentinel() {
|
||||
assert_eq!(validate_capability(CAPABILITY_NAME, 1), Ok(()));
|
||||
assert_eq!(
|
||||
validate_capability(CAPABILITY_NAME, 2),
|
||||
Err(CapabilityError::Unsupported)
|
||||
);
|
||||
assert_eq!(
|
||||
validate_capability("something_else", 1),
|
||||
Err(CapabilityError::Unsupported)
|
||||
);
|
||||
let mut s = SessionStore::new();
|
||||
s.open_session("sidE", ip(IP1), PERSONA, 0.0);
|
||||
// A rejected registration records nothing, so the session stays sentinel.
|
||||
assert_eq!(
|
||||
s.empty_mypacks_mode("sidE", Some(IP1), 1.0),
|
||||
StoreMode::Sentinel
|
||||
);
|
||||
}
|
||||
|
||||
// F: late capability after sentinel freeze -> stays sentinel
|
||||
#[test]
|
||||
fn f_late_capability_after_sentinel_freeze_stays() {
|
||||
let mut s = SessionStore::new();
|
||||
s.open_session("sidF", ip(IP1), PERSONA, 0.0);
|
||||
assert_eq!(
|
||||
s.empty_mypacks_mode("sidF", Some(IP1), 1.0),
|
||||
StoreMode::Sentinel
|
||||
);
|
||||
assert_eq!(
|
||||
s.register_capability(ip(IP1), PERSONA, 1, 2.0),
|
||||
RegisterOutcome::IgnoredLate
|
||||
);
|
||||
assert_eq!(
|
||||
s.empty_mypacks_mode("sidF", Some(IP1), 3.0),
|
||||
StoreMode::Sentinel
|
||||
);
|
||||
}
|
||||
|
||||
// G: capability "disappears" after clean freeze -> stays clean (immutable)
|
||||
#[test]
|
||||
fn g_clean_freeze_immutable() {
|
||||
let mut s = SessionStore::new();
|
||||
s.open_session("sidG", ip(IP1), PERSONA, 0.0);
|
||||
assert_eq!(
|
||||
s.register_capability(ip(IP1), PERSONA, 1, 1.0),
|
||||
RegisterOutcome::Bound
|
||||
);
|
||||
assert_eq!(
|
||||
s.empty_mypacks_mode("sidG", Some(IP1), 2.0),
|
||||
StoreMode::CleanV1
|
||||
);
|
||||
assert_eq!(
|
||||
s.empty_mypacks_mode("sidG", Some(IP1), 3.0),
|
||||
StoreMode::CleanV1
|
||||
);
|
||||
}
|
||||
|
||||
// H: two IPs (A verified, B none) -> A clean, B sentinel (no global leak)
|
||||
#[test]
|
||||
fn h_two_ips_isolated() {
|
||||
let mut s = SessionStore::new();
|
||||
s.open_session("sidHa", ip(IP1), PERSONA, 0.0);
|
||||
s.open_session("sidHb", ip(IP2), PERSONA, 0.0);
|
||||
assert_eq!(
|
||||
s.register_capability(ip(IP1), PERSONA, 1, 1.0),
|
||||
RegisterOutcome::Bound
|
||||
);
|
||||
assert_eq!(
|
||||
s.empty_mypacks_mode("sidHa", Some(IP1), 2.0),
|
||||
StoreMode::CleanV1
|
||||
);
|
||||
assert_eq!(
|
||||
s.empty_mypacks_mode("sidHb", Some(IP2), 2.0),
|
||||
StoreMode::Sentinel
|
||||
);
|
||||
}
|
||||
|
||||
// I: new session after reset -> fresh unpatched -> sentinel
|
||||
#[test]
|
||||
fn i_fresh_session_sentinel() {
|
||||
let mut s = SessionStore::new();
|
||||
s.open_session("sidI", ip(IP1), PERSONA, 10.0);
|
||||
assert_eq!(
|
||||
s.empty_mypacks_mode("sidI", Some(IP1), 11.0),
|
||||
StoreMode::Sentinel
|
||||
);
|
||||
}
|
||||
|
||||
// J: autopatch mismatch => never registers -> sentinel
|
||||
#[test]
|
||||
fn j_no_registration_sentinel() {
|
||||
let mut s = SessionStore::new();
|
||||
s.open_session("sidJ", ip(IP1), PERSONA, 0.0);
|
||||
// (no register_capability call at all)
|
||||
assert_eq!(
|
||||
s.empty_mypacks_mode("sidJ", Some(IP1), 1.0),
|
||||
StoreMode::Sentinel
|
||||
);
|
||||
}
|
||||
|
||||
// K: SAME IP, two sessions (A patched, B not) -> A clean, B sentinel
|
||||
#[test]
|
||||
fn k_same_ip_two_sessions_isolated() {
|
||||
let mut s = SessionStore::new();
|
||||
s.open_session("sidKa", ip(IP1), PERSONA, 0.0);
|
||||
assert_eq!(
|
||||
s.register_capability(ip(IP1), PERSONA, 1, 1.0),
|
||||
RegisterOutcome::Bound
|
||||
);
|
||||
s.open_session("sidKb", ip(IP1), PERSONA, 2.0);
|
||||
assert_eq!(
|
||||
s.empty_mypacks_mode("sidKa", Some(IP1), 3.0),
|
||||
StoreMode::CleanV1
|
||||
);
|
||||
assert_eq!(
|
||||
s.empty_mypacks_mode("sidKb", Some(IP1), 3.0),
|
||||
StoreMode::Sentinel
|
||||
);
|
||||
}
|
||||
|
||||
// L: SAME IP+persona relaunch (old ok, new not) -> new session sentinel
|
||||
#[test]
|
||||
fn l_same_ip_persona_relaunch() {
|
||||
let mut s = SessionStore::new();
|
||||
s.open_session("sidLold", ip(IP1), PERSONA, 0.0);
|
||||
assert_eq!(
|
||||
s.register_capability(ip(IP1), PERSONA, 1, 1.0),
|
||||
RegisterOutcome::Bound
|
||||
);
|
||||
assert_eq!(
|
||||
s.empty_mypacks_mode("sidLold", Some(IP1), 2.0),
|
||||
StoreMode::CleanV1
|
||||
);
|
||||
s.open_session("sidLnew", ip(IP1), PERSONA, 3.0);
|
||||
assert_eq!(
|
||||
s.empty_mypacks_mode("sidLnew", Some(IP1), 4.0),
|
||||
StoreMode::Sentinel
|
||||
);
|
||||
assert_eq!(
|
||||
s.empty_mypacks_mode("sidLold", Some(IP1), 5.0),
|
||||
StoreMode::CleanV1
|
||||
);
|
||||
}
|
||||
|
||||
// M: SAME IP, failed-patch second session -> first clean, second sentinel
|
||||
#[test]
|
||||
fn m_same_ip_failed_patch_second() {
|
||||
let mut s = SessionStore::new();
|
||||
s.open_session("sidMa", ip(IP1), PERSONA, 0.0);
|
||||
assert_eq!(
|
||||
s.register_capability(ip(IP1), PERSONA, 1, 1.0),
|
||||
RegisterOutcome::Bound
|
||||
);
|
||||
s.open_session("sidMb", ip(IP1), PERSONA, 2.0); // failed patch: never registers
|
||||
assert_eq!(
|
||||
s.empty_mypacks_mode("sidMa", Some(IP1), 3.0),
|
||||
StoreMode::CleanV1
|
||||
);
|
||||
assert_eq!(
|
||||
s.empty_mypacks_mode("sidMb", Some(IP1), 3.0),
|
||||
StoreMode::Sentinel
|
||||
);
|
||||
}
|
||||
|
||||
// N: late registration when sessions are frozen -> does not modify active,
|
||||
// and does not stage a pending that a later session could inherit.
|
||||
#[test]
|
||||
fn n_late_registration_no_effect() {
|
||||
let mut s = SessionStore::new();
|
||||
s.open_session("sidN", ip(IP1), PERSONA, 0.0);
|
||||
assert_eq!(
|
||||
s.empty_mypacks_mode("sidN", Some(IP1), 1.0),
|
||||
StoreMode::Sentinel
|
||||
);
|
||||
assert_eq!(
|
||||
s.register_capability(ip(IP1), PERSONA, 1, 2.0),
|
||||
RegisterOutcome::IgnoredLate
|
||||
);
|
||||
assert_eq!(
|
||||
s.empty_mypacks_mode("sidN", Some(IP1), 3.0),
|
||||
StoreMode::Sentinel
|
||||
);
|
||||
// No pending was staged, so a brand-new session cannot inherit it.
|
||||
s.open_session("sidN2", ip(IP1), PERSONA, 4.0);
|
||||
assert_eq!(
|
||||
s.empty_mypacks_mode("sidN2", Some(IP1), 5.0),
|
||||
StoreMode::Sentinel
|
||||
);
|
||||
}
|
||||
|
||||
// O: session cleanup / TTL expiry -> capability gone, sentinel
|
||||
#[test]
|
||||
fn o_ttl_expiry() {
|
||||
// Pending expiry: registered before login, but login arrives too late.
|
||||
let mut s = SessionStore::new();
|
||||
assert_eq!(
|
||||
s.register_capability(ip(IP1), PERSONA, 1, 0.0),
|
||||
RegisterOutcome::Pending
|
||||
);
|
||||
s.open_session("sidO", ip(IP1), PERSONA, PENDING_TTL_SECS + 1.0);
|
||||
assert_eq!(
|
||||
s.empty_mypacks_mode("sidO", Some(IP1), PENDING_TTL_SECS + 2.0),
|
||||
StoreMode::Sentinel
|
||||
);
|
||||
// Session expiry: a known session reaped after idle TTL becomes unknown.
|
||||
let mut s2 = SessionStore::new();
|
||||
s2.open_session("sidO2", ip(IP1), PERSONA, 0.0);
|
||||
assert_eq!(
|
||||
s2.empty_mypacks_mode("sidO2", Some(IP1), SESSION_TTL_SECS + 1.0),
|
||||
StoreMode::Sentinel
|
||||
);
|
||||
assert!(!s2.session_known("sidO2"));
|
||||
}
|
||||
|
||||
// P: duplicate registration for a session -> idempotent; no post-freeze change
|
||||
#[test]
|
||||
fn p_duplicate_registration_idempotent() {
|
||||
let mut s = SessionStore::new();
|
||||
s.open_session("sidP", ip(IP1), PERSONA, 0.0);
|
||||
assert_eq!(
|
||||
s.register_capability(ip(IP1), PERSONA, 1, 1.0),
|
||||
RegisterOutcome::Bound
|
||||
);
|
||||
// Second registration: the session is now bound (resolver set), so it is no
|
||||
// longer an unbound candidate -> ignored-late, and the outcome is unchanged.
|
||||
assert_eq!(
|
||||
s.register_capability(ip(IP1), PERSONA, 1, 2.0),
|
||||
RegisterOutcome::IgnoredLate
|
||||
);
|
||||
assert_eq!(
|
||||
s.empty_mypacks_mode("sidP", Some(IP1), 3.0),
|
||||
StoreMode::CleanV1
|
||||
);
|
||||
}
|
||||
|
||||
// Q: register-before-login (pending consumed) -> clean; single-use
|
||||
#[test]
|
||||
fn q_register_before_login_pending_consumed() {
|
||||
let mut s = SessionStore::new();
|
||||
assert_eq!(
|
||||
s.register_capability(ip(IP1), PERSONA, 1, 0.0),
|
||||
RegisterOutcome::Pending
|
||||
);
|
||||
s.open_session("sidQ", ip(IP1), PERSONA, 1.0);
|
||||
assert_eq!(
|
||||
s.empty_mypacks_mode("sidQ", Some(IP1), 2.0),
|
||||
StoreMode::CleanV1
|
||||
);
|
||||
// Single-use: a second login for the same (ip,persona) gets no capability.
|
||||
s.open_session("sidQ2", ip(IP1), PERSONA, 3.0);
|
||||
assert_eq!(
|
||||
s.empty_mypacks_mode("sidQ2", Some(IP1), 4.0),
|
||||
StoreMode::Sentinel
|
||||
);
|
||||
}
|
||||
|
||||
// R: topology freeze immutable per SID -> no flip either way; new SID fresh
|
||||
#[test]
|
||||
fn r_topology_freeze_immutable_per_sid() {
|
||||
let mut s = SessionStore::new();
|
||||
s.open_session("sidR", ip(IP1), PERSONA, 0.0);
|
||||
assert_eq!(
|
||||
s.register_capability(ip(IP1), PERSONA, 1, 1.0),
|
||||
RegisterOutcome::Bound
|
||||
);
|
||||
assert_eq!(
|
||||
s.empty_mypacks_mode("sidR", Some(IP1), 2.0),
|
||||
StoreMode::CleanV1
|
||||
);
|
||||
assert_eq!(
|
||||
s.empty_mypacks_mode("sidR", Some(IP1), 3.0),
|
||||
StoreMode::CleanV1
|
||||
);
|
||||
// A brand-new SID from the same client is a fresh, unverified session.
|
||||
s.open_session("sidRnew", ip(IP1), PERSONA, 4.0);
|
||||
assert_eq!(
|
||||
s.empty_mypacks_mode("sidRnew", Some(IP1), 5.0),
|
||||
StoreMode::Sentinel
|
||||
);
|
||||
}
|
||||
|
||||
// Extra: sid/ip mismatch is fail-closed and does NOT freeze the real session.
|
||||
#[test]
|
||||
fn sid_ip_mismatch_fails_closed_without_freezing() {
|
||||
let mut s = SessionStore::new();
|
||||
s.open_session("sidX", ip(IP1), PERSONA, 0.0);
|
||||
assert_eq!(
|
||||
s.register_capability(ip(IP1), PERSONA, 1, 1.0),
|
||||
RegisterOutcome::Bound
|
||||
);
|
||||
// Presented from the wrong IP: sentinel, but the session is not frozen.
|
||||
assert_eq!(
|
||||
s.empty_mypacks_mode("sidX", Some(IP2), 2.0),
|
||||
StoreMode::Sentinel
|
||||
);
|
||||
// The genuine client (correct IP) still freezes clean.
|
||||
assert_eq!(
|
||||
s.empty_mypacks_mode("sidX", Some(IP1), 3.0),
|
||||
StoreMode::CleanV1
|
||||
);
|
||||
}
|
||||
|
||||
// Extra: account_sync clears a stale pending for the machine.
|
||||
#[test]
|
||||
fn clear_pending_drops_stale_hand_off() {
|
||||
let mut s = SessionStore::new();
|
||||
assert_eq!(
|
||||
s.register_capability(ip(IP1), PERSONA, 1, 0.0),
|
||||
RegisterOutcome::Pending
|
||||
);
|
||||
s.clear_pending(IP1, 1.0);
|
||||
s.open_session("sidC1", ip(IP1), PERSONA, 2.0);
|
||||
assert_eq!(
|
||||
s.empty_mypacks_mode("sidC1", Some(IP1), 3.0),
|
||||
StoreMode::Sentinel
|
||||
);
|
||||
}
|
||||
|
||||
// Extra: format_sid shape matches the legacy constant length.
|
||||
#[test]
|
||||
fn format_sid_shape() {
|
||||
assert_eq!(
|
||||
format_sid(0x42C15A6F78DC6E74),
|
||||
"OPENFUT-SID-42C15A6F78DC6E74"
|
||||
);
|
||||
assert_eq!(format_sid(0).len(), "OPENFUT-SID-".len() + 16);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! # openfut-adapter-fifa17
|
||||
//!
|
||||
//! The FIFA 17 game adapter: everything that is true of *FIFA 17 specifically*
|
||||
//! and must therefore stay out of OpenFUT Core and out of the generic protocol
|
||||
//! crates.
|
||||
//!
|
||||
//! ## Layering
|
||||
//!
|
||||
//! ```text
|
||||
//! openfut-protocol-blaze generic Blaze: Fire2 framing, Heat2/TDF codec
|
||||
//! ▲
|
||||
//! openfut-adapter-fifa17 THIS: FIFA 17 command tables, response bodies,
|
||||
//! ▲ dispatch ordering, session identity
|
||||
//! OpenFUT Core game-independent FUT domain (not yet wired)
|
||||
//! ```
|
||||
//!
|
||||
//! A second title gets its own adapter crate and reuses the protocol layer
|
||||
//! underneath. Nothing here is written to be shared with one; if something in
|
||||
//! this crate turns out to be title-independent, it belongs one layer down.
|
||||
//!
|
||||
//! ## Modules
|
||||
//!
|
||||
//! * [`blaze`] — the Blaze/Fire2 RPC surface. Implemented, runtime validated.
|
||||
//! * [`redirector`] — the first hop's `<serverinstanceinfo>` response.
|
||||
//! Implemented; runtime validated against the retail client.
|
||||
//! * [`roster`] — the FUT roster-update response, the last gate before the hub.
|
||||
//! Implemented; not yet runtime validated.
|
||||
//! * [`fut`] — FUT/RS4 (UTAS) wire → Core semantic mappings; currently the
|
||||
//! owned-player ("My Squad") search: query parse + FIFA-id→name resolution +
|
||||
//! semantic filter/pagination. Pure mapping; no Rust UTAS host yet.
|
||||
//!
|
||||
//! Still served only by the Python backend: LSX/Origin (`:4216`), UTAS/RS4
|
||||
//! (`:8099`) and POW/EASFC (`:8094`). Roster XML (`:8081`) has an adapter here
|
||||
//! but no Rust host yet.
|
||||
//!
|
||||
//! **Nucleus (`:42131`) is deliberately not ported.** Instrumentation across
|
||||
//! every live session showed the client never dials it: the listener is bound,
|
||||
//! the handler logs unconditionally on connect, the client fetches the
|
||||
//! `OSDK_NUCLEUS` config that carries the URL — and makes zero requests. It is
|
||||
//! dead code on the observed path, so porting it would add an untested
|
||||
//! component for no parity gain. See the vault's Protocol Findings.
|
||||
//!
|
||||
//! ## Provenance
|
||||
//!
|
||||
//! Ported from `fifa17-recon/tools/blaze_responder_v3b.py`, the implementation
|
||||
//! that drove a retail FIFA 17 client from Origin login to an opened FUT pack.
|
||||
//! Parity is tested, not asserted: `fixtures/blaze_transactions.jsonl` records
|
||||
//! real request→response(s) transactions produced by the Python dispatcher, and
|
||||
//! `tests/oracle_parity.rs` replays them byte-for-byte.
|
||||
//!
|
||||
//! ## Not a server
|
||||
//!
|
||||
//! This crate answers frames. It opens no socket, terminates no TLS and owns no
|
||||
//! runtime. Hosting it is a separate, later decision — the Python backend
|
||||
//! remains the live runtime and nothing here is wired into it.
|
||||
//!
|
||||
//! ```
|
||||
//! use openfut_adapter_fifa17::blaze::{Adapter, AdapterConfig, Session};
|
||||
//! use openfut_protocol_blaze::fire2::{Header, MsgType};
|
||||
//! use openfut_protocol_blaze::heat2::Struct;
|
||||
//!
|
||||
//! let adapter = Adapter::new(AdapterConfig::loopback());
|
||||
//! let mut session = Session::new("session-key", 0x656E5553);
|
||||
//!
|
||||
//! // Util::ping
|
||||
//! let request = Header::new(0x0009, 0x0002, 1, MsgType::Message);
|
||||
//! let out = adapter.dispatch(&request, &Struct::new(), &mut session, 1_754_870_400);
|
||||
//!
|
||||
//! assert_eq!(out.len(), 1);
|
||||
//! assert_eq!(out[0].header.msg_type, MsgType::Reply);
|
||||
//! ```
|
||||
|
||||
pub mod blaze;
|
||||
pub mod fut;
|
||||
pub mod redirector;
|
||||
pub mod roster;
|
||||
pub mod tls;
|
||||
|
||||
pub use blaze::{Adapter, AdapterConfig, Session};
|
||||
@@ -0,0 +1,194 @@
|
||||
//! FIFA 17 Blaze redirector: the first hop.
|
||||
//!
|
||||
//! ```text
|
||||
//! FIFA 17 ──TLS──> redirector ──"connect to <ip>:<port>"──> plaintext Fire2 Blaze
|
||||
//! ```
|
||||
//!
|
||||
//! A different protocol from Blaze itself: HTTPS with an XML body (DirtySDK's
|
||||
//! ProtoHttp), not Fire2. Exactly one request is ever seen —
|
||||
//! `POST /redirector/getServerInstance` — observed 9 times across every live
|
||||
//! session with no other path.
|
||||
//!
|
||||
//! # Scope
|
||||
//!
|
||||
//! This module owns the **response**, which is game-specific. It does not own
|
||||
//! TLS or HTTP transport; that belongs to a host, exactly as the Blaze adapter
|
||||
//! owns dispatch while `openfut-blaze-host` owns the socket.
|
||||
//!
|
||||
//! # A TLS constraint that is not this module's problem, but is recorded here
|
||||
//!
|
||||
//! Every observed handshake negotiated `AES256-GCM-SHA384` — TLS 1.2 with
|
||||
//! **static RSA key exchange** (`TLS_RSA_WITH_AES_256_GCM_SHA384`), against a
|
||||
//! Python server offering `ALL:@SECLEVEL=0` and an RSA-2048 certificate
|
||||
//! (DirtySDK rejects ECDSA). `rustls` supports only forward-secret (EC)DHE
|
||||
//! suites, so it cannot serve that negotiation. Whether the client *offers*
|
||||
//! ECDHE at all is UNKNOWN — OpenSSL follows client preference by default, so
|
||||
//! preferring static RSA does not prove it is the only option. Instrument a
|
||||
//! real ClientHello before choosing a TLS stack.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use crate::blaze::config::AdapterConfig;
|
||||
|
||||
/// The only request path the client ever uses.
|
||||
pub const REQUEST_PATH: &str = "/redirector/getServerInstance";
|
||||
|
||||
/// Where the client is told to find Blaze.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BlazeEndpoint {
|
||||
/// Advertised hostname or dotted-quad.
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
/// Whether the Blaze hop is TLS. **0 for FIFA 17** — the second hop is
|
||||
/// plaintext Fire2, which this field independently confirms.
|
||||
pub secure: bool,
|
||||
}
|
||||
|
||||
impl BlazeEndpoint {
|
||||
/// Both host and port come from configuration. Neither is a protocol
|
||||
/// constant: the client goes wherever this response sends it, so hardcoding
|
||||
/// either would make the deployment un-relocatable.
|
||||
pub fn from_config(cfg: &AdapterConfig) -> BlazeEndpoint {
|
||||
BlazeEndpoint {
|
||||
host: cfg.endpoints.advertise.clone(),
|
||||
port: cfg.endpoints.blaze_port,
|
||||
secure: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dotted-quad to the decimal `u32` the client expects in `<ip>`.
|
||||
///
|
||||
/// Host byte order, so `127.0.0.1` is `2130706433` (`0x7F000001`). A non-IPv4
|
||||
/// advertise value (a hostname) has no numeric form; the oracle falls back to
|
||||
/// loopback rather than failing, and that behaviour is reproduced — the client
|
||||
/// reads `<hostname>` too, so the numeric field is not the only route.
|
||||
pub fn ip_to_u32(addr: &str) -> u32 {
|
||||
let octets: Vec<u32> = addr
|
||||
.split('.')
|
||||
.filter_map(|p| p.parse::<u32>().ok())
|
||||
.filter(|n| *n <= 255)
|
||||
.collect();
|
||||
if octets.len() == 4 {
|
||||
(octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]
|
||||
} else {
|
||||
(127 << 24) | 1
|
||||
}
|
||||
}
|
||||
|
||||
/// The `<serverinstanceinfo>` XML body.
|
||||
///
|
||||
/// `<address member="0">` is a `ServerAddress` union; member 0 selects the
|
||||
/// `ipAddress` variant `{hostname, ip, port}`. Tabs and newlines are part of
|
||||
/// the byte-exact output — the client does not care, but parity does.
|
||||
pub fn server_instance_info_xml(endpoint: &BlazeEndpoint) -> String {
|
||||
let mut s = String::with_capacity(320);
|
||||
s.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
s.push_str("<serverinstanceinfo>\n");
|
||||
s.push_str("\t<address member=\"0\">\n");
|
||||
s.push_str("\t\t<valu>\n");
|
||||
let _ = writeln!(s, "\t\t\t<hostname>{}</hostname>", endpoint.host);
|
||||
let _ = writeln!(s, "\t\t\t<ip>{}</ip>", ip_to_u32(&endpoint.host));
|
||||
let _ = writeln!(s, "\t\t\t<port>{}</port>", endpoint.port);
|
||||
s.push_str("\t\t</valu>\n");
|
||||
s.push_str("\t</address>\n");
|
||||
let _ = writeln!(s, "\t<secure>{}</secure>", u8::from(endpoint.secure));
|
||||
s.push_str("\t<trialservicename></trialservicename>\n");
|
||||
s.push_str("\t<defaultdnsaddress>0</defaultdnsaddress>\n");
|
||||
s.push_str("</serverinstanceinfo>\n");
|
||||
s
|
||||
}
|
||||
|
||||
/// The complete HTTP response, headers included.
|
||||
///
|
||||
/// `Connection: close` is the oracle's behaviour and the client accepts it —
|
||||
/// the redirector is a one-shot hop, unlike the long-lived Blaze connection.
|
||||
pub fn redirect_response(cfg: &AdapterConfig) -> Vec<u8> {
|
||||
let body = server_instance_info_xml(&BlazeEndpoint::from_config(cfg));
|
||||
let mut out = String::with_capacity(body.len() + 128);
|
||||
out.push_str("HTTP/1.1 200 OK\r\n");
|
||||
out.push_str("Content-Type: application/xml\r\n");
|
||||
let _ = write!(out, "Content-Length: {}\r\n", body.len());
|
||||
out.push_str("Connection: close\r\n\r\n");
|
||||
out.push_str(&body);
|
||||
out.into_bytes()
|
||||
}
|
||||
|
||||
/// Is this request line the one the client sends?
|
||||
///
|
||||
/// Diagnostics only: the oracle answers *any* request with the same body, so
|
||||
/// dispatch does not branch on this. Reproduced as-is — a redirector that
|
||||
/// started 404ing unexpected paths would be a behaviour change, not a fix.
|
||||
pub fn is_get_server_instance(request_line: &str) -> bool {
|
||||
request_line.starts_with("POST ") && request_line.contains(REQUEST_PATH)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cfg(advertise: &str) -> AdapterConfig {
|
||||
let mut c = AdapterConfig::loopback();
|
||||
c.endpoints.advertise = advertise.into();
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ip_encoding_is_host_order_decimal() {
|
||||
assert_eq!(ip_to_u32("127.0.0.1"), 2_130_706_433);
|
||||
assert_eq!(ip_to_u32("198.51.100.7"), 3_325_256_711);
|
||||
assert_eq!(ip_to_u32("203.0.113.42"), 3_405_803_818);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_ipv4_advertise_falls_back_to_loopback_like_the_oracle() {
|
||||
// The hostname element still carries the real value, so this is not a
|
||||
// dead end for the client.
|
||||
assert_eq!(ip_to_u32("blaze.example.com"), 2_130_706_433);
|
||||
assert_eq!(ip_to_u32("10.0.0"), 2_130_706_433);
|
||||
assert_eq!(ip_to_u32("999.1.1.1"), 2_130_706_433);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secure_is_zero_confirming_the_plaintext_second_hop() {
|
||||
let body = server_instance_info_xml(&BlazeEndpoint::from_config(&cfg("203.0.113.42")));
|
||||
assert!(body.contains("<secure>0</secure>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_advertises_the_configured_address_not_a_hardcoded_one() {
|
||||
let r = String::from_utf8(redirect_response(&cfg("198.51.100.7"))).unwrap();
|
||||
assert!(r.contains("<hostname>198.51.100.7</hostname>"));
|
||||
assert!(r.contains("<ip>3325256711</ip>"));
|
||||
assert!(r.contains("<port>42130</port>"));
|
||||
assert!(!r.contains("127.0.0.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_length_matches_the_body_exactly() {
|
||||
let bytes = redirect_response(&cfg("203.0.113.42"));
|
||||
let text = String::from_utf8(bytes).unwrap();
|
||||
let (head, body) = text.split_once("\r\n\r\n").expect("header/body split");
|
||||
let declared: usize = head
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("Content-Length: "))
|
||||
.and_then(|v| v.trim().parse().ok())
|
||||
.expect("content-length present");
|
||||
assert_eq!(
|
||||
declared,
|
||||
body.len(),
|
||||
"a wrong length would truncate the XML"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognises_the_only_observed_request_line() {
|
||||
assert!(is_get_server_instance(
|
||||
"POST /redirector/getServerInstance HTTP/1.1"
|
||||
));
|
||||
assert!(!is_get_server_instance(
|
||||
"GET /redirector/getServerInstance HTTP/1.1"
|
||||
));
|
||||
assert!(!is_get_server_instance("POST /something/else HTTP/1.1"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
//! The FUT roster-update response — "is there a squad update to download?".
|
||||
//!
|
||||
//! ```text
|
||||
//! FIFA 17 ──HTTPS GET /fifa17/fut/rosterupdate.xml──> <rosterupdate version="0"/>
|
||||
//! ```
|
||||
//!
|
||||
//! # Why this matters more than its size suggests
|
||||
//!
|
||||
//! `checkFUTRostersFlow` downloads this before entering FUT. On success it
|
||||
//! advances to the hub; on failure it aborts with *"An error occurred
|
||||
//! downloading the FUT Squad Update"*. It is the last gate before the hub, and
|
||||
//! because it is a separate TLS connection from the redirector it is also where
|
||||
//! a certificate mismatch elsewhere in the stack first becomes visible. Three
|
||||
//! redirector gates were lost to exactly that.
|
||||
//!
|
||||
//! # What the oracle actually puts on the wire
|
||||
//!
|
||||
//! `roster_server.py` uses `http.server.BaseHTTPRequestHandler`, which shapes
|
||||
//! the response in ways the handler code does not show:
|
||||
//!
|
||||
//! * the status line is **HTTP/1.0**, because `protocol_version` is left at its
|
||||
//! default — not HTTP/1.1, despite the client asking for 1.1
|
||||
//! * `send_response` emits `Server:` and `Date:` *before* any header the
|
||||
//! handler sets, so header order is Server, Date, Content-Type,
|
||||
//! Content-Length, Connection
|
||||
//! * **POST answers with headers only.** The handler writes the body `if method
|
||||
//! == "GET"`, so a POST advertises `Content-Length: 67` and then sends
|
||||
//! nothing. That is preserved here rather than corrected: it is the behaviour
|
||||
//! the retail client was proven against, and "obviously a bug" is exactly the
|
||||
//! kind of judgement that has been wrong before in this port.
|
||||
//!
|
||||
//! Any path gets the same answer — the oracle logs `self.path` and never routes
|
||||
//! on it. A reimplementation that started 404ing unknown paths would be a
|
||||
//! behaviour change, not a fix.
|
||||
|
||||
/// The path the client requests. Recorded for diagnostics; **not** used for
|
||||
/// routing, because the oracle does not route.
|
||||
pub const REQUEST_PATH: &str = "/fifa17/fut/rosterupdate.xml";
|
||||
|
||||
/// The "no update available" body, byte-for-byte from the oracle.
|
||||
pub const ROSTER_XML: &[u8] =
|
||||
b"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<rosterupdate version=\"0\"/>\n";
|
||||
|
||||
/// The `Server:` string the oracle emits.
|
||||
///
|
||||
/// Environment-derived, not protocol-derived: it is Python's version string,
|
||||
/// so it changes when the container's Python does. Kept as a default rather
|
||||
/// than hardcoded into the response builder so a host can match whatever the
|
||||
/// deployed oracle actually sends — the same reasoning that makes the
|
||||
/// advertised address configuration rather than a constant.
|
||||
pub const ORACLE_SERVER: &str = "BaseHTTP/0.6 Python/3.12.13";
|
||||
|
||||
/// Which of the oracle's three handlers a request lands in.
|
||||
///
|
||||
/// `Post` is distinct from `Head` in the oracle's *code* (it drains the request
|
||||
/// body first) but identical in its *response*, so the distinction is kept here
|
||||
/// for the host's benefit rather than the response builder's.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Method {
|
||||
Get,
|
||||
Head,
|
||||
Post,
|
||||
}
|
||||
|
||||
impl Method {
|
||||
/// Parse the request line's method. Unknown methods are `None` — the oracle
|
||||
/// only defines `do_GET`/`do_HEAD`/`do_POST`, and `BaseHTTPRequestHandler`
|
||||
/// answers anything else with its own 501, which is a different response
|
||||
/// this module deliberately does not claim to reproduce.
|
||||
pub fn parse(line0: &str) -> Option<Method> {
|
||||
match line0.split_whitespace().next()? {
|
||||
"GET" => Some(Method::Get),
|
||||
"HEAD" => Some(Method::Head),
|
||||
"POST" => Some(Method::Post),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Only GET carries the body, per the oracle.
|
||||
pub fn carries_body(self) -> bool {
|
||||
matches!(self, Method::Get)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the response exactly as the oracle would.
|
||||
///
|
||||
/// `date` is supplied by the caller rather than read from the clock here, so
|
||||
/// this stays a pure function and the fixtures can pin it. Format is the one
|
||||
/// `BaseHTTPRequestHandler.date_time_string` produces: RFC 7231 IMF-fixdate,
|
||||
/// always GMT.
|
||||
pub fn roster_response(method: Method, server: &str, date: &str) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(256);
|
||||
out.extend_from_slice(b"HTTP/1.0 200 OK\r\n");
|
||||
out.extend_from_slice(format!("Server: {server}\r\n").as_bytes());
|
||||
out.extend_from_slice(format!("Date: {date}\r\n").as_bytes());
|
||||
out.extend_from_slice(b"Content-Type: application/xml\r\n");
|
||||
// Always the body's length, even when no body follows. See the module note.
|
||||
out.extend_from_slice(format!("Content-Length: {}\r\n", ROSTER_XML.len()).as_bytes());
|
||||
out.extend_from_slice(b"Connection: close\r\n");
|
||||
out.extend_from_slice(b"\r\n");
|
||||
if method.carries_body() {
|
||||
out.extend_from_slice(ROSTER_XML);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `Date:` in the oracle's format, from a Unix timestamp.
|
||||
///
|
||||
/// Implemented rather than pulled from a date crate to keep this crate
|
||||
/// dependency-free, matching the protocol layer's constraint. Civil-date
|
||||
/// conversion is the standard days-from-epoch algorithm.
|
||||
pub fn http_date(unix_secs: i64) -> String {
|
||||
const DAYS: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
const MONTHS: [&str; 12] = [
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
];
|
||||
let days = unix_secs.div_euclid(86_400);
|
||||
let secs = unix_secs.rem_euclid(86_400);
|
||||
// 1970-01-01 was a Thursday (index 3).
|
||||
let dow = DAYS[(days + 3).rem_euclid(7) as usize];
|
||||
|
||||
// days-from-civil, inverted (Howard Hinnant's algorithm).
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
|
||||
format!(
|
||||
"{dow}, {d:02} {mon} {y} {h:02}:{mi:02}:{s:02} GMT",
|
||||
mon = MONTHS[(m - 1) as usize],
|
||||
h = secs / 3600,
|
||||
mi = (secs % 3600) / 60,
|
||||
s = secs % 60
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_body_is_the_oracles_67_bytes() {
|
||||
assert_eq!(ROSTER_XML.len(), 67);
|
||||
assert!(ROSTER_XML.starts_with(b"<?xml version=\"1.0\" encoding=\"utf-8\"?>"));
|
||||
assert!(ROSTER_XML.ends_with(b"<rosterupdate version=\"0\"/>\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_get_carries_the_body() {
|
||||
let d = "Tue, 11 Aug 2026 05:15:13 GMT";
|
||||
let get = roster_response(Method::Get, ORACLE_SERVER, d);
|
||||
let head = roster_response(Method::Head, ORACLE_SERVER, d);
|
||||
let post = roster_response(Method::Post, ORACLE_SERVER, d);
|
||||
assert_eq!(get.len(), head.len() + ROSTER_XML.len());
|
||||
assert_eq!(head, post, "the oracle answers POST exactly as HEAD");
|
||||
}
|
||||
|
||||
/// The quirk, asserted so a future "cleanup" has to argue with a test.
|
||||
#[test]
|
||||
fn a_bodiless_response_still_advertises_the_body_length() {
|
||||
let r = roster_response(Method::Head, ORACLE_SERVER, "Tue, 11 Aug 2026 05:15:13 GMT");
|
||||
let text = String::from_utf8_lossy(&r);
|
||||
assert!(text.contains("Content-Length: 67"), "{text}");
|
||||
assert!(text.ends_with("\r\n\r\n"), "no body may follow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_order_matches_basehttprequesthandler() {
|
||||
let r = roster_response(Method::Get, ORACLE_SERVER, "Tue, 11 Aug 2026 05:15:13 GMT");
|
||||
let text = String::from_utf8_lossy(&r);
|
||||
let order: Vec<&str> = [
|
||||
"Server:",
|
||||
"Date:",
|
||||
"Content-Type:",
|
||||
"Content-Length:",
|
||||
"Connection:",
|
||||
]
|
||||
.iter()
|
||||
.map(|h| text.find(h).map(|_| *h).unwrap_or("MISSING"))
|
||||
.collect();
|
||||
assert!(!order.contains(&"MISSING"), "{text}");
|
||||
let positions: Vec<usize> = order.iter().map(|h| text.find(h).unwrap()).collect();
|
||||
let mut sorted = positions.clone();
|
||||
sorted.sort_unstable();
|
||||
assert_eq!(
|
||||
positions, sorted,
|
||||
"headers out of the oracle's order:\n{text}"
|
||||
);
|
||||
assert!(
|
||||
text.starts_with("HTTP/1.0 200 OK\r\n"),
|
||||
"must be HTTP/1.0: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn methods_parse_and_unknown_ones_are_refused() {
|
||||
assert_eq!(Method::parse("GET /x HTTP/1.1"), Some(Method::Get));
|
||||
assert_eq!(Method::parse("HEAD /x HTTP/1.1"), Some(Method::Head));
|
||||
assert_eq!(Method::parse("POST /x HTTP/1.1"), Some(Method::Post));
|
||||
// Not reproduced on purpose: the oracle answers these with its own 501.
|
||||
assert_eq!(Method::parse("PUT /x HTTP/1.1"), None);
|
||||
assert_eq!(Method::parse(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_date_matches_the_oracles_format() {
|
||||
// 1786425313 == Tue, 11 Aug 2026 05:15:13 GMT, the captured fixture time.
|
||||
assert_eq!(http_date(1_786_425_313), "Tue, 11 Aug 2026 05:15:13 GMT");
|
||||
assert_eq!(http_date(0), "Thu, 01 Jan 1970 00:00:00 GMT");
|
||||
// A leap day, because the civil-date maths is the only real logic here.
|
||||
assert_eq!(http_date(1_709_164_800), "Thu, 29 Feb 2024 00:00:00 GMT");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! FIFA 17's TLS profile — what the retail client was *observed* to offer.
|
||||
//!
|
||||
//! # Evidence, not assumption
|
||||
//!
|
||||
//! A retail FIFA 17 client was captured through a passive proxy while reaching
|
||||
//! the FUT hub. It offers **exactly eight suites, every one static-RSA**:
|
||||
//!
|
||||
//! ```text
|
||||
//! 0x009D TLS_RSA_WITH_AES_256_GCM_SHA384 0x0035 TLS_RSA_WITH_AES_256_CBC_SHA
|
||||
//! 0x009C TLS_RSA_WITH_AES_128_GCM_SHA256 0x002F TLS_RSA_WITH_AES_128_CBC_SHA
|
||||
//! 0x003D TLS_RSA_WITH_AES_256_CBC_SHA256 0x0005 TLS_RSA_WITH_RC4_128_SHA
|
||||
//! 0x003C TLS_RSA_WITH_AES_128_CBC_SHA256 0x0004 TLS_RSA_WITH_RC4_128_MD5
|
||||
//!
|
||||
//! client_version TLS 1.2 extensions: server_name, signature_algorithms only
|
||||
//! ```
|
||||
//!
|
||||
//! Zero forward-secret suites — which is why `rustls` cannot serve this client,
|
||||
//! and why the transport hosts link OpenSSL directly.
|
||||
//!
|
||||
//! # Deliberately not enabled
|
||||
//!
|
||||
//! * **RC4 and MD5.** The client offers them; it does not need them. It already
|
||||
//! negotiates `AES256-GCM-SHA384` against the Python oracle, so resurrecting
|
||||
//! RC4 for completeness would weaken the service for nothing.
|
||||
//! * **SSLv3.** Never.
|
||||
//! * **A lowered security level.** Not applied pre-emptively. The default
|
||||
//! policy is tried first; if a retail handshake fails because OpenSSL rejects
|
||||
//! something genuinely required, the narrowest possible change is made and
|
||||
//! documented — not a blanket `SECLEVEL=0`.
|
||||
//!
|
||||
//! # Why this is data and not code
|
||||
//!
|
||||
//! These are facts about FIFA 17, so they live in the FIFA 17 adapter rather
|
||||
//! than in `openfut-tls`, which must stay game-independent. They are plain
|
||||
//! strings so this crate keeps its lean dependency list: an adapter should not
|
||||
//! drag OpenSSL into the build of everything that reads a card table.
|
||||
//!
|
||||
//! Confirmed live on 2026-08-11: the retail client reached the FUT hub through
|
||||
//! a Rust host configured from exactly these values, negotiating
|
||||
//! `TLSv1.2 / AES256-GCM-SHA384` with `sni=winter15.gosredirector.ea.com`.
|
||||
|
||||
/// The suites enabled for FIFA 17: the six RSA+AES options the client offers,
|
||||
/// strongest first, in OpenSSL's pre-TLS-1.3 naming.
|
||||
///
|
||||
/// Order expresses preference; the client's own order put AES-256-GCM first
|
||||
/// anyway, which is what the live handshake selected.
|
||||
pub const CIPHER_LIST: &str =
|
||||
"AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:AES256-SHA:AES128-SHA";
|
||||
|
||||
/// Suites the observed client offers that are deliberately refused.
|
||||
pub const REFUSED_SUITES: [&str; 2] = ["RC4-SHA", "RC4-MD5"];
|
||||
|
||||
/// All eight suites the captured ClientHello offered, for handshake rehearsals.
|
||||
pub const OBSERVED_CLIENT_SUITES: &str =
|
||||
"AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256:\
|
||||
AES256-SHA:AES128-SHA:RC4-SHA:RC4-MD5";
|
||||
|
||||
/// The SNI the retail client sends to the redirector.
|
||||
///
|
||||
/// Nothing routes on it — recorded so a rehearsal handshake matches the real
|
||||
/// one, and so a log line can show whether a connection came from the game or
|
||||
/// from a probe.
|
||||
pub const CLIENT_SNI: &str = "winter15.gosredirector.ea.com";
|
||||
|
||||
/// Protocol floor, as OpenSSL spells it.
|
||||
///
|
||||
/// The floor is NOT dropped to TLS 1.0 pre-emptively. The Python oracle permits
|
||||
/// it, but no evidence shows this client needs it, and "the oracle permits it"
|
||||
/// is not "the client requires it".
|
||||
pub const MIN_VERSION: &str = "TLSv1.2";
|
||||
|
||||
/// Protocol ceiling. The client offers no TLS 1.3, so the window is exact.
|
||||
pub const MAX_VERSION: &str = "TLSv1.2";
|
||||
|
||||
/// The suite the live retail handshake selected, and which a rehearsal is
|
||||
/// expected to reproduce.
|
||||
pub const EXPECTED_SUITE: &str = "AES256-GCM-SHA384";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The enabled list must be exactly the client's offer minus the refusals.
|
||||
/// Stated as a derivation so adding a suite to one constant without the
|
||||
/// other is a test failure rather than a silent divergence.
|
||||
#[test]
|
||||
fn the_enabled_list_is_the_offer_minus_the_refusals() {
|
||||
let offered: Vec<&str> = OBSERVED_CLIENT_SUITES.split(':').collect();
|
||||
let enabled: Vec<&str> = CIPHER_LIST.split(':').collect();
|
||||
let expected: Vec<&str> = offered
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|s| !REFUSED_SUITES.contains(s))
|
||||
.collect();
|
||||
assert_eq!(enabled, expected);
|
||||
assert_eq!(offered.len(), 8, "the capture showed eight suites");
|
||||
assert_eq!(enabled.len(), 6);
|
||||
}
|
||||
|
||||
/// Every enabled suite must be static RSA. An ECDHE suite slipping in would
|
||||
/// be silently useless to this client, which offers none.
|
||||
#[test]
|
||||
fn nothing_forward_secret_is_enabled() {
|
||||
for s in CIPHER_LIST.split(':') {
|
||||
assert!(
|
||||
!s.contains("ECDHE") && !s.contains("DHE"),
|
||||
"{s} is forward-secret; FIFA 17 offers no such suite"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rc4_and_md5_are_refused_not_merely_absent() {
|
||||
for s in REFUSED_SUITES {
|
||||
assert!(
|
||||
OBSERVED_CLIENT_SUITES.contains(s),
|
||||
"{s} must be one the client actually offers"
|
||||
);
|
||||
assert!(!CIPHER_LIST.contains(s), "{s} must not be enabled");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_expected_suite_is_one_we_enable_and_the_client_offers() {
|
||||
assert!(CIPHER_LIST.split(':').any(|s| s == EXPECTED_SUITE));
|
||||
assert!(OBSERVED_CLIENT_SUITES
|
||||
.split(':')
|
||||
.any(|s| s == EXPECTED_SUITE));
|
||||
assert_eq!(
|
||||
CIPHER_LIST.split(':').next(),
|
||||
Some(EXPECTED_SUITE),
|
||||
"preference order should put the observed selection first"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_protocol_window_is_exactly_tls12() {
|
||||
assert_eq!(MIN_VERSION, "TLSv1.2");
|
||||
assert_eq!(MAX_VERSION, "TLSv1.2");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Deployment-address audit: the configured address must reach every
|
||||
//! client-visible endpoint, and nothing may quietly substitute its own.
|
||||
//!
|
||||
//! OpenFUT has to run on arbitrary addresses. The development topology is
|
||||
//! deployment configuration, not architecture, so no crate may contain a
|
||||
//! production destination, an advertised address, or a hidden localhost
|
||||
//! fallback.
|
||||
//!
|
||||
//! Two TEST-NET addresses are used throughout (RFC 5737), deliberately not the
|
||||
//! lab's real LAN addresses: a test that passes only because its constant
|
||||
//! happens to match the current lab proves nothing about relocatability.
|
||||
|
||||
use openfut_adapter_fifa17::blaze::{client_config, AdapterConfig, Endpoints};
|
||||
use openfut_adapter_fifa17::redirector;
|
||||
|
||||
/// TEST-NET-2 and TEST-NET-3. Never routable, never ours, and obviously not a
|
||||
/// lab address to anyone reading a failure.
|
||||
const ADDR_A: &str = "198.51.100.7";
|
||||
const ADDR_B: &str = "203.0.113.42";
|
||||
|
||||
fn cfg(advertise: &str) -> AdapterConfig {
|
||||
AdapterConfig::advertising(advertise)
|
||||
}
|
||||
|
||||
/// Every client-visible string a config produces, for wholesale comparison.
|
||||
fn client_visible_surface(cfg: &AdapterConfig) -> Vec<String> {
|
||||
let mut out = vec![
|
||||
cfg.utas_base(),
|
||||
cfg.nucleus_base(),
|
||||
cfg.pow_content_url(),
|
||||
String::from_utf8(redirector::redirect_response(cfg)).unwrap(),
|
||||
];
|
||||
for section in client_config::known_sections() {
|
||||
for (k, v) in client_config::rows_for(section, cfg) {
|
||||
out.push(format!("{section}/{k}={v}"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// (5) Changing the advertised host must update every applicable generated URL,
|
||||
/// with no recompilation and no leftovers.
|
||||
#[test]
|
||||
fn changing_the_advertised_host_updates_every_client_visible_url() {
|
||||
let a = client_visible_surface(&cfg(ADDR_A));
|
||||
let b = client_visible_surface(&cfg(ADDR_B));
|
||||
|
||||
assert_eq!(a.len(), b.len(), "the surface itself must not change shape");
|
||||
|
||||
let a_has = a.iter().filter(|s| s.contains(ADDR_A)).count();
|
||||
let b_has = b.iter().filter(|s| s.contains(ADDR_B)).count();
|
||||
assert!(a_has > 200, "expected the address throughout, saw {a_has}");
|
||||
assert_eq!(a_has, b_has, "the same entries must carry the new address");
|
||||
|
||||
// Nothing may retain the old address after reconfiguration.
|
||||
let stragglers: Vec<&String> = b.iter().filter(|s| s.contains(ADDR_A)).collect();
|
||||
assert!(
|
||||
stragglers.is_empty(),
|
||||
"these kept the previous address: {:?}",
|
||||
&stragglers[..stragglers.len().min(5)]
|
||||
);
|
||||
}
|
||||
|
||||
/// (1) A remote configuration must not silently become localhost anywhere.
|
||||
#[test]
|
||||
fn remote_configuration_never_silently_becomes_localhost() {
|
||||
let c = cfg(ADDR_A);
|
||||
// Allowlisted: two OAuth redirect targets that are literals in the oracle
|
||||
// and are never dialled (see the compatibility exceptions in the vault).
|
||||
const ALLOWED_LOOPBACK_KEYS: [&str; 2] = ["identityRedirectUri", "redirect_uri"];
|
||||
|
||||
for entry in client_visible_surface(&c) {
|
||||
if entry.contains("127.0.0.1") || entry.contains("localhost") {
|
||||
assert!(
|
||||
ALLOWED_LOOPBACK_KEYS.iter().any(|k| entry.contains(k)),
|
||||
"unexpected loopback in a remote configuration: {entry}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POW hosts in particular must derive from advertise, not fall back alone.
|
||||
assert!(c.endpoints.pow_content_host.starts_with(ADDR_A));
|
||||
assert!(c.endpoints.pow_host.starts_with(ADDR_A));
|
||||
assert!(c.pow_content_url().contains(ADDR_A));
|
||||
}
|
||||
|
||||
/// (3) Bind and advertise are different concepts and must never be conflated.
|
||||
#[test]
|
||||
fn bind_can_differ_from_advertise() {
|
||||
let mut c = cfg(ADDR_A);
|
||||
c.endpoints.bind = "0.0.0.0".into();
|
||||
|
||||
assert_eq!(c.endpoints.advertise, ADDR_A);
|
||||
assert_eq!(c.endpoints.bind, "0.0.0.0");
|
||||
// The advertised surface follows advertise, not bind.
|
||||
assert!(c.utas_base().contains(ADDR_A));
|
||||
assert!(!c.utas_base().contains("0.0.0.0"));
|
||||
|
||||
// COMPATIBILITY EXCEPTION, reproduced deliberately: nucleusConnect follows
|
||||
// BIND in the oracle. Instrumentation showed the client never dials it, so
|
||||
// this is cosmetic on the observed path. Asserted so the exception cannot
|
||||
// be "fixed" by accident without this test failing and forcing the decision
|
||||
// to be made explicitly.
|
||||
assert_eq!(c.nucleus_base(), "http://0.0.0.0:42131");
|
||||
}
|
||||
|
||||
/// (4) The advertised Blaze port must reach the redirect result.
|
||||
#[test]
|
||||
fn changing_the_blaze_port_changes_the_redirect() {
|
||||
let mut c = cfg(ADDR_A);
|
||||
let before = String::from_utf8(redirector::redirect_response(&c)).unwrap();
|
||||
assert!(before.contains("<port>42130</port>"));
|
||||
|
||||
c.endpoints.blaze_port = 42999;
|
||||
let after = String::from_utf8(redirector::redirect_response(&c)).unwrap();
|
||||
assert!(after.contains("<port>42999</port>"), "{after}");
|
||||
assert!(!after.contains("<port>42130</port>"));
|
||||
// And the advertised host still follows config.
|
||||
assert!(after.contains(&format!("<hostname>{ADDR_A}</hostname>")));
|
||||
}
|
||||
|
||||
/// The UTAS port is deployment configuration too, not a constant we own.
|
||||
#[test]
|
||||
fn changing_the_utas_port_changes_every_rs4_url() {
|
||||
let mut c = cfg(ADDR_A);
|
||||
assert!(c.utas_base().contains(":8099/"));
|
||||
|
||||
c.endpoints.utas_port = 9099;
|
||||
assert_eq!(c.utas_base(), format!("http://{ADDR_A}:9099/"));
|
||||
|
||||
let rows = client_config::rows_for("BlazeSDK", &c);
|
||||
let base = rows.iter().find(|(k, _)| k == "FUT_RS4_BASE_URL").unwrap();
|
||||
assert_eq!(base.1, format!("http://{ADDR_A}:9099/"));
|
||||
assert!(!rows.iter().any(|(_, v)| v.contains(":8099")));
|
||||
}
|
||||
|
||||
/// (6) No service-specific helper may construct an endpoint from a different
|
||||
/// source of truth than the central configuration.
|
||||
#[test]
|
||||
fn no_helper_bypasses_the_central_configuration() {
|
||||
// bind MUST differ from advertise here. With them equal, a helper that
|
||||
// wrongly reads `bind` is indistinguishable from one that reads
|
||||
// `advertise` — and reading `bind` is the single most likely bypass,
|
||||
// because the oracle really does it for nucleusConnect. Mutation-tested:
|
||||
// with bind == advertise this test could not detect that substitution.
|
||||
let mut c = cfg(ADDR_B);
|
||||
c.endpoints.bind = "0.0.0.0".into();
|
||||
|
||||
// Every URL-shaped helper resolves through the same Endpoints.
|
||||
assert!(c.utas_base().contains(ADDR_B));
|
||||
assert!(c.pow_content_url().contains(ADDR_B));
|
||||
let ep = redirector::BlazeEndpoint::from_config(&c);
|
||||
assert_eq!(
|
||||
ep.host, c.endpoints.advertise,
|
||||
"the redirector must advertise the ADVERTISED host, not the bind address"
|
||||
);
|
||||
assert_ne!(
|
||||
ep.host, c.endpoints.bind,
|
||||
"bind must not leak into the wire"
|
||||
);
|
||||
let xml = String::from_utf8(redirector::redirect_response(&c)).unwrap();
|
||||
assert!(xml.contains(ADDR_B));
|
||||
assert!(
|
||||
!xml.contains("0.0.0.0"),
|
||||
"the bind address must never reach the client"
|
||||
);
|
||||
assert_eq!(ep.port, c.endpoints.blaze_port);
|
||||
|
||||
// And the config table's URL tokens resolve through those same helpers,
|
||||
// rather than re-deriving a URL shape of their own.
|
||||
let rows = client_config::rows_for("BlazeSDK", &c);
|
||||
let base = rows.iter().find(|(k, _)| k == "FUT_RS4_BASE_URL").unwrap();
|
||||
assert_eq!(base.1, c.utas_base());
|
||||
let nucleus = rows.iter().find(|(k, _)| k == "nucleusConnect").unwrap();
|
||||
assert_eq!(nucleus.1, c.nucleus_base());
|
||||
}
|
||||
|
||||
/// (7) Mutating the configuration must make these tests fail — a suite that
|
||||
/// passes regardless of the configured address would prove nothing.
|
||||
#[test]
|
||||
fn configuration_mutations_are_detectable() {
|
||||
let a = cfg(ADDR_A);
|
||||
let b = cfg(ADDR_B);
|
||||
|
||||
// Each of these is what a mutation would have to defeat.
|
||||
assert_ne!(a.utas_base(), b.utas_base());
|
||||
assert_ne!(a.pow_content_url(), b.pow_content_url());
|
||||
assert_ne!(
|
||||
redirector::redirect_response(&a),
|
||||
redirector::redirect_response(&b)
|
||||
);
|
||||
assert_ne!(
|
||||
client_config::rows_for("BlazeSDK", &a),
|
||||
client_config::rows_for("BlazeSDK", &b)
|
||||
);
|
||||
|
||||
let mut port_changed = a.clone();
|
||||
port_changed.endpoints.blaze_port += 1;
|
||||
assert_ne!(
|
||||
redirector::redirect_response(&a),
|
||||
redirector::redirect_response(&port_changed)
|
||||
);
|
||||
}
|
||||
|
||||
/// Loopback must be a named, deliberate choice — not something a caller can
|
||||
/// reach by omission.
|
||||
#[test]
|
||||
fn loopback_is_explicit_not_a_default() {
|
||||
let l = Endpoints::loopback();
|
||||
assert_eq!(l.advertise, "127.0.0.1");
|
||||
assert!(l.pow_content_host.starts_with("127.0.0.1"));
|
||||
|
||||
// `Endpoints::default()` and `AdapterConfig::default()` deliberately do not
|
||||
// exist; this test documents that, and the crate would not compile if they
|
||||
// were reintroduced and used by accident elsewhere.
|
||||
let explicit = AdapterConfig::loopback();
|
||||
assert_eq!(explicit.endpoints.advertise, "127.0.0.1");
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
{
|
||||
"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,
|
||||
"displayGroupAssetId": 1,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 400,
|
||||
"finalFunds": 400
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 10,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "bronze"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 2,
|
||||
"id": 2,
|
||||
"packType": "BRONZE",
|
||||
"description": "Premium Bronze Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 2,
|
||||
"displayGroupAssetId": 1,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 750,
|
||||
"finalFunds": 750
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 10,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "bronze"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 3,
|
||||
"id": 3,
|
||||
"packType": "SILVER",
|
||||
"description": "Silver Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 3,
|
||||
"displayGroupAssetId": 2,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 2500,
|
||||
"finalFunds": 2500
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 1,
|
||||
"silverQuantity": 11,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "silver"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 4,
|
||||
"id": 4,
|
||||
"packType": "SILVER",
|
||||
"description": "Premium Silver Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 4,
|
||||
"displayGroupAssetId": 2,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 3750,
|
||||
"finalFunds": 3750
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 1,
|
||||
"silverQuantity": 11,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "silver"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 5,
|
||||
"id": 5,
|
||||
"packType": "GOLD",
|
||||
"description": "Gold Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 5,
|
||||
"displayGroupAssetId": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 5000,
|
||||
"finalFunds": 5000
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 10,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 6,
|
||||
"id": 6,
|
||||
"packType": "GOLD",
|
||||
"description": "Premium Gold Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 6,
|
||||
"displayGroupAssetId": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 7500,
|
||||
"finalFunds": 7500
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 10,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 5,
|
||||
"id": 70,
|
||||
"packType": "GOLD",
|
||||
"description": "Reward Gold Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 1,
|
||||
"displayGroupAssetId": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 0,
|
||||
"finalFunds": 0
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": 11,
|
||||
"rareQuantity": 11,
|
||||
"itemQuantity": 11
|
||||
},
|
||||
"unopened": true,
|
||||
"displayGroup": {
|
||||
"value": "mypacks",
|
||||
"priority": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"timestamp": 1596326400
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
{
|
||||
"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,
|
||||
"displayGroupAssetId": 1,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 400,
|
||||
"finalFunds": 400
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 10,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "bronze"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 2,
|
||||
"id": 2,
|
||||
"packType": "BRONZE",
|
||||
"description": "Premium Bronze Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 2,
|
||||
"displayGroupAssetId": 1,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 750,
|
||||
"finalFunds": 750
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 10,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "bronze"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 3,
|
||||
"id": 3,
|
||||
"packType": "SILVER",
|
||||
"description": "Silver Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 3,
|
||||
"displayGroupAssetId": 2,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 2500,
|
||||
"finalFunds": 2500
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 1,
|
||||
"silverQuantity": 11,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "silver"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 4,
|
||||
"id": 4,
|
||||
"packType": "SILVER",
|
||||
"description": "Premium Silver Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 4,
|
||||
"displayGroupAssetId": 2,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 3750,
|
||||
"finalFunds": 3750
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 1,
|
||||
"silverQuantity": 11,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "silver"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 5,
|
||||
"id": 5,
|
||||
"packType": "GOLD",
|
||||
"description": "Gold Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 5,
|
||||
"displayGroupAssetId": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 5000,
|
||||
"finalFunds": 5000
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 10,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 6,
|
||||
"id": 6,
|
||||
"packType": "GOLD",
|
||||
"description": "Premium Gold Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 6,
|
||||
"displayGroupAssetId": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 7500,
|
||||
"finalFunds": 7500
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 10,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
}
|
||||
}
|
||||
],
|
||||
"timestamp": 1596326400
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
{
|
||||
"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,
|
||||
"displayGroupAssetId": 1,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 400,
|
||||
"finalFunds": 400
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 10,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "bronze"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 2,
|
||||
"id": 2,
|
||||
"packType": "BRONZE",
|
||||
"description": "Premium Bronze Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 2,
|
||||
"displayGroupAssetId": 1,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 750,
|
||||
"finalFunds": 750
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 10,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "bronze"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 3,
|
||||
"id": 3,
|
||||
"packType": "SILVER",
|
||||
"description": "Silver Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 3,
|
||||
"displayGroupAssetId": 2,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 2500,
|
||||
"finalFunds": 2500
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 1,
|
||||
"silverQuantity": 11,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "silver"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 4,
|
||||
"id": 4,
|
||||
"packType": "SILVER",
|
||||
"description": "Premium Silver Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 4,
|
||||
"displayGroupAssetId": 2,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 3750,
|
||||
"finalFunds": 3750
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 1,
|
||||
"silverQuantity": 11,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "silver"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 5,
|
||||
"id": 5,
|
||||
"packType": "GOLD",
|
||||
"description": "Gold Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 5,
|
||||
"displayGroupAssetId": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 5000,
|
||||
"finalFunds": 5000
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 10,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 6,
|
||||
"id": 6,
|
||||
"packType": "GOLD",
|
||||
"description": "Premium Gold Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 6,
|
||||
"displayGroupAssetId": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 7500,
|
||||
"finalFunds": 7500
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 10,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 65534,
|
||||
"id": 65534,
|
||||
"packType": "GOLD",
|
||||
"description": "",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 1,
|
||||
"displayGroupAssetId": 3,
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 0,
|
||||
"itemQuantity": 0
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "mypacks",
|
||||
"priority": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"timestamp": 1596326400
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
//! Differential tests: the Rust adapter against the Python Blaze responder.
|
||||
//!
|
||||
//! `openfut-protocol-blaze` proves the *codec* matches. This proves the layer
|
||||
//! that decides **what to say**: for each inbound frame, the exact frames that
|
||||
//! go back and their order.
|
||||
//!
|
||||
//! Every vector in `fixtures/blaze_transactions.jsonl` was produced by calling
|
||||
//! the real `blaze_responder_v3b.dispatch()`. Transactions replay in file order
|
||||
//! against a shared session per connection, so ordering-dependent behaviour is
|
||||
//! exercised rather than assumed — preAuth captures the locale that later ALOC
|
||||
//! fields echo, and login sets the auth code getAuthToken returns afterwards.
|
||||
//!
|
||||
//! Comparison is byte-for-byte, including frame count and order. A missing
|
||||
//! post-login notification or a reply where the oracle stays silent is a
|
||||
//! failure here, which is the whole point.
|
||||
//!
|
||||
//! Regenerate after any oracle change: python3 fixtures/generate.py
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use openfut_adapter_fifa17::blaze::{Adapter, AdapterConfig, Endpoints, Identity, Session};
|
||||
use openfut_protocol_blaze::fire2::Frame;
|
||||
use openfut_protocol_blaze::heat2;
|
||||
use serde_json::Value as J;
|
||||
|
||||
fn records() -> Vec<J> {
|
||||
let path = format!(
|
||||
"{}/fixtures/blaze_transactions.jsonl",
|
||||
env!("CARGO_MANIFEST_DIR")
|
||||
);
|
||||
let text = std::fs::read_to_string(&path)
|
||||
.unwrap_or_else(|e| panic!("cannot read {path}: {e}\nrun: python3 fixtures/generate.py"));
|
||||
text.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| serde_json::from_str(l).expect("fixture line is valid JSON"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn unhex(s: &str) -> Vec<u8> {
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("valid hex"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn hex(b: &[u8]) -> String {
|
||||
b.iter().map(|x| format!("{x:02x}")).collect()
|
||||
}
|
||||
|
||||
fn st(j: &J, k: &str) -> String {
|
||||
j[k].as_str()
|
||||
.unwrap_or_else(|| panic!("{k} is a string"))
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn n(j: &J, k: &str) -> i64 {
|
||||
j[k].as_i64().unwrap_or_else(|| panic!("{k} is a number"))
|
||||
}
|
||||
|
||||
/// Rebuild the exact configuration the fixtures were generated under.
|
||||
///
|
||||
/// The generator uses deliberately non-loopback addresses, so an adapter that
|
||||
/// hardcoded one instead of reading its config fails loudly here rather than
|
||||
/// coincidentally matching a default.
|
||||
fn config_from(record: &J) -> (AdapterConfig, i64) {
|
||||
let id = &record["identity"];
|
||||
let identity = Identity {
|
||||
persona_id: n(id, "persona_id"),
|
||||
persona_name: st(id, "persona_name"),
|
||||
user_id: n(id, "user_id"),
|
||||
ext_id: n(id, "ext_id"),
|
||||
email: st(id, "email"),
|
||||
namespace: st(id, "namespace"),
|
||||
client_platform: n(id, "client_platform"),
|
||||
persona_status: n(id, "persona_status"),
|
||||
user_session_type: n(id, "user_session_type"),
|
||||
account_locale: n(id, "account_locale_int"),
|
||||
locale: st(id, "locale"),
|
||||
content_id: st(id, "content_id"),
|
||||
entitlement_tag: st(id, "entitlement_tag"),
|
||||
entitlement_group: st(id, "entitlement_group"),
|
||||
title_id: st(id, "title_id"),
|
||||
client_id: st(id, "client_id"),
|
||||
platform: st(id, "platform"),
|
||||
};
|
||||
let endpoints = Endpoints {
|
||||
advertise: st(record, "advertise"),
|
||||
bind: st(record, "bind"),
|
||||
pow_content_host: st(record, "pow_content_host"),
|
||||
pow_host: st(record, "pow_host"),
|
||||
..Endpoints::loopback()
|
||||
};
|
||||
let cfg = AdapterConfig {
|
||||
identity,
|
||||
endpoints,
|
||||
server_version: st(&record["identity"], "server_version"),
|
||||
};
|
||||
(cfg, n(record, "now"))
|
||||
}
|
||||
|
||||
struct Replay {
|
||||
adapter: Adapter,
|
||||
now: i64,
|
||||
sessions: HashMap<String, Session>,
|
||||
records: Vec<J>,
|
||||
}
|
||||
|
||||
fn setup() -> Replay {
|
||||
let records = records();
|
||||
let cfg_rec = records
|
||||
.iter()
|
||||
.find(|r| r["kind"] == "config")
|
||||
.expect("fixture carries a config record")
|
||||
.clone();
|
||||
let (cfg, now) = config_from(&cfg_rec);
|
||||
|
||||
let mut sessions = HashMap::new();
|
||||
for r in &records {
|
||||
if r["kind"] == "session" {
|
||||
// The session key is injected, not generated: it appears verbatim
|
||||
// in three responses, so a self-minted one could never match.
|
||||
sessions.insert(
|
||||
st(r, "id"),
|
||||
Session::new(st(r, "session_key"), n(r, "account_locale")),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Replay {
|
||||
adapter: Adapter::new(cfg),
|
||||
now,
|
||||
sessions,
|
||||
records,
|
||||
}
|
||||
}
|
||||
|
||||
/// The headline test: replay every transaction and require identical frames.
|
||||
#[test]
|
||||
fn dispatch_matches_python_oracle_byte_for_byte() {
|
||||
let mut rp = setup();
|
||||
let records = rp.records.clone();
|
||||
let mut checked = 0usize;
|
||||
|
||||
for rec in records.iter().filter(|r| r["kind"] == "tx") {
|
||||
let name = st(rec, "name");
|
||||
let sid = st(rec, "session");
|
||||
let request = unhex(&st(rec, "request_hex"));
|
||||
let expected: Vec<String> = rec["responses"]
|
||||
.as_array()
|
||||
.expect("responses array")
|
||||
.iter()
|
||||
.map(|f| f.as_str().unwrap().to_string())
|
||||
.collect();
|
||||
|
||||
let (frame, used) = Frame::parse(&request)
|
||||
.unwrap_or_else(|e| panic!("{name}: fixture request does not parse: {e}"));
|
||||
assert_eq!(used, request.len(), "{name}: trailing bytes in request");
|
||||
|
||||
let body = if frame.payload.is_empty() {
|
||||
heat2::Struct::new()
|
||||
} else {
|
||||
heat2::decode(&frame.payload)
|
||||
.unwrap_or_else(|e| panic!("{name}: request body is not valid TDF: {e}"))
|
||||
};
|
||||
|
||||
let session = rp.sessions.get_mut(&sid).expect("session declared");
|
||||
let out = rp.adapter.dispatch(&frame.header, &body, session, rp.now);
|
||||
|
||||
assert_eq!(
|
||||
out.len(),
|
||||
expected.len(),
|
||||
"\n{name}: produced {} frame(s), oracle produced {}",
|
||||
out.len(),
|
||||
expected.len()
|
||||
);
|
||||
for (idx, (got, want)) in out.iter().zip(expected.iter()).enumerate() {
|
||||
let got_hex = hex(&got.encode());
|
||||
if &got_hex != want {
|
||||
// Narrow the failure to header vs body before dumping bytes.
|
||||
let want_bytes = unhex(want);
|
||||
let got_bytes = got.encode();
|
||||
assert_eq!(
|
||||
hex(&got_bytes[..16.min(got_bytes.len())]),
|
||||
hex(&want_bytes[..16.min(want_bytes.len())]),
|
||||
"\n{name} frame {idx}: HEADER differs"
|
||||
);
|
||||
panic!(
|
||||
"\n{name} frame {idx}: BODY differs\n got {} bytes\n want {} bytes",
|
||||
got_bytes.len().saturating_sub(16),
|
||||
want_bytes.len().saturating_sub(16)
|
||||
);
|
||||
}
|
||||
}
|
||||
checked += 1;
|
||||
}
|
||||
|
||||
assert!(
|
||||
checked >= 40,
|
||||
"expected the full script, replayed {checked}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Frame counts and ordering are part of the contract, so assert them
|
||||
/// separately from bytes — a rewrite that answered correctly but dropped a
|
||||
/// notification would otherwise fail with an unhelpful byte diff.
|
||||
#[test]
|
||||
fn frame_counts_and_ordering_match() {
|
||||
let mut rp = setup();
|
||||
let records = rp.records.clone();
|
||||
|
||||
for rec in records.iter().filter(|r| r["kind"] == "tx") {
|
||||
let name = st(rec, "name");
|
||||
let request = unhex(&st(rec, "request_hex"));
|
||||
let expected = rec["responses"].as_array().unwrap();
|
||||
|
||||
let (frame, _) = Frame::parse(&request).unwrap();
|
||||
let body = if frame.payload.is_empty() {
|
||||
heat2::Struct::new()
|
||||
} else {
|
||||
heat2::decode(&frame.payload).unwrap()
|
||||
};
|
||||
let session = rp.sessions.get_mut(&st(rec, "session")).unwrap();
|
||||
let out = rp.adapter.dispatch(&frame.header, &body, session, rp.now);
|
||||
|
||||
assert_eq!(out.len(), expected.len(), "{name}: frame count");
|
||||
|
||||
for (got, want_hex) in out.iter().zip(expected.iter()) {
|
||||
let want = Frame::parse(&unhex(want_hex.as_str().unwrap())).unwrap().0;
|
||||
assert_eq!(
|
||||
got.header.component, want.header.component,
|
||||
"{name}: component"
|
||||
);
|
||||
assert_eq!(got.header.command, want.header.command, "{name}: command");
|
||||
assert_eq!(got.header.msg_type, want.header.msg_type, "{name}: msgType");
|
||||
assert_eq!(got.header.msg_num, want.header.msg_num, "{name}: msgNum");
|
||||
assert_eq!(
|
||||
got.header.user_index, want.header.user_index,
|
||||
"{name}: userIndex"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The login burst is the sequence most likely to be silently wrong, so pin it
|
||||
/// explicitly rather than relying on it being buried in the byte comparison.
|
||||
#[test]
|
||||
fn login_emits_reply_then_exactly_three_pushes() {
|
||||
let rp = setup();
|
||||
let login = rp
|
||||
.records
|
||||
.iter()
|
||||
.find(|r| r["kind"] == "tx" && r["name"] == "login")
|
||||
.expect("login transaction present");
|
||||
|
||||
let frames: Vec<Frame> = login["responses"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|h| Frame::parse(&unhex(h.as_str().unwrap())).unwrap().0)
|
||||
.collect();
|
||||
|
||||
assert_eq!(frames.len(), 4, "reply + three UserSessions pushes");
|
||||
assert_eq!(
|
||||
frames[0].header.msg_type,
|
||||
openfut_protocol_blaze::fire2::MsgType::Reply
|
||||
);
|
||||
let notify_ids: Vec<u16> = frames[1..].iter().map(|f| f.header.command).collect();
|
||||
assert_eq!(notify_ids, vec![0x0008, 0x0001, 0x0002]);
|
||||
}
|
||||
|
||||
/// The generator uses non-loopback addresses, so any loopback literal left in a
|
||||
/// response means the adapter hardcoded something it should have read from
|
||||
/// config — the exact regression the client/server split was meant to prevent.
|
||||
///
|
||||
/// Two keys are genuine literals in the oracle, not substitution failures.
|
||||
/// Both are OAuth redirect targets the client never actually dials (the flow is
|
||||
/// forged), so the loopback is inert; they are allowlisted by key rather than
|
||||
/// by pattern so a third one cannot slip in unnoticed.
|
||||
const ALLOWED_LOOPBACK_KEYS: [&str; 2] = ["identityRedirectUri", "redirect_uri"];
|
||||
|
||||
#[test]
|
||||
fn no_response_hardcodes_a_loopback_address() {
|
||||
let mut rp = setup();
|
||||
let records = rp.records.clone();
|
||||
let advertise = "198.51.100.7";
|
||||
|
||||
for rec in records.iter().filter(|r| r["kind"] == "tx") {
|
||||
let name = st(rec, "name");
|
||||
let request = unhex(&st(rec, "request_hex"));
|
||||
let (frame, _) = Frame::parse(&request).unwrap();
|
||||
let body = if frame.payload.is_empty() {
|
||||
heat2::Struct::new()
|
||||
} else {
|
||||
heat2::decode(&frame.payload).unwrap()
|
||||
};
|
||||
let session = rp.sessions.get_mut(&st(rec, "session")).unwrap();
|
||||
let out = rp.adapter.dispatch(&frame.header, &body, session, rp.now);
|
||||
|
||||
for f in &out {
|
||||
let text = String::from_utf8_lossy(&f.payload);
|
||||
for (at, _) in text.match_indices("127.0.0.1") {
|
||||
// TDF strings are length-prefixed and NUL-terminated, so the
|
||||
// owning key sits shortly before the value. Look back far
|
||||
// enough to name it, and require it to be allowlisted.
|
||||
let start = at.saturating_sub(80);
|
||||
let context = &text[start..text.len().min(at + 64)];
|
||||
assert!(
|
||||
ALLOWED_LOOPBACK_KEYS.iter().any(|k| context.contains(k)),
|
||||
"\n{name}: unexpected loopback literal, context {context:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// The advertised address must actually appear somewhere in the config
|
||||
// responses, or substitution silently did nothing.
|
||||
if name.starts_with("fetch_config") || name == "preauth" {
|
||||
let text = String::from_utf8_lossy(&out[0].payload);
|
||||
assert!(
|
||||
text.contains(advertise),
|
||||
"{name}: advertised address missing from the config payload"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Session state must survive across RPCs on one connection, and must NOT leak
|
||||
/// between connections.
|
||||
#[test]
|
||||
fn session_state_is_per_connection() {
|
||||
let mut rp = setup();
|
||||
let records = rp.records.clone();
|
||||
for rec in records.iter().filter(|r| r["kind"] == "tx") {
|
||||
let request = unhex(&st(rec, "request_hex"));
|
||||
let (frame, _) = Frame::parse(&request).unwrap();
|
||||
let body = if frame.payload.is_empty() {
|
||||
heat2::Struct::new()
|
||||
} else {
|
||||
heat2::decode(&frame.payload).unwrap()
|
||||
};
|
||||
let session = rp.sessions.get_mut(&st(rec, "session")).unwrap();
|
||||
rp.adapter.dispatch(&frame.header, &body, session, rp.now);
|
||||
}
|
||||
|
||||
// "main" logged in with an auth code and an enUS preAuth.
|
||||
let main = &rp.sessions["main"];
|
||||
assert!(main.logged_in);
|
||||
assert_eq!(main.auth_code, "OPENFUT-TEST-AUTHCODE");
|
||||
assert_eq!(main.account_locale, 0x656E_5553);
|
||||
|
||||
// "locale" ran a deDE preAuth and a login carrying no AUTH member.
|
||||
let loc = &rp.sessions["locale"];
|
||||
assert_eq!(loc.account_locale, 0x6465_4445, "deDE locale captured");
|
||||
assert_eq!(loc.service_name, "fifa-2017-pc-de");
|
||||
assert!(loc.auth_code.is_empty());
|
||||
|
||||
// "fallbacks" never logged in.
|
||||
assert!(!rp.sessions["fallbacks"].logged_in);
|
||||
}
|
||||
|
||||
// ────────────────────────────── redirector ──────────────────────────────
|
||||
//
|
||||
// The first hop. A different protocol from Blaze — HTTPS with an XML body —
|
||||
// but the same rule: byte-for-byte against the oracle.
|
||||
|
||||
#[test]
|
||||
fn redirect_response_matches_python_oracle_byte_for_byte() {
|
||||
use openfut_adapter_fifa17::redirector;
|
||||
|
||||
let path = format!("{}/fixtures/redirector.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("cannot read {path}: {e}"));
|
||||
let table: serde_json::Map<String, J> = serde_json::from_str(&text).expect("valid JSON");
|
||||
assert!(table.len() >= 3, "expected several advertised addresses");
|
||||
|
||||
for (advertise, want_hex) in &table {
|
||||
let mut cfg = AdapterConfig::loopback();
|
||||
cfg.endpoints.advertise = advertise.clone();
|
||||
|
||||
let got = redirector::redirect_response(&cfg);
|
||||
assert_eq!(
|
||||
hex(&got),
|
||||
want_hex.as_str().unwrap(),
|
||||
"\nredirector response differs for advertise={advertise}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The advertised Blaze endpoint must follow config, and the fixtures use
|
||||
/// deliberately different addresses so a hardcoded one cannot pass.
|
||||
#[test]
|
||||
fn redirect_response_is_configurable_not_baked() {
|
||||
use openfut_adapter_fifa17::redirector;
|
||||
|
||||
let mut a = AdapterConfig::loopback();
|
||||
a.endpoints.advertise = "10.0.0.5".into();
|
||||
let mut b = AdapterConfig::loopback();
|
||||
b.endpoints.advertise = "10.0.0.6".into();
|
||||
|
||||
assert_ne!(
|
||||
redirector::redirect_response(&a),
|
||||
redirector::redirect_response(&b),
|
||||
"the advertised address must reach the wire"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! The roster response, held against bytes captured from the live oracle.
|
||||
//!
|
||||
//! The fixture masks the two volatile fields (`Date:`, `Server:`) and records
|
||||
//! the observed `Server` string separately, so this can assert the full byte
|
||||
//! layout while still failing loudly if the oracle's Python version drifts away
|
||||
//! from the adapter's `ORACLE_SERVER` constant.
|
||||
|
||||
use openfut_adapter_fifa17::roster::{self, Method};
|
||||
|
||||
const MASK: &str = "<MASKED>";
|
||||
|
||||
fn fixture() -> String {
|
||||
let path = format!("{}/fixtures/roster.json", env!("CARGO_MANIFEST_DIR"));
|
||||
std::fs::read_to_string(&path)
|
||||
.unwrap_or_else(|e| panic!("roster fixtures missing at {path}: {e}"))
|
||||
}
|
||||
|
||||
/// Minimal extraction: the fixture is written by our own generator and is a
|
||||
/// flat object, so a JSON dependency would be overkill in a crate that has none.
|
||||
fn field(text: &str, key: &str) -> String {
|
||||
let needle = format!("\"{key}\"");
|
||||
let at = text
|
||||
.find(&needle)
|
||||
.unwrap_or_else(|| panic!("fixture has no {key}"));
|
||||
let rest = &text[at + needle.len()..];
|
||||
let colon = rest.find(':').expect("key: value");
|
||||
let open = rest[colon..].find('"').expect("value opens") + colon;
|
||||
let close = rest[open + 1..].find('"').expect("value closes");
|
||||
rest[open + 1..open + 1 + close].to_string()
|
||||
}
|
||||
|
||||
fn expected(method: &str) -> Vec<u8> {
|
||||
// Scope the search to the responses object so a key never matches elsewhere.
|
||||
let text = fixture();
|
||||
let at = text.find("\"responses\"").expect("responses");
|
||||
let hex = field(&text[at..], method);
|
||||
(0..hex.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("hex"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Re-apply the generator's masking so the comparison is like-for-like.
|
||||
fn mask(raw: &[u8]) -> Vec<u8> {
|
||||
let text = String::from_utf8_lossy(raw);
|
||||
let masked: String = text
|
||||
.split("\r\n")
|
||||
.map(|line| {
|
||||
if line.starts_with("Date: ") {
|
||||
format!("Date: {MASK}")
|
||||
} else if line.starts_with("Server: ") {
|
||||
format!("Server: {MASK}")
|
||||
} else {
|
||||
line.to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\r\n");
|
||||
masked.into_bytes()
|
||||
}
|
||||
|
||||
fn check(method: Method, name: &str) {
|
||||
let got = roster::roster_response(
|
||||
method,
|
||||
roster::ORACLE_SERVER,
|
||||
"Tue, 11 Aug 2026 05:15:13 GMT",
|
||||
);
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&mask(&got)),
|
||||
String::from_utf8_lossy(&expected(name)),
|
||||
"{name} differs from the oracle"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_matches_the_oracle() {
|
||||
check(Method::Get, "GET");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn head_matches_the_oracle() {
|
||||
check(Method::Head, "HEAD");
|
||||
}
|
||||
|
||||
/// Including the quirk: headers advertising a body that never arrives.
|
||||
#[test]
|
||||
fn post_matches_the_oracle() {
|
||||
check(Method::Post, "POST");
|
||||
}
|
||||
|
||||
/// If the container's Python changes, `ORACLE_SERVER` is stale and every
|
||||
/// response this adapter builds is wrong in a byte the oracle would have got
|
||||
/// right. The fixture records what was actually observed so that drift is a
|
||||
/// test failure rather than a silent divergence.
|
||||
#[test]
|
||||
fn the_server_constant_still_matches_the_observed_oracle() {
|
||||
let observed = field(&fixture(), "observed_server");
|
||||
assert_eq!(
|
||||
roster::ORACLE_SERVER,
|
||||
observed,
|
||||
"roster::ORACLE_SERVER is stale — the oracle now sends {observed:?}. \
|
||||
Regenerate fixtures and update the constant."
|
||||
);
|
||||
}
|
||||
|
||||
/// The masking must not be able to hide a real difference. If `Date:` were
|
||||
/// dropped rather than masked, a response missing it entirely would still pass.
|
||||
#[test]
|
||||
fn masking_does_not_hide_a_missing_header() {
|
||||
let good = roster::roster_response(Method::Get, roster::ORACLE_SERVER, "X");
|
||||
let without_date: Vec<u8> = String::from_utf8_lossy(&good)
|
||||
.split("\r\n")
|
||||
.filter(|l| !l.starts_with("Date: "))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\r\n")
|
||||
.into_bytes();
|
||||
assert_ne!(
|
||||
mask(&good),
|
||||
mask(&without_date),
|
||||
"masking collapsed a missing Date into a match"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
//! Adapter-level squad read-after-write / round-trip fidelity, driven entirely by
|
||||
//! committed sanitized capture evidence.
|
||||
//!
|
||||
//! Pipeline exercised end to end, with NO database, socket, or Core:
|
||||
//!
|
||||
//! ```text
|
||||
//! captured PUT ─parse─▶ Fifa17SquadPut
|
||||
//! ─build─▶ ProposedSquad (canonical) + Fifa17SquadExtensionV1
|
||||
//! (simulate committed canonical state: the ProposedSquad IS what Core stored)
|
||||
//! ─project─▶ FIFA 17 squad wire object
|
||||
//! ```
|
||||
//!
|
||||
//! Fidelity is asserted by ownership class:
|
||||
//! CANONICAL player instance per index, formation, captain, bench split, and
|
||||
//! the ownership-backed manager assignment (migration 0023)
|
||||
//! EXTENSION custom, kicktakers, kit numbers (by player), squadType
|
||||
//! SHADOW chemistry/rating/starRating (client-reported, round-tripped as-is)
|
||||
//! DERIVED correct FIFA 17 item identity (wire id + resourceId)
|
||||
//!
|
||||
//! We assert *semantic wire fidelity*, not byte equality: the read oracle was
|
||||
//! produced by the pre-migration Python backend, whose display-only shadow fields
|
||||
//! (`untradeable`, `discardValue`) and server-*recomputed* chemistry are not the
|
||||
//! adapter's to reproduce.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use openfut_adapter_fifa17::fut::item::{
|
||||
CoreOwnedItem, Fifa17Identity, Fifa17StaffIdentity, ItemIdentityResolver, STAFF_CONTRACT,
|
||||
};
|
||||
use openfut_adapter_fifa17::fut::squad::{parse_squad_put, Fifa17SquadPut, SquadWireResolver};
|
||||
use openfut_adapter_fifa17::fut::squad_ext::{build_squad_write, SquadWriteBuild};
|
||||
use openfut_adapter_fifa17::fut::squad_projection::{
|
||||
project_squad, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput,
|
||||
SquadProjection, SquadProjectionInput,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
const PUT_BASELINE: &str = include_str!("../fixtures/utas/squad_put_f442.json");
|
||||
const PUT_SWAP: &str = include_str!("../fixtures/utas/squad_put_swap_f442.json");
|
||||
const PUT_F433: &str = include_str!("../fixtures/utas/squad_put_f433.json");
|
||||
const READ_ORACLE: &str = include_str!("../fixtures/utas/squad_read_usermassinfo.json");
|
||||
|
||||
// ---- host-role stand-ins (identity resolution, entity resolver) -------------
|
||||
|
||||
/// Wire→owned reverse map. In production the host builds this from the identity
|
||||
/// store; here every occupied wire id maps to a stable `oc-<wire>`.
|
||||
struct OcResolver;
|
||||
impl SquadWireResolver for OcResolver {
|
||||
fn owned_id_for_wire(&self, wire: i64) -> Option<String> {
|
||||
Some(format!("oc-{wire}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// owned_card_id → FIFA identity, so two copies of one definition stay distinct.
|
||||
/// `staff` is a second table because a manager resolves through the STAFF
|
||||
/// identity, which carries the chemistry fields a player identity cannot hold.
|
||||
struct TableIdentity(
|
||||
HashMap<String, Fifa17Identity>,
|
||||
HashMap<String, Fifa17StaffIdentity>,
|
||||
);
|
||||
impl ItemIdentityResolver for TableIdentity {
|
||||
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
|
||||
self.0.get(&it.owned_card_id).copied()
|
||||
}
|
||||
fn resolve_staff(&self, it: &CoreOwnedItem) -> Option<Fifa17StaffIdentity> {
|
||||
self.1.get(&it.owned_card_id).copied()
|
||||
}
|
||||
}
|
||||
|
||||
/// Neutral entity resolver — badge/flag ids are covered by `fut::item` tests; the
|
||||
/// projector round-trip asserts item *identity* (wire id + asset), not entity ids.
|
||||
struct NoEntities;
|
||||
impl openfut_adapter_fifa17::fut::entities::ReverseEntityResolver for NoEntities {
|
||||
fn league_id(&self, _: &str) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
fn team_id(&self, _: &str) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
fn nation_id(&self, _: &str) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the owned-item map + identity table from the persisted read oracle, so
|
||||
/// every wire id used by the captures resolves to its real FIFA asset id/rating.
|
||||
/// Keyed by `oc-<wire>` to match `OcResolver`.
|
||||
fn oracle_tables() -> (HashMap<String, CoreOwnedItem>, TableIdentity) {
|
||||
let oracle: Value = serde_json::from_str(READ_ORACLE).unwrap();
|
||||
let mut owned = HashMap::new();
|
||||
let mut ident = HashMap::new();
|
||||
for p in oracle["players"].as_array().unwrap() {
|
||||
let it = &p["itemData"];
|
||||
let wire = it["id"].as_i64().unwrap();
|
||||
if wire == 0 {
|
||||
continue; // empty slot
|
||||
}
|
||||
let oc = format!("oc-{wire}");
|
||||
let asset = it["resourceId"].as_u64().unwrap() as u32;
|
||||
let attrs: Vec<u8> = it["attributeList"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|a| a["value"].as_u64().unwrap() as u8)
|
||||
.collect();
|
||||
owned.insert(
|
||||
oc.clone(),
|
||||
CoreOwnedItem {
|
||||
owned_card_id: oc.clone(),
|
||||
card_id: format!("def-{asset}"),
|
||||
rating: it["rating"].as_u64().unwrap() as u8,
|
||||
position: it["preferredPosition"].as_str().unwrap().to_string(),
|
||||
nation: String::new(),
|
||||
league: String::new(),
|
||||
club: String::new(),
|
||||
attributes: [attrs[0], attrs[1], attrs[2], attrs[3], attrs[4], attrs[5]],
|
||||
},
|
||||
);
|
||||
ident.insert(
|
||||
oc,
|
||||
Fifa17Identity {
|
||||
item_id: wire as u32,
|
||||
asset_id: asset,
|
||||
resource_id: asset,
|
||||
rareflag: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
(owned, TableIdentity(ident, HashMap::new()))
|
||||
}
|
||||
|
||||
/// The full pipeline: parse a captured PUT, build the canonical + extension, then
|
||||
/// project — treating the just-built canonical squad as Core's committed state.
|
||||
fn project_put(
|
||||
put: &Fifa17SquadPut,
|
||||
owned: &HashMap<String, CoreOwnedItem>,
|
||||
ident: &TableIdentity,
|
||||
) -> Value {
|
||||
let SquadWriteBuild {
|
||||
canonical,
|
||||
extension,
|
||||
} = build_squad_write(put, &OcResolver).expect("build must succeed for a full valid squad");
|
||||
let slots: Vec<ProjectionSlot> = canonical
|
||||
.slots
|
||||
.iter()
|
||||
.map(|s| ProjectionSlot {
|
||||
owned_card_id: s.owned_card_id.clone(),
|
||||
index: s.index,
|
||||
is_captain: s.is_captain,
|
||||
is_on_bench: s.is_on_bench,
|
||||
})
|
||||
.collect();
|
||||
let input = SquadProjectionInput {
|
||||
fifa_squad_id: canonical.squad_id,
|
||||
name: canonical.name.clone().unwrap_or_default(),
|
||||
formation: canonical.formation.clone().unwrap(),
|
||||
slots,
|
||||
ext: SquadExtInput::Fresh(extension),
|
||||
owned,
|
||||
// This stand-in supplies no owned manager; the manager is projected from
|
||||
// the ownership-backed assignment, exercised in persisted_read below.
|
||||
manager: None,
|
||||
};
|
||||
match project_squad(&input, ident, &NoEntities).unwrap() {
|
||||
SquadProjection::Projected(v) => v,
|
||||
other => panic!("expected Projected, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Map FIFA-array index → (wire item id, kit number) for the occupied slots of a
|
||||
/// projected or captured squad object.
|
||||
fn occupied(v: &Value) -> HashMap<i64, (i64, i64)> {
|
||||
v["players"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|p| p["itemData"]["id"].as_i64().unwrap() != 0)
|
||||
.map(|p| {
|
||||
(
|
||||
p["index"].as_i64().unwrap(),
|
||||
(
|
||||
p["itemData"]["id"].as_i64().unwrap(),
|
||||
p["kitNumber"].as_i64().unwrap(),
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_projects_the_known_squad_round_trip() {
|
||||
let (owned, ident) = oracle_tables();
|
||||
let put = parse_squad_put(PUT_BASELINE.as_bytes()).unwrap();
|
||||
let projected = project_put(&put, &owned, &ident);
|
||||
|
||||
// Fixed 23-slot array; 11 occupied at 0..=10.
|
||||
assert_eq!(projected["players"].as_array().unwrap().len(), 23);
|
||||
let put_v: Value = serde_json::from_str(PUT_BASELINE).unwrap();
|
||||
assert_eq!(
|
||||
occupied(&projected),
|
||||
occupied(&put_v),
|
||||
"wire id + kit per index round-trip"
|
||||
);
|
||||
|
||||
// CANONICAL: formation verbatim, captain follows the semantic player.
|
||||
assert_eq!(projected["formation"], "f442");
|
||||
assert_eq!(
|
||||
projected["captain"], 100000001,
|
||||
"captain is the player's WIRE id"
|
||||
);
|
||||
// EXTENSION: custom byte-identical, squadType preserved. The manager is now
|
||||
// an ownership-backed assignment (not projected from the PUT/ext); with none
|
||||
// supplied to this stand-in it projects empty.
|
||||
assert_eq!(projected["custom"], put_v["custom"]);
|
||||
assert!(projected["manager"].as_array().unwrap().is_empty());
|
||||
assert_eq!(projected["squadType"], "REGULAR_SQUAD");
|
||||
// SHADOW: client-reported values carried as-is (baseline chemistry 52).
|
||||
assert_eq!(projected["chemistry"], 52);
|
||||
assert_eq!(projected["rating"], 90);
|
||||
assert_eq!(projected["starRating"], 90);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_moves_two_players_with_their_kits_and_round_trips() {
|
||||
// The swap PUT is baseline with two players rotated between slots. Projecting
|
||||
// build(swap) must reproduce the swap wire exactly, and the affected players'
|
||||
// kit numbers must have travelled with them (kit follows the player).
|
||||
let (owned, ident) = oracle_tables();
|
||||
let projected = project_put(
|
||||
&parse_squad_put(PUT_SWAP.as_bytes()).unwrap(),
|
||||
&owned,
|
||||
&ident,
|
||||
);
|
||||
let swap_v: Value = serde_json::from_str(PUT_SWAP).unwrap();
|
||||
let base_v: Value = serde_json::from_str(PUT_BASELINE).unwrap();
|
||||
|
||||
// CANONICAL: the projected occupancy per index matches the swap PUT exactly.
|
||||
assert_eq!(
|
||||
occupied(&projected),
|
||||
occupied(&swap_v),
|
||||
"player+kit per index round-trip"
|
||||
);
|
||||
|
||||
// The swap is real: at least two indices carry a different player than baseline.
|
||||
let (proj_occ, base_occ) = (occupied(&projected), occupied(&base_v));
|
||||
let moved: Vec<i64> = proj_occ
|
||||
.iter()
|
||||
.filter(|(idx, pair)| base_occ.get(idx).map(|b| b.0) != Some(pair.0))
|
||||
.map(|(idx, _)| *idx)
|
||||
.collect();
|
||||
assert!(
|
||||
moved.len() >= 2,
|
||||
"a swap changes at least two slots, got {moved:?}"
|
||||
);
|
||||
|
||||
// kit follows the PLAYER: for every player, its kit in baseline == its kit
|
||||
// in the swap projection, regardless of which slot it now occupies.
|
||||
let kit_by_player = |occ: &HashMap<i64, (i64, i64)>| -> HashMap<i64, i64> {
|
||||
occ.values().map(|(id, kit)| (*id, *kit)).collect()
|
||||
};
|
||||
assert_eq!(
|
||||
kit_by_player(&proj_occ),
|
||||
kit_by_player(&base_occ),
|
||||
"each player kept its kit number through the swap"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
projected["captain"], 100000001,
|
||||
"captain follows the semantic player"
|
||||
);
|
||||
assert_eq!(
|
||||
projected["custom"], swap_v["custom"],
|
||||
"opaque custom unchanged by the swap"
|
||||
);
|
||||
// SHADOW: the client-reported chemistry from THIS PUT (58) is round-tripped
|
||||
// as-is — never reconciled to a server recompute.
|
||||
assert_eq!(projected["chemistry"], 58);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
|
||||
// Simulate Core's committed state for the persisted (post-relaunch) squad by
|
||||
// reconstructing the canonical slots + FIFA extension straight from the read
|
||||
// evidence, then project and require the read back — the strongest fidelity
|
||||
// check across all four ownership classes.
|
||||
use openfut_adapter_fifa17::fut::squad::ClientReportedSquadEval;
|
||||
use openfut_adapter_fifa17::fut::squad_ext::{Fifa17SquadExtensionV1, KicktakerRef};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
let oracle: Value = serde_json::from_str(READ_ORACLE).unwrap();
|
||||
let (owned, mut ident) = oracle_tables();
|
||||
let captain = oracle["captain"].as_i64().unwrap();
|
||||
|
||||
let mut slots = Vec::new();
|
||||
let mut kit_numbers = BTreeMap::new();
|
||||
for p in oracle["players"].as_array().unwrap() {
|
||||
let wire = p["itemData"]["id"].as_i64().unwrap();
|
||||
if wire == 0 {
|
||||
continue;
|
||||
}
|
||||
let index = p["index"].as_i64().unwrap();
|
||||
let oc = format!("oc-{wire}");
|
||||
kit_numbers.insert(oc.clone(), p["kitNumber"].as_i64().unwrap());
|
||||
slots.push(ProjectionSlot {
|
||||
owned_card_id: oc,
|
||||
index,
|
||||
is_captain: wire == captain,
|
||||
is_on_bench: index >= 11,
|
||||
});
|
||||
}
|
||||
// The manager is ownership-backed: register its owned instance + STAFF
|
||||
// identity and pass it as the assignment, not as an opaque extension field.
|
||||
// A real managercards row is used (1000509 Luis Enrique, nation 45, LaLiga
|
||||
// 53, Barcelona 241) so the projected item is a shape the client could
|
||||
// actually merge.
|
||||
let mgr_wire = oracle["manager"][0]["id"].as_i64().unwrap();
|
||||
let mgr_oc = format!("oc-{mgr_wire}");
|
||||
ident.1.insert(
|
||||
mgr_oc.clone(),
|
||||
Fifa17StaffIdentity {
|
||||
item_id: mgr_wire as u32,
|
||||
resource_id: 1_000_509,
|
||||
subtype: 4,
|
||||
nation: 45,
|
||||
league_id: 53,
|
||||
team_id: 241,
|
||||
},
|
||||
);
|
||||
let manager_item = CoreOwnedItem {
|
||||
owned_card_id: mgr_oc,
|
||||
card_id: "def-manager".to_string(),
|
||||
rating: 0,
|
||||
position: String::new(),
|
||||
nation: String::new(),
|
||||
league: String::new(),
|
||||
club: String::new(),
|
||||
attributes: [0; 6],
|
||||
};
|
||||
let kicktakers: Vec<KicktakerRef> =
|
||||
serde_json::from_value(oracle["kicktakers"].clone()).unwrap();
|
||||
let ext = Fifa17SquadExtensionV1 {
|
||||
custom: oracle["custom"].as_str().map(str::to_string),
|
||||
squad_type: oracle["squadType"].as_str().map(str::to_string),
|
||||
kit_numbers,
|
||||
kicktakers,
|
||||
client_reported: ClientReportedSquadEval {
|
||||
chemistry: oracle["chemistry"].as_i64(),
|
||||
rating: oracle["rating"].as_i64(),
|
||||
star_rating: oracle["starRating"].as_i64(),
|
||||
},
|
||||
};
|
||||
let input = SquadProjectionInput {
|
||||
fifa_squad_id: oracle["id"].as_i64().unwrap(),
|
||||
name: oracle["squadName"].as_str().unwrap().to_string(),
|
||||
formation: oracle["formation"].as_str().unwrap().to_string(),
|
||||
slots,
|
||||
ext: SquadExtInput::Fresh(ext),
|
||||
owned: &owned,
|
||||
manager: Some(manager_item),
|
||||
};
|
||||
let SquadProjection::Projected(projected) = project_squad(&input, &ident, &NoEntities).unwrap()
|
||||
else {
|
||||
panic!("expected Projected");
|
||||
};
|
||||
|
||||
// CANONICAL + DERIVED: identity and placement per slot match the read.
|
||||
assert_eq!(
|
||||
occupied(&projected),
|
||||
occupied(&oracle),
|
||||
"player+kit per index"
|
||||
);
|
||||
assert_eq!(projected["captain"], oracle["captain"]);
|
||||
assert_eq!(projected["formation"], oracle["formation"]);
|
||||
for (pp, op) in projected["players"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.zip(oracle["players"].as_array().unwrap())
|
||||
{
|
||||
assert_eq!(
|
||||
pp["itemData"]["id"], op["itemData"]["id"],
|
||||
"wire id per slot"
|
||||
);
|
||||
assert_eq!(
|
||||
pp["itemData"]["resourceId"], op["itemData"]["resourceId"],
|
||||
"asset id per slot"
|
||||
);
|
||||
assert_eq!(pp["itemData"]["rating"], op["itemData"]["rating"]);
|
||||
assert_eq!(
|
||||
pp["itemData"]["preferredPosition"],
|
||||
op["itemData"]["preferredPosition"]
|
||||
);
|
||||
}
|
||||
// EXTENSION + SHADOW: sourced from the read, so they round-trip identically.
|
||||
assert_eq!(projected["custom"], oracle["custom"]);
|
||||
// The manager REF round-trips; the item now rides with it. The capture this
|
||||
// oracle came from carried a bare `{id, dream}`, but its manager was the
|
||||
// dangling one every retail capture has, so it never showed that a populated
|
||||
// ref renders on its own — and in practice it did not.
|
||||
assert_eq!(
|
||||
projected["manager"][0]["id"], oracle["manager"][0]["id"],
|
||||
"the manager wire ref itself must still round-trip"
|
||||
);
|
||||
assert_eq!(
|
||||
projected["manager"][0]["dream"],
|
||||
oracle["manager"][0]["dream"]
|
||||
);
|
||||
let mgr_item = &projected["manager"][0]["itemData"];
|
||||
assert_eq!(mgr_item["id"], oracle["manager"][0]["id"]);
|
||||
assert_eq!(mgr_item["cardsubtypeid"], 4);
|
||||
assert_eq!(mgr_item["resourceId"], 1_000_509);
|
||||
assert_eq!(mgr_item["contract"], STAFF_CONTRACT);
|
||||
assert_eq!(projected["kicktakers"], oracle["kicktakers"]);
|
||||
assert_eq!(projected["squadType"], oracle["squadType"]);
|
||||
assert_eq!(projected["chemistry"], oracle["chemistry"]);
|
||||
assert_eq!(projected["rating"], oracle["rating"]);
|
||||
assert_eq!(projected["starRating"], oracle["starRating"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formation_change_reindexes_without_deriving_slots_and_kit_follows_player() {
|
||||
let (owned, ident) = oracle_tables();
|
||||
let swap = project_put(
|
||||
&parse_squad_put(PUT_SWAP.as_bytes()).unwrap(),
|
||||
&owned,
|
||||
&ident,
|
||||
);
|
||||
let f433 = project_put(
|
||||
&parse_squad_put(PUT_F433.as_bytes()).unwrap(),
|
||||
&owned,
|
||||
&ident,
|
||||
);
|
||||
|
||||
assert_eq!(f433["formation"], "f433");
|
||||
assert_eq!(swap["formation"], "f442");
|
||||
|
||||
// Same 11 starters (same set of wire ids), reindexed.
|
||||
let set = |v: &Value| {
|
||||
let mut ids: Vec<i64> = occupied(v).values().map(|(id, _)| *id).collect();
|
||||
ids.sort();
|
||||
ids
|
||||
};
|
||||
assert_eq!(
|
||||
set(&swap),
|
||||
set(&f433),
|
||||
"same 11 players survive the formation change"
|
||||
);
|
||||
|
||||
// The captain (wire 100000001) moved from index 8 (f442) to index 10 (f433) —
|
||||
// proof indices are round-tripped, not derived from the formation.
|
||||
let idx_of = |v: &Value, wire: i64| -> i64 {
|
||||
occupied(v)
|
||||
.into_iter()
|
||||
.find(|(_, (id, _))| *id == wire)
|
||||
.unwrap()
|
||||
.0
|
||||
};
|
||||
assert_eq!(idx_of(&swap, 100000001), 8);
|
||||
assert_eq!(idx_of(&f433, 100000001), 10);
|
||||
|
||||
// kit follows the PLAYER, not the slot: captain keeps kit 8 across the reindex.
|
||||
let kit_of = |v: &Value, wire: i64| -> i64 {
|
||||
occupied(v)
|
||||
.into_iter()
|
||||
.find(|(_, (id, _))| *id == wire)
|
||||
.unwrap()
|
||||
.1
|
||||
.1
|
||||
};
|
||||
assert_eq!(kit_of(&swap, 100000001), 8);
|
||||
assert_eq!(
|
||||
kit_of(&f433, 100000001),
|
||||
8,
|
||||
"kit stayed with the player despite the reindex"
|
||||
);
|
||||
assert_eq!(f433["captain"], 100000001, "captain still the same player");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_projector_serves_every_endpoint_no_divergence() {
|
||||
let (owned, ident) = oracle_tables();
|
||||
let projected = project_put(
|
||||
&parse_squad_put(PUT_SWAP.as_bytes()).unwrap(),
|
||||
&owned,
|
||||
&ident,
|
||||
);
|
||||
|
||||
// userMassInfo.squad = the projected object + session envelope.
|
||||
let ummi = user_mass_info_squad(projected.clone(), 33068179);
|
||||
assert_eq!(ummi["personaId"], 33068179);
|
||||
assert_eq!(ummi["changed"], 0);
|
||||
assert!(ummi["actives"].is_array());
|
||||
assert_eq!(ummi["players"], projected["players"], "same projected body");
|
||||
assert_eq!(ummi["formation"], projected["formation"]);
|
||||
|
||||
// squad/list = a summary SUBSET of the SAME object, not a second projection.
|
||||
let list = squad_list(&projected);
|
||||
let entry = &list["squad"][0];
|
||||
for k in [
|
||||
"id",
|
||||
"squadName",
|
||||
"formation",
|
||||
"squadType",
|
||||
"rating",
|
||||
"chemistry",
|
||||
] {
|
||||
assert_eq!(
|
||||
entry[k], projected[k],
|
||||
"summary field {k} derived from the one projection"
|
||||
);
|
||||
}
|
||||
// The summary carries only those six keys — no divergent squad shape.
|
||||
assert_eq!(entry.as_object().unwrap().len(), 6);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user