Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb38ee6087 | |||
| cd5983ecdd | |||
| f97654af86 | |||
| 755f237f17 | |||
| 49b18dd4ac | |||
| 274838cc2e | |||
| d76c184cf1 | |||
| d74aee33f7 | |||
| 52df78d24a | |||
| 22cfae830f | |||
| 404e859cb6 | |||
| 59c249e76a | |||
| bea3b49070 | |||
| e44c88dd68 | |||
| a842c5ffb0 | |||
| 7f17cfe439 | |||
| 622a6ab353 | |||
| 67d896615c | |||
| beb505b0fa | |||
| 43aa114bcd | |||
| 4b66906adc | |||
| 9823bdac78 | |||
| 7a4dab04d2 | |||
| 23f120f889 | |||
| 09db5413cd | |||
| 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 |
@@ -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
|
||||
@@ -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
-1
@@ -3166,7 +3166,6 @@ dependencies = [
|
||||
name = "openfut-bridge"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"anyhow",
|
||||
"axum",
|
||||
"bytes",
|
||||
|
||||
@@ -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,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 — 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,18 +37,14 @@ RUN set -eu; \
|
||||
|
||||
COPY data/ /app/data/
|
||||
|
||||
# Redirector/roster TLS cert (CN/SAN = winter15.gosredirector.ea.com). ProtoSSL
|
||||
# cert-verify is patched client-side, so a self-signed cert is fine — but the
|
||||
# client dials the roster and redirector BY IP, and that path still checks the
|
||||
# SAN against the dialed address (it is NOT covered by the two patched gates), so
|
||||
# a cert without a matching IP SAN is rejected with fatal certificate_unknown
|
||||
# (docs/FIFA17_FUT_SQUAD_UPDATE_TLS.md). The advertised LAN IP is a RUNTIME value,
|
||||
# unknown here, so this bakes only a loopback-IP baseline and the entrypoint
|
||||
# reissues with IP:$OPENFUT_ADVERTISE at start.
|
||||
# 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 therefore has to remain in the image for the entrypoint, not be dropped
|
||||
# with the apt lists. The pair is git-ignored (*.pem/*.key); regenerate if absent
|
||||
# so a fresh checkout builds without extra steps.
|
||||
# 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 \
|
||||
|
||||
@@ -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.
|
||||
@@ -25,6 +25,9 @@ services:
|
||||
# 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. 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,45 +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. 203.0.113.10)
|
||||
# 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. 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"
|
||||
|
||||
# The TLS cert every responder serves must carry the ADVERTISED IP in its SAN.
|
||||
# The client dials the roster (:8081) and redirector by that IP, and that path
|
||||
# validates the cert's SAN against the dialed address — it is NOT covered by the
|
||||
# two client-side ProtoSSL gates autopatch patches, so a cert lacking IP:$ADV is
|
||||
# rejected with fatal certificate_unknown and the FUT hub fails with "An error
|
||||
# occurred downloading the FUT Squad Update" (docs/FIFA17_FUT_SQUAD_UPDATE_TLS.md).
|
||||
# The advertised IP is unknown at image-build time, so reconcile it here: reissue
|
||||
# only when the current cert does not already carry it, so a restart reuses the
|
||||
# same cert (no per-start fingerprint churn) and this self-heals if $ADV changes.
|
||||
CERT=redir_cert.pem KEY=redir_key.pem
|
||||
if ! openssl x509 -in "$CERT" -noout -ext subjectAltName 2>/dev/null | grep -qF "IP Address:$ADV"; then
|
||||
echo "[openfut] reissuing TLS cert with SAN IP:$ADV (was missing it)"
|
||||
openssl req -x509 -newkey rsa:2048 -nodes -keyout "$KEY" -out "$CERT" -days 3650 \
|
||||
-subj "/CN=winter15.gosredirector.ea.com" \
|
||||
-addext "subjectAltName=DNS:winter15.gosredirector.ea.com,DNS:*.gosredirector.ea.com,DNS:*.ea.com,IP:$ADV,IP:127.0.0.1" \
|
||||
>/dev/null 2>&1 \
|
||||
&& echo "[openfut] cert SAN now: $(openssl x509 -in "$CERT" -noout -ext subjectAltName 2>/dev/null | tail -1 | tr -s ' ')" \
|
||||
|| { echo "[openfut] FATAL: could not reissue TLS cert" >&2; exit 1; }
|
||||
# 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=(
|
||||
@@ -57,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
|
||||
@@ -69,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…"
|
||||
|
||||
@@ -347,10 +347,17 @@ The client's own dialog names the class: "Search Type: Consumables Search".
|
||||
times a session with the PLAYER stat set, so the panel read seven zeros and never
|
||||
proceeded. Two rounds of item-shape work sat unrequested for want of a counter.
|
||||
2. THE ROUTE IS GET club/consumables/<category>. Not club?type=, which a previous
|
||||
round shipped four arms for, and not the "/consumables/%s" template in .rdata,
|
||||
which the client has still never used. Worse, that path is a /club PREFIX, so it
|
||||
fell through to the generic route and the consumables screen was answered with the
|
||||
194-card player list.
|
||||
round shipped four arms for. That path is a /club PREFIX, so a naive router
|
||||
falls it through to the generic route and answers the consumables screen with
|
||||
the 194-card player list.
|
||||
|
||||
**CORRECTED 2026-08-21.** This item used to add "and not the
|
||||
`/consumables/%s` template in .rdata, which the client has still never used".
|
||||
That is false, and the same sentence is in commit `ccb736f`. It IS exactly
|
||||
that template: action row 9 `ConsumablesSearch` carries base index 3 =
|
||||
`ut/%s/club`, and `FUN_1801308c0` appends `/consumables/%s`. The base was
|
||||
`ut/%s/club` all along, which is why the observed URL and the template look
|
||||
like different things and are not.
|
||||
3. THE ELEMENT IS A STACK WRAPPER, NOT AN ITEM. FutConsumablesSearchServerResponse
|
||||
(RS4 literal 0x1802222f8, factory 0x180130a10, vtable 0x180222200, deser +0x08 =
|
||||
0x180130d10, 6873 chars) reads itemData(0x16b) at the root like the club list, but
|
||||
@@ -402,21 +409,40 @@ the same mapping: balls 37, kits 35, stadium 36, badges 39, league logos 40.
|
||||
|
||||
# Club items: what the research established, 2026-08-05
|
||||
|
||||
Researched after a guessed field crashed the client. Facts first, and the one thing
|
||||
still unknown is named as unknown.
|
||||
> **SUPERSEDED 2026-08-21 in part.** `docs/plan-2026-08-06-card-subsystem.md` is
|
||||
> the authority for club items and for the `itemState` vocabulary; where this
|
||||
> file and that one disagree, that one wins. The corrections are applied inline
|
||||
> below and marked. The subtype question this section calls UNKNOWN is ANSWERED.
|
||||
|
||||
Researched after a guessed field crashed the client. Facts first.
|
||||
|
||||
## VERIFIED IN BINARY
|
||||
|
||||
1. THE CARDTYPE MAP IS EXACT. FUN_1800d8330 (714 chars, read in full) returns cardtype
|
||||
9 for cardsubtypeid 0x1e, 0x1f, 0x91..0x96, 0xe7..0xe9 and 0xec, and nothing else.
|
||||
fcc_misccards carries cardsubtype 231 = 0xe7, which anchors the 0xe7..0xe9 block to
|
||||
misc cards. That leaves 0x1e, 0x1f and 0x91..0x96 for badges, kits, stadia, balls
|
||||
and league logos.
|
||||
2. ITEMSTATE CARRIES THE EQUIPPED STATE. The enum table at 0x180229d20 (stride 0x10)
|
||||
is: WAITING_FOR_GAME, inGame, forSale, offered, activeBadge, activeHomeKit,
|
||||
activeAwayKit, activeBall, activeStadium, active. So an EQUIPPED club item is not a
|
||||
misc cards.
|
||||
|
||||
**CORRECTED 2026-08-21.** The first half is right; the inference that followed
|
||||
it was wrong. It read "that leaves 0x1e, 0x1f and 0x91..0x96 for badges, kits,
|
||||
stadia, balls and league logos". In fact `0x91..0x96` are TROPHIES, and three
|
||||
of the five club families are **cardtype 7, not 9** — `FUN_1800d8330` contains
|
||||
`case 9: case 10: case 0xb: return 7;`. Only ball (0x1e) and league logo
|
||||
(0x1f) are cardtype 9.
|
||||
2. ITEMSTATE CARRIES THE EQUIPPED STATE. So an EQUIPPED club item is not a
|
||||
different subtype, it is the same item with itemState set to one of those five.
|
||||
"free" is correct for owned-but-not-equipped, which is what we send.
|
||||
|
||||
**CORRECTED 2026-08-21.** The table starts at **`0x180229cc0`**, not
|
||||
`0x180229d20` — the recorded address points into the MIDDLE of it, which is why
|
||||
only ten rows were seen. The full vocabulary is TWELVE rows; the six missing
|
||||
from the reading below are `invalid`, `free`, `WAITING_FOR_GAME`, `inGame`,
|
||||
`forSale` and `offered`. Two further consequences the ten-row reading hid:
|
||||
`WAITING_FOR_GAME` and `inGame` are genuine ALIASES (both decode to 2), and
|
||||
OMITTING the key yields `0` = `invalid`, which is NOT the same as `free` — an
|
||||
item left at 0 fails the squad builder's `state == 1 || state == 2` test. The
|
||||
match is also CASE-SENSITIVE (measured 2026-08-21: the comparator is
|
||||
`msvcr120.dll+0x3c330`, a plain `strncmp` with no case folding), so the casing
|
||||
in the table is a contract. See `openfut-adapter-fifa17/src/fut/item_state.rs`.
|
||||
3. CLUB ITEMS HAVE NO CATEGORY GROUP TABLE. Consumables have one at 0x180203260 (seven
|
||||
codes: training, contracts, fitness, healing, playStyle, managerLeagueModifier,
|
||||
position) and staff have one at 0x180203310 (five codes). There is no equivalent
|
||||
@@ -427,16 +453,30 @@ still unknown is named as unknown.
|
||||
type=ball, type=equippables (the combined customisation view). Not the plural stat
|
||||
names, and not a club/<family> path.
|
||||
|
||||
## STILL UNKNOWN, AND NOT GUESSED
|
||||
## ANSWERED 2026-08-06 (was "STILL UNKNOWN, AND NOT GUESSED")
|
||||
|
||||
Which of 0x1e, 0x1f, 0x91..0x96 means ball versus stadium versus badge versus kit.
|
||||
It is in none of the 149 dumped tables, there is no group table, and cardtype 9 has NO
|
||||
arm in the merge, so a wrong subtype cannot announce itself the way a coach's "DB
|
||||
Error" does. Two ways to settle it, in order of preference:
|
||||
a. more RE: find the consumer that switches on subtype for a club item, most likely
|
||||
in the equip path that writes itemState = activeBadge and friends;
|
||||
b. FUT_CLUBITEMS=probe:<family>, which serves ONE family as eight items, one per
|
||||
candidate subtype, so the screen names the right one.
|
||||
The question was "which of 0x1e, 0x1f, 0x91..0x96 means ball versus stadium versus
|
||||
badge versus kit". It was the wrong candidate set — three of the families are not
|
||||
in it at all. The settled map:
|
||||
|
||||
| family | cardsubtypeid | cardtype | how the caption resolves |
|
||||
|---|---|---|---|
|
||||
| kit | **9** | 7 | `TeamName_Abbr15_<teamid>` |
|
||||
| stadium | **10** | 7 | `StadiumName_<assetId>` |
|
||||
| badge | **11** | 7 | `TeamName_Abbr15_<teamid>` |
|
||||
| ball | **30** (0x1e) | 9 | no DB resolver; `FUT_UC_BALL` caption only |
|
||||
| league logo | **31** (0x1f) | 9 | by elimination |
|
||||
|
||||
`0x91..0x96` are TROPHIES, not club items. Route (a) of the two proposals above is
|
||||
what paid off — the consumer is the manager vtable slot `+0x498` =
|
||||
`FUN_180119bd0`, dispatched when `item+0x4c == 7`. Route (b),
|
||||
`FUT_CLUBITEMS=probe:<family>`, would have FAILED for three of the five families,
|
||||
because its candidate set never contained 9, 10 or 11.
|
||||
|
||||
Kit, badge and stadium are served by OpenFUT today. Ball and league logo are
|
||||
withheld: cardtype 9 has no database name resolver, so their name could only come
|
||||
from `localizedName` on the wire, and that is not established as safe to send.
|
||||
One residual probe remains, specified in `plan-2026-08-06-card-subsystem.md` §3.
|
||||
|
||||
## WHY THE CRASH HAPPENED, recorded so it is not repeated
|
||||
|
||||
@@ -447,3 +487,48 @@ taking its time and then dies. None of the three was needed to draw a card. Comp
|
||||
it, the response that crashed was type=equippables carrying 30 items across FIVE
|
||||
unverified subtypes at once, so even the crash taught us nothing about which subtype
|
||||
was wrong. Both are fixed: no extras, equippables withheld, one family per test.
|
||||
|
||||
---
|
||||
|
||||
# Field-map corrections (dated)
|
||||
|
||||
This file's earlier field notes predate the deserializer frame arithmetic. Where
|
||||
they disagree with the table in `plan-2026-08-06-card-subsystem.md` §2, that
|
||||
table wins — it is derived structurally (`FUN_18013fe00` builds the record as a
|
||||
stack struct and hands `&local_188` to the merge, so `record_offset = 0x188 - X`)
|
||||
rather than inferred backwards from an accessor.
|
||||
|
||||
```
|
||||
CORRECTED 2026-08-06 (live diff + deserializer frame arithmetic, record_off = 0x188 - X):
|
||||
+0x34 lastSalePrice (atom 0x185), published to Flash as BOUGHT_FOR
|
||||
+0x48 owners (atom 0x207, u8; constructor default 0)
|
||||
+0x49 TRADEABLE (atom 0x361 untradeable, u8, stored INVERTED; default 1)
|
||||
+0x54 discard LEVEL (3/2/1 by rating >= 0x4b / >= 0x41), NOT an itemType enum
|
||||
+0x5c itemState (atom 0x172 via FUN_180166660, u32)
|
||||
+0x88 playStyle (atom 0x23f via FUN_180136480; only 0xfb..0x111 map to 1..0x17)
|
||||
+0x90 loans (atom 0x19b) -- do not send; loans>0 with contract 0 greys MODIFY
|
||||
+0xbe amount (atom 0x1b, u8) for cardsubtypeid 250..273 (chemistry styles)
|
||||
+0xbf amount (atom 0x1b, u8) for the other consumable classes
|
||||
+0xd9 localizedName (atom 0x19c, 0x38 bytes) for cardtype 9; +0xbc (0x1f) for cardtype 7
|
||||
+0x111 description (atom 0xd1, 0x1f bytes) for cardtype 9; +0x10f for cardtype 7
|
||||
+0x30 is a CLIENT timestamp from FUN_1800d84e0(), not a wire field
|
||||
+0x60 pile is assigned by the owning list, not parsed; there is no 0x226 arm
|
||||
itemType (atom 0x173) is parsed into a heap string and never stored
|
||||
definitionId is NOT AN ATOM
|
||||
```
|
||||
|
||||
**`+0x60`, extended 2026-08-21.** "Assigned by the owning list, not parsed" is
|
||||
right, and the stronger statement is now measured: the pre-match kit selector
|
||||
gates on `+0x60 == 4` at `0x1801c34f2`, and **nothing anywhere stores 4 into that
|
||||
field** — not in CardsDLL (29 immediate stores, constants `{-2,0,1,908,0x3f800000}`),
|
||||
not in FIFA17.exe (zero across 79 MB), and no resident record has ever held it
|
||||
(live: `{1: players, 0: staff}`). Every OTHER input to that gate is already
|
||||
served. So the empty kit-selection screen is a client dead end, not a missing
|
||||
wire field. Tool: `fifa17-recon/tools/kit_gate_probe.py`.
|
||||
|
||||
**`definitionId is NOT AN ATOM`, confirmed a fourth way 2026-08-21.** Every real
|
||||
atom name appears exactly once in CardsDLL's `.rdata` — `resourceId`,
|
||||
`cardsubtypeid`, `itemState`, `assetId`, `cardassetid`, `rareflag`, `owners`,
|
||||
`contract`, `discardValue`, `localizedName` — while `definitionId` is absent
|
||||
entirely. It is still sent on the live-proven player path; it is inert, not
|
||||
harmful, and has not been removed.
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# The client's complete UTAS route surface
|
||||
|
||||
Read out of the running client's own `.rdata` on 2026-08-21 (pid 6580) with
|
||||
`fifa17-recon/tools/url_template_probe.py`, then each route probed against
|
||||
staging. This bounds the server: FIFA 17 cannot ask for a route that is not in
|
||||
this list.
|
||||
|
||||
Staging's Python upstream is deliberately dead, so a `502` there means the Rust
|
||||
host does not own the route — which makes the coverage column a measurement
|
||||
rather than an audit of the source.
|
||||
|
||||
## Route templates in CardsDLL
|
||||
|
||||
`%s` is the sku segment, built from `game/%s` (`0x18021fac8`) → `game/fifa17`.
|
||||
|
||||
```
|
||||
ut/auth ut/delete/auth
|
||||
ut/%s/user ut/delete/%s/user ut/%s/user/list
|
||||
ut/%s/club ut/%s/clubUser
|
||||
ut/%s/item ut/%s/item/resource ut/delete/%s/item
|
||||
ut/%s/defid
|
||||
ut/%s/squad ut/delete/%s/squad ut/%s/squad/mode
|
||||
ut/%s/purchased ut/%s/store ut/v2/%s/store
|
||||
ut/%s/trade ut/delete/%s/trade
|
||||
ut/%s/tradePile ut/%s/watchList ut/delete/%s/watchList
|
||||
ut/%s/auctionhouse ut/%s/marketdata
|
||||
ut/%s/match ut/%s/sbs
|
||||
ut/%s/season ut/%s/season/user ut/%s/season/%%s/user
|
||||
ut/%s/season/%%s/reset ut/%s/season/friendly
|
||||
ut/%s/tournament ut/%s/tournament/user ut/delete/%s/tournament/user
|
||||
ut/%s/champion ut/%s/draft/mode
|
||||
ut/%s/leaderboards ut/%s/leaderboards/options
|
||||
ut/%s/activeMessage ut/%s/livemessage
|
||||
ut/%s/clientdata ut/%s/phishing ut/%s/captcha ut/%s/tfa
|
||||
```
|
||||
|
||||
Suffixes appended to the above, not standalone routes:
|
||||
`/consumables/%s`, `/items`, `/purchasegroup`, `/squadBuildingSets`,
|
||||
`/challenge/%d/squad`, `/choices/manager`, `/purchase/mode/%d/draft`,
|
||||
`/transfermarket?type=%s&start=%d&num=%d`.
|
||||
|
||||
## THE TRAP when reading this list
|
||||
|
||||
A literal in `.rdata` is a **fragment**, not necessarily a callable path. Probing
|
||||
fragments bare manufactures fake gaps. Every one of these looked unserved and was
|
||||
not:
|
||||
|
||||
| looked missing | actually |
|
||||
|---|---|
|
||||
| `clientdata` | real route is `clientdata/<key>`; served (`clientdata/userHubData` → 200) |
|
||||
| `purchasegroup` | a suffix of `store`; `store/purchasegroup/all` is served |
|
||||
| `sbs/challenges` | not a route; the real ones are `sbs/sets`, `sbs/setId/<n>/challenges`, `sbs/challenge/<n>` — all served |
|
||||
| `squadBuildingSets` | not a route in the oracle either |
|
||||
| `club/items` | `items/...` literals are ART ASSET paths, not UTAS |
|
||||
| `item` | only ever PUT (move/pile) and DELETE (quick-sell) |
|
||||
|
||||
Check a candidate gap against `tools/utas_server.py`'s regex table before
|
||||
believing it.
|
||||
|
||||
## Genuinely unserved, and why that is correct
|
||||
|
||||
* `squad/mode` — bare form is never used. The oracle only has Draft sub-paths
|
||||
(`squad/mode/draft/state`, `squad/mode/<n>/draft/choices/*`). Draft is out of
|
||||
scope, so this correctly stays on Python.
|
||||
|
||||
## Fixed by this measurement
|
||||
|
||||
Four handlers existed and were unreachable because `classify` never produced
|
||||
their route, so every request fell through to Python. This is a **recurring
|
||||
defect class** in `openfut-utas-host` — `season/list` and `watchList` were the
|
||||
first two, and their fix comments are still in the file:
|
||||
|
||||
| route | handler | was |
|
||||
|---|---|---|
|
||||
| `captcha` | `handle_static_ack`, returns the oracle's exact `{encodedImg,sequence,sizeBeforeEncode}` | fell to Python |
|
||||
| `tfa` / `livemessage` / `activeMessage` | `handle_static_ack`, `{}` | fell to Python |
|
||||
| `tournament/user` | `FeatureOffEmpty`, `{}` — the oracle's answer with `FUT_MODES` off | fell to Python |
|
||||
|
||||
`Route`'s own doc comment already claimed the first four as "Rust-owned
|
||||
UNCONDITIONAL", so the documentation had been wrong rather than the intent. All
|
||||
five are byte-identical to the oracle, so claiming them is parity, not new
|
||||
behaviour. Invisible in production (the upstream answers); a 502 on staging.
|
||||
|
||||
Two regression tests now pin the vocabularies —
|
||||
`every_static_ack_tail_is_actually_routed` and
|
||||
`the_disabled_mode_reads_are_all_claimed` — so a handler cannot go unreachable a
|
||||
fifth time.
|
||||
|
||||
## No consumable apply endpoint exists
|
||||
|
||||
Support level L5 for consumables was open, with an inherited note saying there is
|
||||
"no training/position/chemistry/manager-league endpoint at all". **The route
|
||||
table confirms it from the binary**: there is no apply/training/position/
|
||||
chemistry route anywhere in CardsDLL. The only owned-item mutations the client
|
||||
can express are:
|
||||
|
||||
```
|
||||
PUT ut/%s/item move / pile
|
||||
DELETE ut/%s/item/<id> quick sell
|
||||
POST ut/delete/%s/item bulk quick sell
|
||||
PUT ut/%s/squad squad write
|
||||
```
|
||||
|
||||
So applying a consumable is **not** a dedicated server route. If it reaches the
|
||||
server at all it must ride `PUT ut/%s/item`, and L5/L6 should be pursued by
|
||||
capturing that PUT's payload while applying a card — not by looking for an
|
||||
endpoint that does not exist.
|
||||
@@ -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.**
|
||||
@@ -925,11 +944,80 @@ freezes any of these — GAPs are "feature missing", not "crash".
|
||||
| 9 | FutDiscardCardByRes | `0x1801279c0` | DELETE `ut/delete/%s/item` (by res) | `totalCredits`(0x326) → int | GAP | HIGH |
|
||||
| 10 | FutMoveCard | `0x180128600` | PUT `ut/%s/item` (move) | `itemData`(0x16b) → **array** [FREEZE-RISK]; `chemistry`(0x81) → bool | GAP | HIGH |
|
||||
| 11 | FutMoveCardByRes | `0x180128e30` | PUT `ut/%s/item` (move by res) | `itemData`(0x16b) → **array** [FREEZE-RISK]; `chemistry`(0x81) → bool (+ 2 str/1 int minor) | GAP | HIGH / extra-fields MED |
|
||||
| 12 | FutConsumablesSearch | `0x180130d10` | GET `ut/%s/item?type=…` (GetFilteredConsumableSearchResults) | `itemData`(0x16b) → **array[consumable-item]** via `0x18013fe00` [FREEZE-RISK]; `displayGroupUseDefaultImage`(0xdb) → int + count scalars | GAP | deser HIGH / scalars MED |
|
||||
| 13 | FutStaffBonus | `0x18012b730` | GET `ut/%s/…` (CardsGetStaffBonuses) | `bonus`(0x5c) → **nested** (branch sets bool @rbp+0x51) [FREEZE-RISK]; `assetId`(0x23) → int | GAP | MED |
|
||||
| 12 | FutConsumablesSearch | `0x180130d10` | GET `ut/%s/club/consumables/<cat>` (ConsumablesSearch) **[CORRECTED 2026-08-21]** | `itemData`(0x16b) → **array[consumable-stack]** via `0x18013fe00` [FREEZE-RISK]; `displayGroupUseDefaultImage`(0xdb) → int + count scalars | SERVED (Rust host) | deser HIGH / scalars MED |
|
||||
| 13 | FutStaffBonus | `0x18012b730` | GET `ut/%s/club/stats/staff` (StaffStats, thunk `0x18012b080`) **[CORRECTED 2026-08-21]** | `bonus`(0x5c) → **nested** (branch sets bool @rbp+0x51) [FREEZE-RISK]; `assetId`(0x23) → int | SERVED (`{}`, the oracle body) | MED |
|
||||
| 14 | FutGetAvailableLoanPlayers | `0x18014e030` → sub `0x18013a1c0` | GET `ut/%s/item` (FUT_AVAILABLE_LOAN_PLAYERS_DP) | `loans`(0x19b) → **array** [FREEZE-RISK]; `itemData`(0x16b) → **array[card-item]** [FREEZE-RISK]; `default`(0xcd) → int | GAP | deser HIGH / fields MED |
|
||||
| 15 | FutSignLoanPlayer | `0x1801642c0` | PUT `ut/%s/item` (sign loan) | **none** (immediate `ret`) | ack — `{}` fine | HIGH |
|
||||
| 16 | FutStickerBookSearch | `0x18012eff0` | GET `ut/%s/…` (stickerbook search) | `itemData`(0x16b) → **array[card-item]** via `0x18013fe00` [FREEZE-RISK] | GAP | HIGH |
|
||||
| 16 | FutStickerBookSearch | `0x18012eff0` | GET `ut/%s/club?<query>` (ClubSearch, `FUN_18012ddf0`) **[CORRECTED 2026-08-21]** | `itemData`(0x16b) → **array[card-item]** via `0x18013fe00` [FREEZE-RISK] | SERVED (Rust host) | HIGH |
|
||||
|
||||
|
||||
### The four `ut/%s/club` routes are a TABLE, not an inference (2026-08-21)
|
||||
|
||||
The URLs for rows 12, 13 and 16 above were previously guessed as `ut/%s/item?…`
|
||||
or left as `ut/%s/…`. The binding is exact: the 125-row action table at
|
||||
`0x1802caa20` indexes the 48-entry URL-base table at `0x18021df80` through column
|
||||
1, and **base index 3 = `ut/%s/club` is carried by exactly four rows** — so the
|
||||
client can emit exactly four request families on that base and no others.
|
||||
|
||||
```
|
||||
| ClubSearch | FUN_18012ddf0 | GET ut/%s/club?<query> | FutStickerBookSearchServerResponse |
|
||||
| ClubStats | FUN_18012f4f0 | GET ut/%s/club/stats/<f>[/<id>] | FutStickerBookStats2ServerResponse |
|
||||
| StaffStats | thunk 0x18012b080 | GET ut/%s/club/stats/staff | FutStaffBonusServerResponse |
|
||||
| ConsumablesSearch | FUN_1801308c0 | GET ut/%s/club/consumables/<cat> | FutConsumablesSearchServerResponse |
|
||||
```
|
||||
|
||||
**Club query grammar**, complete and ordered: `?year=2017` (always, hardcoded),
|
||||
then `type`, `start` (omitted at 0), `count` (omitted at 100), `filter`, then
|
||||
EITHER the filter block (`position, formation, state, level, rare, nation,
|
||||
country, league, playStyle, team, sort`) OR a comma-joined `defId=` list, never
|
||||
both. Live control from the log:
|
||||
`GET /ut/game/fifa17/club?year=2017&type=equippables&count=11&level=any&sort=desc`
|
||||
matches the predicted order and every suppression rule.
|
||||
|
||||
Sub-vocabularies: `filter` = available/base/exact/any; `level` =
|
||||
bronze/silver/gold/any; `sort` = asc/desc; `rare` = the literal string `SP`, not
|
||||
a boolean; `state` = the itemState names plus `any` — and note the REQUEST spells
|
||||
it `onSale` where the RESPONSE value is `forSale`.
|
||||
|
||||
`?type=` has 30 values. Decoded 2026-08-21 from the jump table itself rather
|
||||
than from a case count: `FUN_18012ec50` is `cmp ecx,0x1d` + a 30-entry table at
|
||||
`0x18012ed9c`, and each case is `mov ecx,<atom>; jmp 0x180180cd0` (atom → string).
|
||||
Resolving those atoms against `fut_atoms.tsv` gives the vocabulary in table order:
|
||||
|
||||
```
|
||||
0 any 1 player 2 manager 3 headcoach
|
||||
4 fitnesscoach 5 physio 6 development 7 custom
|
||||
8 unlocks 9 gkcoach 10 staff 11 badge
|
||||
12 kit 13 stadium 14 ball 15 equippables
|
||||
16 leaguelogos 17 offlinetrophy 18 onlinetrophy 19 featuredofflinetrophy
|
||||
20 featuredonlinetrophy 21 allofflinetrophy
|
||||
22 allonlinetrophy 23 healing 24 contract
|
||||
25 training 26 misc 27 playerdefender
|
||||
28 playermidfielder 29 playerforward
|
||||
```
|
||||
|
||||
Notes worth having: there is **no `playergoalkeeper`** — the client has only
|
||||
DEF/MID/FWD tabs, so goalkeepers belong to `playerdefender`, and a GK appearing
|
||||
there is correct rather than a filter bug. `healing`, `contract` and `training`
|
||||
exist here as `?type=` arms even though consumables have their own
|
||||
`club/consumables/<cat>` route. Six of the thirty are trophy arms.
|
||||
|
||||
`openfut-utas-host`'s `club_type_filter` implements all 30 with no extras; a unit
|
||||
test pins the list so a missing arm (an empty real tab) or an invented one (dead
|
||||
code that looks like coverage) fails the build.
|
||||
|
||||
**`/club/stats` has exactly seven forms**: `club`, `year`, `country/<id>`,
|
||||
`league/<id>`, `newcards`, `consumables`, and the separately-dispatched `staff`.
|
||||
**There is no `/club/stats/team/<id>`** — verified twice (the switch has six cases
|
||||
with no such arm, and an exhaustive PE string scan finds no literal containing
|
||||
`stats/team`). Any handling of a `team` stats mode is dead code.
|
||||
|
||||
**Two holes in the base table**, recorded so nobody re-derives them as findings:
|
||||
base index 43 = `ut/v2/%s/store` is carried by no action row and has zero
|
||||
references in `.text`, yet `ut/v2/store` is live-proven; base index 9 =
|
||||
`ut/%s/activeMessage` is a second hole of the same kind. So at least one route is
|
||||
composed OUTSIDE CardsDLL, most likely in the packed exe — every "the table bounds
|
||||
it" statement here is bounded to CardsDLL only.
|
||||
|
||||
Notes:
|
||||
- **`0x1801642c0`** is a shared no-op deserializer (function body = `ret`). Three responses
|
||||
|
||||
@@ -607,6 +607,62 @@ cardtype 6, live-confirmed on the two resident consumables, so for exactly the
|
||||
items the warning was aimed at, the server's rating and rare flag are
|
||||
authoritative.
|
||||
|
||||
**APPLIED (2026-08-21), behind a default-off flag.** The table and the formula
|
||||
above are now in Rust as `openfut-adapter-fifa17::fut::discard`:
|
||||
`cardtype_for_subtype` is the decode, `discard_level` the 3/2/1 ladder,
|
||||
`table_price` the 141-row lookup (`0` for an absent key) and `discard_value` the
|
||||
`round_half_up(rating * price / 100)` formula. `DISCARD_COINS` is generated from
|
||||
`fifa17-recon/data/tables/fcc_discardcoins.json` and a test re-reads that file
|
||||
and asserts they still agree row for row, so the two cannot drift. The four
|
||||
worked examples above (`8 * rating`, `4 * rating`, the 50-rated bronze at 15,
|
||||
and an absent key paying 0) are tests.
|
||||
|
||||
Wire and wallet are now ONE method. `ItemIdentityResolver::discard_value` both
|
||||
stamps the card's `discardValue` and prices the sale, because a non-zero
|
||||
`discardValue` suppresses the client's local computation — so whatever is sent
|
||||
is what the player is promised. The host's separate `quick_sell_value` ladder is
|
||||
deleted (it was a second copy that could drift), and a test with a resolver
|
||||
double returning an impossible price proves the credit follows the wire.
|
||||
|
||||
`OPENFUT_FIFA17_DISCARD_TABLE=1` turns the table on; the default keeps the old
|
||||
placeholder ladder because switching revalues an existing club by **10.5x**
|
||||
(measured over the real 1991-item club: 1,820,400 -> 19,128,955 coins if wholly
|
||||
liquidated). Players drive it (an r93 special goes 1500 -> 74,400); consumables
|
||||
move the OTHER way (2,400 -> 437, i.e. the ladder was overpaying 5.5x).
|
||||
|
||||
STAFF: CLOSED, and the `value`-is-the-rating question is now SETTLED against the
|
||||
running client rather than inferred. A staff wire record carries no `rating`, no
|
||||
`rareflag` and no `discardValue`, so the displayed price had to be read back out
|
||||
of memory. `tools/coach_probe.py` grades the four resident staff records HIT,
|
||||
which requires record `+0xb4` == the table's `value` and `+0x58` == its `rare`;
|
||||
`tools/discard_probe.py` (new) then reads the two discard slots directly —
|
||||
`+0x38` is what we sent, `+0x3c` is what the client computed:
|
||||
|
||||
```
|
||||
resource sub ct rat lvl rar sent+38 calc+3c predicted
|
||||
1000509 4 2 88 3 1 0 282 282 AGREES (manager)
|
||||
9000081 6 10 66 2 0 0 36 36 AGREES (gk coach)
|
||||
3000083 8 4 66 2 0 0 36 36 AGREES (fitness)
|
||||
```
|
||||
|
||||
4 of 4 agree, 0 disagree, and 36 on the `value`-66 GK coach was the stated
|
||||
falsifier. `openfut-import-fifa17::Entities::enrich_staff` now carries `value` ->
|
||||
rating and `rare` -> rareflag for the five families, so the catalog holds what
|
||||
the client re-rates to; verified on staging, a GK coach quick-sells for 36 rather
|
||||
than the 150 floor. The catalog diff is exactly the two coach entries.
|
||||
|
||||
The same probe shows what production is doing to PLAYERS today: all 23 resident
|
||||
player records carry `sent+38 = 1500`, which suppresses the local computation, so
|
||||
the client displays 1500 for every one of them — against its own table's 688..752
|
||||
for a gold rare, 11,102..11,468 for the 21/23/24 specials, 22,080..23,280 for
|
||||
rareflag 11, and 72,800 / 74,400 for the two rareflag 5/6 legends. A 50x underpay
|
||||
at the top and a 2x overpay at the bottom.
|
||||
|
||||
STILL OPEN, and NOT a discard problem: the manager `fifa17_1000509` is owned in
|
||||
Core but has no catalog entry and no card definition (it reaches the client
|
||||
through the opaque squad extension), so pricing declines for it and falls back to
|
||||
the ladder — 150 against the client's 282. That is definition coverage.
|
||||
|
||||
### 3.7 `duplicateItemIdList`
|
||||
|
||||
CONFIRMED shape, INFERRED effect, never observed. Element deser `FUN_180138e10`,
|
||||
|
||||
@@ -304,8 +304,44 @@ elimination:**
|
||||
| kit | **9** | 7 | `FUN_180119bd0` → `FUT_UC_KITS` + `TeamName_Abbr15_<teamid>` | `teamid` |
|
||||
| stadium | **10** | 7 | `FUN_180119bd0` → `Stadium` + `StadiumName_<assetId>` | `assetId` |
|
||||
| badge | **11** | 7 | `FUN_180119bd0` → `Badge` + `TeamName_Abbr15_<teamid>` | `teamid` |
|
||||
| ball | **30** (0x1e) | 9 | none; `FUT_UC_BALL` caption only | `localizedName` |
|
||||
| league logo | **31** (0x1f) | 9 | `FUN_180098f20` keyed on leagueid | `localizedName`, probably |
|
||||
| ball | **30** (0x1e) | 9 | NONE — see the 2026-08-21 measurement below | unnameable |
|
||||
| league logo | **31** (0x1f) | 9 | NONE — see the 2026-08-21 measurement below | unnameable |
|
||||
|
||||
**MEASURED 2026-08-21 against the running client (`tools/cardtype_dispatch_probe.py`,
|
||||
pid 6580): no cardtype-9 family can be named, and no server change can alter that.**
|
||||
Four independent reads, each with a passing positive control:
|
||||
|
||||
1. The merge switch's jump table at rva `0x141eb4` is indexed by `cardtype - 1`
|
||||
and has exactly 10 entries. Cardtypes 1–5 and 10 each get their own DB-merge
|
||||
arm; **cardtypes 6, 7, 8 and 9 all land on the shared tail `0x180141e8a`**,
|
||||
which issues no query and writes no name — it only derives the discard level
|
||||
from the rating.
|
||||
2. Census of every `cmp [reg+0x4c], imm` (cardtype): 0 → 1 site, 1 → 13, 6 → 1,
|
||||
7 → 6, **9 → ZERO**.
|
||||
3. Census of every `cmp [reg+0x50], imm` (cardsubtypeid), which is what actually
|
||||
selects a club-item caption: kit 9, stadium 10 and badge 11 all present
|
||||
(control), **ball 30 → ZERO sites, league logo 31 → ZERO sites**. The only
|
||||
cardtype-9 subtypes that appear at all are `fcc_misccards` 231/232/233/236,
|
||||
and all four sites are one boolean predicate near `0x1801a72da` that returns
|
||||
FALSE for them — an exclusion, not a resolver. (That predicate's identity is
|
||||
NOT established; it reads `+0x49`, `+0x145` and a vtable slot `+0x270`.)
|
||||
4. The cardtype-7 resolver is reached only under `cmp DWORD PTR [rax+0x4c], 0x7`
|
||||
at `0x1800f6f04`, so a cardtype-9 item can never arrive there. Its `jne` path
|
||||
formats `AWARD_LABEL_%i` (`0x1801fd5a0`) — the TROPHY path, not a fallback
|
||||
that would name a ball.
|
||||
|
||||
So the earlier "`localizedName`, probably" for these two rows was optimistic:
|
||||
there is no code that would read it for a caption. Withholding ball and league
|
||||
logo from the projection is a measured limit of the client, not caution.
|
||||
|
||||
CORRECTION, same measurement: `FUN_180119bd0` was recorded elsewhere as having
|
||||
"zero refs in CardsDLL → almost certainly an export, its caller is in
|
||||
FIFA17.exe". It is **not** an export. Its address occurs exactly ONCE in the
|
||||
whole process, at `0x18021c738` in CardsDLL's own `.rdata`, and nothing in
|
||||
FIFA17.exe references it. It is a virtual function: vtable base `0x18021c2a0`,
|
||||
slot **+0x498**, index 147 (ctor LEAs at `0x18010ce10` / `0x18011111b`) — which
|
||||
independently reproduces the "manager vtable slot +0x498" recorded below, by a
|
||||
different method. It has 7 distinct `call [reg+0x498]` sites.
|
||||
|
||||
The premise that all five live in cardtype 9 is wrong, and the root fact is not an
|
||||
inference from a call site. `FUN_1800d8330`, read in full at 714 chars by two
|
||||
@@ -406,6 +442,91 @@ from an accessor. So: send `localizedName` and expect it to show; send
|
||||
`description` and do not be surprised if nothing changes. The same `+0xba` also
|
||||
holds the unresolved kit-variant selector, so these two gaps may be one gap.
|
||||
|
||||
### The cardtype-9 name gap is ONE gap, not three (2026-08-21)
|
||||
|
||||
Worth stating plainly, because it was being tracked as three separate holes.
|
||||
Everything OpenFUT still refuses to project is cardtype 9, and for exactly the
|
||||
same reason:
|
||||
|
||||
| family | subtype(s) | definition table | why withheld |
|
||||
|---|---|---|---|
|
||||
| ball | 30 | `fcc_balls` (42) | no DB name resolver |
|
||||
| league logo | 31 | `fcc_leaguelogos` (44) | no DB name resolver |
|
||||
| misc | 231, 232, 233, 236 | `fcc_misccards` (42) | no DB name resolver |
|
||||
|
||||
The cardtype-7 families (kit 9, badge 11, stadium 10) all resolve their caption
|
||||
from the client's own tables through `FUN_180119bd0`, so the server sends only
|
||||
identity and the name takes care of itself — which is why all three now project.
|
||||
Cardtype 9 has no such resolver, so the displayed name can ONLY come from
|
||||
`localizedName` on the wire, and that single unproven step gates all three
|
||||
families at once.
|
||||
|
||||
Closing it closes the last of the ownable taxonomy. It needs the launch-driven
|
||||
probe in "The one probe still outstanding" above — one item, one family — and
|
||||
nothing else. Ownership, `content_kind`, club/stats counting and restart
|
||||
durability are already in place for all three, so the probe is the only
|
||||
remaining work: the projection arm is a two-line change once the name is proven.
|
||||
|
||||
#### A lead on league logos: a `LeagueName_Abbr_15_%d` path DOES exist
|
||||
|
||||
`FUN_180098f20` (named above as the league-logo function, hedged "localizedName,
|
||||
probably") was read in full on 2026-08-21. It builds a real database query, and
|
||||
the literals settle what it does:
|
||||
|
||||
```
|
||||
table 'fcc_leaguelogos'
|
||||
where 'leagueid' '==' %d ; the id arrives in r9d
|
||||
columns 'carddbid' 'value' 'cardassetid'
|
||||
caption 'LeagueName_Abbr_15_%d' ; a localisation key built from the league id
|
||||
domain 'FUT String'
|
||||
```
|
||||
|
||||
So a database-backed league NAME demonstrably exists in the client, keyed on
|
||||
`leagueid`, in exactly the shape kits use (`TeamName_Abbr15_<teamid>`). That
|
||||
makes the blanket claim "cardtype 9 has no DB name resolver" too strong for
|
||||
league logos specifically.
|
||||
|
||||
WHAT THIS DOES NOT YET SHOW, stated plainly because the obvious next step is a
|
||||
trap. Its ONLY caller is `0x180098da3`, and the `[rbx+0x20]` it passes as the
|
||||
league id is NOT the item record: `rbx` is reloaded from `[rsp+0x48]` and
|
||||
compared against an end pointer, i.e. it is a cursor over a list of small
|
||||
elements (int at `+0x20`, double at `+0x24`, int at `+0x2c`), not the 0x158-byte
|
||||
card record. So this is a CATALOG/BROWSE builder, and it is not established that
|
||||
the owned-item render path reaches it at all. Reading `+0x20` as the record's
|
||||
`assetId` and concluding "send the leagueid as assetId" would be exactly the
|
||||
kind of inference this document exists to prevent.
|
||||
|
||||
The lead worth following: find whether the owned cardtype-9 render path reaches
|
||||
this resolver, and if so which field feeds the league id. If it does, league
|
||||
logos need no `localizedName` at all and separate from the ball/misc gap.
|
||||
|
||||
#### Where to look next, and where NOT to (2026-08-21)
|
||||
|
||||
The lead above was chased and stopped at a useful boundary. `FUN_180119bd0` —
|
||||
the cardtype-7 caption resolver this whole section rests on — has **zero
|
||||
references anywhere in CardsDLL**: no `call`, no `jmp`, and its address is never
|
||||
taken in `.text`, `.rdata` or `.data`. It is nonetheless a genuine function
|
||||
(clean `mov rax,rsp` entry after `int3` padding).
|
||||
|
||||
A real, unreferenced function in a DLL is almost certainly an **export**, which
|
||||
puts its caller in FIFA17.exe. That matches the shape of everything else here:
|
||||
CardsDLL owns the card model and the database, and the EXE owns the UI that asks
|
||||
for captions. `FUN_180098f20`'s only caller likewise iterates a small list
|
||||
element, not a card record — a browse/catalog builder, not the owned-item path.
|
||||
|
||||
So the practical guidance is: **stop looking for the owned cardtype-9 caption
|
||||
path inside CardsDLL.** It is not there. Closing this by static reading means
|
||||
parsing CardsDLL's export table and following the callers in FIFA17.exe's 79 MB,
|
||||
which is a much larger job than the launch probe in "The one probe still
|
||||
outstanding" — one item, one family, and the answer is visible on screen.
|
||||
|
||||
Method note for whoever does dump memory here: CardsDLL's sections are
|
||||
`.text` at image `0x180001000`, `.rdata` at `0x1801e5000`, `.data` at
|
||||
`0x18028a000`. Confusing a LIVE mapping offset with an IMAGE offset silently
|
||||
reads the wrong section and produces false negatives — every atom-name lookup
|
||||
came back ABSENT until the region was corrected, including controls like
|
||||
`resourceId`. Always validate a memory scan against a key known to be present.
|
||||
|
||||
---
|
||||
|
||||
## 4. The card lifecycle
|
||||
@@ -503,13 +624,6 @@ effects move in the permissive direction. There is also a second escape hatch in
|
||||
that gate -- `svc->0x308()` on service `0xed80ed8` -- that nobody resolved, so if
|
||||
squad submission behaves oddly afterwards, that is where to look.
|
||||
|
||||
**"List on Transfer Market" as a separate menu entry was not found.** The eight
|
||||
flags contain `TO_TRADE_PILE` and no listing action. `FUN_18003e550` publishes
|
||||
`DURATION` / `START_PRICE` / `ASKING_PRICE`, which is the listing panel, but
|
||||
whether it has its own enable predicate was not chased. The likely explanation is
|
||||
that listing is only reachable from the trade pile, so both entries share one root
|
||||
cause, but that is an inference and it is not established.
|
||||
|
||||
### Equipping club items
|
||||
|
||||
`itemState` really is the equip mechanism for the `IS_ACTIVE` tick:
|
||||
@@ -528,22 +642,60 @@ will not change the kit.
|
||||
|
||||
### Needs decompiling only
|
||||
|
||||
**Who writes item `+0x60`.** It gates the kit swap at value 4 and we can produce 1
|
||||
and 6. Both attempts to scan for it drowned: `+0x60` returns 1688 and 4144
|
||||
instructions depending on method. The narrower anchor is the `/club` and
|
||||
`/purchased` response handlers -- find the list-insert that assigns it, read the
|
||||
constants. This is the single blocker between "we can mark a kit equipped" and "we
|
||||
can equip a kit".
|
||||
**Who writes item `+0x60`. ANSWERED 2026-08-21 — NOTHING DOES.** It gates the kit
|
||||
swap at value 4 and we can produce 1 and 6. Both earlier scans drowned (`+0x60`
|
||||
returns 1688 and 4144 instructions) because it is a common struct offset. Two
|
||||
filters cut it to a readable set: only an IMMEDIATE store can introduce a
|
||||
constant, and item-record code is recognisable by touching `+0x4c`/`+0x5c`
|
||||
nearby. Measured with `fifa17-recon/tools/kit_gate_probe.py` against pid 6580:
|
||||
|
||||
| evidence | result |
|
||||
|---|---|
|
||||
| live `+0x60`, all 27 resident records | `{1: 23 players, 0: 4 staff}` — never 4 |
|
||||
| `cmp dword [reg+0x60], imm8` in CardsDLL | 4 sites: `0`, `0`, `1`, `4`; the `4` is the gate and is UNIQUE in the process |
|
||||
| immediate stores to `[reg+0x60]`, CardsDLL | 29; constants `{-2, 0, 1, 908, 0x3f800000}` — no 4 |
|
||||
| immediate stores of 4, FIFA17.exe (79 MB) | 0; also 0 comparisons against 4 |
|
||||
| xrefs to the gate function | 1 (`jmp` from `0x1801a5329`); address never taken |
|
||||
| register stores to `+0x60`, CardsDLL | all struct copies or inits to 0/1/-2 |
|
||||
|
||||
So the blocker is not a wire field we have not learned to send: the value the
|
||||
gate demands is never produced by anything. Every OTHER input to the gate is
|
||||
already served — `+0x4c == 7` (subtype 9), `+0x5c` 101/102
|
||||
(`activeHomeKit`/`activeAwayKit`), `+0x94` teamid — leaving only the `+0xba`
|
||||
variant selector below it. A client-side patch is therefore the only remaining
|
||||
avenue, and a small one; it is not proposed here.
|
||||
|
||||
|
||||
**The kit variant selector.** `FUN_1801bfac0` distinguishes home, away and third
|
||||
kits from `FUN_1801a8800` (`+0xba`, u16) and `FUN_1801a8040` (`+0xbf`, signed
|
||||
byte). Which wire atom sets it is unknown, so we cannot serve a specific kit
|
||||
deliberately. Note `+0xba` is the same slot as the unresolved ball subtitle.
|
||||
|
||||
**`FUN_1801aa190`.** The one unopened link inside the eight-flag chain: it is
|
||||
claimed to resolve `statsList[4]` and `[5]` at `+0x104 + idx*4`. It changes no
|
||||
action today because we send no `statsList`, but it is two minutes of work and it
|
||||
would close the chain.
|
||||
**`FUN_1801aa190`. CLOSED 2026-08-21.** The one unopened link inside the
|
||||
eight-flag chain. It is eleven instructions, and it resolves TWO parallel arrays
|
||||
rather than the one the earlier claim described:
|
||||
|
||||
```
|
||||
mov rax, [rcx+0x10] ; the ITEM record (same +0x10 hop the kit gate uses)
|
||||
test r8b, r8b
|
||||
jz .low
|
||||
mov eax, [rax + rdx*4 + 0x124] ; array B
|
||||
ret
|
||||
.low:
|
||||
mov eax, [rax + rcx*4 + 0x104] ; array A <- the claimed statsList
|
||||
ret
|
||||
```
|
||||
|
||||
So the signature is `f(self, int idx, bool which)`: `+0x104 + idx*4` when the
|
||||
flag is clear, `+0x124 + idx*4` when it is set. The two arrays are 0x20 apart,
|
||||
i.e. eight ints each (`+0x104..+0x123`, `+0x124..+0x143`).
|
||||
|
||||
LIVE (pid 6580, production-served records): BOTH arrays read all zeros on every
|
||||
resident record, players included — e.g. resourceId 20801 rating 94 has
|
||||
`A = [0]*8`, `B = [0]*8`. That confirms "changes no action today because we send
|
||||
no statsList", and extends it: the sibling array at `+0x124` is equally empty.
|
||||
Any action flag derived from either is reading 0 in production, so neither can
|
||||
be the reason an action is greyed.
|
||||
|
||||
**The `BOUGHT_FOR` consumer.** `+0x34` = atom `0x185 lastSalePrice` is resolved.
|
||||
What remains is whether the field is visible anywhere worth populating.
|
||||
@@ -553,24 +705,73 @@ depend on it (`FUN_180108c00` carries the same mapping independently), but the
|
||||
dispatch table that reaches it was not identified, and trophies are a whole
|
||||
unimplemented family.
|
||||
|
||||
**Case sensitivity of the `itemState` string match.** Almost certainly
|
||||
unresolvable statically: `FUN_180008190` is a single indirect call through
|
||||
`DAT_1802ddfd8 + 0x248`, a runtime-populated service pointer. Send the exact
|
||||
casing from the table and do not experiment on the live save.
|
||||
**Case sensitivity of the `itemState` string match. RESOLVED 2026-08-21 —
|
||||
CASE-SENSITIVE.** It was expected to be unresolvable statically, because
|
||||
`FUN_180008190` is nothing but a forwarding stub through a runtime-populated
|
||||
slot:
|
||||
|
||||
```
|
||||
mov rax, [DAT_1802ddfd8] ; service object, handed to CardsDLL by the host
|
||||
mov r9, [rax + 0x248]
|
||||
jmp r9
|
||||
```
|
||||
|
||||
Resolved read-only against the running client (pid 6580) with
|
||||
`fifa17-recon/tools/service_ptr_probe.py`, which follows the chain and
|
||||
attributes each hop to a module (Wine maps PE sections anonymously, so the
|
||||
module comes from the nearest preceding named mapping):
|
||||
|
||||
```
|
||||
*(service + 0x248) = 0x146d1c020 FIFA17.exe+0x20f9020 e9 … jmp rel32
|
||||
→ 0x145e27fe0 FIFA17.exe+0x1204fe0 ff 25 jmp [rip+…]
|
||||
→ 0x6ffffd11c330 msvcr120.dll+0x3c330 function body
|
||||
```
|
||||
|
||||
The body is `strncmp`: `sub rdx,rcx` / `test r8,r8` (count) / `test al,al`
|
||||
(NUL stop) / `cmp al,[rcx+rdx]`, then MSVC's 8-byte fast path with the
|
||||
`0x8080808080808080` and `0xfefefefefefefeff` NUL-detect constants. There is no
|
||||
`or ..,0x20` and no folding table anywhere in the body, so the compare is raw
|
||||
bytes.
|
||||
|
||||
CONSEQUENCE: a mis-cased token does not degrade, it matches nothing —
|
||||
`FUN_180166660` returns `0xffffffff`, the record keeps `0` = `invalid`, and the
|
||||
item fails the squad builder's `state == 1 || state == 2` test. The casing in
|
||||
the table at `0x180229cc0` is a contract. Send it verbatim; do not experiment on
|
||||
the live save.
|
||||
|
||||
### Needs a live probe (read-only, no launch)
|
||||
|
||||
**Resolve `DAT_1802ddfd8 + 0x248`** in the running process and identify the string
|
||||
comparator. That answers the casing question without a launch.
|
||||
|
||||
**Re-read `+0x30` after a refetch** to decide between "monotonic clock" and
|
||||
"sequence counter". Low value; nothing we send reaches it.
|
||||
|
||||
**Confirm the FUT roster database is loaded.** The `fcc_discardcoins` result
|
||||
proves `g_db` is loaded and complete; it says nothing about the separate database
|
||||
behind `LoadFUTDatabase` / `.dbFUTVer` / `DL_FUT_LIVEDB`, whose strings live in
|
||||
FIFA17.exe and not in CardsDLL. These are different databases and they should stop
|
||||
being conflated.
|
||||
**Confirm the FUT roster database is loaded. PARTLY ANSWERED 2026-08-21 — the
|
||||
two databases are now definitively distinct; the load FLAG is still unlocated.**
|
||||
The `fcc_discardcoins` result proves `g_db` is loaded and complete; it says
|
||||
nothing about the separate database behind `LoadFUTDatabase` / `.dbFUTVer` /
|
||||
`DL_FUT_LIVEDB`. Scanning FIFA17.exe's 79 MB of code+data in the live process
|
||||
(pid 6580) recovers the whole API name set, and it settles the distinction:
|
||||
|
||||
```
|
||||
SetFUTDatabaseUnloaded UpdateFUTDBVersion StartFUTRosterDownload
|
||||
LoadFUTDatabase UnLoadFUTDatabase GetFUTDBCRC
|
||||
CancelRosterDownload DL_FUT_LIVEDB APPLY_FUT_LIVEDB
|
||||
RosterXMLDownloadedFail .dbFUTVer .dbMajor .dbMinor .dbMajorCRC .dbMinorCRC
|
||||
```
|
||||
|
||||
Every one of those lives in FIFA17.exe; none is in CardsDLL. So the FUT roster
|
||||
DB is a DOWNLOADED, versioned, CRC-checked live database with its own
|
||||
download -> apply -> load/unload lifecycle (and its own failure state,
|
||||
`RosterXMLDownloadedFail`), which is a different kind of thing from the shipped
|
||||
card tables CardsDLL reads. They should stop being conflated, and this is the
|
||||
evidence for saying so.
|
||||
|
||||
What is NOT answered: whether it is loaded right now. The process holds no
|
||||
separate database file open — only Frostbite bundles (`.sb` / `.cas`) — which is
|
||||
consistent with the roster DB living inside a bundle or in memory, so absence of
|
||||
a file handle proves nothing either way. The `SetFUTDatabaseUnloaded` state
|
||||
implies a boolean somewhere; that global was not located, so "is it loaded"
|
||||
remains open and needs the flag found before it can be answered honestly.
|
||||
|
||||
### Needs a launch the user must drive -- ranked, and short
|
||||
|
||||
@@ -870,10 +1071,29 @@ be misrouted onto another field. **Freeze risk: none** -- removing a key the par
|
||||
skips strictly reduces executed code. Low value, zero cost, and it removes a field
|
||||
that three documents describe as if it did something.
|
||||
|
||||
**Fourth verification, 2026-08-21 (independent method).** Searched CardsDLL's
|
||||
own `.rdata` in the running client for the literal key names. Every real atom is
|
||||
present exactly once — `resourceId` `0x18022a3a8`, `cardsubtypeid` `0x180230520`,
|
||||
`itemState` `0x180231490`, `assetId` `0x180230178`, `cardassetid` `0x180204200`,
|
||||
`rareflag`, `untradeable`, `owners`, `contract`, `discardValue`, and notably
|
||||
`localizedName` at `0x1802316d0` — while **`definitionId` is ABSENT entirely**.
|
||||
The client has no string for it, so no arm can exist. That is a different method
|
||||
from the three above (string table rather than key dictionary) and it agrees.
|
||||
|
||||
NOT applied all the same. The player path that carries `definitionId` is
|
||||
live-proven in production, the saving is payload only, and this project's house
|
||||
rule is that a flag defaults to the live-proven value. "Provably inert" is a good
|
||||
reason to stop documenting it as meaningful; it is not on its own a reason to
|
||||
change a working wire. Bundle it with the next change that needs a launch.
|
||||
|
||||
---
|
||||
|
||||
## 7. Proposed corrections to existing documents
|
||||
|
||||
> **APPLIED 2026-08-21.** Every correction below has been made in the named file
|
||||
> and marked there with a dated note. This section is kept as the rationale and
|
||||
> the audit trail, not as an outstanding to-do.
|
||||
|
||||
### `docs/CARD_SYSTEM.md`
|
||||
|
||||
**Replace the "STILL UNKNOWN, AND NOT GUESSED" section entirely.** It is answered.
|
||||
|
||||
@@ -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),
|
||||
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Prove, from the live client, which cardtypes CardsDLL can NAME -- and that
|
||||
cardtype 9 (ball / league logo / fcc_misccards) is not one of them.
|
||||
|
||||
READ-ONLY: /proc/PID/mem opened 'rb'. No write path in this file.
|
||||
|
||||
WHY
|
||||
---
|
||||
Serving an owned ball or league logo was blocked on one question: where does a
|
||||
cardtype-9 item's caption come from? Three independent reads here say: nowhere.
|
||||
|
||||
MEASURED 2026-08-21, pid 6580, CardsDLL live base 0x6ffffc0f0000
|
||||
(module-relative offsets below are stable; live addresses are not).
|
||||
|
||||
1. THE CLUB-ITEM CAPTION RESOLVER IS A VTABLE SLOT, NOT AN EXPORT.
|
||||
An earlier note recorded FUN_180119bd0 as "zero refs in CardsDLL -> almost
|
||||
certainly an export, its caller is in FIFA17.exe". That is WRONG and this
|
||||
tool corrects it. Its address occurs exactly ONCE in the entire process, at
|
||||
image 0x18021c738, inside CardsDLL's own .rdata -- a vtable entry. Nothing in
|
||||
FIFA17.exe references it.
|
||||
|
||||
Walking backwards over "qwords pointing into .text" overshoots the vtable
|
||||
boundary (it runs 826 slots through several adjacent vtables). The reliable
|
||||
discriminator is that a vtable's START is referenced by its constructor via a
|
||||
RIP-relative LEA while interior slots never are:
|
||||
|
||||
vtable base image 0x18021c2a0 (ctor LEAs at 0x18010ce10, 0x18011111b)
|
||||
FUN_180119bd0 slot +0x498, index 147
|
||||
|
||||
which independently reproduces the previously recorded "manager vtable slot
|
||||
+0x498". There are 7 distinct `call [reg+0x498]` sites.
|
||||
|
||||
2. THE CAPTION CALL IS GATED ON cardtype == 7, AND THE ELSE IS TROPHIES.
|
||||
At 0x1800f6f04:
|
||||
|
||||
cmp DWORD PTR [rax+0x4c], 0x7 ; cardtype
|
||||
jne 0x1800f6f82
|
||||
...
|
||||
mov r9d, [rdx+0x94]
|
||||
mov r8d, [rdx+0x50] ; cardsubtypeid
|
||||
mov ecx, [rdx+0x20] ; assetid
|
||||
call QWORD PTR [r10+0x498] ; FUN_180119bd0
|
||||
|
||||
The jne path formats [rdi+0x8] into 'AWARD_LABEL_%i' (0x1801fd5a0) and
|
||||
localises it -- that is the TROPHY path (subtypes 0x91..0x96), not a fallback
|
||||
that would name a ball.
|
||||
|
||||
3. NO CARDTYPE-9 HANDLING EXISTS, BY TWO INDEPENDENT MEASURES.
|
||||
a) Census of every `cmp [reg+0x4c], imm8` in .text:
|
||||
cardtype 0 : 2 sites
|
||||
cardtype 1 : 14 sites
|
||||
cardtype 6 : 1 site
|
||||
cardtype 7 : 6 sites
|
||||
cardtype 9 : 0 sites
|
||||
b) The merge switch's jump table at rva 0x141eb4, indexed by cardtype-1,
|
||||
10 entries:
|
||||
idx 0..4 -> cardtypes 1..5 distinct DB-merge arms
|
||||
idx 5..8 -> cardtypes 6..9 ALL to the shared tail 0x180141e8a
|
||||
idx 9 -> cardtype 10 distinct arm (gkcoach)
|
||||
The shared tail does no DB query and writes no name: it only derives the
|
||||
discard level from the rating.
|
||||
|
||||
A cmp census alone would miss a jump-table switch, and a jump table alone
|
||||
would miss an explicit compare. Both say the same thing.
|
||||
|
||||
CONSEQUENCE
|
||||
-----------
|
||||
A cardtype-9 item cannot receive a client-resolved caption: it has no merge arm
|
||||
to fill a name and it can never reach the cardtype-7 resolver. Withholding ball
|
||||
and league logo from the projection is therefore an evidence-backed limit of the
|
||||
client, not caution -- and no server-side change can lift it.
|
||||
|
||||
BONUS, and it validates the discard work: the shared tail at 0x180141e8a IS the
|
||||
discard level ladder, live --
|
||||
movzx eax,[rdi+0xb4] ; cmp al,0x4b ; -> 3
|
||||
cmp al,0x41 ; sbb eax,eax ; add eax,2 ; -> 2 or 1
|
||||
mov [rdi+0x54], eax
|
||||
which is `discard::discard_level` instruction for instruction.
|
||||
|
||||
Usage:
|
||||
python3 cardtype_dispatch_probe.py
|
||||
"""
|
||||
import collections
|
||||
import struct
|
||||
import sys
|
||||
|
||||
import watch_club_model as W
|
||||
|
||||
TEXT_LO, TEXT_HI = 0x180001000, 0x1801E5000
|
||||
RDATA_LO, RDATA_HI = 0x1801E5000, 0x18028A000
|
||||
CAPTION_FN = 0x180119BD0
|
||||
JUMP_TABLE = 0x180141EB4
|
||||
SHARED_TAIL = 0x180141E8A
|
||||
REGS = {0x78: "rax", 0x79: "rcx", 0x7A: "rdx", 0x7B: "rbx",
|
||||
0x7D: "rbp", 0x7E: "rsi", 0x7F: "rdi"}
|
||||
|
||||
|
||||
def main():
|
||||
pid = W.find_pid()
|
||||
if pid is None:
|
||||
print("FIFA17.exe is not running.")
|
||||
return 1
|
||||
dll = W.dll_base(pid)
|
||||
if dll is None:
|
||||
print("pid %d is up but %s is not mapped yet." % (pid, W.DLL))
|
||||
return 1
|
||||
mem = W.Mem(pid)
|
||||
live = lambda i: dll + (i - W.IMG_BASE)
|
||||
print("pid=%d CardsDLL live base %#x" % (pid, dll))
|
||||
|
||||
text, bad = mem.read_pages(live(TEXT_LO), TEXT_HI - TEXT_LO)
|
||||
text = bytes(text)
|
||||
print("read %#x bytes .text (%d bad pages)" % (len(text), len(bad)))
|
||||
|
||||
# --- 1. locate the caption fn's single reference, and its vtable base ----
|
||||
target = live(CAPTION_FN)
|
||||
rdata, _ = mem.read_pages(live(RDATA_LO), RDATA_HI - RDATA_LO)
|
||||
rdata = bytes(rdata)
|
||||
slots = []
|
||||
needle = struct.pack("<Q", target)
|
||||
i = rdata.find(needle)
|
||||
while i != -1:
|
||||
slots.append(RDATA_LO + i)
|
||||
i = rdata.find(needle, i + 1)
|
||||
print("\n[1] FUN_%x referenced from .rdata at: %s"
|
||||
% (CAPTION_FN, [hex(s) for s in slots]) or "nowhere")
|
||||
|
||||
lea_t = set()
|
||||
for i in range(len(text) - 7):
|
||||
if text[i] in (0x48, 0x4C) and text[i + 1] == 0x8D and text[i + 2] in (
|
||||
0x05, 0x0D, 0x15, 0x1D, 0x25, 0x2D, 0x35, 0x3D):
|
||||
tgt = TEXT_LO + i + 7 + struct.unpack_from("<i", text, i + 3)[0]
|
||||
if RDATA_LO <= tgt < RDATA_HI:
|
||||
lea_t.add(tgt)
|
||||
for slot in slots:
|
||||
base = max((t for t in lea_t if t <= slot), default=None)
|
||||
if base is not None:
|
||||
print(" vtable base %#x -> slot +%#x (index %d)"
|
||||
% (base, slot - base, (slot - base) // 8))
|
||||
|
||||
# --- 2. cardtype compare census -----------------------------------------
|
||||
hits = collections.defaultdict(list)
|
||||
for i in range(len(text) - 4):
|
||||
if text[i] == 0x83 and text[i + 1] in REGS and text[i + 2] == 0x4C:
|
||||
hits[text[i + 3]].append(TEXT_LO + i)
|
||||
print("\n[2] cardtype tests `cmp [reg+0x4c], imm`:")
|
||||
for ct in sorted(hits):
|
||||
print(" cardtype %2d : %3d site(s) e.g. %s"
|
||||
% (ct, len(hits[ct]), ", ".join("%#x" % v for v in hits[ct][:4])))
|
||||
ok_control = 7 in hits and 1 in hits
|
||||
print(" CONTROL (cardtypes 1 and 7 must both appear): %s"
|
||||
% ("OK" if ok_control else "WRONG REGION -- results are meaningless"))
|
||||
print(" cardtype 9 sites: %d" % len(hits.get(9, [])))
|
||||
|
||||
# --- 3. merge jump table -------------------------------------------------
|
||||
jt, _ = mem.read_pages(live(JUMP_TABLE), 0x40)
|
||||
jt = bytes(jt)
|
||||
print("\n[3] merge jump table at %#x (index = cardtype - 1):" % JUMP_TABLE)
|
||||
tail_types = []
|
||||
for n in range(16):
|
||||
rva = struct.unpack_from("<I", jt, n * 4)[0]
|
||||
if not (0x1000 <= rva < 0x1E5000):
|
||||
break
|
||||
va = W.IMG_BASE + rva
|
||||
ct = n + 1
|
||||
mark = " <- SHARED TAIL (no DB query, no name)" if va == SHARED_TAIL else ""
|
||||
print(" cardtype %2d -> %#x%s" % (ct, va, mark))
|
||||
if va == SHARED_TAIL:
|
||||
tail_types.append(ct)
|
||||
|
||||
# --- 4. cardsubtypeid census -------------------------------------------
|
||||
# The club-item CAPTION is chosen by subtype (+0x50), not cardtype, so the
|
||||
# cardtype census alone does not settle whether a ball or logo is nameable.
|
||||
sub = collections.defaultdict(list)
|
||||
for i in range(len(text) - 8):
|
||||
if text[i] == 0x83 and text[i + 1] in REGS and text[i + 2] == 0x50:
|
||||
sub[text[i + 3]].append(TEXT_LO + i)
|
||||
elif text[i] == 0x81 and text[i + 1] in REGS and text[i + 2] == 0x50:
|
||||
sub[struct.unpack_from("<I", text, i + 3)[0]].append(TEXT_LO + i)
|
||||
print("\n[4] cardsubtypeid tests `cmp [reg+0x50], imm`:")
|
||||
for st in sorted(k for k in sub if k <= 400):
|
||||
print(" subtype %3d : %2d site(s) e.g. %s"
|
||||
% (st, len(sub[st]), ", ".join("%#x" % v for v in sub[st][:4])))
|
||||
print(" CONTROL (kit 9 / stadium 10 / badge 11 must appear): %s"
|
||||
% ("OK" if all(s in sub for s in (9, 10, 11)) else "WRONG REGION"))
|
||||
print(" ball(30)=%d leaguelogo(31)=%d misc(231/232/233/236)=%d"
|
||||
% (len(sub.get(30, [])), len(sub.get(31, [])),
|
||||
sum(len(sub.get(s, [])) for s in (231, 232, 233, 236))))
|
||||
print(" NOTE: the misc sites are all one boolean predicate near"
|
||||
" 0x1801a72da that returns FALSE for them -- an exclusion, not a"
|
||||
" caption. Its identity is NOT established.")
|
||||
|
||||
print("\nVERDICT: cardtypes with no merge arm: %s" % tail_types)
|
||||
print(" cardtype 9 named by CardsDLL: %s"
|
||||
% ("NO -- no merge arm and no compare site" if 9 in tail_types
|
||||
and not hits.get(9) else "reconsider"))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+190
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Read back the DISCARD (quick-sell) value the live client holds for every
|
||||
resident card, and check it against the client's own `fcc_discardcoins` table.
|
||||
|
||||
READ-ONLY. Walks the same CardsDb node tree as card_identity_probe / coach_probe
|
||||
via /proc/PID/mem; there is no write path in this file.
|
||||
|
||||
WHAT THE TWO SLOTS MEAN (FUN_18013fe00 / FUN_180141660)
|
||||
-------------------------------------------------------
|
||||
item+0x38 the `discardValue` WE sent (atom 0xd7), stored verbatim.
|
||||
item+0x3c the value the CLIENT computed for itself.
|
||||
|
||||
At 0x180141025 a `cmp dword [rbp+0x198],0` / `ja` SKIPS the whole local
|
||||
computation when +0x38 is non-zero. So:
|
||||
|
||||
* +0x38 non-zero -> the client displays OUR number and +0x3c is not filled.
|
||||
* +0x38 zero -> the client computes, and +0x3c is what the player sees.
|
||||
|
||||
The local computation is
|
||||
SELECT price FROM fcc_discardcoins WHERE cardtype==? AND level==? AND rare==?
|
||||
value = round_half_up(rating * price / 100)
|
||||
with `level` = 3 if rating >= 0x4b, 2 if >= 0x41, else 1 (item+0x54), and
|
||||
cardtype derived from cardsubtypeid by FUN_1800d8330.
|
||||
|
||||
WHY THIS TOOL EXISTS
|
||||
--------------------
|
||||
For cardtypes 2/3/4/5/10 (the five staff families) the client OVERWRITES the
|
||||
rating and rare flag we send with values from its own card database before
|
||||
computing. The server therefore cannot know the displayed price from what it
|
||||
sent -- it has to be read back. +0x3c is that read-back, and it is the ground
|
||||
truth for what the server must credit on a quick sell.
|
||||
|
||||
Usage:
|
||||
python3 discard_probe.py # table of every resident card
|
||||
python3 discard_probe.py --kind staff # only the staff families
|
||||
python3 discard_probe.py --json out.json
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import card_identity_probe as P
|
||||
import watch_club_model as W
|
||||
|
||||
TABLES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "tables")
|
||||
|
||||
F_SERVER_DISCARD = 0x38
|
||||
F_CLIENT_DISCARD = 0x3C
|
||||
F_LEVEL = 0x54
|
||||
F_RARE = 0x58
|
||||
F_RATING = 0xB4
|
||||
|
||||
|
||||
def cardtype_for_subtype(sub):
|
||||
"""FUN_1800d8330, read out of its raw two-level jump table."""
|
||||
if 0 <= sub <= 3:
|
||||
return 1
|
||||
if sub == 4:
|
||||
return 2
|
||||
if sub == 5:
|
||||
return 3
|
||||
if sub == 6:
|
||||
return 10
|
||||
if sub == 7:
|
||||
return 5
|
||||
if sub == 8:
|
||||
return 4
|
||||
if 9 <= sub <= 11:
|
||||
return 7
|
||||
if sub in (30, 31, 236) or 145 <= sub <= 150 or 231 <= sub <= 233:
|
||||
return 9
|
||||
if 51 <= sub <= 136 or 201 <= sub <= 220 or 250 <= sub <= 273 or 300 <= sub <= 341:
|
||||
return 6
|
||||
return 0
|
||||
|
||||
|
||||
def load_prices():
|
||||
"""{(cardtype, level, rare): price} from the client's own dumped table."""
|
||||
path = os.path.join(TABLES, "fcc_discardcoins.json")
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
doc = json.load(open(path))
|
||||
rows = doc["rows"] if isinstance(doc, dict) else doc
|
||||
return {(r["cardtype"], r["level"], r["rare"]): r["price"] for r in rows}
|
||||
|
||||
|
||||
def predict(prices, cardtype, rating, rare):
|
||||
"""The client's formula, reproduced. An absent key pays 0, never a floor."""
|
||||
if prices is None or cardtype == 0 or rating is None:
|
||||
return None
|
||||
level = 3 if rating >= 0x4B else (2 if rating >= 0x41 else 1)
|
||||
price = prices.get((cardtype, level, rare), 0)
|
||||
if price == 0:
|
||||
return 0
|
||||
return (rating * price + 50) // 100
|
||||
|
||||
|
||||
STAFF_SUBTYPES = (4, 5, 6, 7, 8)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--kind", choices=("all", "staff", "player", "other"), default="all")
|
||||
ap.add_argument("--json", metavar="PATH")
|
||||
a = ap.parse_args()
|
||||
|
||||
prices = load_prices()
|
||||
if prices is None:
|
||||
print("WARNING: no fcc_discardcoins.json under %s -- predictions disabled\n" % TABLES)
|
||||
|
||||
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 = P.nodes(mem, obj)
|
||||
print("pid=%d CardsDb=%#x walked=%d\n" % (pid, obj, len(ns)))
|
||||
|
||||
out = []
|
||||
for n in ns:
|
||||
buf = mem.read(n + P.REC, P.REC_LEN)
|
||||
if buf is None or len(buf) < P.REC_LEN:
|
||||
continue
|
||||
sub = P.u32(buf, P.F_SUBTYPE)
|
||||
ct = P.u32(buf, P.F_CARDTYPE)
|
||||
rating = P.u8(buf, F_RATING)
|
||||
rare = P.u32(buf, F_RARE)
|
||||
rec = {
|
||||
"resourceId": P.u32(buf, P.F_RESOURCE),
|
||||
"subtype": sub,
|
||||
"cardtype": ct,
|
||||
"decoded_cardtype": cardtype_for_subtype(sub),
|
||||
"rating": rating,
|
||||
"level": P.u32(buf, F_LEVEL),
|
||||
"rare": rare,
|
||||
"server_discard": P.u32(buf, F_SERVER_DISCARD),
|
||||
"client_discard": P.u32(buf, F_CLIENT_DISCARD),
|
||||
"predicted": predict(prices, ct, rating, rare),
|
||||
}
|
||||
if a.kind == "staff" and sub not in STAFF_SUBTYPES:
|
||||
continue
|
||||
if a.kind == "player" and ct != 1:
|
||||
continue
|
||||
if a.kind == "other" and (ct == 1 or sub in STAFF_SUBTYPES):
|
||||
continue
|
||||
out.append(rec)
|
||||
|
||||
out.sort(key=lambda r: (r["cardtype"], r["subtype"], r["resourceId"]))
|
||||
print("%-10s %-4s %-4s %-4s %-4s %-4s %-9s %-9s %-9s %s"
|
||||
% ("resource", "sub", "ct", "rat", "lvl", "rar", "sent+38", "calc+3c",
|
||||
"predict", "verdict"))
|
||||
agree = disagree = notcomputed = 0
|
||||
for r in out:
|
||||
if r["server_discard"]:
|
||||
verdict = "SERVER-SHOWN (local calc skipped)"
|
||||
notcomputed += 1
|
||||
elif r["predicted"] is None:
|
||||
verdict = "?"
|
||||
elif r["client_discard"] == r["predicted"]:
|
||||
verdict = "AGREES"
|
||||
agree += 1
|
||||
else:
|
||||
verdict = "DISAGREES"
|
||||
disagree += 1
|
||||
print("%-10s %-4s %-4s %-4s %-4s %-4s %-9s %-9s %-9s %s"
|
||||
% (r["resourceId"], r["subtype"], r["cardtype"], r["rating"],
|
||||
r["level"], r["rare"], r["server_discard"], r["client_discard"],
|
||||
r["predicted"], verdict))
|
||||
|
||||
print("\nAGREES=%d DISAGREES=%d server-shown=%d total=%d"
|
||||
% (agree, disagree, notcomputed, len(out)))
|
||||
if a.json:
|
||||
json.dump(out, open(a.json, "w"), indent=2)
|
||||
print("wrote %s" % a.json)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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
|
||||
|
||||
@@ -299,10 +299,16 @@ def player_item(item_id, player, special=False):
|
||||
# The cause is the guard the table work reversed. FUN_18013fe00 stores our
|
||||
# discardValue at item +0x38; at 0x180141025 a `cmp dword [rbp+0x198],0` / `ja` skips
|
||||
# the client's own local computation when that value is NON-ZERO. We seed 0, so the
|
||||
# client runs its own fcc_discardcoins lookup, that lookup returns no row for our
|
||||
# cards, the price register stays 0, and it renders 0. WHY its lookup misses is still
|
||||
# UNKNOWN and worth knowing, but it does not have to be answered to fix the display:
|
||||
# sending a non-zero value bypasses the lookup entirely and the client uses ours.
|
||||
# client runs its own fcc_discardcoins lookup and the price register stays 0.
|
||||
#
|
||||
# CORRECTED 2026-08-06: the two claims that used to sit here -- "that lookup
|
||||
# returns no row for our cards" and "WHY its lookup misses is still UNKNOWN" --
|
||||
# are both FALSE. The lookup does not miss; real rows exist for both rare values
|
||||
# on (cardtype 6, level, rare). The tile reads a DIFFERENT property, which is why
|
||||
# the wallet and the screen disagreed. Sending a non-zero value still fixes the
|
||||
# display, for the reason below -- it bypasses the local computation entirely --
|
||||
# but do not carry the "missing row" story forward: it sent one round of work
|
||||
# looking for a table defect that was never there.
|
||||
#
|
||||
# Freeze risk: low and in the safe direction. discardValue is a plain INT read by the
|
||||
# scalar getter 0x1801c79d0. The freezes on this project have all come from feeding an
|
||||
|
||||
Executable
+177
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Settle the pre-match kit selector gate: who, if anyone, writes item `+0x60`.
|
||||
|
||||
READ-ONLY. /proc/PID/mem is opened 'rb'; there is no write path in this file.
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
`plan-2026-08-06-card-subsystem.md` section 5 calls `+0x60` "the single blocker
|
||||
between 'we can mark a kit equipped' and 'we can equip a kit'", and records that
|
||||
two attempts to find its writer drowned: scanning for the offset returned 1688
|
||||
and 4144 instructions depending on method.
|
||||
|
||||
The scan drowns because `+0x60` is a common struct offset. Two cheap filters cut
|
||||
it to something a person can read:
|
||||
|
||||
* only IMMEDIATE stores can introduce a constant (a register store propagates
|
||||
one from somewhere else), and
|
||||
* item-record code is recognisable by touching `+0x4c` (cardtype) or `+0x5c`
|
||||
(itemState) within a few instructions.
|
||||
|
||||
WHAT IT REPORTS
|
||||
---------------
|
||||
1. The live `+0x60` distribution over every resident CardsDb record.
|
||||
2. Every `cmp dword [reg+0x60], imm8` in CardsDLL .text -- the readers.
|
||||
3. Every immediate store to `[reg+0x60]` and the constants they use.
|
||||
4. Which of those stores sit next to item-record code.
|
||||
|
||||
MEASURED 2026-08-21 (pid 6580, 27 resident records):
|
||||
live +0x60 : {1: 23 (players), 0: 4 (staff)} -- never 4
|
||||
readers : 4 total; exactly ONE compares against 4, at 0x1801c34f2,
|
||||
which is the kit gate in FUN_1801c3480
|
||||
immediate stores: 27 total; constants {-2, 0, 1, 908, 0x3f800000} -- NO 4
|
||||
FIFA17.exe : 0 immediate stores of 4 to +0x60 across its 79MB of code,
|
||||
and 0 comparisons against 4
|
||||
gate xrefs : 1 (a jmp from 0x1801a5329); address never taken
|
||||
|
||||
The gate at 0x1801c34f2 decodes as:
|
||||
|
||||
cmp [rdi+0x4c], 7 cardtype 7 = kit/stadium/badge <- we produce this
|
||||
cmp [rdi+0x60], 4 <- THE BLOCKER
|
||||
mov eax, [rdi+0x5c] itemState
|
||||
cmp eax, 0x65 / 0x66 101 activeHomeKit / 102 activeAwayKit <- we produce
|
||||
mov r8d, [rdi+0x94] teamid <- we produce
|
||||
mov r9d, [rdi+0xba] kit variant selector (unresolved)
|
||||
|
||||
So every input EXCEPT `+0x60` is already satisfied by what OpenFUT serves, and
|
||||
no instruction in either module ever stores the constant 4 there.
|
||||
|
||||
Usage: python3 kit_gate_probe.py
|
||||
"""
|
||||
import collections
|
||||
import struct
|
||||
import sys
|
||||
|
||||
import watch_club_model as W
|
||||
|
||||
try:
|
||||
import card_identity_probe as P
|
||||
except Exception: # pragma: no cover - probe is optional for the static half
|
||||
P = None
|
||||
|
||||
TEXT_START = 0x180001000
|
||||
FIELD = 0x60
|
||||
REGS = ["rax", "rcx", "rdx", "rbx", "rsp", "rbp", "rsi", "rdi"]
|
||||
REC_SIZE = 0x158
|
||||
F_SUBTYPE = 0x50
|
||||
|
||||
|
||||
def live_distribution(mem, base):
|
||||
"""(+0x60 histogram, (subtype,+0x60) histogram) over resident records."""
|
||||
if P is None:
|
||||
return None, None
|
||||
obj = mem.q(base + (W.G_CARDSDB - W.IMG_BASE))
|
||||
if not obj:
|
||||
return None, None
|
||||
by_value = collections.Counter()
|
||||
by_pair = collections.Counter()
|
||||
for node in P.nodes(mem, obj):
|
||||
buf = mem.read(node + 0x28, REC_SIZE)
|
||||
if not buf or len(buf) < REC_SIZE:
|
||||
continue
|
||||
subtype = struct.unpack_from("<I", buf, F_SUBTYPE)[0]
|
||||
value = struct.unpack_from("<i", buf, FIELD)[0]
|
||||
by_value[value] += 1
|
||||
by_pair[(subtype, value)] += 1
|
||||
return by_value, by_pair
|
||||
|
||||
|
||||
def scan_text(text):
|
||||
"""(readers, immediate stores, item-record markers) over a .text image."""
|
||||
readers, stores, markers = [], [], set()
|
||||
for i in range(len(text) - 8):
|
||||
op, modrm = text[i], text[i + 1]
|
||||
mod, reg, rm = modrm >> 6, (modrm >> 3) & 7, modrm & 7
|
||||
if mod != 1 or rm == 4:
|
||||
continue
|
||||
disp = text[i + 2]
|
||||
if disp in (0x4C, 0x5C) and op in (0x8B, 0x89, 0x83, 0x39, 0x3B, 0xC7, 0x0F):
|
||||
markers.add(TEXT_START + i)
|
||||
if disp != FIELD:
|
||||
continue
|
||||
if op == 0x83 and reg == 7: # cmp dword [reg+0x60], imm8
|
||||
readers.append((TEXT_START + i, REGS[rm], text[i + 3]))
|
||||
elif op == 0xC7 and reg == 0: # mov dword [reg+0x60], imm32
|
||||
stores.append((TEXT_START + i, REGS[rm], struct.unpack_from("<i", text, i + 3)[0], "dword"))
|
||||
elif op == 0xC6 and reg == 0: # mov byte [reg+0x60], imm8
|
||||
stores.append((TEXT_START + i, REGS[rm], text[i + 3], "byte"))
|
||||
return readers, stores, markers
|
||||
|
||||
|
||||
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." % (pid, W.DLL))
|
||||
return 1
|
||||
mem = W.Mem(pid)
|
||||
|
||||
print("pid=%d %s base=%#x" % (pid, W.DLL, base))
|
||||
print()
|
||||
|
||||
by_value, by_pair = live_distribution(mem, base)
|
||||
print("── live records ──")
|
||||
if by_value is None:
|
||||
print(" CardsDb is empty (no FUT session loaded); static half still runs.")
|
||||
else:
|
||||
print(" +0x60 distribution : %s" % dict(by_value))
|
||||
print(" (cardsubtypeid, +0x60) : %s" % dict(by_pair))
|
||||
print(" holds the gate value 4 : %s" % ("YES" if 4 in by_value else "NO"))
|
||||
print()
|
||||
|
||||
# .text is the second CardsDLL mapping; read it whole and scan.
|
||||
size = 0x1E4000
|
||||
buf, bad = mem.read_pages(base + 0x1000, size)
|
||||
if bad:
|
||||
print(" WARNING: %d unreadable page(s); the scan is incomplete." % len(bad))
|
||||
text = bytes(buf)
|
||||
|
||||
readers, stores, markers = scan_text(text)
|
||||
print("── readers: cmp dword [reg+0x60], imm8 ──")
|
||||
for va, reg, imm in readers:
|
||||
flag = " <-- THE KIT GATE" if imm == 4 else ""
|
||||
print(" %#x cmp [%s+0x60], %d%s" % (va, reg, imm, flag))
|
||||
print()
|
||||
|
||||
print("── immediate stores to [reg+0x60] ──")
|
||||
consts = collections.Counter(s[2] for s in stores)
|
||||
print(" %d store(s); constants %s" % (len(stores), dict(sorted(consts.items()))))
|
||||
near = [s for s in stores if any(abs(m - s[0]) <= 96 for m in markers)]
|
||||
print(" %d of them sit within 96B of item-record code (+0x4c/+0x5c):" % len(near))
|
||||
for va, reg, imm, width in near:
|
||||
print(" %#x mov %s [%s+0x60], %d" % (va, width, reg, imm))
|
||||
print()
|
||||
|
||||
print("=" * 70)
|
||||
if any(s[2] == 4 for s in stores):
|
||||
print("A store of 4 EXISTS -- the gate is reachable. Follow the sites above.")
|
||||
return 0
|
||||
print("NO instruction in CardsDLL stores the constant 4 into +0x60.")
|
||||
print("Combined with the live records (never 4) and the fact that every OTHER")
|
||||
print("gate input is already served, the pre-match kit selector cannot be")
|
||||
print("opened by anything the server sends. This is a CLIENT-side dead end,")
|
||||
print("not a missing wire field.")
|
||||
print()
|
||||
print("Scope of the claim: immediate stores, all widths, disp8 form. A value")
|
||||
print("could still arrive by register copy -- but in CardsDLL every register")
|
||||
print("store to +0x60 is a field-by-field struct copy or an init to 0/1/-2.")
|
||||
print("=" * 70)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
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())
|
||||
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())
|
||||
Executable
+161
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Resolve the runtime string comparator behind `DAT_1802ddfd8 + 0x248`, and
|
||||
settle whether the `itemState` match is case-sensitive.
|
||||
|
||||
READ-ONLY. /proc/PID/mem is opened 'rb'; there is no write path in this file.
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
`itemState` arrives on the wire as a STRING ("free", "activeHomeKit", ...) and
|
||||
the client turns it into its runtime enum by comparing that string against its
|
||||
own table. The compare goes through `FUN_180008190`, whose whole body is:
|
||||
|
||||
mov rax, [DAT_1802ddfd8] ; the service object, populated at runtime
|
||||
mov r9, [rax + 0x248] ; slot 0x248
|
||||
jmp r9 ; tail-jump
|
||||
|
||||
The slot is empty on disk, so `plan-2026-08-06-card-subsystem.md` section 5
|
||||
recorded the casing question as "almost certainly unresolvable statically" and
|
||||
listed this as a read-only live probe. It is worth answering: every shaper in
|
||||
openfut-adapter-fifa17 emits these tokens, and if the comparator folded case then
|
||||
our table's casing would be a convention rather than a contract.
|
||||
|
||||
WHAT IT DOES
|
||||
------------
|
||||
Reads the slot in the live process and follows the forwarding chain
|
||||
(`e9` rel32 thunk -> `ff 25` IAT jump -> body), attributing each hop to a module.
|
||||
Wine maps PE images as anonymous, so a mapping's own path is usually empty; the
|
||||
module is recovered from the nearest PRECEDING named mapping, which is the PE
|
||||
header page.
|
||||
|
||||
At the body it decides case sensitivity from the instruction stream rather than
|
||||
from a symbol name: a case-insensitive comparator MUST fold case, so it carries
|
||||
an `or ..,0x20` / lowercase-table lookup. A byte compare with no folding is
|
||||
case-SENSITIVE.
|
||||
|
||||
MEASURED 2026-08-21 (pid 6580):
|
||||
slot -> 0x146d1c020 (thunk) -> 0x145e27fe0 (IAT) -> msvcr120.dll + 0x3c330
|
||||
body is strncmp: `sub rdx,rcx` / `test r8,r8` (count) / `test al,al` (NUL) /
|
||||
`cmp al,[rcx+rdx]` with NO case folding, plus the MSVC NUL-detect constants
|
||||
0x8080808080808080 and 0xfefefefefefefeff.
|
||||
=> the itemState match is CASE-SENSITIVE. Emit the table's exact casing.
|
||||
|
||||
Usage: python3 service_ptr_probe.py
|
||||
"""
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
|
||||
import watch_club_model as W
|
||||
|
||||
DAT_SERVICE = 0x1802DDFD8
|
||||
SLOT = 0x248
|
||||
MAX_HOPS = 8
|
||||
|
||||
# A case-insensitive comparator has to fold case somewhere. These are the two
|
||||
# ways MSVC does it; neither appears in a plain strcmp/strncmp/memcmp.
|
||||
FOLD_OR_IMM8 = b"\x0c\x20" # or al, 0x20
|
||||
FOLD_OR_EAX = b"\x83\xc8\x20" # or eax, 0x20
|
||||
|
||||
|
||||
def mappings(pid):
|
||||
out = []
|
||||
with open("/proc/%d/maps" % pid) as fh:
|
||||
for line in fh:
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) (\S{4}) \S+ \S+ \S+\s*(.*)", line)
|
||||
if m:
|
||||
out.append((int(m.group(1), 16), int(m.group(2), 16), m.group(3), m.group(4)))
|
||||
return out
|
||||
|
||||
|
||||
def attribute(maps, va):
|
||||
"""(module_path, perms, offset_from_module_base) for `va`.
|
||||
|
||||
Wine maps PE sections anonymously, so the owning mapping usually has no
|
||||
path; the module is the nearest preceding NAMED mapping (its header page).
|
||||
"""
|
||||
named = None
|
||||
for start, end, perms, path in maps:
|
||||
if path:
|
||||
named = (start, path)
|
||||
if start <= va < end:
|
||||
if named:
|
||||
return named[1], perms, va - named[0]
|
||||
return path or "[anonymous]", perms, None
|
||||
return None, None, None
|
||||
|
||||
|
||||
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." % (pid, W.DLL))
|
||||
return 1
|
||||
|
||||
mem = W.Mem(pid)
|
||||
maps = mappings(pid)
|
||||
glob = base + (DAT_SERVICE - W.IMG_BASE)
|
||||
svc = mem.q(glob)
|
||||
print("pid=%d %s base=%#x" % (pid, W.DLL, base))
|
||||
print("DAT_1802ddfd8 @ %#x -> service %#x" % (glob, svc or 0))
|
||||
if not svc:
|
||||
print("service pointer is NULL; the host has not handed CardsDLL its table yet.")
|
||||
return 2
|
||||
|
||||
va = mem.q(svc + SLOT)
|
||||
print("*(service + %#x) = %#x" % (SLOT, va or 0))
|
||||
if not va:
|
||||
print("slot %#x is empty." % SLOT)
|
||||
return 2
|
||||
print()
|
||||
|
||||
body = None
|
||||
for hop in range(MAX_HOPS):
|
||||
buf = mem.read(va, 16)
|
||||
if not buf or len(buf) < 6:
|
||||
print("hop %d: %#x unreadable" % (hop, va))
|
||||
return 2
|
||||
path, perms, off = attribute(maps, va)
|
||||
where = "%s+%#x" % (path, off) if off is not None else str(path)
|
||||
print("hop %d: %#x [%s] %s %s" % (hop, va, perms, where, buf[:8].hex()))
|
||||
if buf[0] == 0xE9: # jmp rel32
|
||||
va = va + 5 + struct.unpack("<i", buf[1:5])[0]
|
||||
elif buf[0] == 0xFF and buf[1] == 0x25: # jmp [rip+rel32]
|
||||
nxt = mem.q(va + 6 + struct.unpack("<i", buf[2:6])[0])
|
||||
if not nxt:
|
||||
print(" IAT slot is empty.")
|
||||
return 2
|
||||
va = nxt
|
||||
else:
|
||||
body = (va, path, off)
|
||||
print(" -> function body")
|
||||
break
|
||||
if body is None:
|
||||
print("chain did not settle within %d hops." % MAX_HOPS)
|
||||
return 2
|
||||
|
||||
addr, path, off = body
|
||||
code = mem.read(addr, 256) or b""
|
||||
folds = FOLD_OR_IMM8 in code or FOLD_OR_EAX in code
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("COMPARATOR: %s+%#x (%#x)" % (path, off if off is not None else 0, addr))
|
||||
print("case folding in first %d bytes: %s" % (len(code), "YES" if folds else "NO"))
|
||||
if folds:
|
||||
print("VERDICT: case-INSENSITIVE. itemState casing is a convention, not a contract.")
|
||||
else:
|
||||
print("VERDICT: case-SENSITIVE. A byte compare with no folding means the")
|
||||
print(" wire token must match the table's casing EXACTLY -- a")
|
||||
print(" mis-cased token silently resolves to itemState 0 (invalid).")
|
||||
print(" openfut-adapter-fifa17's fut::item_state table is therefore")
|
||||
print(" a contract: emit its casing verbatim.")
|
||||
print("=" * 70)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(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())
|
||||
Executable
+102
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Enumerate every UTAS URL template CardsDLL can build, from live memory.
|
||||
|
||||
READ-ONLY: /proc/PID/mem opened 'rb'. No write path in this file.
|
||||
|
||||
WHY
|
||||
---
|
||||
Support level L5 ("apply endpoint") for consumables was recorded as unreversed,
|
||||
with an earlier note claiming there is "no training/position/chemistry/
|
||||
manager-league endpoint at all" and that the only owned-item mutations upstream
|
||||
are quick sell and move/pile. That claim is load-bearing -- if true, applying a
|
||||
consumable is not a server route at all and L5/L6 cannot be implemented as one --
|
||||
so it deserves to be checked against the binary rather than inherited.
|
||||
|
||||
This scans CardsDLL's .rdata for route-shaped strings and prints them, so the
|
||||
full reachable surface can be read at once.
|
||||
|
||||
Positive control: known-live routes MUST appear (e.g. a 'item' path and a
|
||||
'club' path). If the control is empty the region is wrong, not the game.
|
||||
|
||||
Usage:
|
||||
python3 url_template_probe.py # route-shaped strings
|
||||
python3 url_template_probe.py --all # every printable string >= 6 chars
|
||||
python3 url_template_probe.py --grep pat # substring filter (case-insensitive)
|
||||
"""
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
|
||||
import watch_club_model as W
|
||||
|
||||
RDATA_LO, RDATA_HI = 0x1801E5000, 0x18028A000
|
||||
DATA_LO, DATA_HI = 0x18028A000, 0x1802F0000
|
||||
|
||||
# Route-ish: contains a slash and no spaces, or looks like a UTAS path fragment.
|
||||
ROUTE_HINTS = ("ut/", "game/", "item", "club", "squad", "purchase", "consumable",
|
||||
"apply", "training", "position", "chemistry", "contract",
|
||||
"fitness", "healing", "playstyle", "manager", "pile", "delete",
|
||||
"transfer", "market", "auction", "sbs", "pack", "store")
|
||||
|
||||
PRINTABLE = re.compile(rb"[\x20-\x7e]{6,}")
|
||||
|
||||
|
||||
def strings(mem, lo, hi):
|
||||
buf, bad = mem.read_pages(W_live(lo), hi - lo)
|
||||
if not buf:
|
||||
return [], bad
|
||||
out = []
|
||||
for m in PRINTABLE.finditer(bytes(buf)):
|
||||
out.append((lo + m.start(), m.group().decode("ascii")))
|
||||
return out, bad
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--all", action="store_true")
|
||||
ap.add_argument("--grep")
|
||||
a = ap.parse_args()
|
||||
|
||||
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)
|
||||
|
||||
global W_live
|
||||
W_live = lambda i: base + (i - W.IMG_BASE)
|
||||
|
||||
print("pid=%d CardsDLL live base %#x" % (pid, base))
|
||||
found = []
|
||||
for lo, hi, name in ((RDATA_LO, RDATA_HI, ".rdata"), (DATA_LO, DATA_HI, ".data")):
|
||||
ss, bad = strings(mem, lo, hi)
|
||||
print(" %s: %d strings (%d bad pages)" % (name, len(ss), len(bad)))
|
||||
found.extend(ss)
|
||||
|
||||
if a.grep:
|
||||
pat = a.grep.lower()
|
||||
sel = [(va, s) for va, s in found if pat in s.lower()]
|
||||
elif a.all:
|
||||
sel = found
|
||||
else:
|
||||
sel = [(va, s) for va, s in found
|
||||
if "/" in s and " " not in s
|
||||
and any(h in s.lower() for h in ROUTE_HINTS)]
|
||||
|
||||
print("\n%d matching string(s):" % len(sel))
|
||||
for va, s in sel:
|
||||
print(" %#x %s" % (va, s))
|
||||
|
||||
ctrl = [s for _, s in found if "ut/game" in s.lower()]
|
||||
print("\nCONTROL ('ut/game' present): %s (%d)"
|
||||
% ("OK" if ctrl else "EMPTY -> wrong region", len(ctrl)))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -38,6 +38,38 @@ pub struct Fifa17CardIdentity {
|
||||
/// 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.
|
||||
@@ -58,6 +90,29 @@ impl Fifa17WireItemIdPolicy {
|
||||
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
|
||||
@@ -131,6 +186,27 @@ struct RawCard {
|
||||
/// 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 {
|
||||
@@ -187,6 +263,13 @@ impl Fifa17CardCatalog {
|
||||
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,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -272,6 +355,52 @@ mod tests {
|
||||
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(
|
||||
@@ -397,7 +526,9 @@ mod tests {
|
||||
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_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();
|
||||
@@ -407,5 +538,10 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,33 +11,103 @@ use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::content_taxonomy::ContentKind;
|
||||
use crate::fut::entities::ReverseEntityResolver;
|
||||
use crate::fut::item::shape_item;
|
||||
use crate::fut::item::{shape_club_item, shape_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, Fifa17Identity, ItemIdentityResolver, ShapeStats};
|
||||
pub use crate::fut::item::{
|
||||
CoreOwnedItem, Fifa17ConsumableIdentity, Fifa17Identity, Fifa17KitIdentity,
|
||||
Fifa17StaffIdentity, ItemIdentityResolver, ShapeStats,
|
||||
};
|
||||
|
||||
/// Shape the whole `/club` response. Items without a resolvable real asset id
|
||||
/// are dropped (counted in `ShapeStats`), never emitted with a fabricated id.
|
||||
/// 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 {
|
||||
// Exclude non-player content (consumables/staff): a `/club` player list
|
||||
// must never render them as 0-rated players. Counted, never emitted.
|
||||
if ident.kind_of(item) != ContentKind::Player {
|
||||
stats.excluded_non_player += 1;
|
||||
continue;
|
||||
}
|
||||
match ident.resolve(item) {
|
||||
Some(id) => {
|
||||
out.push(shape_item(item, id, ent));
|
||||
stats.emitted += 1;
|
||||
match ident.kind_of(item) {
|
||||
ContentKind::Player => match ident.resolve(item) {
|
||||
Some(id) => {
|
||||
out.push(shape_item(item, id, ent, ident.discard_value(item)));
|
||||
stats.emitted += 1;
|
||||
}
|
||||
None => stats.dropped_no_asset += 1,
|
||||
},
|
||||
// Kit, badge and stadium are ONE cardtype-7 record with one
|
||||
// client-side resolver; only the equipped designation differs.
|
||||
ContentKind::Kit | ContentKind::Badge | ContentKind::Stadium => {
|
||||
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_club_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;
|
||||
}
|
||||
// The cardtype-9 families. Unlike kits/badges/stadia these have NO
|
||||
// database name resolver at all, so the displayed name can only come
|
||||
// from `localizedName` on the wire. That offset is confirmed
|
||||
// (`+0xd9`), but "the parser reads it" is NOT "sending it is safe",
|
||||
// and this project pays for that distinction with a client freeze.
|
||||
// Counted and withheld rather than guessed: ownership stays
|
||||
// authoritative in Core either way, and club/stats still counts the
|
||||
// families so the screen's own numbers are right.
|
||||
ContentKind::Ball | ContentKind::Misc => {
|
||||
stats.excluded_non_player += 1;
|
||||
}
|
||||
None => stats.dropped_no_asset += 1,
|
||||
}
|
||||
}
|
||||
(json!({ "itemData": out }), stats)
|
||||
@@ -207,11 +277,19 @@ mod tests {
|
||||
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)
|
||||
@@ -221,7 +299,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_and_staff_are_excluded_from_club_players() {
|
||||
fn consumables_are_excluded_but_staff_is_shaped() {
|
||||
let ent = entities();
|
||||
let id = |item_id: u32, asset: u32| Fifa17Identity {
|
||||
item_id,
|
||||
@@ -233,8 +311,19 @@ mod tests {
|
||||
ids: HashMap::from([
|
||||
("card_player".to_string(), id(100000001, 20801)),
|
||||
("card_consumable".to_string(), id(100000002, 5003012)),
|
||||
("card_staff".to_string(), id(100000003, 3000083)),
|
||||
]),
|
||||
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),
|
||||
@@ -254,12 +343,204 @@ mod tests {
|
||||
item("oc3", "card_staff", 0, "", "", "", ""),
|
||||
];
|
||||
let (body, stats) = shape_club_response(&items, &ent, &ident);
|
||||
assert_eq!(stats.emitted, 1, "only the player is emitted");
|
||||
assert_eq!(stats.excluded_non_player, 2, "consumable + staff excluded");
|
||||
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(), 1);
|
||||
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());
|
||||
}
|
||||
|
||||
/// Kit, badge and stadium are one cardtype-7 record and MUST all project.
|
||||
/// Ball and league logo are cardtype 9, have no database name resolver, and
|
||||
/// stay withheld until `localizedName` is established as safe to send.
|
||||
/// Counting a family in club/stats while never shaping it is the divergence
|
||||
/// this test pins: the wire set and the withheld set are both asserted.
|
||||
#[test]
|
||||
fn cardtype7_club_items_project_and_cardtype9_stay_withheld() {
|
||||
let ent = entities();
|
||||
let kit_id = |item_id, resource, subtype, art| Fifa17KitIdentity {
|
||||
item_id,
|
||||
asset_id: resource,
|
||||
resource_id: resource,
|
||||
card_asset_id: art,
|
||||
subtype,
|
||||
team_id: 21,
|
||||
};
|
||||
let ident = KindMapIdentity {
|
||||
ids: HashMap::new(),
|
||||
kinds: HashMap::from([
|
||||
("c_kit".to_string(), ContentKind::Kit),
|
||||
("c_badge".to_string(), ContentKind::Badge),
|
||||
("c_stadium".to_string(), ContentKind::Stadium),
|
||||
("c_ball".to_string(), ContentKind::Ball),
|
||||
("c_logo".to_string(), ContentKind::Misc),
|
||||
]),
|
||||
kits: HashMap::from([
|
||||
("c_kit".to_string(), kit_id(1, 6_300_006, 9, 35)),
|
||||
("c_badge".to_string(), kit_id(2, 6_000_005, 11, 39)),
|
||||
("c_stadium".to_string(), kit_id(3, 6_200_000, 10, 36)),
|
||||
// Resolvable on purpose: withholding must be a decision about the
|
||||
// FAMILY, not an accident of a missing identity.
|
||||
("c_ball".to_string(), kit_id(4, 8_120_194, 30, 37)),
|
||||
("c_logo".to_string(), kit_id(5, 8_010_015, 31, 40)),
|
||||
]),
|
||||
staff: HashMap::new(),
|
||||
};
|
||||
let items: Vec<CoreOwnedItem> = ["c_kit", "c_badge", "c_stadium", "c_ball", "c_logo"]
|
||||
.iter()
|
||||
.map(|c| item(&format!("oc_{c}"), c, 0, "", "", "", ""))
|
||||
.collect();
|
||||
|
||||
let (body, stats) = shape_club_response_with_kits(
|
||||
&items,
|
||||
&ent,
|
||||
&ident,
|
||||
ActiveKitAssignments {
|
||||
home: None,
|
||||
away: None,
|
||||
},
|
||||
);
|
||||
let arr = body["itemData"].as_array().unwrap();
|
||||
assert_eq!(stats.emitted, 3, "kit + badge + stadium");
|
||||
assert_eq!(stats.excluded_non_player, 2, "ball + league logo withheld");
|
||||
assert_eq!(stats.dropped_no_asset, 0, "withholding is not a drop");
|
||||
|
||||
let subtypes: Vec<i64> = arr
|
||||
.iter()
|
||||
.map(|i| i["cardsubtypeid"].as_i64().unwrap())
|
||||
.collect();
|
||||
assert_eq!(subtypes, vec![9, 11, 10]);
|
||||
// teamid only where the caption resolves TeamName_Abbr15_<teamid>.
|
||||
assert_eq!(arr[0]["teamid"], 21, "kit");
|
||||
assert_eq!(arr[1]["teamid"], 21, "badge");
|
||||
assert!(
|
||||
arr[2].get("teamid").is_none(),
|
||||
"stadium caption reads assetId"
|
||||
);
|
||||
for it in arr {
|
||||
assert!(
|
||||
item_state::is_recovered(it["itemState"].as_str().unwrap()),
|
||||
"every emitted state must be a recovered token"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
//! (`FUN_18012fd40` atom table). The body is `{"stat":[{contextId,contextValue,
|
||||
//! type,typeValue}, …]}`:
|
||||
//! * a GLOBAL bucket (contextId 1, contextValue 0) with player tier counts,
|
||||
//! staff-by-family, consumables-by-family, and honest zeros for club items;
|
||||
//! 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.
|
||||
//!
|
||||
@@ -23,14 +24,18 @@ 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` come from the FIFA catalog; `nation_id`/`league_id`/
|
||||
/// `team_id` from the reverse entity resolver (None = unresolved, bucket skipped).
|
||||
/// 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>,
|
||||
@@ -57,7 +62,26 @@ 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> {
|
||||
@@ -184,10 +208,10 @@ pub fn club_stats_body(items: &[ClubStatInput], ctx: ContextField) -> Value {
|
||||
g.insert(sid, 0);
|
||||
}
|
||||
let mut staff_total = 0i64;
|
||||
for it in items
|
||||
.iter()
|
||||
.filter(|i| matches!(i.kind, ContentKind::Staff))
|
||||
{
|
||||
// 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;
|
||||
@@ -215,12 +239,31 @@ pub fn club_stats_body(items: &[ClubStatInput], ctx: ContextField) -> Value {
|
||||
}
|
||||
g.insert(S_CONSUMABLES, cons_total);
|
||||
|
||||
// club items: honest zeros (Core holds none; each is read by some panel).
|
||||
for sid in [
|
||||
0x14, 0x1E, 0x28, 0x29, 0x2A, 0x2D, 0x2E, 0x2F, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38,
|
||||
] {
|
||||
// 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();
|
||||
|
||||
@@ -238,10 +281,30 @@ pub fn club_stats_body(items: &[ClubStatInput], ctx: ContextField) -> Value {
|
||||
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, 0));
|
||||
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;
|
||||
@@ -270,6 +333,7 @@ mod tests {
|
||||
subtype: 0,
|
||||
rating,
|
||||
rare,
|
||||
asset_id: 158023,
|
||||
nation_id: nation,
|
||||
league_id: None,
|
||||
team_id: None,
|
||||
@@ -281,6 +345,7 @@ mod tests {
|
||||
subtype,
|
||||
rating: 0,
|
||||
rare: false,
|
||||
asset_id: 0,
|
||||
nation_id: None,
|
||||
league_id: None,
|
||||
team_id: None,
|
||||
@@ -292,11 +357,34 @@ mod tests {
|
||||
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"]
|
||||
@@ -338,6 +426,59 @@ mod tests {
|
||||
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
|
||||
@@ -355,6 +496,64 @@ mod tests {
|
||||
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![
|
||||
@@ -379,7 +578,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn honest_zero_club_items_present() {
|
||||
let g = global(&club_stats_body(&[player(90, false, None)], ContextField::Nation));
|
||||
let g = global(&club_stats_body(
|
||||
&[player(90, false, None)],
|
||||
ContextField::Nation,
|
||||
));
|
||||
for atom in [
|
||||
"stadia",
|
||||
"balls",
|
||||
@@ -411,7 +613,10 @@ mod tests {
|
||||
.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();
|
||||
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).
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@
|
||||
//! * 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).
|
||||
//! `FUN_1801bfac0`) and `fifa17-recon/docs/plan-2026-08-06-card-subsystem.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.
|
||||
//!
|
||||
@@ -18,15 +18,33 @@
|
||||
//! resolves to `None` — the caller DEFERS it (mirroring the player NoName gate),
|
||||
//! never fabricating a family.
|
||||
|
||||
/// The disjoint content classes a FIFA 17 owned card can belong to. Player is
|
||||
/// 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,
|
||||
Consumable,
|
||||
/// 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 {
|
||||
@@ -34,8 +52,14 @@ impl ContentKind {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ContentKind::Player => "player",
|
||||
ContentKind::Consumable => "consumable",
|
||||
ContentKind::Manager => "manager",
|
||||
ContentKind::Staff => "staff",
|
||||
ContentKind::Consumable => "consumable",
|
||||
ContentKind::Kit => "kit",
|
||||
ContentKind::Badge => "badge",
|
||||
ContentKind::Ball => "ball",
|
||||
ContentKind::Stadium => "stadium",
|
||||
ContentKind::Misc => "misc",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,11 +71,62 @@ impl ContentKind {
|
||||
#[allow(clippy::should_implement_trait)]
|
||||
pub fn from_str(s: &str) -> ContentKind {
|
||||
match s {
|
||||
"consumable" => ContentKind::Consumable,
|
||||
"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)
|
||||
}
|
||||
|
||||
/// True for the three club-customisation kinds that share the **cardtype-7**
|
||||
/// record: kit (9), stadium (10) and badge (11).
|
||||
///
|
||||
/// `FUN_1800d8330` maps all three subtypes to cardtype 7, and one client-side
|
||||
/// resolver (`FUN_180119bd0`, dispatched on `item+0x4c == 7`) captions all
|
||||
/// three. They therefore share ONE wire record
|
||||
/// ([`crate::fut::item::shape_club_item`]) and one identity resolver.
|
||||
///
|
||||
/// Ball (30) and league logo (31) are cardtype 9 and are deliberately NOT in
|
||||
/// this family. They are not merely unproven — they are UNNAMEABLE, measured
|
||||
/// against the running client on 2026-08-21
|
||||
/// (`fifa17-recon/tools/cardtype_dispatch_probe.py`):
|
||||
///
|
||||
/// * the merge switch's jump table (rva `0x141eb4`, indexed `cardtype - 1`)
|
||||
/// sends cardtypes 6/7/8/9 to a shared tail that runs no query and writes
|
||||
/// no name;
|
||||
/// * `cmp [reg+0x4c], 9` occurs ZERO times in `.text`;
|
||||
/// * `cmp [reg+0x50], 30` and `… , 31` occur ZERO times, while kit 9,
|
||||
/// stadium 10 and badge 11 all appear (the positive control);
|
||||
/// * the cardtype-7 resolver is gated `cmp [rax+0x4c], 7`, so a cardtype-9
|
||||
/// item can never reach it.
|
||||
///
|
||||
/// So no `localizedName` we send could become a caption: nothing reads one
|
||||
/// for these subtypes. Serving them would draw unnamed cards, and no
|
||||
/// server-side change can fix that.
|
||||
pub fn is_cardtype7_club_item(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
ContentKind::Kit | ContentKind::Stadium | ContentKind::Badge
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The functional family + honest display label for a consumable `cardsubtypeid`,
|
||||
@@ -80,6 +155,12 @@ pub fn consumable_family(subtype: i64) -> Option<(&'static str, &'static str)> {
|
||||
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.
|
||||
@@ -95,6 +176,172 @@ pub fn staff_role(subtype: i64) -> Option<(&'static str, &'static str)> {
|
||||
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::*;
|
||||
@@ -102,19 +349,171 @@ mod tests {
|
||||
#[test]
|
||||
fn content_kind_round_trips_and_defaults_to_player() {
|
||||
assert_eq!(ContentKind::default(), ContentKind::Player);
|
||||
for k in [
|
||||
// The FULL Core content vocabulary, every token round-tripping.
|
||||
let all = [
|
||||
ContentKind::Player,
|
||||
ContentKind::Consumable,
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
//! FIFA 17 **discard (quick-sell) pricing** — the client's OWN table and formula.
|
||||
//!
|
||||
//! Nothing here is invented. The client computes a card's discard value locally
|
||||
//! whenever the server sends `discardValue == 0` (`FUN_18013fe00` stores our
|
||||
//! value at item `+0x38`; `0x180141025` `cmp dword [rbp+0x198],0` / `ja` skips
|
||||
//! the local path when it is non-zero). The local path runs
|
||||
//!
|
||||
//! ```sql
|
||||
//! SELECT "price" FROM "fcc_discardcoins" WHERE "cardtype"==? AND "level"==? AND "rare"==?
|
||||
//! ```
|
||||
//!
|
||||
//! and then, at `0x180141119..0x180141140`:
|
||||
//!
|
||||
//! ```text
|
||||
//! value = (rating * price) / 100, rounded half up
|
||||
//! ```
|
||||
//!
|
||||
//! Sources, all in-repo:
|
||||
//! * `fifa17-recon/docs/plan-2026-08-05-store-subsystem.md` §3.6 — the SQL, the
|
||||
//! formula, the `cardsubtypeid -> cardtype` decode (checked against every
|
||||
//! subtype `0..599` with zero disagreements) and the `level` ladder.
|
||||
//! * `fifa17-recon/data/tables/fcc_discardcoins.json` — the 141-row table itself.
|
||||
//! [`DISCARD_COINS`] is generated from that file and a test re-reads the file
|
||||
//! and asserts they still agree row for row, so the two cannot drift.
|
||||
//!
|
||||
//! The reversal was **verified against 22 live club items, 22 of 22 exact**.
|
||||
//!
|
||||
//! A key that is not in the table pays `0` (the client's price register stays 0),
|
||||
//! so [`table_price`] returns `0` rather than panicking or substituting a floor.
|
||||
|
||||
/// `(cardtype, level, rare, price)`, generated verbatim from the client's
|
||||
/// `fcc_discardcoins` table. Sorted; keys are unique.
|
||||
const DISCARD_COINS: [(u8, u8, u8, i64); 141] = [
|
||||
(1, 1, 0, 30),
|
||||
(1, 1, 1, 75),
|
||||
(1, 1, 2, 2000),
|
||||
(1, 1, 3, 2000),
|
||||
(1, 1, 4, 6000),
|
||||
(1, 1, 5, 20000),
|
||||
(1, 1, 6, 20000),
|
||||
(1, 1, 7, 1500),
|
||||
(1, 1, 8, 6000),
|
||||
(1, 1, 9, 6000),
|
||||
(1, 1, 10, 2000),
|
||||
(1, 1, 11, 10000),
|
||||
(1, 1, 12, 120000),
|
||||
(1, 1, 13, 2000),
|
||||
(1, 1, 17, 2000),
|
||||
(1, 1, 18, 2000),
|
||||
(1, 1, 19, 2000),
|
||||
(1, 1, 20, 2000),
|
||||
(1, 1, 21, 2000),
|
||||
(1, 1, 22, 2000),
|
||||
(1, 1, 23, 2000),
|
||||
(1, 1, 24, 2000),
|
||||
(1, 1, 25, 2000),
|
||||
(1, 1, 26, 2000),
|
||||
(1, 1, 27, 2000),
|
||||
(1, 1, 28, 2000),
|
||||
(1, 1, 29, 2000),
|
||||
(1, 1, 30, 2000),
|
||||
(1, 1, 31, 2000),
|
||||
(1, 2, 0, 150),
|
||||
(1, 2, 1, 350),
|
||||
(1, 2, 2, 7000),
|
||||
(1, 2, 3, 7000),
|
||||
(1, 2, 4, 10000),
|
||||
(1, 2, 5, 40000),
|
||||
(1, 2, 6, 40000),
|
||||
(1, 2, 7, 5000),
|
||||
(1, 2, 8, 10000),
|
||||
(1, 2, 9, 10000),
|
||||
(1, 2, 10, 7000),
|
||||
(1, 2, 11, 15000),
|
||||
(1, 2, 12, 120000),
|
||||
(1, 2, 13, 7000),
|
||||
(1, 2, 17, 7000),
|
||||
(1, 2, 18, 7000),
|
||||
(1, 2, 19, 7000),
|
||||
(1, 2, 20, 7000),
|
||||
(1, 2, 21, 7000),
|
||||
(1, 2, 22, 7000),
|
||||
(1, 2, 23, 7000),
|
||||
(1, 2, 24, 7000),
|
||||
(1, 2, 25, 7000),
|
||||
(1, 2, 26, 7000),
|
||||
(1, 2, 27, 7000),
|
||||
(1, 2, 28, 7000),
|
||||
(1, 2, 29, 7000),
|
||||
(1, 2, 30, 7000),
|
||||
(1, 2, 31, 7000),
|
||||
(1, 3, 0, 400),
|
||||
(1, 3, 1, 800),
|
||||
(1, 3, 2, 12200),
|
||||
(1, 3, 3, 12200),
|
||||
(1, 3, 4, 18000),
|
||||
(1, 3, 5, 80000),
|
||||
(1, 3, 6, 80000),
|
||||
(1, 3, 7, 9000),
|
||||
(1, 3, 8, 18000),
|
||||
(1, 3, 9, 18000),
|
||||
(1, 3, 10, 12200),
|
||||
(1, 3, 11, 24000),
|
||||
(1, 3, 12, 120000),
|
||||
(1, 3, 13, 12200),
|
||||
(1, 3, 17, 12200),
|
||||
(1, 3, 18, 12200),
|
||||
(1, 3, 19, 12200),
|
||||
(1, 3, 20, 12200),
|
||||
(1, 3, 21, 12200),
|
||||
(1, 3, 22, 12200),
|
||||
(1, 3, 23, 12200),
|
||||
(1, 3, 24, 12200),
|
||||
(1, 3, 25, 12200),
|
||||
(1, 3, 26, 12200),
|
||||
(1, 3, 27, 12200),
|
||||
(1, 3, 28, 12200),
|
||||
(1, 3, 29, 12200),
|
||||
(1, 3, 30, 12200),
|
||||
(1, 3, 31, 12200),
|
||||
(2, 1, 0, 20),
|
||||
(2, 1, 1, 25),
|
||||
(2, 2, 0, 70),
|
||||
(2, 2, 1, 120),
|
||||
(2, 3, 0, 110),
|
||||
(2, 3, 1, 320),
|
||||
(3, 1, 0, 10),
|
||||
(3, 1, 1, 50),
|
||||
(3, 2, 0, 55),
|
||||
(3, 2, 1, 100),
|
||||
(3, 3, 0, 110),
|
||||
(3, 3, 1, 300),
|
||||
(4, 1, 0, 10),
|
||||
(4, 1, 1, 50),
|
||||
(4, 2, 0, 55),
|
||||
(4, 2, 1, 100),
|
||||
(4, 3, 0, 110),
|
||||
(4, 3, 1, 300),
|
||||
(5, 1, 0, 10),
|
||||
(5, 1, 1, 50),
|
||||
(5, 2, 0, 55),
|
||||
(5, 2, 1, 100),
|
||||
(5, 3, 0, 110),
|
||||
(5, 3, 1, 300),
|
||||
(6, 1, 0, 5),
|
||||
(6, 1, 1, 20),
|
||||
(6, 2, 0, 20),
|
||||
(6, 2, 1, 50),
|
||||
(6, 3, 0, 40),
|
||||
(6, 3, 1, 70),
|
||||
(7, 1, 0, 5),
|
||||
(7, 1, 1, 20),
|
||||
(7, 2, 0, 20),
|
||||
(7, 2, 1, 50),
|
||||
(7, 3, 0, 40),
|
||||
(7, 3, 1, 70),
|
||||
(8, 1, 0, 5),
|
||||
(8, 1, 1, 20),
|
||||
(8, 2, 0, 20),
|
||||
(8, 2, 1, 50),
|
||||
(8, 3, 0, 40),
|
||||
(8, 3, 1, 70),
|
||||
(9, 1, 0, 5),
|
||||
(9, 1, 1, 20),
|
||||
(9, 2, 0, 20),
|
||||
(9, 2, 1, 50),
|
||||
(9, 3, 0, 40),
|
||||
(9, 3, 1, 70),
|
||||
(10, 1, 0, 10),
|
||||
(10, 1, 1, 50),
|
||||
(10, 2, 0, 55),
|
||||
(10, 2, 1, 100),
|
||||
(10, 3, 0, 110),
|
||||
(10, 3, 1, 300),
|
||||
];
|
||||
|
||||
/// The `cardsubtypeid -> cardtype` decode (`FUN_1800d8330`, read out of its raw
|
||||
/// two-level jump table). `0` = no table row, which prices at `0`.
|
||||
///
|
||||
/// The staff arms agree independently with
|
||||
/// [`super::content_taxonomy::staff_role`]'s family selector (4=manager,
|
||||
/// 5=headcoach, 6=gkcoach, 7=physio, 8=fitnesscoach) and with the five card
|
||||
/// tables the client re-queries for those cardtypes — see [`client_rerates`].
|
||||
pub fn cardtype_for_subtype(subtype: i64) -> u8 {
|
||||
match subtype {
|
||||
0..=3 => 1,
|
||||
4 => 2,
|
||||
5 => 3,
|
||||
6 => 10,
|
||||
7 => 5,
|
||||
8 => 4,
|
||||
9..=11 => 7,
|
||||
30 | 31 | 145..=150 | 231..=233 | 236 => 9,
|
||||
51..=136 | 201..=220 | 250..=273 | 300..=341 => 6,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Discard `level` from rating: `3` if `>= 75`, `2` if `65..=74`, else `1`.
|
||||
///
|
||||
/// Derived purely from rating at the tail of `FUN_180141660`
|
||||
/// (`0x180141e8a..0x180141ea3`). It is NOT a wire field — the slot at item
|
||||
/// `+0x54` is never written through the deserializer's frame.
|
||||
pub const fn discard_level(rating: u8) -> u8 {
|
||||
if rating >= 75 {
|
||||
3
|
||||
} else if rating >= 65 {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the client OVERWRITES the rating and rare flag we send with values
|
||||
/// from its own card database before pricing.
|
||||
///
|
||||
/// True for cardtypes 2, 3, 4, 5 and 10 (the staff families — it re-queries
|
||||
/// `managercards`, `headcoachcards`, `fitnesscoachcards`, `physiocards` and
|
||||
/// `gkcoachcards` by `carddbid`). For cardtypes 6, 7, 8 and 9 the jump table at
|
||||
/// rva `0x141eb4` goes straight to the default arm with no DB query and no
|
||||
/// overwrite, so for consumables and club items the server's values are
|
||||
/// authoritative.
|
||||
pub const fn client_rerates(cardtype: u8) -> bool {
|
||||
matches!(cardtype, 2 | 3 | 4 | 5 | 10)
|
||||
}
|
||||
|
||||
/// `fcc_discardcoins` price for a key, or `0` when the table has no such row.
|
||||
pub fn table_price(cardtype: u8, level: u8, rare: i64) -> i64 {
|
||||
if !(0..=255).contains(&rare) {
|
||||
return 0;
|
||||
}
|
||||
let rare = rare as u8;
|
||||
DISCARD_COINS
|
||||
.iter()
|
||||
.find(|&&(c, l, r, _)| c == cardtype && l == level && r == rare)
|
||||
.map_or(0, |&(_, _, _, price)| price)
|
||||
}
|
||||
|
||||
/// The client's discard value for a card: `round_half_up(rating * price / 100)`.
|
||||
///
|
||||
/// Returns `0` for a key the table does not carry, exactly as the client does.
|
||||
pub fn discard_value(cardtype: u8, rating: u8, rare: i64) -> i64 {
|
||||
let price = table_price(cardtype, discard_level(rating), rare);
|
||||
if price == 0 {
|
||||
return 0;
|
||||
}
|
||||
(i64::from(rating) * price + 50) / 100
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The generated table MUST still equal the client's own file, row for row.
|
||||
/// This is what makes [`DISCARD_COINS`] a transcription of evidence rather
|
||||
/// than a hand-authored economy.
|
||||
#[test]
|
||||
fn the_table_still_matches_the_clients_own_file() {
|
||||
let path = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../fifa17-recon/data/tables/fcc_discardcoins.json"
|
||||
);
|
||||
let raw = std::fs::read_to_string(path)
|
||||
.unwrap_or_else(|e| panic!("client discard table missing at {path}: {e}"));
|
||||
let doc: serde_json::Value = serde_json::from_str(&raw).expect("table is JSON");
|
||||
assert_eq!(doc["table"], "fcc_discardcoins");
|
||||
let declared = doc["rowcount"].as_u64().expect("rowcount") as usize;
|
||||
let rows = doc["rows"].as_array().expect("rows array");
|
||||
assert_eq!(rows.len(), declared, "file disagrees with its own rowcount");
|
||||
|
||||
let mut from_file: Vec<(u8, u8, u8, i64)> = rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let g = |k: &str| r.get(k).and_then(serde_json::Value::as_i64).unwrap();
|
||||
(
|
||||
g("cardtype") as u8,
|
||||
g("level") as u8,
|
||||
g("rare") as u8,
|
||||
g("price"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
from_file.sort_unstable();
|
||||
|
||||
let mut ours = DISCARD_COINS.to_vec();
|
||||
ours.sort_unstable();
|
||||
assert_eq!(
|
||||
ours, from_file,
|
||||
"generated table drifted from the client file"
|
||||
);
|
||||
assert_eq!(from_file.len(), 141);
|
||||
}
|
||||
|
||||
/// The four worked examples stated in the reversal, which was checked
|
||||
/// against 22 live club items.
|
||||
#[test]
|
||||
fn the_documented_live_verified_prices_reproduce() {
|
||||
// "A gold rare player is 8 * rating: 75 gives 600, 94 gives 752."
|
||||
assert_eq!(discard_value(1, 94, 1), 752);
|
||||
assert_eq!(discard_value(1, 75, 1), 600);
|
||||
for rating in 75..=99u8 {
|
||||
assert_eq!(discard_value(1, rating, 1), 8 * i64::from(rating));
|
||||
}
|
||||
// "A gold common is 4 * rating."
|
||||
for rating in 75..=99u8 {
|
||||
assert_eq!(discard_value(1, rating, 0), 4 * i64::from(rating));
|
||||
}
|
||||
// "A 50-rated bronze common is 15."
|
||||
assert_eq!(discard_value(1, 50, 0), 15);
|
||||
}
|
||||
|
||||
/// "A key we do not have pays 0, so use a `.get(key, 0)`, not a subscript."
|
||||
#[test]
|
||||
fn an_absent_key_pays_zero_and_never_a_floor() {
|
||||
// rare 14, 15, 16 are absent for cardtype 1 ...
|
||||
for rare in [14, 15, 16] {
|
||||
assert_eq!(table_price(1, 3, rare), 0);
|
||||
assert_eq!(discard_value(1, 94, rare), 0);
|
||||
}
|
||||
// ... and cardtypes 2..=10 carry only rare 0 and 1.
|
||||
for cardtype in 2..=10u8 {
|
||||
assert_eq!(table_price(cardtype, 3, 2), 0);
|
||||
}
|
||||
// A subtype outside every documented range decodes to cardtype 0.
|
||||
assert_eq!(cardtype_for_subtype(600), 0);
|
||||
assert_eq!(discard_value(0, 94, 1), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_level_ladder_is_the_clients() {
|
||||
assert_eq!(discard_level(74), 2);
|
||||
assert_eq!(discard_level(75), 3);
|
||||
assert_eq!(discard_level(65), 2);
|
||||
assert_eq!(discard_level(64), 1);
|
||||
assert_eq!(discard_level(0), 1);
|
||||
}
|
||||
|
||||
/// Rounding is half UP, not truncation: 66 * 55 / 100 = 36.3 -> 36, but
|
||||
/// 67 * 55 / 100 = 36.85 -> 37.
|
||||
#[test]
|
||||
fn the_rounding_is_half_up() {
|
||||
assert_eq!(discard_value(10, 66, 0), 36);
|
||||
assert_eq!(discard_value(10, 67, 0), 37);
|
||||
}
|
||||
|
||||
/// The subtype decode must agree with the settled club-item subtypes and the
|
||||
/// staff family selector this crate already carries.
|
||||
#[test]
|
||||
fn the_decode_agrees_with_the_settled_subtypes() {
|
||||
use crate::fut::content_taxonomy as tax;
|
||||
// Kit, stadium and badge are cardtype 7 ...
|
||||
for s in [tax::KIT_SUBTYPE, tax::STADIUM_SUBTYPE, tax::BADGE_SUBTYPE] {
|
||||
assert_eq!(cardtype_for_subtype(s), 7, "subtype {s}");
|
||||
}
|
||||
// ... ball and league logo are cardtype 9 ...
|
||||
for s in [tax::BALL_SUBTYPE, tax::LEAGUE_LOGO_SUBTYPE] {
|
||||
assert_eq!(cardtype_for_subtype(s), 9, "subtype {s}");
|
||||
}
|
||||
// ... and `fcc_misccards` is cardtype 9 too.
|
||||
for s in [231, 232, 233, 236] {
|
||||
assert_eq!(cardtype_for_subtype(s), 9, "subtype {s}");
|
||||
}
|
||||
// A manager is cardtype 2, and every staff subtype is one the client
|
||||
// re-rates from its own database.
|
||||
assert_eq!(cardtype_for_subtype(tax::MANAGER_SUBTYPE), 2);
|
||||
for subtype in 4..=8 {
|
||||
assert!(
|
||||
client_rerates(cardtype_for_subtype(subtype)),
|
||||
"staff subtype {subtype} must be client-re-rated"
|
||||
);
|
||||
assert!(tax::staff_role(subtype).is_some());
|
||||
}
|
||||
// Consumables are cardtype 6, and the server's values ARE authoritative
|
||||
// for them.
|
||||
for subtype in [52, 54, 92, 98, 100] {
|
||||
assert_eq!(cardtype_for_subtype(subtype), 6);
|
||||
assert!(!client_rerates(6));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,52 +2,13 @@
|
||||
//!
|
||||
//! These translate FIFA 17 wire semantics into the generic amounts the host
|
||||
//! feeds to Core economy authority. They own NO state — Core owns balances and
|
||||
//! inventory; these are the FIFA-specific numbers/derivations. Values are the
|
||||
//! current OpenFUT economy (match rewards are the Python oracle's
|
||||
//! `MATCH_COINS`/`MATCH_PARTICIPATION` at production defaults); pack prices come
|
||||
//! from the Store catalogue.
|
||||
//! 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;
|
||||
|
||||
/// Normalized match outcome for reward purposes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MatchResult {
|
||||
Win,
|
||||
Draw,
|
||||
Loss,
|
||||
}
|
||||
|
||||
/// Participation award added to every match reward (oracle `MATCH_PARTICIPATION`
|
||||
/// default = 0).
|
||||
pub const MATCH_PARTICIPATION: i64 = 0;
|
||||
|
||||
/// Per-result match coins (oracle `MATCH_COINS`: won 400 / draw 200 / loss 100).
|
||||
pub fn match_result_coins(result: MatchResult) -> i64 {
|
||||
match result {
|
||||
MatchResult::Win => 400,
|
||||
MatchResult::Draw => 200,
|
||||
MatchResult::Loss => 100,
|
||||
}
|
||||
}
|
||||
|
||||
/// Total match reward = per-result coins + participation.
|
||||
pub fn match_reward_total(result: MatchResult) -> i64 {
|
||||
match_result_coins(result) + MATCH_PARTICIPATION
|
||||
}
|
||||
|
||||
/// Derive the outcome from the match `endReason` enum (the oracle's primary
|
||||
/// signal, `_END_REASON`). Unknown/absent reasons default to `Draw`, matching
|
||||
/// the oracle's conservative default. Score-based derivation is a fallback the
|
||||
/// oracle also supports; the enum is authoritative when present.
|
||||
pub fn result_from_end_reason(end_reason: Option<&str>) -> MatchResult {
|
||||
match end_reason.unwrap_or("").to_ascii_uppercase().as_str() {
|
||||
"WIN" | "DNF_WIN" => MatchResult::Win,
|
||||
"LOSS" | "QUIT" | "DNF" | "DNF_LOSS" => MatchResult::Loss,
|
||||
// "DRAW", "DNF_DRAW", "NO_CONTEST", unknown -> draw.
|
||||
_ => MatchResult::Draw,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Store buy-now price for a pack id (`None` for unknown/owned-only packs,
|
||||
/// which are never purchasable).
|
||||
pub fn pack_price(pack_id: u64) -> Option<u64> {
|
||||
@@ -100,24 +61,6 @@ pub fn seller_proceeds(gross: i64) -> i64 {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn match_rewards_match_oracle() {
|
||||
assert_eq!(match_reward_total(MatchResult::Win), 400);
|
||||
assert_eq!(match_reward_total(MatchResult::Draw), 200);
|
||||
assert_eq!(match_reward_total(MatchResult::Loss), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_reason_maps_to_outcome() {
|
||||
assert_eq!(result_from_end_reason(Some("WIN")), MatchResult::Win);
|
||||
assert_eq!(result_from_end_reason(Some("dnf_win")), MatchResult::Win);
|
||||
assert_eq!(result_from_end_reason(Some("LOSS")), MatchResult::Loss);
|
||||
assert_eq!(result_from_end_reason(Some("QUIT")), MatchResult::Loss);
|
||||
assert_eq!(result_from_end_reason(Some("DRAW")), MatchResult::Draw);
|
||||
assert_eq!(result_from_end_reason(None), MatchResult::Draw);
|
||||
assert_eq!(result_from_end_reason(Some("weird")), MatchResult::Draw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_price_rejects_unknown_and_owned_only() {
|
||||
assert!(pack_price(1).is_some());
|
||||
|
||||
@@ -24,8 +24,12 @@
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::content_taxonomy::ContentKind;
|
||||
use crate::fut::content_taxonomy::{
|
||||
consumable_family, consumable_needs, ConsumableNeeds, ContentKind, BADGE_SUBTYPE, KIT_SUBTYPE,
|
||||
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)]
|
||||
@@ -66,12 +70,172 @@ pub struct Fifa17Identity {
|
||||
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>;
|
||||
|
||||
/// Classify a Core item's definition as player/consumable/staff. Defaults to
|
||||
/// 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 card's discard (quick-sell) value in coins — BOTH the number the
|
||||
/// client displays on the card and the number the server MUST credit when
|
||||
/// it is sold. One method serves both so the wire and the wallet cannot
|
||||
/// disagree: a non-zero `discardValue` suppresses the client's own local
|
||||
/// computation (`0x180141025`), so whatever is sent here is what the player
|
||||
/// is promised.
|
||||
///
|
||||
/// NON-MINTING by contract, like [`Self::subtype_of`] — it takes the Core
|
||||
/// item, never a resolved [`Fifa17Identity`], so pricing a card on a read
|
||||
/// path cannot allocate a wire id.
|
||||
///
|
||||
/// The default is the [`legacy_discard_value`] placeholder ladder, which
|
||||
/// preserves the behaviour of every resolver without a catalog behind it.
|
||||
/// The catalog-backed FIFA17 resolver overrides it with the client's own
|
||||
/// `fcc_discardcoins` table (see [`super::discard`]).
|
||||
fn discard_value(&self, item: &CoreOwnedItem) -> i64 {
|
||||
legacy_discard_value(item.rating)
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -85,15 +249,32 @@ pub trait ItemIdentityResolver {
|
||||
#[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,
|
||||
/// Consumable/staff items excluded from a player projection (they must never
|
||||
/// render as a 0-rated player). Counted, never emitted.
|
||||
/// 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 {
|
||||
/// The ORIGINAL rating-only quick-sell ladder. **A placeholder, not
|
||||
/// EA-authentic**: it is blind to both card type and `rareflag`, so it prices a
|
||||
/// 94-rated TOTW special and a 94-rated gold common identically, and it pays a
|
||||
/// flat floor for every non-player (whose Core rating is 0).
|
||||
///
|
||||
/// The client's real value is `round_half_up(rating * fcc_discardcoins.price /
|
||||
/// 100)` — see [`super::discard`], which reproduces it exactly. This ladder is
|
||||
/// retained as the [`ItemIdentityResolver::discard_value`] default so a resolver
|
||||
/// with no catalog behind it keeps its existing behaviour, and so the deployed
|
||||
/// economy only changes when an operator opts in.
|
||||
pub fn legacy_discard_value(rating: u8) -> i64 {
|
||||
match rating {
|
||||
r if r >= 85 => 1500,
|
||||
r if r >= 80 => 900,
|
||||
@@ -117,6 +298,7 @@ pub fn shape_item(
|
||||
item: &CoreOwnedItem,
|
||||
id: Fifa17Identity,
|
||||
ent: &impl ReverseEntityResolver,
|
||||
discard_value: i64,
|
||||
) -> Value {
|
||||
let asset = id.asset_id;
|
||||
let league_id = ent.league_id(&item.league).unwrap_or(0);
|
||||
@@ -144,7 +326,7 @@ pub fn shape_item(
|
||||
"leagueId": league_id,
|
||||
"playStyle": 250,
|
||||
"attributeList": attribute_list,
|
||||
"itemState": "free",
|
||||
"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
|
||||
@@ -154,13 +336,195 @@ pub fn shape_item(
|
||||
"untradeable": false,
|
||||
"contract": 7,
|
||||
"fitness": 99,
|
||||
"discardValue": discard_value(item.rating),
|
||||
"discardValue": discard_value,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build one FIFA 17 **cardtype-7 club item**: a kit (subtype 9), a badge (11)
|
||||
/// or a stadium (10). `item_state` is the proven wire enum token — `free`, or
|
||||
/// one of the `active*` designations the client deserializes to 100..104.
|
||||
///
|
||||
/// All three families share one record and one client-side resolver
|
||||
/// (`FUN_180119bd0`, dispatched when `item+0x4c == 7`), differing only in the
|
||||
/// field their caption reads:
|
||||
///
|
||||
/// * kit `FUT_UC_KITS` + `TeamName_Abbr15_<teamid>` — needs `teamid`
|
||||
/// * badge `Badge` + `TeamName_Abbr15_<teamid>` — needs `teamid`
|
||||
/// * stadium `Stadium` + `StadiumName_<assetId>` — needs `assetId`, which
|
||||
/// `resourceId` already supplies
|
||||
///
|
||||
/// `teamid` (atom 0x306, record `+0x94`) is therefore emitted for kits and
|
||||
/// badges and WITHHELD for stadiums, whose resolver never reads it. It is an
|
||||
/// established scalar field, not a new shape.
|
||||
///
|
||||
/// The cardtype-9 families (ball 30, league logo 31) are deliberately NOT
|
||||
/// shaped here: they have no DB name resolver, so their display name can only
|
||||
/// come from `localizedName` on the wire, and while that offset is confirmed
|
||||
/// it is NOT established that sending it is safe.
|
||||
pub fn shape_club_item(id: Fifa17KitIdentity, item_state: &str) -> 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,
|
||||
"itemState": item_state,
|
||||
"owners": 1,
|
||||
"untradeable": false,
|
||||
});
|
||||
if matches!(id.subtype, KIT_SUBTYPE | BADGE_SUBTYPE) {
|
||||
item["teamid"] = json!(id.team_id);
|
||||
}
|
||||
item
|
||||
}
|
||||
|
||||
/// 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::content_taxonomy::STADIUM_SUBTYPE;
|
||||
use crate::fut::entities::Fifa17Entities;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -197,6 +561,7 @@ mod tests {
|
||||
rareflag: 1,
|
||||
},
|
||||
&ent,
|
||||
legacy_discard_value(86),
|
||||
);
|
||||
assert_eq!(it["id"], 100000001, "wire instance id");
|
||||
assert_eq!(it["resourceId"], 20801);
|
||||
@@ -230,6 +595,7 @@ mod tests {
|
||||
rareflag: 1,
|
||||
},
|
||||
&ent,
|
||||
legacy_discard_value(84),
|
||||
);
|
||||
let b = shape_item(
|
||||
&item("oc-b", "fifa17_101490", 84, "ST"),
|
||||
@@ -240,6 +606,7 @@ mod tests {
|
||||
rareflag: 1,
|
||||
},
|
||||
&ent,
|
||||
legacy_discard_value(84),
|
||||
);
|
||||
assert_eq!(
|
||||
a["resourceId"], b["resourceId"],
|
||||
@@ -268,6 +635,7 @@ mod tests {
|
||||
rareflag: 3,
|
||||
},
|
||||
&ent,
|
||||
legacy_discard_value(92),
|
||||
);
|
||||
assert_eq!(
|
||||
it["resourceId"], 117617092,
|
||||
@@ -281,4 +649,203 @@ mod tests {
|
||||
"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,
|
||||
legacy_discard_value(86),
|
||||
);
|
||||
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_club_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"
|
||||
);
|
||||
}
|
||||
|
||||
/// Kit, badge and stadium share ONE cardtype-7 record, but their captions do
|
||||
/// not read the same field: kit and badge resolve
|
||||
/// `TeamName_Abbr15_<teamid>`, while a stadium resolves
|
||||
/// `StadiumName_<assetId>` and its resolver never reads teamid. Sending a
|
||||
/// field the resolver does not read is how this project earned a client
|
||||
/// freeze, so the record carries exactly what each family consumes.
|
||||
#[test]
|
||||
fn a_club_item_carries_only_the_field_its_caption_resolves() {
|
||||
let ident = |subtype| Fifa17KitIdentity {
|
||||
item_id: 100_000_500,
|
||||
asset_id: 6_000_005,
|
||||
resource_id: 6_000_005,
|
||||
card_asset_id: 39,
|
||||
subtype,
|
||||
team_id: 21,
|
||||
};
|
||||
for subtype in [KIT_SUBTYPE, BADGE_SUBTYPE] {
|
||||
let it = shape_club_item(ident(subtype), item_state::FREE);
|
||||
assert_eq!(
|
||||
it["teamid"], 21,
|
||||
"subtype {subtype} resolves TeamName_Abbr15"
|
||||
);
|
||||
assert_eq!(it["cardsubtypeid"], subtype);
|
||||
}
|
||||
let stadium = shape_club_item(ident(STADIUM_SUBTYPE), item_state::FREE);
|
||||
assert!(
|
||||
stadium.get("teamid").is_none(),
|
||||
"a stadium caption reads assetId, never teamid"
|
||||
);
|
||||
// The art id is the FAMILY's, never a copy of the resource id: a card
|
||||
// whose card_asset_id equals its asset_id draws the notfound box.
|
||||
assert_eq!(stadium["cardassetid"], 39);
|
||||
assert_ne!(stadium["cardassetid"], stadium["resourceId"]);
|
||||
// Every cardtype-7 family keeps the established minimal key set.
|
||||
for key in [
|
||||
"id",
|
||||
"resourceId",
|
||||
"assetId",
|
||||
"cardassetid",
|
||||
"cardsubtypeid",
|
||||
"itemState",
|
||||
"owners",
|
||||
"untradeable",
|
||||
] {
|
||||
assert!(stadium.get(key).is_some(), "missing {key}");
|
||||
}
|
||||
// Never a player field: these have no rating, contract or attributes.
|
||||
for key in [
|
||||
"attributeList",
|
||||
"contract",
|
||||
"fitness",
|
||||
"rating",
|
||||
"discardValue",
|
||||
] {
|
||||
assert!(stadium.get(key).is_none(), "club item must not carry {key}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! **The casing is a CONTRACT, not a convention** — measured, not assumed. The
|
||||
//! table lookup compares through a slot the host fills at runtime
|
||||
//! (`FUN_180008190` is just `mov rax,[DAT_1802ddfd8]; mov r9,[rax+0x248]; jmp r9`),
|
||||
//! so this was long recorded as unresolvable without a live process. Resolved
|
||||
//! read-only against the running client on 2026-08-21
|
||||
//! (`fifa17-recon/tools/service_ptr_probe.py`): the slot forwards through two
|
||||
//! FIFA17.exe thunks into `msvcr120.dll+0x3c330`, whose body is `strncmp` — a
|
||||
//! plain byte compare (`cmp al,[rcx+rdx]`) with NO case folding anywhere. So a
|
||||
//! mis-cased token does not "mostly work": it matches nothing, decodes to
|
||||
//! [`INVALID`], and the item fails the squad builder. Emit these strings
|
||||
//! verbatim.
|
||||
//!
|
||||
//! (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);
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,20 @@
|
||||
pub mod catalog;
|
||||
pub mod club_response;
|
||||
pub mod club_stats;
|
||||
pub mod consumables;
|
||||
pub mod content_taxonomy;
|
||||
pub mod discard;
|
||||
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;
|
||||
|
||||
@@ -24,48 +24,50 @@ pub fn accountinfo_body() -> Value {
|
||||
json!({})
|
||||
}
|
||||
|
||||
/// `GET …/settings` — the FUT client applies this `configs` array via the applier
|
||||
/// `FUN_18011dc50`, the ONLY writer of the `IS_*` UI gate bytes (each
|
||||
/// `byte = (field == 1)`). A flag never sent is a gate never opened — which is why
|
||||
/// Single-Player Seasons/Draft refuse to open while issuing ZERO server requests
|
||||
/// (`friendlySeasonsEnabled` → slot `[0x16]` → model byte `0x1fd3a`
|
||||
/// `IS_FRIENDLY_SEASON_ENABLED`; RE-confirmed via pyghidra on CardsDLL).
|
||||
/// The commerce flags the client leaves OFF unless a `configs` row turns them on.
|
||||
///
|
||||
/// Default (`OPENFUT_FUT_SETTINGS` unset/`off`) is the historical empty baseline.
|
||||
/// `OPENFUT_FUT_SETTINGS=gates` populates the array. SAFETY: populating it is what
|
||||
/// makes the applier RUN, and it then writes EVERY gate byte, so the already-live
|
||||
/// store flags MUST be re-asserted here or enabling Seasons would clear the working
|
||||
/// Store (those reach the client today via the Blaze FUT_RS4_CONFIG store, not
|
||||
/// here). Mirrors the audited list in `utas_server.py` `_SETTINGS_KEEP`/`_GATES`.
|
||||
pub fn settings_body() -> Value {
|
||||
if std::env::var("OPENFUT_FUT_SETTINGS").as_deref() != Ok("gates") {
|
||||
/// `FutGetSettingsServerResponse` (deser `0x18013c6d0`, read end to end) has a
|
||||
/// single wrapper key `configs` (0xa2) holding an array of
|
||||
/// `{ type (0x354), value (0x377) }`, and nothing else. `type` is the setting
|
||||
/// NAME, not an index.
|
||||
///
|
||||
/// These matter because the struct's defaults are NOT uniform: several fields
|
||||
/// default to 1, but `tradingEnabled` defaults to **0**. It is not a flag we have
|
||||
/// been overwriting — it is a flag nobody has ever sent. It gates the service
|
||||
/// half of the `TO_TRADE_PILE` predicate (vtable slot `+0x270` =
|
||||
/// `FUN_18011c670`, reading gate byte `0x1fd2e`, measured 0 in the live client),
|
||||
/// which is one of the two reasons "Place on Transfer List" is greyed. The other
|
||||
/// reason — `untradeable` — this crate already handles: [`crate::fut::item`]
|
||||
/// emits `false` for owned copies.
|
||||
const COMMERCE_SETTINGS: [&str; 8] = [
|
||||
"storeEnabled",
|
||||
"storeEnabled_JP",
|
||||
"coinEnabled",
|
||||
"coinEnabled_JP",
|
||||
"cardPackStoreEnabled",
|
||||
"cardPackStoreEnabled_JP",
|
||||
"pointsPackStoreEnabled",
|
||||
"tradingEnabled",
|
||||
];
|
||||
|
||||
/// `GET …/settings` — an empty config list by default, matching the production
|
||||
/// oracle and the live-proven behaviour.
|
||||
///
|
||||
/// `enable_commerce` opts in to [`COMMERCE_SETTINGS`]. It defaults OFF because
|
||||
/// the flags are RECOVERED BUT UNTESTED: the schema is high-confidence and this
|
||||
/// is the exact row shape the oracle would emit, but no launch has yet confirmed
|
||||
/// what the client does with them. Turning it on is the server half of the
|
||||
/// transfer-list fix; it explains why the menu entry is greyed and does NOT
|
||||
/// promise that the transfer market behind it works.
|
||||
pub fn settings_body(enable_commerce: bool) -> Value {
|
||||
if !enable_commerce {
|
||||
return json!({ "configs": [] });
|
||||
}
|
||||
// Already-live store flags re-asserted (pinned to their working state).
|
||||
const KEEP: &[&str] = &[
|
||||
"storeEnabled",
|
||||
"storeEnabled_JP",
|
||||
"coinEnabled",
|
||||
"coinEnabled_JP",
|
||||
"cardPackStoreEnabled",
|
||||
"cardPackStoreEnabled_JP",
|
||||
"pointsPackStoreEnabled",
|
||||
"tradingEnabled",
|
||||
];
|
||||
// Mode gates that nothing has ever populated (the point of the change).
|
||||
const GATES: &[&str] = &[
|
||||
"friendlySeasonsEnabled",
|
||||
"enableDraftMode",
|
||||
"enableSinglePlayerDraftMode",
|
||||
"enableOfflineDraftMode",
|
||||
"tournamentQuitEnabled",
|
||||
];
|
||||
let configs: Vec<Value> = KEEP
|
||||
let rows: Vec<Value> = COMMERCE_SETTINGS
|
||||
.iter()
|
||||
.chain(GATES.iter())
|
||||
.map(|k| json!({ "type": k, "value": 1 }))
|
||||
.map(|name| json!({ "type": name, "value": 1 }))
|
||||
.collect();
|
||||
json!({ "configs": configs })
|
||||
json!({ "configs": rows })
|
||||
}
|
||||
|
||||
/// `GET …/leaderboards/options` — production oracle (FUT_MODES off) returns an
|
||||
@@ -106,12 +108,19 @@ pub fn feature_off_body() -> Value {
|
||||
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 (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()
|
||||
@@ -404,7 +413,11 @@ pub fn user_mass_info_body(
|
||||
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);
|
||||
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;
|
||||
@@ -454,12 +467,44 @@ mod tests {
|
||||
#[test]
|
||||
fn static_bodies_match_oracle() {
|
||||
assert_eq!(accountinfo_body(), json!({}));
|
||||
assert_eq!(settings_body(), json!({ "configs": [] }));
|
||||
assert_eq!(settings_body(false), json!({ "configs": [] }));
|
||||
assert_eq!(leaderboard_options_body(), json!({}));
|
||||
assert_eq!(match_reset_body(), json!({}));
|
||||
assert_eq!(club_stats_staff_body(), json!({}));
|
||||
}
|
||||
|
||||
/// The default MUST stay the live-proven empty list, and the opt-in body must
|
||||
/// match the schema exactly: `configs` holding `{type, value}` rows where
|
||||
/// `type` is the setting NAME. `tradingEnabled` is the one that matters — the
|
||||
/// client defaults it to 0 and it gates the transfer-list menu entry.
|
||||
#[test]
|
||||
fn commerce_settings_are_opt_in_and_shaped_to_the_schema() {
|
||||
assert_eq!(
|
||||
settings_body(false),
|
||||
json!({ "configs": [] }),
|
||||
"default must remain the live-proven body"
|
||||
);
|
||||
|
||||
let on = settings_body(true);
|
||||
let rows = on["configs"].as_array().expect("configs is an array");
|
||||
assert_eq!(rows.len(), COMMERCE_SETTINGS.len());
|
||||
for row in rows {
|
||||
let obj = row.as_object().expect("each config row is an OBJECT");
|
||||
assert_eq!(
|
||||
obj.keys().collect::<Vec<_>>(),
|
||||
vec!["type", "value"],
|
||||
"the deserializer knows exactly two keys; an extra one is skipped \
|
||||
at best and a type desync at worst"
|
||||
);
|
||||
assert!(obj["type"].is_string(), "type is the setting NAME");
|
||||
assert_eq!(obj["value"], 1);
|
||||
}
|
||||
assert!(
|
||||
rows.iter().any(|r| r["type"] == "tradingEnabled"),
|
||||
"the whole point of the opt-in"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_parse() {
|
||||
assert_eq!(
|
||||
@@ -667,8 +712,14 @@ mod tests {
|
||||
});
|
||||
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["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.
|
||||
|
||||
@@ -48,6 +48,9 @@ use std::collections::HashMap;
|
||||
/// 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.
|
||||
@@ -66,6 +69,18 @@ pub struct Fifa17ClubQuery {
|
||||
pub start: Option<u32>,
|
||||
/// Pagination page size.
|
||||
pub count: Option<u32>,
|
||||
/// `defId=` — a comma-joined list of DEFINITION ids. The documented club
|
||||
/// grammar says the client sends EITHER the filter block above OR this list,
|
||||
/// never both.
|
||||
///
|
||||
/// CAPTURED BUT NOT APPLIED, deliberately. The grammar is single-source (one
|
||||
/// decompile plus one live log line, and that log line carried no `defId`),
|
||||
/// so the exact semantics — definition id as `resourceId`, presumably — are
|
||||
/// not confirmed against an observed request. Filtering on a wrong reading
|
||||
/// would turn "too many items" into "zero items", which is the worse failure.
|
||||
/// The host logs it instead, so the first real occurrence is visible and the
|
||||
/// filter can be written against evidence rather than a guess.
|
||||
pub def_ids: Vec<i64>,
|
||||
}
|
||||
|
||||
/// Minimal percent/`+` decoding, dependency-free. FIFA sends bare tokens and
|
||||
@@ -115,6 +130,7 @@ pub fn parse_club_query(query: &str) -> Fifa17ClubQuery {
|
||||
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),
|
||||
@@ -124,6 +140,14 @@ pub fn parse_club_query(query: &str) -> Fifa17ClubQuery {
|
||||
"sort" => out.sort = Some(v),
|
||||
"start" => out.start = v.parse().ok(),
|
||||
"count" => out.count = v.parse().ok(),
|
||||
// Comma-joined, digits only — the same reading the item-definition
|
||||
// routes already use for this parameter.
|
||||
"defId" => {
|
||||
out.def_ids = v
|
||||
.split(',')
|
||||
.filter_map(|d| d.trim().parse::<i64>().ok())
|
||||
.collect()
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -325,6 +349,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
q,
|
||||
Fifa17ClubQuery {
|
||||
item_type: Some("player".into()),
|
||||
level: Some("gold".into()),
|
||||
rare: None,
|
||||
position: Some("ST".into()),
|
||||
@@ -334,6 +359,7 @@ mod tests {
|
||||
sort: Some("desc".into()),
|
||||
start: Some(10),
|
||||
count: Some(11),
|
||||
def_ids: Vec::new(),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -523,3 +549,25 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod def_id_tests {
|
||||
use super::*;
|
||||
|
||||
/// `defId=` is CAPTURED so the host can report it, and deliberately does NOT
|
||||
/// participate in the Core query — narrowing on an unconfirmed reading of the
|
||||
/// parameter would answer "zero items" where today we answer "too many".
|
||||
#[test]
|
||||
fn def_id_list_is_captured_but_never_narrows_the_core_query() {
|
||||
let q = parse_club_query("?year=2017&type=player&defId=20801,117617092,84044103");
|
||||
assert_eq!(q.def_ids, vec![20801, 117_617_092, 84_044_103]);
|
||||
assert_eq!(q.item_type.as_deref(), Some("player"));
|
||||
|
||||
// Non-numeric entries are dropped rather than poisoning the list.
|
||||
let q = parse_club_query("?defId=20801,,notanid,42");
|
||||
assert_eq!(q.def_ids, vec![20801, 42]);
|
||||
|
||||
// Absent means empty, never a phantom filter.
|
||||
assert!(parse_club_query("?type=player").def_ids.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,14 +90,30 @@ pub fn generate_pack_contents(
|
||||
}
|
||||
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 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);
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +116,15 @@ pub fn challenges_body(set_id: i64, challenges: &[ChallengeView]) -> Value {
|
||||
"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": []
|
||||
})
|
||||
})
|
||||
@@ -277,10 +286,11 @@ mod tests {
|
||||
times_completed: 1,
|
||||
};
|
||||
|
||||
let repeatable_view = challenges_body(CHALLENGES[0].set_id, &[repeatable.clone()]);
|
||||
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, &[once.clone()]);
|
||||
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]);
|
||||
@@ -360,8 +370,16 @@ mod tests {
|
||||
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_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);
|
||||
}
|
||||
}
|
||||
@@ -141,9 +141,31 @@ pub struct ProposedSquad {
|
||||
/// never used to derive slot layout.
|
||||
pub formation: Option<String>,
|
||||
pub slots: Vec<ProposedSlot>,
|
||||
/// Occupied wire item ids the resolver could not map. A caller MUST refuse the
|
||||
/// replacement if this is non-empty — a save must never silently drop an
|
||||
/// owned player it failed to identify.
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
@@ -180,12 +202,14 @@ pub fn parse_squad_put(body: &[u8]) -> Result<Fifa17SquadPut, SquadError> {
|
||||
|
||||
/// Resolve a parsed save into a **canonical** [`ProposedSquad`]: drop empty
|
||||
/// (`id == 0`) slots, reverse-map each occupied slot's wire id to a Core
|
||||
/// `owned_card_id`, flag the captain, and derive the bench split from the fixed
|
||||
/// 23-slot array. FIFA-only state (`custom`, manager, kicktakers, kit numbers,
|
||||
/// squadType) and client-reported evaluation are NOT canonical — they are built
|
||||
/// separately into [`crate::fut::squad_ext::Fifa17SquadExtensionV1`]. The
|
||||
/// formation token is carried verbatim (never mapped). Unresolvable occupied ids
|
||||
/// are reported, never guessed or dropped.
|
||||
/// `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();
|
||||
@@ -205,11 +229,25 @@ pub fn to_proposed(put: &Fifa17SquadPut, resolver: &dyn SquadWireResolver) -> Pr
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -237,9 +275,11 @@ mod tests {
|
||||
}
|
||||
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,
|
||||
100000001, 100000002, 100000025, 100000427,
|
||||
];
|
||||
MapResolver(ids.iter().map(|&w| (w, format!("oc-{w}"))).collect())
|
||||
}
|
||||
@@ -283,6 +323,8 @@ mod tests {
|
||||
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]
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
//! | `custom` | opaque 33-int string; meaning UNKNOWN, round-tripped verbatim |
|
||||
//! | `squad_type` | an observed FIFA wire token; no matching generic Core concept |
|
||||
//! | `kit_numbers` | keyed by **`owned_card_id`** — evidence: kit follows the player |
|
||||
//! | `manager` | a FIFA manager item ref; not a squad player, semantics opaque |
|
||||
//! | ~~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 |
|
||||
//!
|
||||
@@ -30,7 +30,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::fut::squad::{ClientReportedSquadEval, Fifa17SquadPut, ProposedSquad, SquadEntityRef};
|
||||
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";
|
||||
@@ -49,15 +49,6 @@ pub struct WireItemRef {
|
||||
pub dream: bool,
|
||||
}
|
||||
|
||||
impl From<&SquadEntityRef> for WireItemRef {
|
||||
fn from(r: &SquadEntityRef) -> Self {
|
||||
WireItemRef {
|
||||
id: r.id,
|
||||
dream: r.dream,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A kicktaker slot preserved verbatim. `index` is the role slot (0..=4 observed);
|
||||
/// `item` is the referenced FIFA wire item. The role→player meaning and any
|
||||
/// relationship to the captain are UNKNOWN, so this is stored opaquely and never
|
||||
@@ -83,9 +74,9 @@ pub struct Fifa17SquadExtensionV1 {
|
||||
/// proves the kit number follows the player across swaps and formation change.
|
||||
#[serde(default)]
|
||||
pub kit_numbers: BTreeMap<String, i64>,
|
||||
/// Manager item ref(s), opaque. Not a squad player; not shaped as an item.
|
||||
#[serde(default)]
|
||||
pub manager: Vec<WireItemRef>,
|
||||
// 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>,
|
||||
@@ -132,7 +123,6 @@ impl Fifa17SquadExtensionV1 {
|
||||
custom: put.custom.clone(),
|
||||
squad_type: put.squad_type.clone(),
|
||||
kit_numbers,
|
||||
manager: put.manager.iter().map(WireItemRef::from).collect(),
|
||||
kicktakers: put
|
||||
.kicktakers
|
||||
.iter()
|
||||
@@ -256,6 +246,9 @@ mod tests {
|
||||
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())
|
||||
}
|
||||
@@ -308,22 +301,41 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_and_kicktakers_preserved_opaquely() {
|
||||
let ext = built().extension;
|
||||
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!(
|
||||
ext.manager,
|
||||
vec![WireItemRef {
|
||||
id: 100000427,
|
||||
dream: false
|
||||
}]
|
||||
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);
|
||||
// All five reference the same wire id in this capture; carried verbatim,
|
||||
// NEVER normalized to the captain even though they coincide here.
|
||||
assert!(ext.kicktakers.iter().all(|k| k.item.id == 100000001));
|
||||
assert_eq!(ext.kicktakers[0].index, 0);
|
||||
}
|
||||
|
||||
/// 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();
|
||||
|
||||
@@ -36,7 +36,9 @@ use std::collections::HashMap;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::entities::ReverseEntityResolver;
|
||||
use crate::fut::item::{shape_item, CoreOwnedItem, ItemIdentityResolver};
|
||||
use crate::fut::item::{
|
||||
shape_item, shape_staff_item, CoreOwnedItem, ItemIdentityResolver, STAFF_CONTRACT,
|
||||
};
|
||||
use crate::fut::squad::FIFA17_SQUAD_SLOTS;
|
||||
use crate::fut::squad_ext::Fifa17SquadExtensionV1;
|
||||
|
||||
@@ -77,6 +79,11 @@ pub struct SquadProjectionInput<'a> {
|
||||
/// 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.
|
||||
@@ -159,7 +166,7 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
|
||||
.unwrap_or(0);
|
||||
players.push(json!({
|
||||
"index": index,
|
||||
"itemData": shape_item(item, id, ent),
|
||||
"itemData": shape_item(item, id, ent, ident.discard_value(item)),
|
||||
"kitNumber": kit,
|
||||
}));
|
||||
}
|
||||
@@ -171,6 +178,35 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -180,7 +216,7 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
|
||||
"starRating": ext.client_reported.star_rating,
|
||||
"rating": ext.client_reported.rating,
|
||||
"captain": captain_wire,
|
||||
"manager": ext.manager,
|
||||
"manager": manager,
|
||||
"custom": ext.custom,
|
||||
"players": players,
|
||||
"kicktakers": ext.kicktakers,
|
||||
@@ -219,14 +255,22 @@ pub fn squad_list(projected: &Value) -> Value {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::fut::entities::Fifa17Entities;
|
||||
use crate::fut::item::Fifa17Identity;
|
||||
use crate::fut::item::{Fifa17Identity, Fifa17StaffIdentity};
|
||||
|
||||
// A resolver that mints a distinct wire id per owned item and a fixed asset.
|
||||
struct TableIdentity(HashMap<String, Fifa17Identity>);
|
||||
// `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 {
|
||||
@@ -262,6 +306,7 @@ mod tests {
|
||||
}],
|
||||
ext,
|
||||
owned,
|
||||
manager: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,7 +317,6 @@ mod tests {
|
||||
custom: Some("[1,2,3]".into()),
|
||||
squad_type: Some("REGULAR_SQUAD".into()),
|
||||
kit_numbers: kit,
|
||||
manager: vec![],
|
||||
kicktakers: vec![],
|
||||
client_reported: Default::default(),
|
||||
}
|
||||
@@ -282,15 +326,18 @@ mod tests {
|
||||
fn fresh_projects_full_23_slot_array_with_captain_wire_id() {
|
||||
let mut owned = HashMap::new();
|
||||
owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
|
||||
let ident = TableIdentity(HashMap::from([(
|
||||
"oc1".to_string(),
|
||||
Fifa17Identity {
|
||||
item_id: 100000042,
|
||||
asset_id: 20801,
|
||||
resource_id: 20801,
|
||||
rareflag: 1,
|
||||
},
|
||||
)]));
|
||||
let 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");
|
||||
@@ -318,7 +365,7 @@ mod tests {
|
||||
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());
|
||||
let ident = TableIdentity(HashMap::new(), HashMap::new());
|
||||
assert_eq!(
|
||||
project_squad(&input, &ident, &ent()).unwrap(),
|
||||
SquadProjection::Stale
|
||||
@@ -329,7 +376,7 @@ mod tests {
|
||||
fn missing_is_explicit_never_fabricated() {
|
||||
let owned = HashMap::new();
|
||||
let input = one_slot_input(&owned, SquadExtInput::Missing);
|
||||
let ident = TableIdentity(HashMap::new());
|
||||
let ident = TableIdentity(HashMap::new(), HashMap::new());
|
||||
assert_eq!(
|
||||
project_squad(&input, &ident, &ent()).unwrap(),
|
||||
SquadProjection::Missing
|
||||
@@ -340,7 +387,7 @@ mod tests {
|
||||
fn occupied_starter_without_asset_identity_is_refused_not_faked() {
|
||||
let mut owned = HashMap::new();
|
||||
owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
|
||||
let ident = TableIdentity(HashMap::new()); // resolves nothing
|
||||
let 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()),
|
||||
@@ -355,26 +402,29 @@ mod tests {
|
||||
let mut owned = HashMap::new();
|
||||
owned.insert("oc-a".to_string(), owned_item("oc-a", "fifa17_101490"));
|
||||
owned.insert("oc-b".to_string(), owned_item("oc-b", "fifa17_101490"));
|
||||
let ident = TableIdentity(HashMap::from([
|
||||
(
|
||||
"oc-a".to_string(),
|
||||
Fifa17Identity {
|
||||
item_id: 100000030,
|
||||
asset_id: 101490,
|
||||
resource_id: 101490,
|
||||
rareflag: 1,
|
||||
},
|
||||
),
|
||||
(
|
||||
"oc-b".to_string(),
|
||||
Fifa17Identity {
|
||||
item_id: 100000031,
|
||||
asset_id: 101490,
|
||||
resource_id: 101490,
|
||||
rareflag: 1,
|
||||
},
|
||||
),
|
||||
]));
|
||||
let 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);
|
||||
@@ -403,6 +453,7 @@ mod tests {
|
||||
],
|
||||
ext: SquadExtInput::Fresh(ext),
|
||||
owned: owned_ref,
|
||||
manager: None,
|
||||
};
|
||||
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
|
||||
panic!();
|
||||
@@ -423,4 +474,89 @@ mod tests {
|
||||
"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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,45 +68,153 @@ impl PackDef {
|
||||
/// 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 },
|
||||
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 },
|
||||
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 },
|
||||
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 },
|
||||
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).
|
||||
|
||||
@@ -199,7 +199,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 70,
|
||||
"assetId": 5,
|
||||
"id": 70,
|
||||
"packType": "GOLD",
|
||||
"description": "Reward Gold Pack",
|
||||
@@ -212,6 +212,13 @@
|
||||
"isPremium": false,
|
||||
"sortPriority": 1,
|
||||
"displayGroupAssetId": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 0,
|
||||
"finalFunds": 0
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 0,
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
//! ```
|
||||
//!
|
||||
//! Fidelity is asserted by ownership class:
|
||||
//! CANONICAL player instance per index, formation, captain, bench split
|
||||
//! EXTENSION custom, kicktakers, manager, kit numbers (by player), squadType
|
||||
//! 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)
|
||||
//!
|
||||
@@ -23,7 +24,9 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use openfut_adapter_fifa17::fut::item::{CoreOwnedItem, Fifa17Identity, ItemIdentityResolver};
|
||||
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::{
|
||||
@@ -49,11 +52,19 @@ impl SquadWireResolver for OcResolver {
|
||||
}
|
||||
|
||||
/// owned_card_id → FIFA identity, so two copies of one definition stay distinct.
|
||||
struct TableIdentity(HashMap<String, Fifa17Identity>);
|
||||
/// `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
|
||||
@@ -115,7 +126,7 @@ fn oracle_tables() -> (HashMap<String, CoreOwnedItem>, TableIdentity) {
|
||||
},
|
||||
);
|
||||
}
|
||||
(owned, TableIdentity(ident))
|
||||
(owned, TableIdentity(ident, HashMap::new()))
|
||||
}
|
||||
|
||||
/// The full pipeline: parse a captured PUT, build the canonical + extension, then
|
||||
@@ -146,6 +157,9 @@ fn project_put(
|
||||
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,
|
||||
@@ -194,9 +208,11 @@ fn baseline_projects_the_known_squad_round_trip() {
|
||||
projected["captain"], 100000001,
|
||||
"captain is the player's WIRE id"
|
||||
);
|
||||
// EXTENSION: custom byte-identical, manager + squadType preserved.
|
||||
// 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_eq!(projected["manager"], put_v["manager"]);
|
||||
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);
|
||||
@@ -268,13 +284,11 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
|
||||
// evidence, then project and require the read back — the strongest fidelity
|
||||
// check across all four ownership classes.
|
||||
use openfut_adapter_fifa17::fut::squad::ClientReportedSquadEval;
|
||||
use openfut_adapter_fifa17::fut::squad_ext::{
|
||||
Fifa17SquadExtensionV1, KicktakerRef, WireItemRef,
|
||||
};
|
||||
use openfut_adapter_fifa17::fut::squad_ext::{Fifa17SquadExtensionV1, KicktakerRef};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
let oracle: Value = serde_json::from_str(READ_ORACLE).unwrap();
|
||||
let (owned, ident) = oracle_tables();
|
||||
let (owned, mut ident) = oracle_tables();
|
||||
let captain = oracle["captain"].as_i64().unwrap();
|
||||
|
||||
let mut slots = Vec::new();
|
||||
@@ -294,14 +308,40 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
|
||||
is_on_bench: index >= 11,
|
||||
});
|
||||
}
|
||||
let manager: Vec<WireItemRef> = serde_json::from_value(oracle["manager"].clone()).unwrap();
|
||||
// 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,
|
||||
manager,
|
||||
kicktakers,
|
||||
client_reported: ClientReportedSquadEval {
|
||||
chemistry: oracle["chemistry"].as_i64(),
|
||||
@@ -316,6 +356,7 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
|
||||
slots,
|
||||
ext: SquadExtInput::Fresh(ext),
|
||||
owned: &owned,
|
||||
manager: Some(manager_item),
|
||||
};
|
||||
let SquadProjection::Projected(projected) = project_squad(&input, &ident, &NoEntities).unwrap()
|
||||
else {
|
||||
@@ -352,7 +393,23 @@ fn persisted_read_round_trips_via_reconstructed_canonical_and_extension() {
|
||||
}
|
||||
// EXTENSION + SHADOW: sourced from the read, so they round-trip identically.
|
||||
assert_eq!(projected["custom"], oracle["custom"]);
|
||||
assert_eq!(projected["manager"], oracle["manager"]);
|
||||
// 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"]);
|
||||
|
||||
+1
-1
Submodule openfut-bridge updated: 0f581ebef3...07e83fe36c
+1
-1
Submodule openfut-core updated: 271c3639ed...233df1d99d
@@ -70,6 +70,11 @@ pub struct GenericClub {
|
||||
pub struct GenericOwned {
|
||||
pub owned_item_id: String,
|
||||
pub card_id: String,
|
||||
/// Generic classification for Core's ownership row. FIFA17 is the only side
|
||||
/// that can map `cardsubtypeid` onto this, and Core defaults to `player`, so
|
||||
/// leaving it off would durably record a coach or a contract card as a
|
||||
/// player — wrong in the ownership authority even when the wire looks right.
|
||||
pub content_kind: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -174,6 +179,7 @@ pub fn plan_apply(
|
||||
owned.push(GenericOwned {
|
||||
owned_item_id: core_id.clone(),
|
||||
card_id: def.card_id.clone(),
|
||||
content_kind: "player",
|
||||
});
|
||||
mappings.push(IdentityMapping {
|
||||
core_id,
|
||||
@@ -191,6 +197,7 @@ pub fn plan_apply(
|
||||
owned.push(GenericOwned {
|
||||
owned_item_id: core_id.clone(),
|
||||
card_id: def.card_id.clone(),
|
||||
content_kind: def.kind.as_str(),
|
||||
});
|
||||
mappings.push(IdentityMapping {
|
||||
core_id,
|
||||
@@ -208,7 +215,15 @@ pub fn plan_apply(
|
||||
.and_then(|s| s.get(0))
|
||||
.context("report says a squad is present but profile has no squads[0]")?;
|
||||
let body = serde_json::to_vec(raw_squad).context("re-serialize source squad")?;
|
||||
let put = parse_squad_put(&body).map_err(|e| anyhow::anyhow!("parse source squad: {e}"))?;
|
||||
let mut put =
|
||||
parse_squad_put(&body).map_err(|e| anyhow::anyhow!("parse source squad: {e}"))?;
|
||||
// A HISTORICAL profile may reference a manager whose owned instance is
|
||||
// not imported (unsupported/deferred, or a dangling id with no owned
|
||||
// item at all). Drop such a manager ref here rather than failing the
|
||||
// whole import — the manager assignment is only imported when its owned
|
||||
// instance is. (A LIVE squad PUT still refuses an unresolved manager,
|
||||
// because the client is actively assigning one it must own.)
|
||||
put.manager.retain(|m| wire_to_owned.contains_key(&m.id));
|
||||
let build = build_squad_write(&put, &MapResolver(&wire_to_owned))
|
||||
.map_err(|e| anyhow::anyhow!("build squad write: {e}"))?;
|
||||
let formation = build
|
||||
|
||||
@@ -87,16 +87,37 @@ pub fn load_roster(path: impl AsRef<Path>) -> Result<Roster> {
|
||||
|
||||
// --------------------------------------------------------------- entities
|
||||
|
||||
/// Forward numeric-id -> name resolution for the committed FIFA 17 tables
|
||||
/// (`leagues.json`/`nations.json`/`teams.json`, `{schema, rows:[…]}` dump).
|
||||
/// Mirrors `scripts/seed_fifa17_cards.py` (`_table_map`).
|
||||
/// Forward lookups over the committed FIFA 17 tables: numeric id -> name for
|
||||
/// `leagues.json`/`nations.json`/`teams.json` (`{schema, rows:[…]}` dump, mirrors
|
||||
/// `scripts/seed_fifa17_cards.py`'s `_table_map`), plus EA's authored rating and
|
||||
/// rare flag for the five STAFF families.
|
||||
#[derive(Default)]
|
||||
pub struct Entities {
|
||||
leagues: BTreeMap<i64, String>,
|
||||
nations: BTreeMap<i64, String>,
|
||||
teams: BTreeMap<i64, String>,
|
||||
/// `(cardsubtypeid, carddbid)` -> `(value, rare)` for the five staff tables.
|
||||
///
|
||||
/// `value` is the RATING the client re-rates a staff card to, and `rare`
|
||||
/// selects its discard price column. Both are LIVE-VERIFIED: reading the
|
||||
/// running client's own card records back out of memory
|
||||
/// (`tools/coach_probe.py`, 4/4 HIT) shows record `+0xb4` == this `value`
|
||||
/// and `+0x58` == this `rare`, and the discard value the client computes at
|
||||
/// record `+0x3c` (`tools/discard_probe.py`) matches the price those two
|
||||
/// select — 36 for the `value`-66 GK coach, 282 for the `rare`-1,
|
||||
/// `value`-88 manager.
|
||||
staff: BTreeMap<(i64, i64), (i64, i64)>,
|
||||
}
|
||||
|
||||
/// `cardsubtypeid` -> staff table basename (`FUN_1800d8330`'s family selector).
|
||||
const STAFF_TABLES: [(i64, &str); 5] = [
|
||||
(4, "managercards"),
|
||||
(5, "headcoachcards"),
|
||||
(6, "gkcoachcards"),
|
||||
(7, "physiocards"),
|
||||
(8, "fitnesscoachcards"),
|
||||
];
|
||||
|
||||
fn load_table(path: &Path, id_key: &str, name_key: &str) -> Result<BTreeMap<i64, String>> {
|
||||
let raw = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("reading table {}", path.display()))?;
|
||||
@@ -118,17 +139,48 @@ fn load_table(path: &Path, id_key: &str, name_key: &str) -> Result<BTreeMap<i64,
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// `carddbid -> (value, rare)` from one staff table dump.
|
||||
///
|
||||
/// `value` is EA's authored rating for the card and `rare` its rare flag; both
|
||||
/// are read verbatim, never defaulted, and a row missing either is skipped so a
|
||||
/// gap stays a gap.
|
||||
fn load_staff_table(path: &Path) -> Result<BTreeMap<i64, (i64, i64)>> {
|
||||
let raw = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("reading staff table {}", path.display()))?;
|
||||
let doc: serde_json::Value = serde_json::from_str(&raw)
|
||||
.with_context(|| format!("parsing staff table {}", path.display()))?;
|
||||
let rows = doc
|
||||
.get("rows")
|
||||
.and_then(|r| r.as_array())
|
||||
.with_context(|| format!("staff table {} has no rows[]", path.display()))?;
|
||||
let mut map = BTreeMap::new();
|
||||
for row in rows {
|
||||
let get = |k: &str| row.get(k).and_then(serde_json::Value::as_i64);
|
||||
if let (Some(id), Some(value), Some(rare)) = (get("carddbid"), get("value"), get("rare")) {
|
||||
map.insert(id, (value, rare));
|
||||
}
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
impl Entities {
|
||||
pub fn from_tables_dir(dir: impl AsRef<Path>) -> Result<Self> {
|
||||
let dir = dir.as_ref();
|
||||
let mut staff = BTreeMap::new();
|
||||
for (subtype, table) in STAFF_TABLES {
|
||||
for (carddbid, row) in load_staff_table(&dir.join(format!("{table}.json")))? {
|
||||
staff.insert((subtype, carddbid), row);
|
||||
}
|
||||
}
|
||||
Ok(Entities {
|
||||
leagues: load_table(&dir.join("leagues.json"), "leagueid", "leaguename")?,
|
||||
nations: load_table(&dir.join("nations.json"), "nationid", "nationname")?,
|
||||
teams: load_table(&dir.join("teams.json"), "teamid", "teamname")?,
|
||||
staff,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build directly from id->name maps (tests).
|
||||
/// Build directly from id->name maps (tests). Carries no staff table.
|
||||
pub fn from_maps(
|
||||
leagues: BTreeMap<i64, String>,
|
||||
nations: BTreeMap<i64, String>,
|
||||
@@ -138,6 +190,32 @@ impl Entities {
|
||||
leagues,
|
||||
nations,
|
||||
teams,
|
||||
staff: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// EA's `(rating, rare)` for one staff definition, or `None` when the id is
|
||||
/// absent from its family's table (the client would draw its own miss-fill).
|
||||
pub fn staff_stats(&self, subtype: i64, carddbid: i64) -> Option<(i64, i64)> {
|
||||
self.staff.get(&(subtype, carddbid)).copied()
|
||||
}
|
||||
|
||||
/// Fill in the rating and rare flag the client re-rates STAFF cards to.
|
||||
///
|
||||
/// The wire never sends either (measured: a staff record arrives with no
|
||||
/// `rating`, no `rareflag` and no `discardValue`), so without this a staff
|
||||
/// definition reaches the catalog with `rating: None` and the server cannot
|
||||
/// price a quick sell to match what the client displays. Players and
|
||||
/// consumables are untouched — their wire values are authoritative.
|
||||
pub fn enrich_staff(&self, plan: &mut NonPlayerPlan) {
|
||||
for def in &mut plan.supported {
|
||||
if def.kind != ContentKind::Staff && def.kind != ContentKind::Manager {
|
||||
continue;
|
||||
}
|
||||
if let Some((value, rare)) = self.staff_stats(def.subtype, def.resource_id) {
|
||||
def.rating.get_or_insert(value);
|
||||
def.rareflag.get_or_insert(rare);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,16 +238,61 @@ pub fn load_profile(path: impl AsRef<Path>) -> Result<Profile> {
|
||||
|
||||
// ------------------------------------------------------------- classification
|
||||
|
||||
/// A club item's family, settled from the CardsDLL club-item resolver
|
||||
/// (`FUN_180119bd0`, `plan-2026-08-06-card-subsystem.md`) and cross-checked
|
||||
/// against the shipped definition tables. Each family's `cardassetid` is
|
||||
/// CONSTANT across every row it ships, which is what makes it a usable gate:
|
||||
/// kit 35 (1482 rows), stadium 36 (78), badge 39 (656), ball 37 (42),
|
||||
/// league logo 40 (44).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ClubFamily {
|
||||
pub kind: ContentKind,
|
||||
pub label: &'static str,
|
||||
/// The `cardassetid` every definition in this family carries.
|
||||
pub card_asset_id: i64,
|
||||
}
|
||||
|
||||
/// Club-item `cardsubtypeid` -> family. These subtypes cannot collide with the
|
||||
/// other classes: consumables occupy 51..=341 and staff 4/6/8.
|
||||
pub fn club_family(subtype: i64) -> Option<ClubFamily> {
|
||||
let f = |kind, label, card_asset_id| {
|
||||
Some(ClubFamily {
|
||||
kind,
|
||||
label,
|
||||
card_asset_id,
|
||||
})
|
||||
};
|
||||
match subtype {
|
||||
9 => f(ContentKind::Kit, "Kit", 35),
|
||||
10 => f(ContentKind::Stadium, "Stadium", 36),
|
||||
11 => f(ContentKind::Badge, "Club Badge", 39),
|
||||
30 => f(ContentKind::Ball, "Ball", 37),
|
||||
// League logos have no equipped slot of their own, so Core holds them
|
||||
// as generic owned content rather than inventing a designation.
|
||||
31 => f(ContentKind::Misc, "League Logo", 40),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The disjoint source item classes. Every source item is exactly one.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ItemClass {
|
||||
PlayerCard,
|
||||
Consumable,
|
||||
Staff,
|
||||
/// A club item (kit / stadium / badge / ball / league logo).
|
||||
Club(ClubFamily),
|
||||
Other,
|
||||
}
|
||||
|
||||
pub fn classify(item: &Item) -> ItemClass {
|
||||
// Subtype is the settled discriminator, so a club item is recognised by what
|
||||
// it IS rather than by the id range it happens to occupy — kits used to be
|
||||
// matched on 6_300_000..=6_400_654, which classified every other club family
|
||||
// as `Other` and silently dropped it from the import.
|
||||
if let Some(family) = item.cardsubtypeid.and_then(club_family) {
|
||||
return ItemClass::Club(family);
|
||||
}
|
||||
match item.item_type.as_str() {
|
||||
"staff" => ItemClass::Staff,
|
||||
"player" => {
|
||||
@@ -189,12 +312,15 @@ pub struct ItemCounts {
|
||||
pub player_cards: usize,
|
||||
pub consumables: usize,
|
||||
pub staff: usize,
|
||||
/// Club items: kits, stadiums, badges, balls, league logos.
|
||||
pub club_items: usize,
|
||||
pub other: usize,
|
||||
}
|
||||
|
||||
impl ItemCounts {
|
||||
pub fn balances(&self) -> bool {
|
||||
self.player_cards + self.consumables + self.staff + self.other == self.total
|
||||
self.player_cards + self.consumables + self.staff + self.club_items + self.other
|
||||
== self.total
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +334,7 @@ pub fn count_items(profile: &Profile) -> ItemCounts {
|
||||
ItemClass::PlayerCard => c.player_cards += 1,
|
||||
ItemClass::Consumable => c.consumables += 1,
|
||||
ItemClass::Staff => c.staff += 1,
|
||||
ItemClass::Club(_) => c.club_items += 1,
|
||||
ItemClass::Other => c.other += 1,
|
||||
}
|
||||
}
|
||||
@@ -524,21 +651,43 @@ pub fn plan_definitions(
|
||||
|
||||
// ------------------------------------------------------- non-player content
|
||||
|
||||
/// An honest, profile-derived NON-player CardDefinition proposal (consumable or
|
||||
/// staff), keyed by `fifa17_<resourceId>`. Neutral player fields are supplied at
|
||||
/// emit time; this carries only the identity + honest functional `name` (the
|
||||
/// taxonomy label, never a marketing name).
|
||||
/// An honest, profile-derived non-player CardDefinition proposal, keyed by
|
||||
/// `fifa17_<resourceId>`. Neutral player fields are supplied at emit time; FIFA
|
||||
/// render metadata stays here and in the adapter catalog, never generic Core.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NonPlayerDefinition {
|
||||
pub card_id: String,
|
||||
pub resource_id: i64,
|
||||
/// Base asset id when the source carries one (consumables: `== resource_id`);
|
||||
/// staff carry no `assetId`, so this is `None`.
|
||||
/// Base asset id when the source carries one.
|
||||
pub asset_id: Option<i64>,
|
||||
pub kind: ContentKind,
|
||||
/// FIFA `cardsubtypeid` (consumable family / staff role selector).
|
||||
/// FIFA `cardsubtypeid` (consumable family, staff role, or kit family).
|
||||
pub subtype: i64,
|
||||
/// Honest functional label (e.g. "Player Contract", "GK Coach").
|
||||
/// Non-player card-art id (`35` for kits), when present.
|
||||
pub card_asset_id: Option<i64>,
|
||||
/// Source team id for a kit definition, when present.
|
||||
pub team_id: Option<i64>,
|
||||
/// Consumable effect magnitude (`amount`), when the source carries one.
|
||||
///
|
||||
/// Definition-level, and measured to be so: across every owned consumable in
|
||||
/// the real profile the observed `amount` equals its `fcc_*` table row
|
||||
/// (1, 2, 4, 5, 10, 15 — no disagreements), and a wire omission corresponds
|
||||
/// to a table `amount` of 0. It is NOT a stack count: two copies of 5003068
|
||||
/// arrive as two separate instances, each with the same amount.
|
||||
pub amount: Option<i64>,
|
||||
/// Contract-card payload (`contract`), when the source carries one. Present
|
||||
/// on exactly the contract families (subtypes 201/202) and absent from every
|
||||
/// other owned consumable, so it is the card's own field rather than the
|
||||
/// generic per-item contract atom that players and staff carry.
|
||||
pub contract: Option<i64>,
|
||||
/// Card rating, when the source carries one (matches the `fcc_*` row).
|
||||
pub rating: Option<i64>,
|
||||
/// EA's authored `rare` flag for a STAFF definition, from the same table row
|
||||
/// as [`Self::rating`]. The wire never carries it (the client re-rates staff
|
||||
/// from its own DB), and it is NOT cosmetic: it selects the discard price
|
||||
/// column, so a `rare`-1 manager and a `rare`-0 coach price differently.
|
||||
pub rareflag: Option<i64>,
|
||||
/// Honest functional label (e.g. "Player Contract", "GK Coach", "Kit").
|
||||
pub name: String,
|
||||
/// Wire ids of every owned copy of this resourceId (preserved).
|
||||
pub wire_ids: Vec<i64>,
|
||||
@@ -563,6 +712,8 @@ pub struct NonPlayerPlan {
|
||||
pub consumables: usize,
|
||||
/// Count of SUPPORTED staff definitions.
|
||||
pub staff: usize,
|
||||
/// Count of SUPPORTED kit definitions.
|
||||
pub kits: usize,
|
||||
}
|
||||
|
||||
impl NonPlayerPlan {
|
||||
@@ -572,15 +723,16 @@ impl NonPlayerPlan {
|
||||
}
|
||||
}
|
||||
|
||||
/// Plan the non-player (consumable + staff) CardDefinitions. Groups Consumable
|
||||
/// and Staff items by `resourceId`; each group must agree on `cardsubtypeid`
|
||||
/// across copies (a disagreement DEFERS with `subtype_conflict`), then resolves
|
||||
/// the family (consumable) or role (staff) via the adapter's evidence-based
|
||||
/// taxonomy. A missing or unknown `cardsubtypeid` DEFERS — never a placeholder.
|
||||
/// Plan non-player CardDefinitions. Consumable, Staff, and Kit groups must agree
|
||||
/// on their definition-level metadata across every owned copy; disagreement or
|
||||
/// missing required metadata defers the whole group, never fabricates a value.
|
||||
pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan {
|
||||
let mut groups: BTreeMap<i64, Vec<&Item>> = BTreeMap::new();
|
||||
for it in &profile.items {
|
||||
if matches!(classify(it), ItemClass::Consumable | ItemClass::Staff) {
|
||||
if matches!(
|
||||
classify(it),
|
||||
ItemClass::Consumable | ItemClass::Staff | ItemClass::Club(_)
|
||||
) {
|
||||
groups.entry(it.resource_id).or_default().push(it);
|
||||
}
|
||||
}
|
||||
@@ -622,6 +774,31 @@ pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan {
|
||||
continue;
|
||||
};
|
||||
|
||||
let card_asset_id = items[0].cardassetid;
|
||||
let team_id = items[0].teamid;
|
||||
// These are definition-level, so every owned copy must agree. Two copies
|
||||
// of one consumable that disagreed would mean the field is really
|
||||
// per-instance, and silently taking the first copy's value would bake a
|
||||
// guess into the catalog — so defer the whole group instead.
|
||||
let amount = items[0].amount;
|
||||
let contract = items[0].contract;
|
||||
let rating = items[0].rating;
|
||||
if items.iter().any(|item| {
|
||||
item.cardassetid != card_asset_id
|
||||
|| item.teamid != team_id
|
||||
|| item.amount != amount
|
||||
|| item.contract != contract
|
||||
|| item.rating != rating
|
||||
}) {
|
||||
plan.deferred.push(DeferredNonPlayer {
|
||||
resource_id,
|
||||
subtype: Some(subtype),
|
||||
wire_ids,
|
||||
reason: "render_metadata_conflict".to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let (kind, label) = match class {
|
||||
ItemClass::Consumable => match consumable_family(subtype) {
|
||||
Some((_family, label)) => (ContentKind::Consumable, label),
|
||||
@@ -647,15 +824,55 @@ pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan {
|
||||
continue;
|
||||
}
|
||||
},
|
||||
_ => unreachable!("only Consumable/Staff were grouped"),
|
||||
ItemClass::Club(family) => {
|
||||
// Every definition in a club family ships the SAME cardassetid,
|
||||
// so a copy carrying anything else is not the item it claims to
|
||||
// be and is deferred rather than rendered as the wrong art.
|
||||
if card_asset_id != Some(family.card_asset_id) {
|
||||
plan.deferred.push(DeferredNonPlayer {
|
||||
resource_id,
|
||||
subtype: Some(subtype),
|
||||
wire_ids,
|
||||
reason: "missing_club_item_render_metadata".to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// A kit additionally needs its team: the kit identity resolver
|
||||
// keys on it, and this requirement is observed on real owned
|
||||
// kits. The other families ship no teamid at all, so demanding
|
||||
// one would defer every legitimate badge, ball and stadium.
|
||||
if family.kind == ContentKind::Kit && team_id.is_none() {
|
||||
plan.deferred.push(DeferredNonPlayer {
|
||||
resource_id,
|
||||
subtype: Some(subtype),
|
||||
wire_ids,
|
||||
reason: "missing_kit_render_metadata".to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
(family.kind, family.label)
|
||||
}
|
||||
_ => unreachable!("only Consumable/Staff/Club were grouped"),
|
||||
};
|
||||
|
||||
plan.supported.push(NonPlayerDefinition {
|
||||
card_id: format!("fifa17_{resource_id}"),
|
||||
resource_id,
|
||||
asset_id: items[0].asset_id,
|
||||
// A club item's carddbid IS its asset id; the source may not repeat
|
||||
// it in an `assetId` field.
|
||||
asset_id: if matches!(class, ItemClass::Club(_)) {
|
||||
Some(resource_id)
|
||||
} else {
|
||||
items[0].asset_id
|
||||
},
|
||||
kind,
|
||||
subtype,
|
||||
card_asset_id,
|
||||
team_id,
|
||||
amount,
|
||||
contract,
|
||||
rating,
|
||||
rareflag: None,
|
||||
name: label.to_string(),
|
||||
wire_ids,
|
||||
});
|
||||
@@ -670,6 +887,11 @@ pub fn plan_non_player_definitions(profile: &Profile) -> NonPlayerPlan {
|
||||
.iter()
|
||||
.filter(|d| d.kind == ContentKind::Staff)
|
||||
.count();
|
||||
plan.kits = plan
|
||||
.supported
|
||||
.iter()
|
||||
.filter(|definition| definition.kind == ContentKind::Kit)
|
||||
.count();
|
||||
plan
|
||||
}
|
||||
|
||||
@@ -815,10 +1037,11 @@ impl Report {
|
||||
let mut b = Vec::new();
|
||||
if !self.counts.balances() {
|
||||
b.push(format!(
|
||||
"item-type accounting does not balance ({}+{}+{}+{} != {})",
|
||||
"item-type accounting does not balance ({}+{}+{}+{}+{} != {})",
|
||||
self.counts.player_cards,
|
||||
self.counts.consumables,
|
||||
self.counts.staff,
|
||||
self.counts.club_items,
|
||||
self.counts.other,
|
||||
self.counts.total
|
||||
));
|
||||
@@ -874,7 +1097,10 @@ pub fn analyze(
|
||||
let identity = plan_identity(profile, &supported_rids);
|
||||
let supported_wire: BTreeSet<i64> = identity.import_wire_ids.iter().copied().collect();
|
||||
let squad = plan_squad(profile, &supported_wire);
|
||||
let non_player = plan_non_player_definitions(profile);
|
||||
let mut non_player = plan_non_player_definitions(profile);
|
||||
// Staff carry no rating or rare flag on the wire; fill them from the tables
|
||||
// the client itself re-rates from, so a quick sell can be priced to match.
|
||||
entities.enrich_staff(&mut non_player);
|
||||
Report {
|
||||
game: "fifa17".to_string(),
|
||||
persona_id: profile.persona_id,
|
||||
@@ -911,11 +1137,12 @@ impl std::fmt::Display for Report {
|
||||
)?;
|
||||
writeln!(
|
||||
f,
|
||||
"\nSOURCE ITEMS total={} player_cards={} consumables={} staff={} other={} balances={}",
|
||||
"\nSOURCE ITEMS total={} player_cards={} consumables={} staff={} club_items={} other={} balances={}",
|
||||
self.counts.total,
|
||||
self.counts.player_cards,
|
||||
self.counts.consumables,
|
||||
self.counts.staff,
|
||||
self.counts.club_items,
|
||||
self.counts.other,
|
||||
self.counts.balances()
|
||||
)?;
|
||||
@@ -976,10 +1203,11 @@ impl std::fmt::Display for Report {
|
||||
}
|
||||
writeln!(
|
||||
f,
|
||||
"\nNON-PLAYER CONTENT (consumable/staff) supported={} (consumables={} staff={}) deferred_groups={} deferred_instances={}",
|
||||
"\nNON-PLAYER CONTENT (consumable/staff/kit) supported={} (consumables={} staff={} kits={}) deferred_groups={} deferred_instances={}",
|
||||
np.supported.len(),
|
||||
np.consumables,
|
||||
np.staff,
|
||||
np.kits,
|
||||
np.deferred.len(),
|
||||
np.deferred_instances()
|
||||
)?;
|
||||
@@ -1033,6 +1261,9 @@ pub struct EmitSummary {
|
||||
pub content_pack: std::path::PathBuf,
|
||||
pub host_catalog: std::path::PathBuf,
|
||||
pub manifest: std::path::PathBuf,
|
||||
/// Core `reclassify` request correcting the content_kind of rows imported
|
||||
/// before the taxonomy existed.
|
||||
pub reclassify: std::path::PathBuf,
|
||||
pub definitions: usize,
|
||||
pub catalog_entries: usize,
|
||||
pub supported_instances: usize,
|
||||
@@ -1126,13 +1357,26 @@ pub fn emit_content(
|
||||
}
|
||||
for d in &report.non_player.supported {
|
||||
// asset_id falls back to resource_id (staff carry no assetId); version 0,
|
||||
// rareflag 0 — a consumable/staff never renders as a special card.
|
||||
// because a consumable/staff never renders as a versioned special card.
|
||||
cards.insert(
|
||||
d.card_id.clone(),
|
||||
serde_json::json!({
|
||||
"card_asset_id": d.card_asset_id,
|
||||
"team_id": d.team_id,
|
||||
// A consumable is unrenderable without these: the adapter refuses
|
||||
// to emit a card whose art id it does not know, and the families
|
||||
// that read `amount`/`contract` draw "-1" or grant nothing when
|
||||
// the field is missing. Omitting them here is what kept every
|
||||
// owned consumable off the wire.
|
||||
"amount": d.amount,
|
||||
"contract": d.contract,
|
||||
"rating": d.rating,
|
||||
"asset_id": d.asset_id.unwrap_or(d.resource_id),
|
||||
"version": 0,
|
||||
"rareflag": 0,
|
||||
// NOT cosmetic: `rareflag` selects the discard price column, and
|
||||
// a staff card's is EA's own `rare`, filled from its family table
|
||||
// by `Entities::enrich_staff`. 0 for everything else, as before.
|
||||
"rareflag": d.rareflag.unwrap_or(0),
|
||||
"kind": d.kind.as_str(),
|
||||
"subtype": d.subtype,
|
||||
}),
|
||||
@@ -1152,6 +1396,28 @@ pub fn emit_content(
|
||||
let host_catalog = content_dir.join("fifa17-production-catalog.json");
|
||||
write_json_pretty(&host_catalog, &catalog)?;
|
||||
|
||||
// ---- Core reclassify request (content_kind of already-imported rows) ----
|
||||
// A profile import is once-only, so a club imported before the taxonomy
|
||||
// existed still records its coaches, kits and consumables as players. Core
|
||||
// cannot derive the right kind (it is FIFA17 taxonomy), so hand it the
|
||||
// mapping. Players are included too: a definition that was mis-set stays
|
||||
// correctable, and a rerun changes nothing.
|
||||
let assignments: Vec<serde_json::Value> =
|
||||
report
|
||||
.definitions
|
||||
.supported
|
||||
.iter()
|
||||
.map(|d| serde_json::json!({ "card_id": d.card_id, "content_kind": "player" }))
|
||||
.chain(report.non_player.supported.iter().map(
|
||||
|d| serde_json::json!({ "card_id": d.card_id, "content_kind": d.kind.as_str() }),
|
||||
))
|
||||
.collect();
|
||||
let reclassify = content_dir.join("fifa17-reclassify.json");
|
||||
write_json_pretty(
|
||||
&reclassify,
|
||||
&serde_json::json!({ "game_id": "fifa17", "assignments": assignments }),
|
||||
)?;
|
||||
|
||||
// ---- PRIVATE: import manifest (wire ids + deferred set) ----
|
||||
let supported: Vec<serde_json::Value> = report
|
||||
.definitions
|
||||
@@ -1195,6 +1461,8 @@ pub fn emit_content(
|
||||
.iter()
|
||||
.map(|d| {
|
||||
serde_json::json!({
|
||||
"card_asset_id": d.card_asset_id,
|
||||
"team_id": d.team_id,
|
||||
"card_id": d.card_id,
|
||||
"resource_id": d.resource_id,
|
||||
"asset_id": d.asset_id,
|
||||
@@ -1259,6 +1527,7 @@ pub fn emit_content(
|
||||
Ok(EmitSummary {
|
||||
content_pack,
|
||||
host_catalog,
|
||||
reclassify,
|
||||
manifest: manifest_path,
|
||||
definitions: report.definitions.supported.len(),
|
||||
catalog_entries: report.definitions.supported.len() + non_player_definitions,
|
||||
|
||||
@@ -123,6 +123,11 @@ fn run() -> Result<ExitCode> {
|
||||
sum.supported_instances,
|
||||
sum.deferred_instances
|
||||
);
|
||||
println!(
|
||||
" reclassify : {} (run `openfut-core reclassify <file>` to correct \
|
||||
content_kind on a club imported before the taxonomy existed)",
|
||||
sum.reclassify.display()
|
||||
);
|
||||
println!(
|
||||
" non-player : {} definition(s), {} instance(s) (consumable/staff)",
|
||||
sum.non_player_definitions, sum.non_player_instances
|
||||
|
||||
@@ -70,12 +70,17 @@ pub struct Item {
|
||||
/// Consumable ART id (small id), distinct from `resourceId`. Permissive.
|
||||
#[serde(default)]
|
||||
pub cardassetid: Option<i64>,
|
||||
/// Consumable stack size (`amount`). Permissive.
|
||||
/// Consumable effect magnitude (`amount`) — NOT a stack size: every owned
|
||||
/// copy is its own instance and carries its definition's value, which is
|
||||
/// exactly the `fcc_*` table's `amount` column. Permissive.
|
||||
#[serde(default)]
|
||||
pub amount: Option<i64>,
|
||||
/// Staff/contract `contract` count. Permissive.
|
||||
#[serde(default)]
|
||||
pub contract: Option<i64>,
|
||||
/// Owned-item lifecycle state (`activeHomeKit` / `activeAwayKit` for kits).
|
||||
#[serde(rename = "itemState", default)]
|
||||
pub item_state: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
|
||||
@@ -72,11 +72,21 @@ fn classification_balances_across_disjoint_classes() {
|
||||
player(100000002, VER5_176580, 176580, 92),
|
||||
r#"{"id":100000239,"resourceId":5003012,"assetId":5003012,"itemType":"player","rating":85}"#.to_string(),
|
||||
r#"{"id":100000427,"resourceId":3000083,"itemType":"staff"}"#.to_string(),
|
||||
r#"{"id":100000500,"resourceId":6300006,"assetId":6300006,
|
||||
"cardsubtypeid":9,"cardassetid":35,"teamid":21,"itemState":"activeHomeKit"}"#
|
||||
.to_string(),
|
||||
];
|
||||
let c = count_items(&profile(&items, "[]", 100000500));
|
||||
let c = count_items(&profile(&items, "[]", 100000501));
|
||||
assert_eq!(
|
||||
(c.total, c.player_cards, c.consumables, c.staff, c.other),
|
||||
(4, 2, 1, 1, 0)
|
||||
(
|
||||
c.total,
|
||||
c.player_cards,
|
||||
c.consumables,
|
||||
c.staff,
|
||||
c.club_items,
|
||||
c.other
|
||||
),
|
||||
(5, 2, 1, 1, 1, 0)
|
||||
);
|
||||
assert!(c.balances());
|
||||
}
|
||||
@@ -649,6 +659,14 @@ fn staff(id: i64, resource: i64, subtype: i64) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn kit(id: i64, resource: i64, team_id: i64, item_state: &str) -> String {
|
||||
format!(
|
||||
r#"{{"id":{id},"resourceId":{resource},"assetId":{resource},
|
||||
"cardsubtypeid":9,"cardassetid":35,"teamid":{team_id},
|
||||
"itemState":"{item_state}","owners":1,"untradeable":false}}"#
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_non_player_supports_seventeen_consumables_and_three_staff() {
|
||||
// The exact record set from the ticket: distinct resourceIds, so each is its
|
||||
@@ -676,6 +694,7 @@ fn plan_non_player_supports_seventeen_consumables_and_three_staff() {
|
||||
);
|
||||
assert_eq!(plan.consumables, 17);
|
||||
assert_eq!(plan.staff, 3);
|
||||
assert_eq!(plan.kits, 0);
|
||||
assert!(plan.deferred.is_empty(), "0 deferred: {:?}", plan.deferred);
|
||||
|
||||
// Honest labels + kinds resolve from the taxonomy (spot checks).
|
||||
@@ -695,6 +714,138 @@ fn plan_non_player_supports_seventeen_consumables_and_three_staff() {
|
||||
assert_eq!(by_id("fifa17_3000003").name, "GK Coach");
|
||||
}
|
||||
|
||||
/// A consumable the adapter cannot render is a consumable the club cannot use:
|
||||
/// the shaper drops any card whose art id it does not know, and the families
|
||||
/// that read `amount`/`contract` would draw "-1" or grant nothing. Emitting the
|
||||
/// definition without these fields is exactly what kept all 17 owned
|
||||
/// consumables off the wire while club/stats still counted them.
|
||||
#[test]
|
||||
fn consumable_definitions_carry_the_fields_the_client_renders() {
|
||||
let items = vec![
|
||||
// training +15: carries `amount`, no `contract`
|
||||
r#"{"id":100000201,"resourceId":5003012,"assetId":5003012,"itemType":"player",
|
||||
"cardsubtypeid":54,"cardassetid":3,"amount":15,"rating":85,"rareflag":0}"#
|
||||
.to_string(),
|
||||
// player contract: carries `contract`, no `amount`
|
||||
r#"{"id":100000202,"resourceId":5001004,"assetId":5001004,"itemType":"player",
|
||||
"cardsubtypeid":201,"cardassetid":7,"contract":7,"rating":60,"rareflag":0}"#
|
||||
.to_string(),
|
||||
];
|
||||
let plan = plan_non_player_definitions(&profile(&items, "[]", 100000500));
|
||||
assert!(plan.deferred.is_empty(), "{:?}", plan.deferred);
|
||||
let by_id = |cid: &str| plan.supported.iter().find(|d| d.card_id == cid).unwrap();
|
||||
|
||||
let training = by_id("fifa17_5003012");
|
||||
assert_eq!(training.card_asset_id, Some(3), "card art id");
|
||||
assert_eq!(training.amount, Some(15), "effect magnitude");
|
||||
assert_eq!(training.contract, None);
|
||||
assert_eq!(training.rating, Some(85));
|
||||
|
||||
let contract = by_id("fifa17_5001004");
|
||||
assert_eq!(contract.card_asset_id, Some(7));
|
||||
assert_eq!(contract.contract, Some(7));
|
||||
assert_eq!(contract.amount, None, "contract families ignore amount");
|
||||
}
|
||||
|
||||
/// `amount` is definition-level (every owned copy of a card carries the same
|
||||
/// value), so two copies that DISAGREE mean the field is really per-instance.
|
||||
/// Taking the first copy's value would silently bake a guess into the catalog.
|
||||
#[test]
|
||||
fn disagreeing_render_metadata_defers_rather_than_guessing() {
|
||||
let items = vec![
|
||||
r#"{"id":100000201,"resourceId":5003012,"assetId":5003012,"itemType":"player",
|
||||
"cardsubtypeid":54,"cardassetid":3,"amount":15,"rareflag":0}"#
|
||||
.to_string(),
|
||||
r#"{"id":100000202,"resourceId":5003012,"assetId":5003012,"itemType":"player",
|
||||
"cardsubtypeid":54,"cardassetid":3,"amount":10,"rareflag":0}"#
|
||||
.to_string(),
|
||||
];
|
||||
let plan = plan_non_player_definitions(&profile(&items, "[]", 100000500));
|
||||
assert!(plan.supported.is_empty(), "must not pick a winner");
|
||||
assert_eq!(plan.deferred.len(), 1);
|
||||
assert_eq!(plan.deferred[0].reason, "render_metadata_conflict");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn club_item_missing_render_metadata_defers() {
|
||||
// No cardassetid at all: it cannot be drawn, whatever family it claims.
|
||||
let item = r#"{"id":100000501,"resourceId":6300006,"assetId":6300006,
|
||||
"cardsubtypeid":9,"itemState":"activeHomeKit"}"#
|
||||
.to_string();
|
||||
let plan = plan_non_player_definitions(&profile(&[item], "[]", 100000600));
|
||||
assert!(plan.supported.is_empty());
|
||||
assert_eq!(plan.deferred.len(), 1);
|
||||
assert_eq!(plan.deferred[0].reason, "missing_club_item_render_metadata");
|
||||
|
||||
// Correct kit art, but no team: the kit identity resolver keys on teamid.
|
||||
let item = r#"{"id":100000502,"resourceId":6300007,"assetId":6300007,
|
||||
"cardsubtypeid":9,"cardassetid":35,"itemState":"activeHomeKit"}"#
|
||||
.to_string();
|
||||
let plan = plan_non_player_definitions(&profile(&[item], "[]", 100000600));
|
||||
assert!(plan.supported.is_empty());
|
||||
assert_eq!(plan.deferred[0].reason, "missing_kit_render_metadata");
|
||||
|
||||
// A kit carrying another family's art is not the item it claims to be.
|
||||
let item = r#"{"id":100000503,"resourceId":6300008,"assetId":6300008,
|
||||
"cardsubtypeid":9,"cardassetid":39,"teamid":21}"#
|
||||
.to_string();
|
||||
let plan = plan_non_player_definitions(&profile(&[item], "[]", 100000600));
|
||||
assert!(plan.supported.is_empty());
|
||||
assert_eq!(plan.deferred[0].reason, "missing_club_item_render_metadata");
|
||||
}
|
||||
|
||||
/// Club items are settled by `cardsubtypeid`, not by the id range they occupy.
|
||||
/// Keying kits off 6_300_000..=6_400_654 classified every OTHER club family as
|
||||
/// `Other`, so a badge, ball, stadium or league logo was silently dropped from
|
||||
/// the import even though its definition table ships with the game.
|
||||
#[test]
|
||||
fn every_club_family_classifies_and_imports() {
|
||||
let club = |id: i64, resource: i64, subtype: i64, art: i64| {
|
||||
format!(
|
||||
r#"{{"id":{id},"resourceId":{resource},"assetId":{resource},
|
||||
"cardsubtypeid":{subtype},"cardassetid":{art},"teamid":21}}"#
|
||||
)
|
||||
};
|
||||
let items = vec![
|
||||
club(100000501, 6_300_006, 9, 35), // kit
|
||||
club(100000502, 6_200_001, 10, 36), // stadium
|
||||
club(100000503, 6_000_012, 11, 39), // badge
|
||||
club(100000504, 8_120_194, 30, 37), // ball
|
||||
club(100000505, 8_010_001, 31, 40), // league logo
|
||||
];
|
||||
let counts = count_items(&profile(&items, "[]", 100000600));
|
||||
assert_eq!(
|
||||
counts.club_items, 5,
|
||||
"no club family falls through to Other"
|
||||
);
|
||||
assert_eq!(counts.other, 0);
|
||||
|
||||
let plan = plan_non_player_definitions(&profile(&items, "[]", 100000600));
|
||||
assert!(plan.deferred.is_empty(), "{:?}", plan.deferred);
|
||||
let kind = |cid: &str| {
|
||||
plan.supported
|
||||
.iter()
|
||||
.find(|d| d.card_id == cid)
|
||||
.unwrap_or_else(|| panic!("{cid} not imported"))
|
||||
.kind
|
||||
};
|
||||
assert_eq!(kind("fifa17_6300006"), ContentKind::Kit);
|
||||
assert_eq!(kind("fifa17_6200001"), ContentKind::Stadium);
|
||||
assert_eq!(kind("fifa17_6000012"), ContentKind::Badge);
|
||||
assert_eq!(kind("fifa17_8120194"), ContentKind::Ball);
|
||||
// A league logo has no equipped slot, so it is generic owned content.
|
||||
assert_eq!(kind("fifa17_8010001"), ContentKind::Misc);
|
||||
// A club item's carddbid IS its asset id.
|
||||
assert_eq!(
|
||||
plan.supported
|
||||
.iter()
|
||||
.find(|d| d.card_id == "fifa17_6000012")
|
||||
.unwrap()
|
||||
.asset_id,
|
||||
Some(6_000_012)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_subtype_consumable_defers_never_fabricated() {
|
||||
let plan = plan_non_player_definitions(&profile(
|
||||
@@ -763,6 +914,7 @@ fn emit_content_writes_non_player_defs_catalog_kind_and_manifest() {
|
||||
player(100000001, 20801, 20801, 94),
|
||||
consumable(100000201, 5003012, 201), // Player Contract
|
||||
staff(100000427, 3000083, 8), // Fitness Coach
|
||||
kit(100000500, 6300006, 21, "activeHomeKit"),
|
||||
];
|
||||
let rep = analyze(
|
||||
&profile(&items, "[]", 100000500),
|
||||
@@ -771,16 +923,16 @@ fn emit_content_writes_non_player_defs_catalog_kind_and_manifest() {
|
||||
&none(),
|
||||
);
|
||||
assert!(!rep.has_blockers(), "blockers: {:?}", rep.blockers());
|
||||
assert_eq!(rep.non_player.supported.len(), 2);
|
||||
assert_eq!(rep.non_player.supported.len(), 3);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sum = emit_content(&rep, dir.path(), "fp").unwrap();
|
||||
assert_eq!(sum.definitions, 1, "one player definition");
|
||||
assert_eq!(sum.non_player_definitions, 2);
|
||||
assert_eq!(sum.non_player_instances, 2);
|
||||
assert_eq!(sum.non_player_definitions, 3);
|
||||
assert_eq!(sum.non_player_instances, 3);
|
||||
assert_eq!(
|
||||
sum.catalog_entries, 3,
|
||||
"player + 2 non-player catalog entries"
|
||||
sum.catalog_entries, 4,
|
||||
"player + 3 non-player catalog entries"
|
||||
);
|
||||
|
||||
// Content pack: neutral non-player CardDefinition with honest name.
|
||||
@@ -807,21 +959,29 @@ fn emit_content_writes_non_player_defs_catalog_kind_and_manifest() {
|
||||
assert_eq!(cat["cards"]["fifa17_3000083"]["kind"], "staff");
|
||||
assert_eq!(cat["cards"]["fifa17_3000083"]["subtype"], 8);
|
||||
assert_eq!(cat["cards"]["fifa17_3000083"]["asset_id"], 3000083);
|
||||
assert_eq!(cat["cards"]["fifa17_6300006"]["kind"], "kit");
|
||||
assert_eq!(cat["cards"]["fifa17_6300006"]["subtype"], 9);
|
||||
assert_eq!(cat["cards"]["fifa17_6300006"]["card_asset_id"], 35);
|
||||
assert_eq!(cat["cards"]["fifa17_6300006"]["team_id"], 21);
|
||||
|
||||
let loaded = Fifa17CardCatalog::from_file(&sum.host_catalog).unwrap();
|
||||
assert_eq!(loaded.kind_of("fifa17_20801"), ContentKind::Player);
|
||||
assert_eq!(loaded.kind_of("fifa17_5003012"), ContentKind::Consumable);
|
||||
assert_eq!(loaded.subtype_of("fifa17_5003012"), 201);
|
||||
assert_eq!(loaded.kind_of("fifa17_3000083"), ContentKind::Staff);
|
||||
assert_eq!(loaded.kind_of("fifa17_6300006"), ContentKind::Kit);
|
||||
let loaded_kit = loaded.lookup("fifa17_6300006").unwrap();
|
||||
assert_eq!(loaded_kit.card_asset_id, 35);
|
||||
assert_eq!(loaded_kit.team_id, 21);
|
||||
|
||||
// Manifest: private non_player section with preserved wire ids.
|
||||
let man: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&sum.manifest).unwrap()).unwrap();
|
||||
assert_eq!(man["non_player"]["supported_instances"], 2);
|
||||
assert_eq!(man["non_player"]["supported_instances"], 3);
|
||||
let np = man["non_player"]["supported_definitions"]
|
||||
.as_array()
|
||||
.unwrap();
|
||||
assert_eq!(np.len(), 2);
|
||||
assert_eq!(np.len(), 3);
|
||||
let cons_man = np
|
||||
.iter()
|
||||
.find(|d| d["card_id"] == "fifa17_5003012")
|
||||
@@ -836,13 +996,14 @@ fn plan_apply_mints_non_player_owned_instances() {
|
||||
player(100000001, 20801, 20801, 94),
|
||||
consumable(100000201, 5003012, 201),
|
||||
staff(100000427, 3000083, 8),
|
||||
kit(100000500, 6300006, 21, "activeHomeKit"),
|
||||
];
|
||||
let (report, raw) = report_and_raw(&items, "[]", 100000500);
|
||||
let plan = plan_apply(&report, &raw, "fp").unwrap();
|
||||
// 1 player + 2 non-player owned instances, minted via the identical path.
|
||||
assert_eq!(plan.request.owned.len(), 3);
|
||||
assert_eq!(plan.mappings.len(), 3);
|
||||
assert_eq!(plan.supported_instances, 3);
|
||||
// Player, consumable, staff, and kit instances mint through one generic path.
|
||||
assert_eq!(plan.request.owned.len(), 4);
|
||||
assert_eq!(plan.mappings.len(), 4);
|
||||
assert_eq!(plan.supported_instances, 4);
|
||||
assert_eq!(plan.deferred_instances, 0);
|
||||
let cards: BTreeSet<&str> = plan
|
||||
.request
|
||||
@@ -852,6 +1013,31 @@ fn plan_apply_mints_non_player_owned_instances() {
|
||||
.collect();
|
||||
assert!(cards.contains("fifa17_5003012"), "consumable minted");
|
||||
assert!(cards.contains("fifa17_3000083"), "staff minted");
|
||||
assert!(cards.contains("fifa17_6300006"), "kit minted");
|
||||
// Core is the ownership authority, and it defaults an unstated row to
|
||||
// `player`. A coach or a contract card durably recorded as a player is wrong
|
||||
// in the authority even while the catalog-driven wire still looks right.
|
||||
let kind_of = |card: &str| {
|
||||
plan.request
|
||||
.owned
|
||||
.iter()
|
||||
.find(|o| o.card_id == card)
|
||||
.unwrap()
|
||||
.content_kind
|
||||
};
|
||||
assert_eq!(kind_of("fifa17_20801"), "player");
|
||||
assert_eq!(kind_of("fifa17_5003012"), "consumable");
|
||||
assert_eq!(kind_of("fifa17_3000083"), "staff");
|
||||
assert_eq!(kind_of("fifa17_6300006"), "kit");
|
||||
// `amount` is an effect magnitude, not a stack count: two copies of one
|
||||
// consumable are two rows, never one row of quantity 2. The importer states
|
||||
// no quantity at all, and Core's default for an absent quantity is "not a
|
||||
// stack" — so the guarantee is that the key never appears in the request.
|
||||
let wire = serde_json::to_string(&plan.request).unwrap();
|
||||
assert!(
|
||||
!wire.contains("quantity"),
|
||||
"no instance may claim to be a stack: {wire}"
|
||||
);
|
||||
// Deterministic OwnedItemId per (persona, wire) — same rule as players.
|
||||
let m = plan
|
||||
.mappings
|
||||
@@ -882,3 +1068,76 @@ fn deferred_non_player_instances_gate_a_production_apply() {
|
||||
assert!(gate_staging(&plan, false).is_err(), "production blocks");
|
||||
assert!(gate_staging(&plan, true).unwrap(), "staging opt-in allows");
|
||||
}
|
||||
|
||||
/// The staff rating and rare flag come from the client's OWN tables, and these
|
||||
/// exact values were read back out of the RUNNING client's memory:
|
||||
/// `tools/coach_probe.py` graded all four resident staff records HIT (record
|
||||
/// `+0xb4` == `value`, `+0x58` == `rare`), and `tools/discard_probe.py` read the
|
||||
/// discard value the client computed for itself at record `+0x3c` — 36 for both
|
||||
/// `value`-66 coaches and 282 for the `rare`-1, `value`-88 manager.
|
||||
///
|
||||
/// So this is not a table-parsing test. It pins the importer to numbers the live
|
||||
/// client demonstrably uses.
|
||||
#[test]
|
||||
fn staff_stats_are_the_values_the_live_client_re_rates_to() {
|
||||
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../fifa17-recon/data/tables");
|
||||
let ent = Entities::from_tables_dir(dir).expect("committed tables load");
|
||||
|
||||
// (subtype, carddbid) -> (value, rare), verified live.
|
||||
assert_eq!(ent.staff_stats(6, 9000081), Some((66, 0)), "GK coach");
|
||||
assert_eq!(ent.staff_stats(8, 3000083), Some((66, 0)), "fitness coach");
|
||||
assert_eq!(ent.staff_stats(4, 1000509), Some((88, 1)), "manager");
|
||||
|
||||
// Keyed per family: a coach id must not resolve through another's table.
|
||||
assert_eq!(
|
||||
ent.staff_stats(4, 9000081),
|
||||
None,
|
||||
"gkcoach id is not a manager"
|
||||
);
|
||||
assert_eq!(ent.staff_stats(6, 12345678), None, "absent id stays absent");
|
||||
}
|
||||
|
||||
/// `enrich_staff` fills ONLY staff, and only where the wire left a gap.
|
||||
#[test]
|
||||
fn enrich_staff_fills_staff_and_leaves_everything_else_alone() {
|
||||
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../fifa17-recon/data/tables");
|
||||
let ent = Entities::from_tables_dir(dir).expect("committed tables load");
|
||||
|
||||
let items = vec![
|
||||
// A GK coach (subtype 6) and a contract consumable, which carries its own
|
||||
// rating on the wire and must not be touched.
|
||||
staff(100000280, 9000081, 6),
|
||||
consumable(100000300, 5001004, 201),
|
||||
];
|
||||
let mut plan = plan_non_player_definitions(&profile(&items, "[]", 100000500));
|
||||
let before: Vec<Option<i64>> = plan.supported.iter().map(|d| d.rating).collect();
|
||||
ent.enrich_staff(&mut plan);
|
||||
|
||||
let coach = plan
|
||||
.supported
|
||||
.iter()
|
||||
.find(|d| d.resource_id == 9000081)
|
||||
.expect("coach planned");
|
||||
assert_eq!(
|
||||
coach.rating,
|
||||
Some(66),
|
||||
"rating filled from gkcoachcards.value"
|
||||
);
|
||||
assert_eq!(coach.rareflag, Some(0), "rare filled from the same row");
|
||||
|
||||
let cons = plan
|
||||
.supported
|
||||
.iter()
|
||||
.find(|d| d.resource_id == 5001004)
|
||||
.expect("consumable planned");
|
||||
assert_eq!(cons.rareflag, None, "a consumable gets no staff rare flag");
|
||||
let cons_before = before[plan
|
||||
.supported
|
||||
.iter()
|
||||
.position(|d| d.resource_id == 5001004)
|
||||
.unwrap()];
|
||||
assert_eq!(
|
||||
cons.rating, cons_before,
|
||||
"the wire rating is left untouched"
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
Submodule openfut-launcher updated: 1cd4f18e92...5294f589ad
@@ -31,7 +31,9 @@ S2 live-staging defect where the v2 Store BUY escaped to Python.
|
||||
| Quick-sell (path) | `DELETE …/item/<digits>` | `QuickSellPath` |
|
||||
| Quick-sell (body) | `POST (/ut/delete/game\|/ut/v2/delete/game)/<sku>/item` | `QuickSellBody` |
|
||||
| Move | `PUT …/item` | `MoveItems` |
|
||||
| Match end | `POST (/ut/delete/game\|/ut/v2/delete/game)/<sku>/match` | `MatchEnd` |
|
||||
| Match create / play | `…/match` (any verb; `matchId` in body = FutPlayGame) | `MatchCreate` |
|
||||
| Match ready | `…/match/ready` (any verb) | `MatchReady` |
|
||||
| Match end | `…/match/end` (any verb) — also `POST (/ut/delete/game\|/ut/v2/delete/game)/<sku>/match` | `MatchEnd` |
|
||||
| Market list | `POST …/auctionhouse` \| `…/transfermarket` | `MarketList` |
|
||||
| Market query | `GET …/tradePile` **and** `…/tradePile/counts` (CASE-INSENSITIVE: `tradepile` too) | `MarketQuery` |
|
||||
| Market buy | `…/trade/<id>` | `MarketBuy` |
|
||||
@@ -76,7 +78,9 @@ per-route authority (all economy routes owner = Rust, Python proxy = NO):
|
||||
| `/item/<id>` (DELETE) | R | -/R | -/R | - | - | - | - | NO | `handle_quick_sell_path` → `sell_item` |
|
||||
| `/ut/delete/…/item` (POST) | R | -/R | -/R | - | - | - | - | NO | `handle_quick_sell_body` → `sell_item` |
|
||||
| `/item` (PUT) | R | - | R/- | - | -/R | - | - | NO | `handle_move_items` (PileStore) |
|
||||
| `/ut/delete/…/match` (POST) | R | -/R | - | - | - | - | - | NO | `handle_match_end` → `grant_reward` |
|
||||
| `/match` (any verb) | R | - | - | - | - | - | - | NO | `handle_match_create` (mints the session id; no economy) |
|
||||
| `/match/ready` (any verb) | R | - | - | - | - | - | - | NO | `handle_match_ready` |
|
||||
| `/match/end` (any verb) | R | -/R | - | - | - | - | - | NO | `handle_match_end` → Core `complete_match` |
|
||||
| `/auctionhouse`,`/transfermarket` | R | R/- | - | - | - | -/R | - | NO | `handle_market_list` (MarketStore) |
|
||||
| `/tradePile` (GET) | R | R/- | - | - | - | R/- | - | NO | `handle_market_query` |
|
||||
| `/trade/<id>` (POST/PUT/GET) | R | R/R | -/R | - | - | R/R | - | NO | `handle_market_buy` → `purchase_item` |
|
||||
@@ -177,8 +181,11 @@ Landed (Core authority + transport + several handlers; classifier NOT yet flippe
|
||||
full-gen (`handle_purchasegroup`, no Python body dependency), `userMassInfo`
|
||||
economy overlay (`overlay_massinfo_economy`). Invariant test: all three read one
|
||||
Core state.
|
||||
- **Writer handler**: `/match` reward (`handle_match_end` → Core `grant_reward`,
|
||||
oracle `destroy_match_body` shape).
|
||||
- **Writer handler**: `/match/end` reward (`handle_match_end` → Core
|
||||
`complete_match`, the single exactly-once transaction — NOT `grant_reward`).
|
||||
The per-match identity is the id minted by `POST …/match`, which is what makes
|
||||
two abandoned matches (whose bodies are byte-identical) both payable while a
|
||||
replay of either is refused.
|
||||
- **Adapter policy mappers** (`181bd94`): match reward, pack price.
|
||||
- All Core-backed, fail-closed (503, never Python), `FakeEconomy`-tested.
|
||||
- **Store/item writers** (`4d2b8b9`, `economy_store.rs`, unrouted): `handle_store_buy`
|
||||
|
||||
@@ -162,7 +162,12 @@ fn shape_minted(deps: &StoreDeps<'_>, minted: &[Minted]) -> Vec<Value> {
|
||||
.filter_map(|m| {
|
||||
let item = core_owned(m);
|
||||
let id = deps.assets.resolve(&item)?;
|
||||
Some(shape_item(&item, id, deps.entities))
|
||||
Some(shape_item(
|
||||
&item,
|
||||
id,
|
||||
deps.entities,
|
||||
deps.assets.discard_value(&item),
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -371,22 +376,13 @@ pub struct QuickSellDeps<'a> {
|
||||
/// uses). Identity only; ownership is authorized by [`OwnedItemLookup`].
|
||||
pub reverse: &'a dyn SquadWireResolver,
|
||||
pub items: &'a dyn OwnedItemLookup,
|
||||
}
|
||||
|
||||
/// OPENFUT CURRENT quick-sell value by rating (PLACEHOLDER, not EA-authentic).
|
||||
/// Mirrors the on-wire `discardValue` that
|
||||
/// `openfut_adapter_fifa17::fut::item` stamps, so the coins credited equal the
|
||||
/// value the client displayed. (The Python oracle used a *different* invented
|
||||
/// fallback — 600/300/150/50, `fut_store.py:505` — which disagreed with the wire
|
||||
/// `discardValue`; crediting the displayed figure keeps them consistent.)
|
||||
fn quick_sell_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,
|
||||
}
|
||||
/// Prices the sale. This is the SAME resolver, and the same method, that
|
||||
/// stamps `discardValue` onto the shaped card, so the coins credited are by
|
||||
/// construction the number the client displayed — there is no second ladder
|
||||
/// to drift. (The Python oracle had exactly that bug: an invented
|
||||
/// 600/300/150/50 fallback in `fut_store.py:505` that disagreed with the
|
||||
/// wire `discardValue`.)
|
||||
pub assets: &'a dyn ItemIdentityResolver,
|
||||
}
|
||||
|
||||
/// Quick-sell every owned card in `wire_ids` (server-priced). Skips ids that do
|
||||
@@ -409,7 +405,10 @@ pub fn handle_quick_sell(wire_ids: &[i64], deps: &QuickSellDeps<'_>) -> WireResp
|
||||
Some(i) => i,
|
||||
None => continue, // resolvable id, but not owned: never sold
|
||||
};
|
||||
match deps.econ.sell_item(&core_id, quick_sell_value(item.rating)) {
|
||||
match deps
|
||||
.econ
|
||||
.sell_item(&core_id, deps.assets.discard_value(&item))
|
||||
{
|
||||
Ok(balance) => {
|
||||
last_balance = Some(balance);
|
||||
sold_ids.push(wire);
|
||||
@@ -464,7 +463,10 @@ mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicI64, AtomicU32, Ordering};
|
||||
|
||||
use crate::{EconomyEntitlement, EconomyPurchase, EconomySale, EconomySaleReceipt};
|
||||
use crate::{
|
||||
CoreMatchCompletion, CoreMatchReceipt, EconomyEntitlement, EconomyPurchase, EconomySale,
|
||||
EconomySaleReceipt,
|
||||
};
|
||||
|
||||
// ── Recording economy double ────────────────────────────────────────────
|
||||
|
||||
@@ -599,6 +601,13 @@ mod tests {
|
||||
// Sale settlement is not exercised by the Store/quick-sell paths.
|
||||
Err(CoreError::Status(501))
|
||||
}
|
||||
fn complete_match(
|
||||
&self,
|
||||
_m: &CoreMatchCompletion<'_>,
|
||||
) -> Result<CoreMatchReceipt, CoreError> {
|
||||
// Match completion is not exercised by the Store/quick-sell paths.
|
||||
Err(CoreError::Status(501))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Identity / entity / lookup doubles ──────────────────────────────────
|
||||
@@ -980,6 +989,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Prices sales with the trait's DEFAULT (legacy ladder) implementation, so
|
||||
/// these tests pin the deployed behaviour: no catalog, no table.
|
||||
struct LadderAssets;
|
||||
impl ItemIdentityResolver for LadderAssets {
|
||||
fn resolve(&self, _item: &CoreOwnedItem) -> Option<Fifa17Identity> {
|
||||
None
|
||||
}
|
||||
}
|
||||
static LADDER_ASSETS: LadderAssets = LadderAssets;
|
||||
|
||||
fn qs_deps<'a>(
|
||||
econ: &'a RecEcon,
|
||||
reverse: &'a FakeReverse,
|
||||
@@ -989,6 +1008,7 @@ mod tests {
|
||||
econ,
|
||||
reverse,
|
||||
items,
|
||||
assets: &LADDER_ASSETS,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1009,6 +1029,64 @@ mod tests {
|
||||
assert_eq!(sold[0], ("c1".to_string(), 1500));
|
||||
}
|
||||
|
||||
/// THE INVARIANT: the coins credited are whatever
|
||||
/// [`ItemIdentityResolver::discard_value`] says — the SAME method, on the
|
||||
/// SAME resolver, that stamps `discardValue` onto the shaped card. A second
|
||||
/// pricing ladder living in this module is exactly the drift this pins
|
||||
/// against, so the double returns a value no ladder could produce.
|
||||
#[test]
|
||||
fn the_sale_credits_whatever_priced_the_card_on_the_wire() {
|
||||
struct OddPriced;
|
||||
impl ItemIdentityResolver for OddPriced {
|
||||
fn resolve(&self, _item: &CoreOwnedItem) -> Option<Fifa17Identity> {
|
||||
None
|
||||
}
|
||||
fn discard_value(&self, _item: &CoreOwnedItem) -> i64 {
|
||||
10_980
|
||||
}
|
||||
}
|
||||
let econ = RecEcon::new(1000);
|
||||
let reverse = FakeReverse(HashMap::from([(100_000_001, "c1".to_string())]));
|
||||
// Rating 88 -> the legacy ladder would pay 1500. The resolver must win.
|
||||
let items = FakeItems(HashMap::from([("c1".to_string(), owned("c1", 88))]));
|
||||
let deps = QuickSellDeps {
|
||||
econ: &econ,
|
||||
reverse: &reverse,
|
||||
items: &items,
|
||||
assets: &OddPriced,
|
||||
};
|
||||
let resp = handle_quick_sell_path(100_000_001, &deps);
|
||||
assert_eq!(resp.status, 200);
|
||||
let b: Value = serde_json::from_slice(&resp.body).unwrap();
|
||||
assert_eq!(b["totalCredits"], 11_980, "1000 + the card's own price");
|
||||
assert_eq!(econ.sold.lock()[0], ("c1".to_string(), 10_980));
|
||||
}
|
||||
|
||||
/// The trait default MUST stay the deployed ladder, so a resolver with no
|
||||
/// catalog behind it prices exactly as it did before the table existed.
|
||||
#[test]
|
||||
fn the_default_price_is_still_the_legacy_ladder() {
|
||||
for (rating, expected) in [
|
||||
(94u8, 1500i64),
|
||||
(88, 1500),
|
||||
(82, 900),
|
||||
(77, 600),
|
||||
(66, 300),
|
||||
(50, 150),
|
||||
(0, 150),
|
||||
] {
|
||||
assert_eq!(
|
||||
LADDER_ASSETS.discard_value(&owned("c", rating)),
|
||||
expected,
|
||||
"rating {rating}"
|
||||
);
|
||||
assert_eq!(
|
||||
openfut_adapter_fifa17::fut::item::legacy_discard_value(rating),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_sell_body_form_and_alias_match_path_form() {
|
||||
for req in [
|
||||
|
||||
+1455
-165
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,7 @@ use serde_json::{json, Value};
|
||||
|
||||
use openfut_adapter_fifa17::fut::entities::ReverseEntityResolver;
|
||||
use openfut_adapter_fifa17::fut::item::{shape_item, ItemIdentityResolver};
|
||||
use openfut_adapter_fifa17::fut::item_state;
|
||||
use openfut_adapter_fifa17::fut::non_economy;
|
||||
use openfut_adapter_fifa17::fut::squad::SquadWireResolver;
|
||||
|
||||
@@ -81,7 +82,7 @@ fn trade_id_from_path(path: &str) -> Option<String> {
|
||||
/// snapshot persisted at listing time; a row written before snapshots existed
|
||||
/// degrades to the stub (honest, not fabricated).
|
||||
///
|
||||
/// `item_state` overrides the card's `itemState`. FIFA 17's vocabulary is the
|
||||
/// `state` overrides the card's `itemState`. FIFA 17's vocabulary is the
|
||||
/// 12-row `{const char*, int}` table at `0x180229cc0`, and `forSale` (5) is its
|
||||
/// value for an item offered for sale. The Python oracle stamps `listFS` on the
|
||||
/// seller's own pile instead — a token that does NOT EXIST in FIFA 17 (zero
|
||||
@@ -89,8 +90,8 @@ fn trade_id_from_path(path: &str) -> Option<String> {
|
||||
/// memory) and therefore decodes to `-1` through `FUN_180166660`, i.e. the client
|
||||
/// is handed an unrecognised `CARD_OFFERSTATE`. Where the binary contradicts the
|
||||
/// oracle, the binary wins.
|
||||
fn auction_record_as(l: &Listing, item_state: &str) -> Value {
|
||||
auction_record_tuned(l, item_state, None, 0)
|
||||
fn auction_record_as(l: &Listing, state: &str) -> Value {
|
||||
auction_record_tuned(l, state, None, 0)
|
||||
}
|
||||
|
||||
/// [`auction_record_as`] with the two fields the staging sold experiment varies.
|
||||
@@ -105,7 +106,7 @@ fn auction_record_as(l: &Listing, item_state: &str) -> Value {
|
||||
/// what makes the client's reaction attributable to the token.
|
||||
fn auction_record_tuned(
|
||||
l: &Listing,
|
||||
item_state: &str,
|
||||
state: &str,
|
||||
sold_bid_state: Option<&str>,
|
||||
coins_processed: i64,
|
||||
) -> Value {
|
||||
@@ -152,7 +153,7 @@ fn auction_record_tuned(
|
||||
.map(|mut card| {
|
||||
// Keep the wire identity and presentation state authoritative here.
|
||||
card["id"] = json!(item_id);
|
||||
card["itemState"] = json!(item_state);
|
||||
card["itemState"] = json!(state);
|
||||
card["untradeable"] = json!(false);
|
||||
card
|
||||
})
|
||||
@@ -160,7 +161,7 @@ fn auction_record_tuned(
|
||||
json!({
|
||||
"id": item_id,
|
||||
"resourceId": resource,
|
||||
"itemState": item_state,
|
||||
"itemState": state,
|
||||
"untradeable": false,
|
||||
})
|
||||
});
|
||||
@@ -204,9 +205,9 @@ fn auction_record_tuned(
|
||||
/// closed/sold echoes the buy path returns.
|
||||
fn auction_record(l: &Listing) -> Value {
|
||||
let state = if l.state == "active" {
|
||||
"forSale"
|
||||
item_state::FOR_SALE
|
||||
} else {
|
||||
"free"
|
||||
item_state::FREE
|
||||
};
|
||||
auction_record_as(l, state)
|
||||
}
|
||||
@@ -283,7 +284,7 @@ pub fn resolve_market_list<E: ReverseEntityResolver>(
|
||||
let identity = resolver.resolve(&owned);
|
||||
let resource_id = identity.map(|id| id.resource_id as i64);
|
||||
let item_json = identity
|
||||
.map(|id| shape_item(&owned, id, ent))
|
||||
.map(|id| shape_item(&owned, id, ent, resolver.discard_value(&owned)))
|
||||
.and_then(|card| serde_json::to_string(&card).ok());
|
||||
Some(ResolvedListing {
|
||||
item_id,
|
||||
@@ -444,7 +445,7 @@ pub async fn handle_market_query(
|
||||
};
|
||||
let mut auctions: Vec<Value> = listings
|
||||
.iter()
|
||||
.map(|l| auction_record_as(l, "forSale"))
|
||||
.map(|l| auction_record_as(l, item_state::FOR_SALE))
|
||||
.collect();
|
||||
// STAGING ONLY. FIFA 17's bulk `DELETE …/trade/sold` verb only makes sense if
|
||||
// sold rows persist in the seller's pile until acknowledged, so the experiment
|
||||
@@ -456,7 +457,7 @@ pub async fn handle_market_query(
|
||||
for l in &sold {
|
||||
auctions.push(auction_record_tuned(
|
||||
l,
|
||||
"forSale",
|
||||
item_state::FOR_SALE,
|
||||
exp.bid_state,
|
||||
exp.coins_processed,
|
||||
));
|
||||
@@ -607,7 +608,7 @@ pub async fn handle_market_status(
|
||||
};
|
||||
let auctions: Vec<Value> = listings
|
||||
.iter()
|
||||
.map(|l| auction_record_tuned(l, "forSale", exp.bid_state, exp.coins_processed))
|
||||
.map(|l| auction_record_tuned(l, item_state::FOR_SALE, exp.bid_state, exp.coins_processed))
|
||||
.collect();
|
||||
eprintln!(
|
||||
"utas-host owner=RUST route=market-status requested={} returned={} query={}",
|
||||
@@ -723,7 +724,7 @@ pub async fn handle_market_buy(
|
||||
rec["tradeState"] = json!("closed");
|
||||
rec["bidState"] = json!("highest");
|
||||
rec["currentBid"] = json!(price);
|
||||
rec["itemData"]["itemState"] = json!("free");
|
||||
rec["itemData"]["itemState"] = json!(item_state::FREE);
|
||||
ok_json(&json!({ "auctionInfo": [rec], "credits": new_balance }))
|
||||
}
|
||||
// Insufficient funds surfaced by Core (concurrent debit) -> 461.
|
||||
@@ -805,7 +806,8 @@ pub async fn handle_move_items(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
EconomyEntitlement, EconomyGrantItem, EconomyPurchase, EconomySale, EconomySaleReceipt,
|
||||
CoreMatchCompletion, CoreMatchReceipt, EconomyEntitlement, EconomyGrantItem,
|
||||
EconomyPurchase, EconomySale, EconomySaleReceipt,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicI64, AtomicU64, AtomicUsize, Ordering};
|
||||
@@ -959,6 +961,13 @@ mod tests {
|
||||
squad_slots_freed: 0,
|
||||
})
|
||||
}
|
||||
fn complete_match(
|
||||
&self,
|
||||
_m: &CoreMatchCompletion<'_>,
|
||||
) -> Result<CoreMatchReceipt, CoreError> {
|
||||
// Match completion is not exercised through the market double.
|
||||
Err(CoreError::Status(501))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- SquadWireResolver double -----------------------------------------
|
||||
@@ -1147,6 +1156,10 @@ mod tests {
|
||||
let pile = handle_market_query("active", &econ, &store, SoldExperiment::OFF).await;
|
||||
let rec = parse(&pile)["auctionInfo"][0].clone();
|
||||
assert_eq!(rec["itemData"]["itemState"], "forSale");
|
||||
assert!(
|
||||
item_state::is_recovered(rec["itemData"]["itemState"].as_str().unwrap()),
|
||||
"every emitted itemState must be in FIFA 17's own 12-row table"
|
||||
);
|
||||
assert_eq!(rec["itemData"]["rating"], 84);
|
||||
assert_eq!(rec["itemData"]["id"], 100004617i64);
|
||||
assert_eq!(rec["itemData"]["resourceId"], 169193);
|
||||
|
||||
@@ -226,16 +226,11 @@ fn set_balance(client: &HttpCoreClient, target: i64) {
|
||||
assert_eq!(client.balance().unwrap(), target, "balance set");
|
||||
}
|
||||
|
||||
/// The on-wire quick-sell `discardValue` tiers (host `economy_store::quick_sell_value`),
|
||||
/// replicated to assert the EXACT credit — the client-visible contract.
|
||||
/// The on-wire quick-sell `discardValue`, taken from the ONE implementation the
|
||||
/// server uses rather than replicated here: a local copy silently passes while
|
||||
/// the real price changes underneath it.
|
||||
fn qs_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,
|
||||
}
|
||||
openfut_adapter_fifa17::fut::item::legacy_discard_value(rating)
|
||||
}
|
||||
|
||||
fn owns(client: &HttpCoreClient, core_id: &str) -> bool {
|
||||
@@ -436,8 +431,10 @@ fn case_d_two_market_buyers(h: &Harness) -> String {
|
||||
"POST",
|
||||
"/ut/game/fifa17/auctionhouse",
|
||||
&[],
|
||||
format!(r#"{{"itemData":{{"id":{item_id}}},"buyNowPrice":1000,"startingBid":500}}"#)
|
||||
.as_bytes(),
|
||||
format!(
|
||||
r#"{{"itemData":{{"id":{item_id}}},"buyNowPrice":1000,"startingBid":500}}"#
|
||||
)
|
||||
.as_bytes(),
|
||||
None,
|
||||
)
|
||||
.expect("list routed");
|
||||
@@ -484,20 +481,39 @@ fn case_d_two_market_buyers(h: &Harness) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// E: match REWARD + Store BUY concurrently → final balance is one legal
|
||||
/// serialization (no lost update). Reward (+400) and buy (−400) commute, so the
|
||||
/// final balance must equal the start exactly.
|
||||
/// E: match REWARD + Store BUY concurrently → one legal serialization (no lost
|
||||
/// update). The two commute, so the final balance must equal exactly one serial
|
||||
/// outcome: the match's reported post-credit balance (`allCoins`), or that minus
|
||||
/// the buy's debit — never a torn value from a clobbered write. Amount-agnostic,
|
||||
/// so it holds even when a WIN also triggers an XP level-up bonus.
|
||||
fn case_e_reward_and_buy(h: &Harness) -> String {
|
||||
// Measure the store BUY's deterministic debit once.
|
||||
set_balance(&h.client, 50_000);
|
||||
let pre_probe = h.client.balance().unwrap();
|
||||
let probe = fire(
|
||||
&h.server,
|
||||
vec![(
|
||||
"PUT",
|
||||
"/ut/game/fifa17/store/transaction".into(),
|
||||
b"{\"packId\":1}".to_vec(),
|
||||
)],
|
||||
);
|
||||
assert_eq!(probe[0].status, 200, "probe buy ok");
|
||||
let buy_debit = pre_probe - h.client.balance().unwrap();
|
||||
assert!(buy_debit > 0, "store buy must debit a positive price");
|
||||
|
||||
let start = 8_000i64;
|
||||
for _ in 0..ITERS {
|
||||
for i in 0..ITERS {
|
||||
set_balance(&h.client, start);
|
||||
// A DISTINCT match per iteration (unique matchReportId) so the
|
||||
// exactly-once reward applies every time.
|
||||
let rs = fire(
|
||||
&h.server,
|
||||
vec![
|
||||
(
|
||||
"POST",
|
||||
"/ut/delete/game/fifa17/match".into(),
|
||||
b"{\"endReason\":\"WIN\"}".to_vec(),
|
||||
format!("{{\"matchReportId\":{i},\"endReason\":\"WIN\"}}").into_bytes(),
|
||||
),
|
||||
(
|
||||
"PUT",
|
||||
@@ -507,13 +523,15 @@ fn case_e_reward_and_buy(h: &Harness) -> String {
|
||||
],
|
||||
);
|
||||
assert!(rs.iter().all(|r| r.status == 200), "both ops succeed");
|
||||
assert_eq!(
|
||||
h.client.balance().unwrap(),
|
||||
start,
|
||||
"reward(+400) and buy(-400) both applied: no lost update"
|
||||
let all = bj(&rs[0])["allCoins"].as_i64().expect("allCoins");
|
||||
let after = h.client.balance().unwrap();
|
||||
// Both writers serialized: `after` is one of the two legal orderings.
|
||||
assert!(
|
||||
after == all || after == all - buy_debit,
|
||||
"no lost update: after={after}, match allCoins={all}, buy_debit={buy_debit}"
|
||||
);
|
||||
}
|
||||
format!("E reward+buy: {ITERS} iters, final==start ({start}) every time (no lost update)")
|
||||
format!("E reward+buy: {ITERS} iters, no lost update (buy_debit={buy_debit})")
|
||||
}
|
||||
|
||||
/// F: MOVE + QUICK-SELL of the same item → one coherent final state (item sold
|
||||
|
||||
@@ -432,6 +432,36 @@ fn json_shape(value: &Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// Coins Core has granted through ACHIEVEMENT unlocks so far, summed from Core's
|
||||
/// own report.
|
||||
///
|
||||
/// Core's progression system (objectives + achievements) has no counterpart in
|
||||
/// the Python oracle, and `complete_match` unlocks achievements in the SAME
|
||||
/// transaction that pays the match reward. So a raw balance delta around a match
|
||||
/// is `match reward + newly unlocked achievements`, and the differential has to
|
||||
/// account for the second term instead of pretending it does not exist.
|
||||
///
|
||||
/// NOTE: `GET /achievements` is unlock-on-read (it calls `check_and_unlock`), so
|
||||
/// this snapshot must be taken BEFORE the pre-op balance is captured; that flushes
|
||||
/// any already-satisfied unlock and leaves the measured window attributable to the
|
||||
/// op under test.
|
||||
fn achievement_coins_granted(http: &reqwest::blocking::Client, core_base: &str) -> i64 {
|
||||
let body: Value = http
|
||||
.get(format!("{core_base}/achievements"))
|
||||
.header("X-OpenFUT-Game", "fifa17")
|
||||
.send()
|
||||
.expect("GET /achievements")
|
||||
.json()
|
||||
.expect("achievements json");
|
||||
body["achievements"]
|
||||
.as_array()
|
||||
.expect("achievements array")
|
||||
.iter()
|
||||
.filter(|a| a["unlocked"].as_bool().unwrap_or(false))
|
||||
.map(|a| a["reward_coins"].as_i64().unwrap_or(0))
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Every op runs on a plain OS thread with NO ambient Tokio runtime (the blocking
|
||||
/// Core client + `reqwest::blocking` require this), exactly like the
|
||||
/// thread-per-connection server — the bridge takes its direct `block_on` path.
|
||||
@@ -832,6 +862,11 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||
matrix.push(("move-items PUT /item", "PARITY"));
|
||||
|
||||
// ── OP 10: match reward (WIN = +400) ───────────────────────────────────
|
||||
// The oracle pays the match reward and nothing else. Core pays the same match
|
||||
// reward and, in the same transaction, any achievement the match unlocks — a
|
||||
// deliberate Rust-authority feature the oracle never had. Flush pending
|
||||
// unlocks first so the measured window belongs to this match.
|
||||
let r_ach_before = achievement_coins_granted(&http, core_base);
|
||||
let o_mbal = oracle.coins();
|
||||
let (o_mms, o_mm) = oracle.req(
|
||||
"POST",
|
||||
@@ -889,7 +924,15 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
|
||||
);
|
||||
}
|
||||
assert_eq!(oracle.coins() - o_mbal, 400, "oracle WIN +400");
|
||||
assert_eq!(client.balance().unwrap() - r_mbal, 400, "rust WIN +400");
|
||||
// Same match reward on both sides; Core additionally credits exactly the
|
||||
// achievements this match unlocked, and nothing unexplained.
|
||||
let r_ach_awarded = achievement_coins_granted(&http, core_base) - r_ach_before;
|
||||
assert_eq!(
|
||||
client.balance().unwrap() - r_mbal,
|
||||
400 + r_ach_awarded,
|
||||
"rust WIN credits +400 plus exactly the achievements it unlocked \
|
||||
(achievement coins = {r_ach_awarded})"
|
||||
);
|
||||
matrix.push(("match reward (WIN)", "PARITY"));
|
||||
|
||||
// ── OP 11: market list (POST /auctionhouse) ────────────────────────────
|
||||
|
||||
@@ -30,9 +30,9 @@ use openfut_utas_host::async_bridge::AsyncBridge;
|
||||
use openfut_utas_host::market_store::MarketStore;
|
||||
use openfut_utas_host::pile_store::PileStore;
|
||||
use openfut_utas_host::{
|
||||
build_content_pool, CoreAccess, CoreEconomy, CoreError, EconomyEntitlement, EconomyGrantItem,
|
||||
EconomyPurchase, EconomySale, EconomySaleReceipt, EconomyServices, Fifa17IdentityResolver,
|
||||
HttpCoreClient, PassClient, Server, WireResponse,
|
||||
build_content_pool, CoreAccess, CoreEconomy, CoreError, CoreMatchCompletion, CoreMatchReceipt,
|
||||
EconomyEntitlement, EconomyGrantItem, EconomyPurchase, EconomySale, EconomySaleReceipt,
|
||||
EconomyServices, Fifa17IdentityResolver, HttpCoreClient, PassClient, Server, WireResponse,
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::Value;
|
||||
@@ -140,6 +140,12 @@ impl CoreEconomy for FaultEconomy {
|
||||
}
|
||||
self.inner.settle_sale(sale)
|
||||
}
|
||||
fn complete_match(&self, m: &CoreMatchCompletion<'_>) -> Result<CoreMatchReceipt, CoreError> {
|
||||
if self.trip("complete_match") {
|
||||
return Err(Self::injected());
|
||||
}
|
||||
self.inner.complete_match(m)
|
||||
}
|
||||
}
|
||||
|
||||
/// An `ExternalIdentityStore` that forwards to a real `JsonIdentityStore` but can
|
||||
|
||||
@@ -140,8 +140,8 @@ fn pack_ids(pg: &Value) -> Vec<u64> {
|
||||
}
|
||||
|
||||
/// Seed via the real Core HTTP API, then exercise the host handlers + transport.
|
||||
/// Returns nothing; panics on any mismatch.
|
||||
fn seed_and_exercise(base: &str) {
|
||||
/// Returns the final Core balance so the restart phase can assert persistence.
|
||||
fn seed_and_exercise(base: &str) -> i64 {
|
||||
wait_ready(base);
|
||||
let http = reqwest::blocking::Client::new();
|
||||
|
||||
@@ -155,12 +155,43 @@ fn seed_and_exercise(base: &str) {
|
||||
let credits: Value = serde_json::from_slice(&handle_credits(&client).body).unwrap();
|
||||
assert_eq!(credits["currencies"][0]["funds"], 5000, "seeded balance");
|
||||
|
||||
// Match-reward WRITER: win credits +400 via Core grant_reward, end to end.
|
||||
let m = handle_match_end(&client, br#"{"endReason":"WIN"}"#);
|
||||
// Match-reward WRITER: a WIN applies its reward through Core's authoritative,
|
||||
// exactly-once complete_match transaction, end to end. The reward is at least
|
||||
// the match coins; Core may also grant XP-driven level-up and first-win
|
||||
// achievement coins, so assert the flat match coins + a relative delta.
|
||||
let before_match = client.balance().unwrap();
|
||||
let m = handle_match_end(&client, 1, Some(200_000_001), br#"{"endReason":"WIN"}"#);
|
||||
assert_eq!(m.status, 200);
|
||||
let mb: Value = serde_json::from_slice(&m.body).unwrap();
|
||||
assert_eq!(mb["allCoins"], 5400, "match reward credited in Core");
|
||||
assert_eq!(client.balance().unwrap(), 5400);
|
||||
assert_eq!(mb["matchCoins"], 400, "flat match coins");
|
||||
let after_match = client.balance().unwrap();
|
||||
assert!(
|
||||
after_match >= before_match + 400,
|
||||
"match credited at least +400"
|
||||
);
|
||||
assert_eq!(
|
||||
mb["allCoins"].as_i64().unwrap(),
|
||||
after_match,
|
||||
"response echoes the authoritative Core balance"
|
||||
);
|
||||
// Idempotent replay: the SAME match session does NOT double-credit.
|
||||
let replay = handle_match_end(&client, 1, Some(200_000_001), br#"{"endReason":"WIN"}"#);
|
||||
assert_eq!(replay.status, 200);
|
||||
assert_eq!(
|
||||
client.balance().unwrap(),
|
||||
after_match,
|
||||
"replay must not re-credit"
|
||||
);
|
||||
// A DIFFERENT session with a BYTE-IDENTICAL body is a different match and
|
||||
// must credit again. Keyed on the body alone it would not, which is the
|
||||
// silent under-credit every abandoned match would have hit.
|
||||
let second = handle_match_end(&client, 1, Some(200_000_002), br#"{"endReason":"WIN"}"#);
|
||||
assert_eq!(second.status, 200);
|
||||
let after_second = client.balance().unwrap();
|
||||
assert!(
|
||||
after_second >= after_match + 400,
|
||||
"a second, distinct match must credit: {after_second} vs {after_match}"
|
||||
);
|
||||
|
||||
// Buy a numeric entitlement "70" through the Core economy API (debit 600).
|
||||
post(
|
||||
@@ -169,11 +200,16 @@ fn seed_and_exercise(base: &str) {
|
||||
"/economy/purchase-entitlement",
|
||||
json!({ "cost": 600, "definition_id": "70" }),
|
||||
);
|
||||
assert_eq!(client.balance().unwrap(), 4800, "debit applied atomically");
|
||||
let after_buy = after_second - 600;
|
||||
assert_eq!(
|
||||
client.balance().unwrap(),
|
||||
after_buy,
|
||||
"debit applied atomically"
|
||||
);
|
||||
|
||||
// credits reflects the debit through the same Core state.
|
||||
let credits2: Value = serde_json::from_slice(&handle_credits(&client).body).unwrap();
|
||||
assert_eq!(credits2["currencies"][0]["funds"], 4800);
|
||||
assert_eq!(credits2["currencies"][0]["funds"], after_buy);
|
||||
|
||||
// purchasegroup full-gen shows the owned pack 70 and NO sentinel.
|
||||
let pg: Value =
|
||||
@@ -194,25 +230,27 @@ fn seed_and_exercise(base: &str) {
|
||||
client.balance().unwrap(),
|
||||
client.entitlements().unwrap().len(),
|
||||
);
|
||||
assert_eq!(mass["userInfo"]["currencies"][0]["funds"], 4800);
|
||||
assert_eq!(mass["userInfo"]["currencies"][0]["funds"], after_buy);
|
||||
// Invariant: credits coins == userMassInfo coins == Core balance.
|
||||
assert_eq!(
|
||||
credits2["currencies"][0]["funds"],
|
||||
mass["userInfo"]["currencies"][0]["funds"]
|
||||
);
|
||||
|
||||
after_buy
|
||||
}
|
||||
|
||||
/// After a Core restart from the same DB file, all economy state persists.
|
||||
fn verify_after_restart(base: &str) {
|
||||
fn verify_after_restart(base: &str, expected_balance: i64) {
|
||||
wait_ready(base);
|
||||
let client = HttpCoreClient::new(base, "fifa17");
|
||||
assert_eq!(
|
||||
client.balance().unwrap(),
|
||||
4800,
|
||||
expected_balance,
|
||||
"coins persisted across restart"
|
||||
);
|
||||
let credits: Value = serde_json::from_slice(&handle_credits(&client).body).unwrap();
|
||||
assert_eq!(credits["currencies"][0]["funds"], 4800);
|
||||
assert_eq!(credits["currencies"][0]["funds"], expected_balance);
|
||||
let pg: Value =
|
||||
serde_json::from_slice(&handle_purchasegroup(&client, StoreMode::Sentinel).body).unwrap();
|
||||
assert!(
|
||||
@@ -239,12 +277,12 @@ async fn economy_end_to_end_and_restart_persistence() {
|
||||
let b1 = base1.clone();
|
||||
let r = tokio::task::spawn_blocking(move || seed_and_exercise(&b1)).await;
|
||||
h1.abort();
|
||||
r.expect("exercise phase");
|
||||
let expected_balance = r.expect("exercise phase");
|
||||
|
||||
// --- Core instance #2: same on-disk DB, prove persistence ---
|
||||
let (h2, base2) = start_core(&db_url).await;
|
||||
let b2 = base2.clone();
|
||||
let r2 = tokio::task::spawn_blocking(move || verify_after_restart(&b2)).await;
|
||||
let r2 = tokio::task::spawn_blocking(move || verify_after_restart(&b2, expected_balance)).await;
|
||||
h2.abort();
|
||||
r2.expect("restart phase");
|
||||
|
||||
@@ -422,7 +460,8 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
|
||||
"totalCredits == absolute Core balance"
|
||||
);
|
||||
|
||||
// 4) Match END reward through dispatch (WIN = +400).
|
||||
// 4) Match END reward through dispatch. The WIN credits at least the flat
|
||||
// match coins (Core may also add XP level-up / first-win achievement coins).
|
||||
let before_match = client.balance().unwrap();
|
||||
let mm = server
|
||||
.try_handle_economy(
|
||||
@@ -434,10 +473,11 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
|
||||
)
|
||||
.expect("match routed");
|
||||
assert_eq!(mm.status, 200);
|
||||
assert_eq!(
|
||||
client.balance().unwrap(),
|
||||
before_match + 400,
|
||||
"WIN credited +400 via Core"
|
||||
let mmb: Value = serde_json::from_slice(&mm.body).unwrap();
|
||||
assert_eq!(mmb["matchCoins"], 400, "flat match coins");
|
||||
assert!(
|
||||
client.balance().unwrap() >= before_match + 400,
|
||||
"WIN credited at least +400 via Core"
|
||||
);
|
||||
|
||||
// 5) MARKET buy-now (async handlers via the bridge): list -> query -> buy ->
|
||||
@@ -629,6 +669,98 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
|
||||
.owned_id_for_wire(move_wire)
|
||||
.expect("moved item reverses to a Core id");
|
||||
|
||||
// 8) THE MATCH LIFECYCLE, through the REAL dispatch rather than the handler.
|
||||
// `POST …/match` and `PUT …/match/end` were not classified at all and were
|
||||
// proxied to Python, which the client reported as "There was an error
|
||||
// creating your game session".
|
||||
let created = server
|
||||
.try_handle_economy(
|
||||
"POST",
|
||||
"/ut/game/fifa17/match",
|
||||
&[],
|
||||
br#"{"squadId":0,"type":"OFFLINE","seasonId":1,"divisionId":10}"#,
|
||||
None,
|
||||
)
|
||||
.expect("match create must be claimed by the economy dispatch, never proxied");
|
||||
assert_eq!(created.status, 200);
|
||||
let created_body: Value = serde_json::from_slice(&created.body).unwrap();
|
||||
let match_id = created_body["id"].as_i64().expect("a match id is minted");
|
||||
assert!(match_id > 0, "the client needs a non-zero session id");
|
||||
assert_eq!(created_body["reportIdEnabled"], false);
|
||||
assert!(
|
||||
created_body.get("squad").is_none(),
|
||||
"`squad` is nested and a documented freeze risk — it must be omitted"
|
||||
);
|
||||
// The match id lives in its OWN identity scope: it must not reverse-map to
|
||||
// an owned card, or quick-sell and move would resolve a match as an item.
|
||||
assert!(
|
||||
resolver.owned_id_for_wire(match_id).is_none(),
|
||||
"a match id must never appear in the owned-item reverse map"
|
||||
);
|
||||
|
||||
// FutPlayGame reuses the create path, discriminated by an integer matchId.
|
||||
// It must ack without minting a second session.
|
||||
let play = server
|
||||
.try_handle_economy(
|
||||
"POST",
|
||||
"/ut/game/fifa17/match",
|
||||
&[],
|
||||
format!(r#"{{"matchId":{match_id}}}"#).as_bytes(),
|
||||
None,
|
||||
)
|
||||
.expect("play routed");
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(&play.body).unwrap(),
|
||||
serde_json::json!({}),
|
||||
"FutPlayGame parses no fields"
|
||||
);
|
||||
|
||||
// Every abandoned match sends a BYTE-IDENTICAL body. The first credits…
|
||||
let dnf = br#"{"matchReportId":0,"endReason":"DNF","items":[],"matchData":"","matchPerfTelemetry01":"","matchStatusFlags":0}"#;
|
||||
let before_dnf = client.balance().unwrap();
|
||||
let ended = server
|
||||
.try_handle_economy("PUT", "/ut/game/fifa17/match/end", &[], dnf, None)
|
||||
.expect("match end must be claimed, never proxied");
|
||||
assert_eq!(ended.status, 200);
|
||||
let after_dnf = client.balance().unwrap();
|
||||
assert!(after_dnf > before_dnf, "the abandoned match credited");
|
||||
|
||||
// …a REPLAY of that same match must not. Holding (not taking) the in-flight
|
||||
// id is what makes the replay reuse one identity; taking it sent the replay
|
||||
// down the body-fingerprint path, a different identity, and Core paid twice.
|
||||
server
|
||||
.try_handle_economy("PUT", "/ut/game/fifa17/match/end", &[], dnf, None)
|
||||
.expect("replay routed");
|
||||
assert_eq!(
|
||||
client.balance().unwrap(),
|
||||
after_dnf,
|
||||
"a replayed match end must NOT credit again"
|
||||
);
|
||||
|
||||
// …and a NEW session with the identical body is a different match, which
|
||||
// must credit. Keyed on the body alone every abandoned match after the
|
||||
// first would silently pay nothing.
|
||||
let created2 = server
|
||||
.try_handle_economy(
|
||||
"POST",
|
||||
"/ut/game/fifa17/match",
|
||||
&[],
|
||||
br#"{"squadId":0,"type":"OFFLINE"}"#,
|
||||
None,
|
||||
)
|
||||
.expect("second create routed");
|
||||
let second_id = serde_json::from_slice::<Value>(&created2.body).unwrap()["id"]
|
||||
.as_i64()
|
||||
.unwrap();
|
||||
assert_ne!(second_id, match_id, "each match gets its own id");
|
||||
server
|
||||
.try_handle_economy("PUT", "/ut/game/fifa17/match/end", &[], dnf, None)
|
||||
.expect("second end routed");
|
||||
assert!(
|
||||
client.balance().unwrap() > after_dnf,
|
||||
"a second, distinct abandoned match must credit"
|
||||
);
|
||||
|
||||
SeqResult {
|
||||
final_balance: client.balance().unwrap(),
|
||||
sold_listing: trade_id.to_string(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+309
@@ -0,0 +1,309 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Take a READ-ONLY snapshot of the operator's real imported FIFA-17 club.
|
||||
|
||||
Why this script exists at all
|
||||
----------------------------
|
||||
The real club -- the 1986-item CAGE import, persona 33068179 -- lives in the
|
||||
production state directory `/home/alex/openfut-promotion/state`. That directory is
|
||||
listed in FORBIDDEN_PATHS by both staging lifecycle scripts, which refuse to open
|
||||
any path underneath it. That guard is deliberate and stays absolute: the staging
|
||||
scripts must not be able to reach production state even by accident.
|
||||
|
||||
So the club cannot enter staging directly. This script is the ONE place allowed to
|
||||
read production state, it is read-only by construction, and its only output is a
|
||||
snapshot in a directory OUTSIDE the guard. `sold-staging-up.py --club real` then
|
||||
installs from the snapshot and never learns where it came from.
|
||||
|
||||
Read-only by construction
|
||||
-------------------------
|
||||
* The Core database is opened `mode=ro` and copied with sqlite3's online backup
|
||||
API, so the copy is transactionally consistent and the source is never written
|
||||
(a plain file copy of a database with a hot WAL can tear).
|
||||
* Every destination is asserted to be outside the production state directory
|
||||
before anything is opened for writing.
|
||||
* The sha256 of every source file is taken before and after the copy and compared.
|
||||
A mismatch aborts loudly -- that would mean this script, or something racing it,
|
||||
modified production state.
|
||||
|
||||
The snapshot is a point-in-time artifact, not a live mirror. Re-run it to refresh.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from openfut_production import ProductionError, production_state # noqa: E402
|
||||
|
||||
# --- fixed facts -------------------------------------------------------------------
|
||||
|
||||
# The production state directory. This script READS these files and writes nothing
|
||||
# back. It is the same path both staging scripts refuse outright.
|
||||
PROD_STATE = "/home/alex/openfut-promotion/state"
|
||||
SRC_CORE_DB = os.path.join(PROD_STATE, "prod-core.db")
|
||||
SRC_IDENTITY = os.path.join(PROD_STATE, "prod-identity.json")
|
||||
SRC_CLIENTDATA = os.path.join(PROD_STATE, "clientdata.json")
|
||||
|
||||
DEFAULT_SNAPSHOT_DIR = "/home/alex/openfut-club-snapshot"
|
||||
|
||||
# Snapshot member names. `sold-staging-up.py` knows these and nothing else.
|
||||
SNAP_CORE_DB = "core.db"
|
||||
SNAP_IDENTITY = "identity.json"
|
||||
SNAP_CLIENTDATA = "clientdata.json"
|
||||
SNAP_MANIFEST = "snapshot.json"
|
||||
|
||||
GAME = "fifa17"
|
||||
|
||||
|
||||
def banner(title: str) -> None:
|
||||
print()
|
||||
print("=" * 78)
|
||||
print(f" {title}")
|
||||
print("=" * 78)
|
||||
|
||||
|
||||
def step(msg: str) -> None:
|
||||
print(f" {msg}")
|
||||
|
||||
|
||||
def ok(msg: str) -> None:
|
||||
print(f" [ OK ] {msg}")
|
||||
|
||||
|
||||
class Fatal(ProductionError):
|
||||
"""Anything that must abort the snapshot loudly rather than degrade."""
|
||||
|
||||
|
||||
def sha256(path: str) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def assert_outside_production(path: str, what: str) -> str:
|
||||
"""Every write target goes through here. The snapshot must never be able to
|
||||
land inside the directory this script is reading."""
|
||||
resolved = os.path.abspath(path)
|
||||
if resolved == PROD_STATE or resolved.startswith(PROD_STATE + os.sep):
|
||||
raise Fatal(
|
||||
f"REFUSING: {what} {resolved!r} is inside the production state "
|
||||
f"directory {PROD_STATE!r} -- this script never writes there"
|
||||
)
|
||||
return resolved
|
||||
|
||||
|
||||
def read_club_facts(db_path: str) -> dict:
|
||||
"""Describe the club in a database WITHOUT modifying it. Used on the source (to
|
||||
record what was taken) and on the copy (to prove the copy is faithful)."""
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
try:
|
||||
profiles = conn.execute(
|
||||
"SELECT id, username, game_id, import_fingerprint FROM profiles "
|
||||
"WHERE game_id = ?", (GAME,)
|
||||
).fetchall()
|
||||
if len(profiles) != 1:
|
||||
raise Fatal(
|
||||
f"expected exactly one {GAME} profile in {db_path}, found "
|
||||
f"{len(profiles)} -- refusing to snapshot an ambiguous club"
|
||||
)
|
||||
profile_id, username, game_id, fingerprint = profiles[0]
|
||||
club = conn.execute(
|
||||
"SELECT id, name, coins FROM clubs WHERE profile_id = ?", (profile_id,)
|
||||
).fetchone()
|
||||
if club is None:
|
||||
raise Fatal(f"{GAME} profile {profile_id} has no club in {db_path}")
|
||||
club_id, club_name, coins = club
|
||||
owned, distinct = conn.execute(
|
||||
"SELECT COUNT(*), COUNT(DISTINCT card_id) FROM owned_cards WHERE club_id = ?",
|
||||
(club_id,),
|
||||
).fetchone()
|
||||
squads = conn.execute(
|
||||
"SELECT id, name, formation FROM squads WHERE club_id = ?", (club_id,)
|
||||
).fetchall()
|
||||
squad_players = conn.execute(
|
||||
"SELECT COUNT(*) FROM squad_players sp JOIN squads s ON s.id = sp.squad_id "
|
||||
"WHERE s.club_id = ?", (club_id,)
|
||||
).fetchone()[0]
|
||||
migration = conn.execute(
|
||||
"SELECT MAX(version) FROM _sqlx_migrations"
|
||||
).fetchone()[0]
|
||||
return {
|
||||
"profile_id": profile_id,
|
||||
"username": username,
|
||||
"game_id": game_id,
|
||||
"import_fingerprint": fingerprint,
|
||||
"club_id": club_id,
|
||||
"club_name": club_name,
|
||||
"coins": coins,
|
||||
"owned_cards": owned,
|
||||
"distinct_card_ids": distinct,
|
||||
"squads": [
|
||||
{"id": s[0], "name": s[1], "formation": s[2]} for s in squads
|
||||
],
|
||||
"squad_players": squad_players,
|
||||
"schema_version": migration,
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def copy_database(src: str, dst: str) -> None:
|
||||
"""Online-backup copy. The source is opened read-only, so this cannot write to
|
||||
production state even if the backup API wanted to."""
|
||||
source = sqlite3.connect(f"file:{src}?mode=ro", uri=True)
|
||||
try:
|
||||
if os.path.exists(dst):
|
||||
os.remove(dst)
|
||||
target = sqlite3.connect(dst)
|
||||
try:
|
||||
source.backup(target)
|
||||
finally:
|
||||
target.close()
|
||||
finally:
|
||||
source.close()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("--dir", default=os.environ.get("OPENFUT_CLUB_SNAPSHOT_DIR",
|
||||
DEFAULT_SNAPSHOT_DIR),
|
||||
help=f"snapshot directory (default: {DEFAULT_SNAPSHOT_DIR})")
|
||||
args = ap.parse_args()
|
||||
|
||||
try:
|
||||
banner("SNAPSHOT THE REAL CLUB (read-only on production state)")
|
||||
dest = assert_outside_production(args.dir, "snapshot directory")
|
||||
step(f"source (read-only): {PROD_STATE}")
|
||||
step(f"destination : {dest}")
|
||||
|
||||
# Production is not involved in a file copy -- its Core runs from a docker
|
||||
# volume, not from this directory -- but proving the freeze held across the
|
||||
# operation costs nothing and keeps the evidence uniform with the other
|
||||
# lifecycle scripts.
|
||||
before_state = production_state()
|
||||
ok("production before: " + ", ".join(before_state.describe()))
|
||||
|
||||
missing = [p for p in (SRC_CORE_DB, SRC_IDENTITY, SRC_CLIENTDATA)
|
||||
if not os.path.isfile(p)]
|
||||
if missing:
|
||||
raise Fatal("missing production artifact(s):\n " + "\n ".join(missing))
|
||||
|
||||
before = {p: sha256(p) for p in (SRC_CORE_DB, SRC_IDENTITY, SRC_CLIENTDATA)}
|
||||
for path, digest in before.items():
|
||||
step(f"{os.path.basename(path):20s} {os.path.getsize(path):>9,d} B "
|
||||
f"sha256 {digest[:16]}")
|
||||
|
||||
facts = read_club_facts(SRC_CORE_DB)
|
||||
ok(
|
||||
f"club to snapshot: {facts['username']} ({facts['club_name']}) "
|
||||
f"{facts['owned_cards']} items, {facts['distinct_card_ids']} distinct "
|
||||
f"card ids, {facts['coins']:,} coins, schema v{facts['schema_version']}"
|
||||
)
|
||||
|
||||
banner("COPY")
|
||||
os.makedirs(dest, exist_ok=True)
|
||||
snap_db = assert_outside_production(os.path.join(dest, SNAP_CORE_DB), "core db")
|
||||
copy_database(SRC_CORE_DB, snap_db)
|
||||
ok(f"{SNAP_CORE_DB} written by sqlite online backup (consistent copy)")
|
||||
|
||||
for src, name in ((SRC_IDENTITY, SNAP_IDENTITY),
|
||||
(SRC_CLIENTDATA, SNAP_CLIENTDATA)):
|
||||
dst = assert_outside_production(os.path.join(dest, name), name)
|
||||
shutil.copy2(src, dst)
|
||||
# The source is root-owned; the copy must be writable by the staging user
|
||||
# that installs it.
|
||||
os.chmod(dst, 0o644)
|
||||
ok(f"{SNAP_IDENTITY} and {SNAP_CLIENTDATA} copied")
|
||||
|
||||
banner("PROVE THE COPY IS FAITHFUL AND THE SOURCE IS UNTOUCHED")
|
||||
after = {p: sha256(p) for p in before}
|
||||
changed = [os.path.basename(p) for p in before if before[p] != after[p]]
|
||||
if changed:
|
||||
raise Fatal(
|
||||
"PRODUCTION STATE WAS MODIFIED during the snapshot: "
|
||||
f"{changed} -- this must never happen"
|
||||
)
|
||||
ok("every source file byte-identical before and after (sha256)")
|
||||
|
||||
copy_facts = read_club_facts(snap_db)
|
||||
differences = {
|
||||
key: (facts[key], copy_facts[key])
|
||||
for key in facts
|
||||
if facts[key] != copy_facts[key]
|
||||
}
|
||||
if differences:
|
||||
raise Fatal(f"snapshot does not match the source: {differences}")
|
||||
ok(
|
||||
f"snapshot matches source exactly: {copy_facts['owned_cards']} owned "
|
||||
f"cards, {copy_facts['coins']:,} coins, "
|
||||
f"{copy_facts['squad_players']} squad players"
|
||||
)
|
||||
|
||||
identity = json.load(open(os.path.join(dest, SNAP_IDENTITY)))
|
||||
rows = identity.get("rows", [])
|
||||
owned_rows = [r for r in rows if r.get("entity_kind") == "owned-item"]
|
||||
conn = sqlite3.connect(f"file:{snap_db}?mode=ro", uri=True)
|
||||
try:
|
||||
owned_ids = {r[0] for r in conn.execute("SELECT id FROM owned_cards")}
|
||||
finally:
|
||||
conn.close()
|
||||
unmapped = owned_ids - {r["core_id"] for r in owned_rows}
|
||||
if unmapped:
|
||||
raise Fatal(
|
||||
f"{len(unmapped)} owned cards have no wire id in the identity store; "
|
||||
"the client would see them appear under freshly minted ids and its "
|
||||
f"cached squad would break. Sample: {sorted(unmapped)[:5]}"
|
||||
)
|
||||
ok(
|
||||
f"identity store maps all {len(owned_ids)} owned cards to stable wire "
|
||||
f"ids ({len(owned_rows)} rows, watermarks {identity.get('watermarks')})"
|
||||
)
|
||||
|
||||
manifest = {
|
||||
"taken_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"source_dir": PROD_STATE,
|
||||
"sources": {
|
||||
os.path.basename(p): {"sha256": before[p], "bytes": os.path.getsize(p)}
|
||||
for p in before
|
||||
},
|
||||
"members": {
|
||||
"core_db": SNAP_CORE_DB,
|
||||
"identity": SNAP_IDENTITY,
|
||||
"clientdata": SNAP_CLIENTDATA,
|
||||
},
|
||||
"club": copy_facts,
|
||||
"identity_rows": len(rows),
|
||||
"watermarks": identity.get("watermarks"),
|
||||
}
|
||||
with open(os.path.join(dest, SNAP_MANIFEST), "w") as fh:
|
||||
json.dump(manifest, fh, indent=2)
|
||||
fh.write("\n")
|
||||
ok(f"manifest written: {os.path.join(dest, SNAP_MANIFEST)}")
|
||||
|
||||
after_state = production_state()
|
||||
ok("production after : " + ", ".join(after_state.describe()))
|
||||
|
||||
banner("SNAPSHOT READY")
|
||||
print(f" {dest}")
|
||||
print()
|
||||
print(" Install it into staging with:")
|
||||
print(" python3 scripts/sold-staging-up.py --club real "
|
||||
"--variant highest \\")
|
||||
print(" --roster-host winter15.gosredirector.ea.com:8081")
|
||||
return 0
|
||||
except ProductionError as exc:
|
||||
print(f"\nFATAL: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+146
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Authoritative Core state snapshot for FIFA17 FUT loop verification.
|
||||
|
||||
Queries openfut-core's authoritative read API directly (NOT the FIFA UI, NOT the
|
||||
host projection, NOT Python) and emits a machine-readable JSON snapshot with the
|
||||
integrity fields needed to detect, across any economy operation or a full stack
|
||||
restart:
|
||||
|
||||
* coin drift (balance vs club.coins cross-check + absolute value)
|
||||
* duplicate ownership (repeated owned_card_id)
|
||||
* missing ownership (squad references an owned_card_id not in the collection)
|
||||
* resurrection (owned-set hash changes when it must not)
|
||||
* entitlement dup/count (unopened pack entitlements)
|
||||
* stale squad mappings (squad player ids absent from ownership)
|
||||
|
||||
This is verification/oracle tooling, not production behavior. It reads only.
|
||||
|
||||
Usage:
|
||||
core-snapshot.py [--core URL] [--game fifa17] [--squad-ns fifa17.squad]
|
||||
[--out FILE] [--label NAME]
|
||||
Env fallbacks: OPENFUT_CORE_URL, OPENFUT_GAME, OPENFUT_SQUAD_NS.
|
||||
Exit code 0 always for a successful read; integrity problems are reported IN the
|
||||
snapshot (`integrity.ok` / `integrity.problems`) so callers can diff/assert.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def fetch(core, game, path):
|
||||
req = urllib.request.Request(core + path, headers={"X-OpenFUT-Game": game})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as f:
|
||||
return f.status, f.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read()
|
||||
except Exception as e: # noqa: BLE001 - surface transport errors in the snapshot
|
||||
return None, str(e).encode()
|
||||
|
||||
|
||||
def fetch_json(core, game, path):
|
||||
st, body = fetch(core, game, path)
|
||||
if st != 200:
|
||||
raise SystemExit(f"Core read {path} failed: status={st} body={body[:200]!r}")
|
||||
return json.loads(body)
|
||||
|
||||
|
||||
def sha(items):
|
||||
h = hashlib.sha256()
|
||||
for it in items:
|
||||
h.update(str(it).encode())
|
||||
h.update(b"\x00")
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--core", default=os.environ.get("OPENFUT_CORE_URL", "http://127.0.0.1:18101"))
|
||||
ap.add_argument("--game", default=os.environ.get("OPENFUT_GAME", "fifa17"))
|
||||
ap.add_argument("--squad-ns", default=os.environ.get("OPENFUT_SQUAD_NS", "fifa17.squad"))
|
||||
ap.add_argument("--out")
|
||||
ap.add_argument("--label", default="")
|
||||
args = ap.parse_args()
|
||||
|
||||
balance = fetch_json(args.core, args.game, "/economy/balance")
|
||||
entitlements = fetch_json(args.core, args.game, "/economy/entitlements")
|
||||
profile = fetch_json(args.core, args.game, "/profile")
|
||||
club = fetch_json(args.core, args.game, "/club")
|
||||
collection = fetch_json(args.core, args.game, "/collection")["collection"]
|
||||
squad = fetch_json(args.core, args.game, f"/squad/ext?namespace={args.squad_ns}")
|
||||
|
||||
coins_balance = balance.get("balance")
|
||||
coins_club = club.get("coins")
|
||||
|
||||
owned_ids = sorted(x["owned_card_id"] for x in collection)
|
||||
card_ids = sorted(x["card"]["id"] for x in collection)
|
||||
dup_owned = sorted({i for i in owned_ids if owned_ids.count(i) > 1}) if len(owned_ids) != len(set(owned_ids)) else []
|
||||
loans = [x["owned_card_id"] for x in collection if x.get("is_loan")]
|
||||
|
||||
# Squad player ownership references (from the opaque extension payload).
|
||||
squad_player_ids = []
|
||||
ext = squad.get("extension") or {}
|
||||
payload_raw = ext.get("payload")
|
||||
kit_numbers = {}
|
||||
if payload_raw:
|
||||
try:
|
||||
payload = json.loads(payload_raw)
|
||||
kit_numbers = payload.get("kit_numbers", {}) or {}
|
||||
squad_player_ids = sorted(kit_numbers.keys())
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
owned_set = set(owned_ids)
|
||||
squad_missing = sorted(pid for pid in squad_player_ids if pid not in owned_set)
|
||||
|
||||
problems = []
|
||||
if coins_balance != coins_club:
|
||||
problems.append(f"coin cross-check mismatch: balance={coins_balance} club={coins_club}")
|
||||
if dup_owned:
|
||||
problems.append(f"duplicate owned_card_id: {dup_owned[:10]} (+{max(0,len(dup_owned)-10)} more)")
|
||||
if squad_missing:
|
||||
problems.append(f"squad references non-owned ids: {squad_missing}")
|
||||
|
||||
snap = {
|
||||
"label": args.label,
|
||||
"captured_at": datetime.now(timezone.utc).isoformat(),
|
||||
"core": args.core,
|
||||
"game": args.game,
|
||||
"coins": {"balance": coins_balance, "club": coins_club},
|
||||
"profile": {"username": profile.get("username"), "level": profile.get("level"), "xp": profile.get("xp")},
|
||||
"entitlements": {"count": len(entitlements), "ids": entitlements},
|
||||
"collection": {
|
||||
"owned": len(collection),
|
||||
"distinct_owned_card_id": len(set(owned_ids)),
|
||||
"distinct_card_id": len(set(card_ids)),
|
||||
"loans": len(loans),
|
||||
"duplicate_owned_card_id": dup_owned,
|
||||
"owned_set_sha256": sha(owned_ids),
|
||||
"card_multiset_sha256": sha(card_ids),
|
||||
},
|
||||
"squad": {
|
||||
"namespace": args.squad_ns,
|
||||
"verdict": squad.get("verdict"),
|
||||
"player_count": len(squad_player_ids),
|
||||
"player_ids": squad_player_ids,
|
||||
"missing_from_ownership": squad_missing,
|
||||
"ext_schema_version": ext.get("schema_version"),
|
||||
},
|
||||
"integrity": {"ok": not problems, "problems": problems},
|
||||
}
|
||||
|
||||
text = json.dumps(snap, indent=2, sort_keys=True)
|
||||
if args.out:
|
||||
with open(args.out, "w") as f:
|
||||
f.write(text + "\n")
|
||||
print(text)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+830
@@ -0,0 +1,830 @@
|
||||
#!/usr/bin/env python3
|
||||
"""FIFA 17 definition-coverage guard.
|
||||
|
||||
Classifies EVERY table in the FIFA 17 resident-database dump
|
||||
(`fifa17-recon/data/tables/*.json`, produced by `fifa17-recon/tools/db_dump.py`)
|
||||
into exactly one coverage bucket, and every definition id those tables ship into
|
||||
exactly one of them too. A table with no rule lands in UNKNOWN and the run
|
||||
FAILS: that is the whole point. Adding a newly dumped table, or renaming one,
|
||||
must force a human to classify it rather than letting it silently disappear into
|
||||
"presentation".
|
||||
|
||||
This is OFFLINE ANALYSIS TOOLING (an RE/coverage oracle). It is not authoritative
|
||||
for anything at runtime: the authoritative reader/writer/owner of FUT items is
|
||||
Rust Core plus the FIFA 17 adapter.
|
||||
|
||||
Authority for the taxonomy below, in precedence order:
|
||||
1. `fifa17-recon/docs/plan-2026-08-06-card-subsystem.md` (SETTLED; outranks
|
||||
`fifa17-recon/docs/CARD_SYSTEM.md` wherever the two disagree)
|
||||
2. the shipped tables themselves (`--tables`) and
|
||||
`fifa17-recon/data/consumables.json` (`--consumables`)
|
||||
3. the Rust taxonomy that consumes them:
|
||||
`openfut-adapter-fifa17/src/fut/content_taxonomy.rs` and
|
||||
`openfut-import-fifa17/src/lib.rs::club_family`
|
||||
|
||||
Buckets
|
||||
-------
|
||||
KNOWN_OWNABLE A FUT club can hold an inventory instance of these
|
||||
definitions, and the class is inside OpenFUT's item
|
||||
vocabulary (player / staff / consumable / kit / badge /
|
||||
ball / stadium / league logo).
|
||||
KNOWN_PRESENTATION_ONLY Shipped and rendered, but never owned as an inventory
|
||||
instance: match/career content, asset registries, pure
|
||||
lookup and localisation tables, empty scratch mirrors.
|
||||
Trophies belong to this class conceptually but ship NO
|
||||
definition table at all (see TROPHY_NOTE).
|
||||
KNOWN_UNSUPPORTED A real card-definition class, ownable in the retail
|
||||
game, that OpenFUT deliberately does not support yet.
|
||||
Every entry carries a one-line stated reason.
|
||||
UNKNOWN Cannot be placed from evidence. MUST be zero.
|
||||
|
||||
Usage
|
||||
-----
|
||||
python3 scripts/fifa17-definition-coverage.py [--tables DIR] [--json] [-v]
|
||||
|
||||
Exits 0 only when UNKNOWN == 0 and every invariant check passes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
OWNABLE = "KNOWN_OWNABLE"
|
||||
PRESENTATION = "KNOWN_PRESENTATION_ONLY"
|
||||
UNSUPPORTED = "KNOWN_UNSUPPORTED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
BUCKET_ORDER = (OWNABLE, UNSUPPORTED, PRESENTATION, UNKNOWN)
|
||||
|
||||
|
||||
class Rule(NamedTuple):
|
||||
"""One table's classification.
|
||||
|
||||
bucket -- which coverage bucket the table belongs to.
|
||||
id_col -- the column holding the CARD DEFINITION id, or None when the
|
||||
table ships no definition id of its own (a table may still hold
|
||||
foreign keys into somebody else's id space; those are not
|
||||
definition ids and are never counted here).
|
||||
id_space -- the shared id-space key. Two tables with the same key are
|
||||
counted as a UNION, never summed (see ID_SPACES).
|
||||
reason -- why this table is in this bucket. Evidence, not opinion.
|
||||
"""
|
||||
|
||||
bucket: str
|
||||
id_col: str | None
|
||||
id_space: str | None
|
||||
reason: str
|
||||
|
||||
|
||||
def ownable(id_col: str, id_space: str, reason: str) -> Rule:
|
||||
return Rule(OWNABLE, id_col, id_space, reason)
|
||||
|
||||
|
||||
def presentation(reason: str) -> Rule:
|
||||
# Presentation tables ship no definition id of their own by construction:
|
||||
# if one did, it would be a card class and would not be in this bucket.
|
||||
return Rule(PRESENTATION, None, None, reason)
|
||||
|
||||
|
||||
def unsupported(id_col: str, id_space: str, reason: str) -> Rule:
|
||||
return Rule(UNSUPPORTED, id_col, id_space, reason)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# THE ONE CLASSIFICATION TABLE. Audit this dict and you have audited the tool.
|
||||
# One entry per table in the dump; a table absent from here becomes UNKNOWN.
|
||||
# ---------------------------------------------------------------------------
|
||||
RULES: dict[str, Rule] = {
|
||||
# -- KNOWN_OWNABLE: the 16 real card-definition tables ---------------------
|
||||
# (15 here + fcc_misccards, which is the 16th definition table but sits in
|
||||
# KNOWN_UNSUPPORTED; the 15 below + misccards are the settled 16.)
|
||||
"players": ownable(
|
||||
"playerid", "player.playerid",
|
||||
"cardtype 1 player definitions; playerid IS the FUT assetId. The "
|
||||
"reference ownable class — read/write/ownership already in Core.",
|
||||
),
|
||||
"managercards": ownable(
|
||||
"carddbid", "staff.manager.carddbid",
|
||||
"staff cardsubtypeid 4 (manager); cardtype 2 merge FUN_1801356c0. "
|
||||
"Ownable staff card (carries value/rare/nation/formationid).",
|
||||
),
|
||||
"headcoachcards": ownable(
|
||||
"carddbid", "staff.headcoach.carddbid",
|
||||
"staff cardsubtypeid 5 (head coach) in the FUN_1800d8330 family "
|
||||
"selector; ownable staff card.",
|
||||
),
|
||||
"gkcoachcards": ownable(
|
||||
"carddbid", "staff.gkcoach.carddbid",
|
||||
"staff cardsubtypeid 6 (GK coach) in the FUN_1800d8330 family "
|
||||
"selector; ownable staff card.",
|
||||
),
|
||||
"physiocards": ownable(
|
||||
"carddbid", "staff.physio.carddbid",
|
||||
"staff cardsubtypeid 7 (physio) in the FUN_1800d8330 family selector; "
|
||||
"ownable staff card.",
|
||||
),
|
||||
"fitnesscoachcards": ownable(
|
||||
"carddbid", "staff.fitnesscoach.carddbid",
|
||||
"staff cardsubtypeid 8 (fitness coach) in the FUN_1800d8330 family "
|
||||
"selector; ownable staff card.",
|
||||
),
|
||||
"fcc_contractcards": ownable(
|
||||
"carddbid", "consumable.contract.carddbid",
|
||||
"consumable definitions, cardsubtype 201 (player contract) / 202 "
|
||||
"(manager contract); both in data/consumables.json.",
|
||||
),
|
||||
"fcc_healingcards": ownable(
|
||||
"carddbid", "consumable.healing.carddbid",
|
||||
"consumable definitions, cardsubtype 211..218 (healing) + 219/220 "
|
||||
"(player/squad fitness); all in data/consumables.json.",
|
||||
),
|
||||
"fcc_trainingcards": ownable(
|
||||
"carddbid", "consumable.training.carddbid",
|
||||
"consumable definitions: GK/player training 51..67, position 91..110, "
|
||||
"formation 121..136, chemistry styles 250..273, manager league "
|
||||
"300..341; all in data/consumables.json.",
|
||||
),
|
||||
"fcc_kitcards": ownable(
|
||||
"carddbid", "club.kit.carddbid",
|
||||
"cardtype 7 club item, cardsubtypeid 9 (kit) via resolver FUN_180119bd0; "
|
||||
"constant cardassetid 35 on all rows. Ownable club customisation.",
|
||||
),
|
||||
"fcc_stadium": ownable(
|
||||
"carddbid", "club.stadium.carddbid",
|
||||
"cardtype 7 club item, cardsubtypeid 10 (stadium); constant cardassetid "
|
||||
"36. Ownable club customisation (stadiumid is a foreign key into the "
|
||||
"match-venue registry, not this table's identity).",
|
||||
),
|
||||
"fcc_badgecards": ownable(
|
||||
"carddbid", "club.badge.carddbid",
|
||||
"cardtype 7 club item, cardsubtypeid 11 (club badge); constant "
|
||||
"cardassetid 39. Ownable club customisation.",
|
||||
),
|
||||
"fcc_balls": ownable(
|
||||
"carddbid", "club.ball.carddbid",
|
||||
"cardtype 9 club item, cardsubtypeid 30 (ball, FUT_UC_BALL caption); "
|
||||
"constant cardassetid 37. Ownable club customisation.",
|
||||
),
|
||||
"fcc_leaguelogos": ownable(
|
||||
"carddbid", "club.leaguelogo.carddbid",
|
||||
"cardtype 9 club item, cardsubtypeid 31 (league logo, by elimination "
|
||||
"over FUN_1800d8330's cardtype-9 set); constant cardassetid 40. "
|
||||
"SHARES its carddbid space with fcc_leaguelogostickers — see "
|
||||
"ID_SPACES/collision report.",
|
||||
),
|
||||
"fcc_leaguelogostickers": ownable(
|
||||
"carddbid", "club.leaguelogo.carddbid",
|
||||
"same cardsubtypeid 31 / cardassetid 40 league-logo class; its 39 "
|
||||
"carddbids ALL collide with fcc_leaguelogos' ids (shipped data bug), so "
|
||||
"it contributes 0 new definition ids and is counted as a union.",
|
||||
),
|
||||
|
||||
# -- KNOWN_UNSUPPORTED: real card classes OpenFUT deliberately defers ------
|
||||
"fcc_misccards": unsupported(
|
||||
"carddbid", "misc.carddbid",
|
||||
"cardtype 9 cardsubtype {231,232,233,236} = FUN_1800d8330's "
|
||||
"0xe7..0xe9/0xec block, matched exactly by this table's cardsubtype "
|
||||
"column. These ARE owned items (the client's TO_TRADE_PILE gate "
|
||||
"excludes exactly {0xe7,0xe8,0xe9,0xec}, which only means anything for "
|
||||
"owned inventory), but the per-subtype semantics (cardassetid 43..46, "
|
||||
"meaning of amount/rating) are NOT reverse-engineered, the subtypes are "
|
||||
"absent from data/consumables.json's 172, and neither "
|
||||
"content_taxonomy::consumable_family nor import::club_family maps them. "
|
||||
"UNSUPPORTED rather than guessed.",
|
||||
),
|
||||
"fut_storymodehero": unsupported(
|
||||
"carddbid", "storymodehero.carddbid",
|
||||
"a distinct carddbid space (22800..22879) for The Journey story-mode "
|
||||
"hero cards, but the shipped row is only {carddbid, teamid}: no name, "
|
||||
"rating, value, cardassetid or cardsubtypeid, so no FUT item can be "
|
||||
"shaped from client data without fabricating it. OpenFUT does not "
|
||||
"support story-mode hero items.",
|
||||
),
|
||||
|
||||
# -- KNOWN_PRESENTATION_ONLY ----------------------------------------------
|
||||
# Crowd / atmosphere / match-engine lookups.
|
||||
"BigAttendance": presentation(
|
||||
"crowd-emotion band lookup (min/max/emotion) for a full stadium; "
|
||||
"match presentation, not a card definition."),
|
||||
"NoAttendance": presentation(
|
||||
"crowd-emotion band lookup for an empty stadium; match presentation."),
|
||||
"MatchIntensity": presentation(
|
||||
"score-difference x match-minute AI intensity curve; match engine "
|
||||
"lookup, no ids."),
|
||||
"fcc_GrandStandPlayers": presentation(
|
||||
"single playerid column choosing which player heads populate the FUT "
|
||||
"hub grandstand. Foreign keys into the player id space (several do not "
|
||||
"even resolve in the shipped players table); rendered, never owned."),
|
||||
"celebrations": presentation(
|
||||
"celebration animation ids; match presentation."),
|
||||
"videos": presentation(
|
||||
"per-locale video asset ids; front-end presentation."),
|
||||
"eatrax": presentation(
|
||||
"licensed soundtrack rows (song/artist/album); audio presentation."),
|
||||
"songplaylistlinks": presentation(
|
||||
"song-to-playlist links; audio presentation."),
|
||||
"audionation": presentation(
|
||||
"per-nation commentary/crowd audio bank indices; audio lookup."),
|
||||
"audiostadium": presentation(
|
||||
"per-stadium commentary language index; audio lookup."),
|
||||
"dynamicimages": presentation(
|
||||
"ad/sponsor dynamic image descriptors; stadium dressing presentation."),
|
||||
"sponsors": presentation(
|
||||
"ad sponsor registry (adsponserid); stadium dressing presentation."),
|
||||
"teamsponsorlinks": presentation("team-to-sponsor links; presentation."),
|
||||
"competitionsponsorlinks": presentation(
|
||||
"competition-to-sponsor links; presentation."),
|
||||
"modeadboardlinks": presentation("mode-to-adboard links; presentation."),
|
||||
"presentationcompsettings": presentation(
|
||||
"per-competition presentation feature flags; presentation."),
|
||||
"presentationmodesettings": presentation(
|
||||
"per-mode presentation feature flags; presentation."),
|
||||
"assetcryptokeys": presentation(
|
||||
"asset decryption keys (empty in this dump); build plumbing, not a "
|
||||
"card definition."),
|
||||
|
||||
# Asset registries the FUT card tables POINT AT. The ownable definition is
|
||||
# the fcc_* card; these registries are the rendered asset behind it.
|
||||
"teams": presentation(
|
||||
"club/crest asset registry (assetid = team asset). fcc_badgecards' "
|
||||
"teamid points here; the ownable badge definition is the fcc_ row."),
|
||||
"teamkits": presentation(
|
||||
"in-match kit asset registry. fcc_kitcards is the ownable FUT kit "
|
||||
"definition; these rows are the geometry/colour asset it renders."),
|
||||
"stadiums": presentation(
|
||||
"match-venue registry (stadiumid). fcc_stadium is the ownable FUT "
|
||||
"stadium definition; these rows are the venue it renders."),
|
||||
"teamballs": presentation(
|
||||
"in-match ball asset registry (ballid, isavailableinstore). fcc_balls "
|
||||
"is the ownable FUT ball definition."),
|
||||
"teamballremapping": presentation(
|
||||
"ballid remap table (empty in this dump); asset plumbing."),
|
||||
"dlcballs": presentation(
|
||||
"DLC ball asset names (empty in this dump); asset registry, and FIFA 17 "
|
||||
"FUT owns balls through fcc_balls, not through DLC asset rows."),
|
||||
"dlcboots": presentation(
|
||||
"DLC boot asset names (empty in this dump). FIFA 17 FUT has no boot "
|
||||
"item class at all — boots are rendered on players, never owned."),
|
||||
"playerboots": presentation(
|
||||
"boot asset registry (shoetype/colour/licensing). Rendered on players; "
|
||||
"FIFA 17 FUT ships no boot card class."),
|
||||
"playerbootremapping": presentation(
|
||||
"shoetype remap table (empty in this dump); asset plumbing."),
|
||||
"shoecolors": presentation("boot colour palette; asset lookup."),
|
||||
"manager": presentation(
|
||||
"career-mode manager PERSONS (managerid, head/suit appearance). The "
|
||||
"ownable FUT manager card is managercards."),
|
||||
"physio": presentation(
|
||||
"career-mode physio PERSONS (physioid, appearance). The ownable FUT "
|
||||
"physio card is physiocards."),
|
||||
"referee": presentation(
|
||||
"referee persons/appearance (refereeid); match presentation, never "
|
||||
"owned."),
|
||||
"leaguerefereelinks": presentation("league-to-referee links; lookup."),
|
||||
|
||||
# FUT front-end lookup / calculator tables (fcc_* that are NOT card tables).
|
||||
"fcc_bonusvalues": presentation(
|
||||
"chemistry/boost bonus value lookup (bonusid, bonuslevel); pure "
|
||||
"calculator input, not a card definition."),
|
||||
"fcc_managerbonusvalues": presentation(
|
||||
"manager bonus value lookup; pure calculator input."),
|
||||
"fcc_chemlinkcalc": presentation(
|
||||
"formation x position chemistry-link matrix; pure calculator input."),
|
||||
"fcc_nationcalc": presentation(
|
||||
"nation grouping for chemistry (nationid -> groupid); lookup."),
|
||||
"fcc_leagues": presentation(
|
||||
"FUT league name/country lookup (leagueid -> futcountryid). "
|
||||
"fcc_leaguelogos' leagueid points here; the ownable definition is the "
|
||||
"fcc_leaguelogos row."),
|
||||
"fcc_coinrewards": presentation(
|
||||
"leaderboard objective coin reward lookup; economy table, not a card."),
|
||||
"fcc_discardcoins": presentation(
|
||||
"quick-sell price lookup keyed by (cardtype, level, rare). Prices for "
|
||||
"cards, not card definitions."),
|
||||
"fcc_myclubs": presentation("MY CLUB tab id/name lookup; UI structure."),
|
||||
"fcc_myclubscategories": presentation(
|
||||
"MY CLUB category rows (categoryid, isteamcategory); UI structure."),
|
||||
"fcc_preferredpositioncalc": presentation(
|
||||
"preferred-position calculator matrix; calculator input."),
|
||||
"fcc_preferredformationcalcgk": presentation(
|
||||
"preferred-formation calculator matrix (GK); calculator input."),
|
||||
"fcc_preferredformationcalcback": presentation(
|
||||
"preferred-formation calculator matrix (defence); calculator input."),
|
||||
"fcc_preferredformationcalcmid": presentation(
|
||||
"preferred-formation calculator matrix (midfield); calculator input."),
|
||||
"fcc_preferredformationcalcst": presentation(
|
||||
"preferred-formation calculator matrix (attack); calculator input."),
|
||||
"fcc_formationcardspositions_hd": presentation(
|
||||
"on-screen card X/Y coordinates per formation (HD layout); UI layout."),
|
||||
"fcc_formationcardspositions_kc": presentation(
|
||||
"on-screen card X/Y coordinates per formation (KC layout); UI layout."),
|
||||
"fcc_textposvalues_hd": presentation(
|
||||
"on-screen text offsets per formation (HD layout); UI layout."),
|
||||
"fcc_textposvalues_kc": presentation(
|
||||
"on-screen text offsets per formation (KC layout); UI layout."),
|
||||
"fcc_navcoords_hd": presentation(
|
||||
"D-pad navigation graph between squad slots (HD). Its `playerid` column "
|
||||
"is a 0..11 SLOT INDEX, not a player id; UI navigation."),
|
||||
"fcc_navcoords_kc": presentation(
|
||||
"D-pad navigation graph between squad slots (KC); UI navigation."),
|
||||
|
||||
# Squad / formation / tactics data.
|
||||
"formations": presentation(
|
||||
"formation definitions (positions, offsets, instructions); tactics "
|
||||
"data, never owned."),
|
||||
"formationlayout": presentation("formation-to-formation slot remap; lookup."),
|
||||
"defformation": presentation("default formation rows; tactics data."),
|
||||
"customformations": presentation(
|
||||
"user custom formations (empty in this dump); tactics data."),
|
||||
"customteamstyles": presentation(
|
||||
"user custom team styles (empty in this dump); tactics data."),
|
||||
"teamformationteamstylelinks": presentation(
|
||||
"team-to-formation/style links (empty in this dump); tactics lookup."),
|
||||
"teamsheets": presentation("team sheet rows; tactics data."),
|
||||
"default_teamsheets": presentation("default team sheet rows; tactics data."),
|
||||
"defaultteamdata": presentation("default per-team tactics; tactics data."),
|
||||
"teamsheetanalysis": presentation(
|
||||
"playerid x recorded position analysis rows; tactics lookup."),
|
||||
"fieldpositionboundingboxes": presentation(
|
||||
"pitch bounding boxes per position id; match engine geometry."),
|
||||
"playerpositionzones": presentation(
|
||||
"position zone polygons; match engine geometry."),
|
||||
"attributeprefpositionformula": presentation(
|
||||
"attribute weighting per position; rating calculator input."),
|
||||
"playerattributesmapping": presentation(
|
||||
"raw-attribute to displayed-attribute mapping; calculator input."),
|
||||
"playerattributesmapping_g4": presentation(
|
||||
"gen-4 variant of the attribute mapping; calculator input."),
|
||||
"starratingboundaries": presentation(
|
||||
"overall-rating to star-rating bands; UI lookup."),
|
||||
|
||||
# Reference / geography / competition structure.
|
||||
"nations": presentation("nation registry (nationid, iso, confederation); lookup."),
|
||||
"leagues": presentation("league registry (leagueid, level); lookup."),
|
||||
"leagueteamlinks": presentation("league table state per team; lookup/state."),
|
||||
"teamnationlinks": presentation("international team-to-nation links; lookup."),
|
||||
"rowteamnationlinks": presentation("rest-of-world team-to-nation links; lookup."),
|
||||
"teamstadiumlinks": presentation("team-to-stadium links; lookup."),
|
||||
"teamstadiumlinkscache": presentation(
|
||||
"cached team-to-stadium links (empty in this dump); lookup."),
|
||||
"stadiumassignments": presentation(
|
||||
"custom stadium name assignments (empty in this dump); lookup."),
|
||||
"competition": presentation(
|
||||
"competition definitions and presentation flags (competitionid); "
|
||||
"match/competition structure, never owned."),
|
||||
"rivals": presentation("team rivalry pairs; match presentation."),
|
||||
"fixtures": presentation("fixture rows (empty in this dump); schedule state."),
|
||||
"factory_teams": presentation(
|
||||
"single factory/template team row; default data, not a card."),
|
||||
"fifaGameDefaults": presentation(
|
||||
"default player/team/ball/referee ids per settings context; defaults "
|
||||
"lookup."),
|
||||
"fifaGameSettings": presentation("game settings rows; user settings."),
|
||||
"version": presentation("database schema/version metadata."),
|
||||
"server_db_version": presentation("server database version metadata."),
|
||||
|
||||
# Player-adjacent state and career tables (state about players, not new
|
||||
# definitions; every playerid here is a foreign key into players.playerid).
|
||||
"teamplayerlinks": presentation(
|
||||
"player-to-team squad links plus season form/stats; state over "
|
||||
"players.playerid, not a new definition id."),
|
||||
"previousteam": presentation(
|
||||
"player's previous team ids; state over players.playerid."),
|
||||
"playerloans": presentation(
|
||||
"loan rows (playerid, teamidloanedfrom); career state."),
|
||||
"playersuspensions": presentation(
|
||||
"suspension rows (empty in this dump); career state."),
|
||||
"restrictedplayers": presentation(
|
||||
"restricted/replacement player rows (empty in this dump); career state."),
|
||||
"playerformdiff": presentation(
|
||||
"player rating deltas (empty in this dump); career state."),
|
||||
"teamformdiff": presentation(
|
||||
"team rating deltas (empty in this dump); career state."),
|
||||
"transfers": presentation("transfer rows (empty in this dump); career state."),
|
||||
"transactionhistory": presentation(
|
||||
"transfer history rows (empty in this dump); career state."),
|
||||
"player_grudgelove": presentation(
|
||||
"player-to-team emotional affinity; career/story lookup."),
|
||||
"dna": presentation("player DNA rows (empty in this dump); career data."),
|
||||
"stories": presentation("career story engine rows (empty in this dump)."),
|
||||
"career_calendar": presentation("career calendar dates; career data."),
|
||||
"career_clinchedobjectives": presentation(
|
||||
"career objective flags (empty in this dump); career state."),
|
||||
"career_squadranking": presentation(
|
||||
"career squad ranking (empty in this dump); career state."),
|
||||
"career_playerlastmatchhistory": presentation(
|
||||
"career last-match history (empty in this dump); career state."),
|
||||
"career_playermatchratinghistory": presentation(
|
||||
"career match rating history (empty in this dump); career state."),
|
||||
|
||||
# Localisation / name string tables.
|
||||
"playernames": presentation(
|
||||
"nameid -> player name strings; localisation, not a card definition."),
|
||||
"dcplayernames": presentation(
|
||||
"DLC player name strings (empty in this dump); localisation."),
|
||||
"editedplayernames": presentation(
|
||||
"user-edited player names (empty in this dump); localisation."),
|
||||
"commentarynames": presentation("commentary name strings; localisation."),
|
||||
"clubcommentarynames": presentation("club commentary name strings; localisation."),
|
||||
"createclubnames": presentation("create-a-club name affixes; localisation."),
|
||||
"career_commonnames": presentation(
|
||||
"career common-name strings (empty in this dump); localisation."),
|
||||
"career_firstnames": presentation(
|
||||
"career first-name strings (empty in this dump); localisation."),
|
||||
"career_lastnames": presentation(
|
||||
"career last-name strings (empty in this dump); localisation."),
|
||||
"trainingteamplayernames": presentation(
|
||||
"training/arena team player name strings; localisation."),
|
||||
|
||||
# Create-a-player / customisation input tables.
|
||||
"createplayer": presentation(
|
||||
"create-a-player morph sliders (empty in this dump); customisation "
|
||||
"input, not a card definition."),
|
||||
"temp_createplayer": presentation(
|
||||
"scratch mirror of createplayer (empty in this dump)."),
|
||||
"createplayerpositiontemplates": presentation(
|
||||
"create-a-player attribute templates per position; customisation input."),
|
||||
"createplayerviews": presentation(
|
||||
"create-a-player UI view/attribute mapping; UI structure."),
|
||||
|
||||
# Skill-games / arena / training rosters (fixed non-FUT rosters).
|
||||
"smplayers": presentation(
|
||||
"skill-games roster player rows; a fixed non-FUT roster, never owned."),
|
||||
"smrivals": presentation("skill-games rivalry pairs; presentation."),
|
||||
"trainingteamplayers": presentation(
|
||||
"training/arena roster player rows; a fixed non-FUT roster."),
|
||||
"trainingteamplayerlinks": presentation(
|
||||
"training/arena roster squad links; lookup."),
|
||||
|
||||
# Empty scratch/staging mirrors (0 rows in this dump; the game copies live
|
||||
# tables into them). No definition ids of their own by construction.
|
||||
"temp_players": presentation("empty scratch mirror of players."),
|
||||
"temp_teams": presentation("empty scratch mirror of teams."),
|
||||
"temp_teamplayerlinks": presentation("empty scratch mirror of teamplayerlinks."),
|
||||
"temp_formations": presentation("empty scratch mirror of formations."),
|
||||
"temp_arenaplayer": presentation("empty scratch arena player table."),
|
||||
"temp_arenaplayername": presentation("empty scratch arena player name table."),
|
||||
"temp_arenateam": presentation("empty scratch arena team table."),
|
||||
"temp_arenateamplayerlinks": presentation("empty scratch arena squad links."),
|
||||
|
||||
# Companion-app / cloud ("cz_") staging tables, all empty in this dump.
|
||||
"cz_players": presentation(
|
||||
"companion/cloud staging copy of players (empty in this dump)."),
|
||||
"cz_teams": presentation(
|
||||
"companion/cloud staging copy of teams (empty in this dump)."),
|
||||
"cz_teamkits": presentation(
|
||||
"companion/cloud staging copy of teamkits (empty in this dump)."),
|
||||
"cz_leagues": presentation(
|
||||
"companion/cloud staging copy of leagues (empty in this dump)."),
|
||||
"cz_assets": presentation(
|
||||
"companion/cloud asset descriptors (empty in this dump)."),
|
||||
}
|
||||
|
||||
# Tables that share ONE carddbid space and must therefore be counted as a
|
||||
# UNION. Each entry is (id_space, expected_union, note) — the note is printed so
|
||||
# the bug cannot regress silently.
|
||||
ID_SPACES: dict[str, tuple[int, str]] = {
|
||||
"club.leaguelogo.carddbid": (
|
||||
44,
|
||||
"SHIPPED DATA BUG: fcc_leaguelogos (44 rows) and "
|
||||
"fcc_leaguelogostickers (39 rows) both start their carddbid at "
|
||||
"8010000, so all 39 sticker ids collide with logo ids. The league-logo "
|
||||
"definition count is the UNION = 44, never the naive sum 83.",
|
||||
),
|
||||
}
|
||||
|
||||
# The SETTLED inventory: 16 real definition tables holding 20918 distinct ids
|
||||
# (league logos counted as their union of 44). Checked as an invariant so a
|
||||
# re-dump that gains or loses definitions is caught here.
|
||||
SETTLED_DEFINITION_TABLES = (
|
||||
"players", "managercards", "headcoachcards", "gkcoachcards",
|
||||
"fitnesscoachcards", "physiocards", "fcc_contractcards",
|
||||
"fcc_healingcards", "fcc_trainingcards", "fcc_misccards", "fcc_kitcards",
|
||||
"fcc_badgecards", "fcc_balls", "fcc_stadium", "fcc_leaguelogos",
|
||||
"fcc_leaguelogostickers",
|
||||
)
|
||||
SETTLED_DEFINITION_ID_TOTAL = 20918
|
||||
|
||||
# Each club family ships a CONSTANT cardassetid, verified across all shipped
|
||||
# rows; it is the deserializer's own per-subtype default.
|
||||
CLUB_CARD_ASSET_IDS = {
|
||||
"fcc_kitcards": 35,
|
||||
"fcc_stadium": 36,
|
||||
"fcc_balls": 37,
|
||||
"fcc_badgecards": 39,
|
||||
"fcc_leaguelogos": 40,
|
||||
"fcc_leaguelogostickers": 40,
|
||||
}
|
||||
|
||||
# Consumable definition tables whose every cardsubtype must appear in
|
||||
# data/consumables.json. fcc_misccards is deliberately absent: its subtypes
|
||||
# {231,232,233,236} are NOT consumables and NOT in that file — that is exactly
|
||||
# why it is KNOWN_UNSUPPORTED.
|
||||
CONSUMABLE_DEFINITION_TABLES = (
|
||||
"fcc_contractcards", "fcc_healingcards", "fcc_trainingcards",
|
||||
)
|
||||
|
||||
TROPHY_NOTE = (
|
||||
"TROPHIES (cardsubtypeid 0x91..0x96): FUN_180108c00 computes "
|
||||
"subtype = tournamentType + 0x91 and FUN_1800fed90 is the only function "
|
||||
"whose case set is exactly {0x91..0x96}. They are rendered from wire "
|
||||
"FUT::TournamentInfo data and ship NO definition table in this dump, so "
|
||||
"they contribute 0 definition ids. Presentation-only by evidence, not by "
|
||||
"convenience."
|
||||
)
|
||||
|
||||
|
||||
def load_table(path: Path) -> dict:
|
||||
with path.open(encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
def classify(tables_dir: Path, consumables_path: Path) -> dict:
|
||||
files = sorted(tables_dir.glob("*.json"))
|
||||
if not files:
|
||||
raise SystemExit(f"no *.json tables under {tables_dir}")
|
||||
|
||||
per_table: list[dict] = []
|
||||
ids_by_space: dict[str, set[int]] = {}
|
||||
space_bucket: dict[str, str] = {}
|
||||
problems: list[str] = []
|
||||
|
||||
for path in files:
|
||||
name = path.stem
|
||||
data = load_table(path)
|
||||
rows = data.get("rows") or []
|
||||
columns = {c["name"] for c in data.get("schema", [])}
|
||||
rule = RULES.get(name)
|
||||
if rule is None:
|
||||
per_table.append({
|
||||
"table": name,
|
||||
"bucket": UNKNOWN,
|
||||
"rows": len(rows),
|
||||
"definition_ids": 0,
|
||||
"id_space": None,
|
||||
"reason": "NO CLASSIFICATION RULE — classify it in RULES before "
|
||||
"this dump can be trusted.",
|
||||
})
|
||||
continue
|
||||
|
||||
ids: set[int] = set()
|
||||
if rule.id_col is not None:
|
||||
if rule.id_col not in columns:
|
||||
problems.append(
|
||||
f"{name}: declared id column {rule.id_col!r} is absent from "
|
||||
f"the shipped schema"
|
||||
)
|
||||
ids = {r[rule.id_col] for r in rows if rule.id_col in r}
|
||||
space = rule.id_space
|
||||
assert space is not None, f"{name}: id column without id space"
|
||||
prior = space_bucket.setdefault(space, rule.bucket)
|
||||
if prior != rule.bucket:
|
||||
problems.append(
|
||||
f"id space {space!r} spans two buckets ({prior} and "
|
||||
f"{rule.bucket}); a shared id space must be one class"
|
||||
)
|
||||
ids_by_space.setdefault(space, set()).update(ids)
|
||||
|
||||
per_table.append({
|
||||
"table": name,
|
||||
"bucket": rule.bucket,
|
||||
"rows": len(rows),
|
||||
"definition_ids": len(ids),
|
||||
"id_space": rule.id_space,
|
||||
"reason": rule.reason,
|
||||
})
|
||||
|
||||
# Definition ids per bucket, deduplicated across shared id spaces.
|
||||
bucket_ids: dict[str, set[int]] = {b: set() for b in BUCKET_ORDER}
|
||||
for space, ids in ids_by_space.items():
|
||||
bucket_ids[space_bucket[space]].update(ids)
|
||||
|
||||
totals = {}
|
||||
for bucket in BUCKET_ORDER:
|
||||
entries = [e for e in per_table if e["bucket"] == bucket]
|
||||
totals[bucket] = {
|
||||
"tables": len(entries),
|
||||
"rows": sum(e["rows"] for e in entries),
|
||||
"definition_ids": len(bucket_ids[bucket]),
|
||||
}
|
||||
|
||||
collisions = []
|
||||
for space, (expected, note) in ID_SPACES.items():
|
||||
members = [e["table"] for e in per_table if e["id_space"] == space]
|
||||
counts = {
|
||||
e["table"]: e["definition_ids"]
|
||||
for e in per_table if e["id_space"] == space
|
||||
}
|
||||
union = len(ids_by_space.get(space, set()))
|
||||
collisions.append({
|
||||
"id_space": space,
|
||||
"tables": members,
|
||||
"per_table_ids": counts,
|
||||
"naive_sum": sum(counts.values()),
|
||||
"union": union,
|
||||
"expected_union": expected,
|
||||
"ok": union == expected,
|
||||
"note": note,
|
||||
})
|
||||
|
||||
checks = [{
|
||||
"check": "leaguelogo carddbid union",
|
||||
"ok": all(c["ok"] for c in collisions),
|
||||
"detail": "; ".join(
|
||||
f"{c['id_space']}: union={c['union']} expected={c['expected_union']} "
|
||||
f"naive_sum={c['naive_sum']}" for c in collisions
|
||||
),
|
||||
}]
|
||||
|
||||
settled_spaces = {
|
||||
RULES[t].id_space for t in SETTLED_DEFINITION_TABLES if t in RULES
|
||||
}
|
||||
settled_total = sum(len(ids_by_space.get(s, set())) for s in settled_spaces)
|
||||
checks.append({
|
||||
"check": "settled 16 definition tables total",
|
||||
"ok": settled_total == SETTLED_DEFINITION_ID_TOTAL,
|
||||
"detail": f"{settled_total} distinct ids across "
|
||||
f"{len(SETTLED_DEFINITION_TABLES)} tables "
|
||||
f"({len(settled_spaces)} id spaces); "
|
||||
f"expected {SETTLED_DEFINITION_ID_TOTAL}",
|
||||
})
|
||||
|
||||
asset_detail = []
|
||||
asset_ok = True
|
||||
for table, expected in CLUB_CARD_ASSET_IDS.items():
|
||||
path = tables_dir / f"{table}.json"
|
||||
if not path.exists():
|
||||
asset_ok = False
|
||||
asset_detail.append(f"{table}=MISSING")
|
||||
continue
|
||||
seen = {r.get("cardassetid") for r in load_table(path).get("rows") or []}
|
||||
ok = seen == {expected}
|
||||
asset_ok = asset_ok and ok
|
||||
asset_detail.append(
|
||||
f"{table}={expected}" if ok else f"{table}=UNEXPECTED{sorted(seen)}"
|
||||
)
|
||||
checks.append({
|
||||
"check": "constant cardassetid per club family",
|
||||
"ok": asset_ok,
|
||||
"detail": ", ".join(asset_detail),
|
||||
})
|
||||
|
||||
cons_ok = True
|
||||
if not consumables_path.exists():
|
||||
cons_ok = False
|
||||
cons_detail = f"{consumables_path} missing — cannot verify"
|
||||
else:
|
||||
with consumables_path.open(encoding="utf-8") as fh:
|
||||
known = {e["cardsubtypeid"] for e in json.load(fh)["subtypes"]}
|
||||
unlisted: set[int] = set()
|
||||
for table in CONSUMABLE_DEFINITION_TABLES:
|
||||
path = tables_dir / f"{table}.json"
|
||||
if not path.exists():
|
||||
cons_ok = False
|
||||
continue
|
||||
unlisted |= {
|
||||
r["cardsubtype"] for r in load_table(path).get("rows") or []
|
||||
} - known
|
||||
cons_ok = cons_ok and not unlisted
|
||||
cons_detail = (
|
||||
f"{len(known)} documented subtypes; unlisted in consumable "
|
||||
f"definition tables: {sorted(unlisted) if unlisted else 'none'}"
|
||||
)
|
||||
checks.append({
|
||||
"check": "consumable subtypes all documented",
|
||||
"ok": cons_ok,
|
||||
"detail": cons_detail,
|
||||
})
|
||||
|
||||
for problem in problems:
|
||||
checks.append({"check": "schema/id-space integrity", "ok": False,
|
||||
"detail": problem})
|
||||
|
||||
rules_without_table = sorted(
|
||||
set(RULES) - {e["table"] for e in per_table}
|
||||
)
|
||||
all_ids = set()
|
||||
for ids in ids_by_space.values():
|
||||
all_ids |= ids
|
||||
|
||||
return {
|
||||
"tables_dir": str(tables_dir),
|
||||
"table_count": len(per_table),
|
||||
"per_table": per_table,
|
||||
"totals": totals,
|
||||
"definition_ids_total": len(all_ids),
|
||||
"id_space_collisions": collisions,
|
||||
"checks": checks,
|
||||
"rules_without_shipped_table": rules_without_table,
|
||||
"trophy_note": TROPHY_NOTE,
|
||||
"unknown": totals[UNKNOWN]["tables"],
|
||||
"ok": totals[UNKNOWN]["tables"] == 0 and all(c["ok"] for c in checks),
|
||||
}
|
||||
|
||||
|
||||
def render(report: dict, verbose: bool) -> None:
|
||||
print(f"FIFA 17 definition coverage — {report['tables_dir']}")
|
||||
print(f"{report['table_count']} shipped tables\n")
|
||||
|
||||
for bucket in BUCKET_ORDER:
|
||||
entries = [e for e in report["per_table"] if e["bucket"] == bucket]
|
||||
if not entries:
|
||||
print(f"{bucket}: (none)\n")
|
||||
continue
|
||||
t = report["totals"][bucket]
|
||||
print(f"{bucket} — {t['tables']} tables, {t['rows']} rows, "
|
||||
f"{t['definition_ids']} distinct definition ids")
|
||||
detailed = bucket != PRESENTATION or verbose
|
||||
for e in sorted(entries, key=lambda e: (-e["definition_ids"], e["table"])):
|
||||
print(f" {e['table']:<32} rows={e['rows']:>6} "
|
||||
f"ids={e['definition_ids']:>6}"
|
||||
+ (f" space={e['id_space']}" if e["id_space"] else ""))
|
||||
if detailed:
|
||||
for line in wrap(e["reason"], 72):
|
||||
print(f" {line}")
|
||||
print()
|
||||
|
||||
print("ID-SPACE COLLISIONS")
|
||||
for c in report["id_space_collisions"]:
|
||||
per = ", ".join(f"{k}={v}" for k, v in sorted(c["per_table_ids"].items()))
|
||||
print(f" {c['id_space']}: {per}")
|
||||
print(f" naive sum={c['naive_sum']} UNION={c['union']} "
|
||||
f"expected={c['expected_union']} "
|
||||
f"{'OK' if c['ok'] else 'MISMATCH'}")
|
||||
for line in wrap(c["note"], 72):
|
||||
print(f" {line}")
|
||||
print()
|
||||
|
||||
print("TROPHIES")
|
||||
for line in wrap(report["trophy_note"], 74):
|
||||
print(f" {line}")
|
||||
print()
|
||||
|
||||
print("INVARIANT CHECKS")
|
||||
for c in report["checks"]:
|
||||
print(f" [{'PASS' if c['ok'] else 'FAIL'}] {c['check']}: {c['detail']}")
|
||||
print()
|
||||
|
||||
if report["rules_without_shipped_table"]:
|
||||
print("RULES WITH NO SHIPPED TABLE (informational — stale or "
|
||||
"guard entries)")
|
||||
for name in report["rules_without_shipped_table"]:
|
||||
print(f" {name}")
|
||||
print()
|
||||
|
||||
print("SUMMARY")
|
||||
for bucket in BUCKET_ORDER:
|
||||
t = report["totals"][bucket]
|
||||
print(f" {bucket:<24} {t['tables']:>4} tables "
|
||||
f"{t['definition_ids']:>6} definition ids")
|
||||
print(f" {'TOTAL':<24} {report['table_count']:>4} tables "
|
||||
f"{report['definition_ids_total']:>6} definition ids")
|
||||
print(f" UNKNOWN = {report['unknown']}")
|
||||
print(" RESULT: " + ("OK" if report["ok"] else "FAIL"))
|
||||
|
||||
|
||||
def wrap(text: str, width: int) -> list[str]:
|
||||
words = text.split()
|
||||
lines: list[str] = []
|
||||
cur = ""
|
||||
for w in words:
|
||||
if cur and len(cur) + 1 + len(w) > width:
|
||||
lines.append(cur)
|
||||
cur = w
|
||||
else:
|
||||
cur = f"{cur} {w}".strip()
|
||||
if cur:
|
||||
lines.append(cur)
|
||||
return lines
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
repo = Path(__file__).resolve().parent.parent
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("--tables", type=Path,
|
||||
default=repo / "fifa17-recon/data/tables",
|
||||
help="directory of dumped *.json definition tables")
|
||||
ap.add_argument("--consumables", type=Path,
|
||||
default=repo / "fifa17-recon/data/consumables.json",
|
||||
help="consumable subtype evidence file")
|
||||
ap.add_argument("--json", action="store_true",
|
||||
help="emit the machine-readable report on stdout")
|
||||
ap.add_argument("-v", "--verbose", action="store_true",
|
||||
help="also print the reason for every presentation table")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
report = classify(args.tables, args.consumables)
|
||||
if args.json:
|
||||
json.dump(report, sys.stdout, indent=2, sort_keys=False)
|
||||
sys.stdout.write("\n")
|
||||
else:
|
||||
render(report, args.verbose)
|
||||
return 0 if report["ok"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Runtime identity of the PRODUCTION FIFA-17 stack, shared by the staging
|
||||
lifecycle scripts.
|
||||
|
||||
This exists because both `sold-staging-up.py` and `sold-staging-down.py` need the
|
||||
same answer to the same safety question -- "what is production right now, and is
|
||||
it still healthy?" -- and two hand-maintained copies of a safety gate is two
|
||||
chances to rot.
|
||||
|
||||
Production runs in containers, so its pids are NOT stable facts: every pid changes
|
||||
when a container is restarted. A hardcoded pid list decays into the worst of both
|
||||
worlds -- a kill-refusal gate that guards nothing (the real production pids are no
|
||||
longer in it) and a liveness gate that fails a perfectly good teardown (the pids it
|
||||
does list are long dead). So pids and published ports are both resolved from the
|
||||
container runtime at the moment they are needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
# Production containers. Anything running inside one of these is production.
|
||||
PROD_CONTAINERS = ("openfut-fut-backend", "openfut-bridge-1", "openfut-core-1")
|
||||
|
||||
# Reserved ports: staging may never bind one of these, whether or not it is
|
||||
# currently published. 8199 (Python oracle) and 18080 (Core) belonged to the
|
||||
# retired host-process deployment and are kept so an old port map cannot be
|
||||
# silently reused by staging.
|
||||
PROD_PORTS = frozenset(
|
||||
{8080, 8081, 8085, 8094, 8099, 8199, 8443, 4216, 18080, 42127, 42130, 42131}
|
||||
)
|
||||
|
||||
|
||||
class ProductionError(RuntimeError):
|
||||
"""Production could not be observed, or is not healthy."""
|
||||
|
||||
|
||||
def _inspect(container: str, template: str) -> str:
|
||||
"""One `docker inspect -f` field.
|
||||
|
||||
Any failure is fatal by design: a script that cannot see production must
|
||||
refuse to signal anything rather than assume the best.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "inspect", "-f", template, container],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise ProductionError(
|
||||
f"cannot inspect production container {container!r}: {exc}"
|
||||
) from exc
|
||||
if result.returncode != 0:
|
||||
detail = result.stderr.strip() or f"docker exited {result.returncode}"
|
||||
raise ProductionError(
|
||||
f"cannot inspect production container {container!r}: {detail}"
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def listening_ports() -> set[int]:
|
||||
"""Ports in state LISTEN, from the kernel socket table.
|
||||
|
||||
A trial bind() would report EADDRINUSE for a stopped server's TIME_WAIT
|
||||
sockets and so wrongly claim a port is still served.
|
||||
"""
|
||||
ports: set[int] = set()
|
||||
for path in ("/proc/net/tcp", "/proc/net/tcp6"):
|
||||
try:
|
||||
with open(path) as fh:
|
||||
next(fh, None) # header
|
||||
for line in fh:
|
||||
fields = line.split()
|
||||
if len(fields) < 4 or fields[3] != "0A": # TCP_LISTEN
|
||||
continue
|
||||
ports.add(int(fields[1].rsplit(":", 1)[1], 16))
|
||||
except OSError:
|
||||
continue
|
||||
return ports
|
||||
|
||||
|
||||
class ProductionState:
|
||||
"""A snapshot of production as the container runtime reports it."""
|
||||
|
||||
__slots__ = ("pids", "published")
|
||||
|
||||
def __init__(self, pids: dict[int, str], published: dict[int, str]) -> None:
|
||||
self.pids = pids
|
||||
self.published = published
|
||||
|
||||
def describe(self) -> list[str]:
|
||||
return [f"{what} pid {pid}" for pid, what in sorted(self.pids.items())]
|
||||
|
||||
def assert_serving(self) -> None:
|
||||
"""Every port production publishes must actually be listening."""
|
||||
listening = listening_ports()
|
||||
silent = sorted(port for port in self.published if port not in listening)
|
||||
if silent:
|
||||
raise ProductionError(
|
||||
"production port(s) no longer listening: "
|
||||
+ ", ".join(f"{port} ({self.published[port]})" for port in silent)
|
||||
)
|
||||
|
||||
def assert_unchanged(self, before: "ProductionState") -> None:
|
||||
"""Production must be the same processes serving the same ports."""
|
||||
if self.pids != before.pids:
|
||||
raise ProductionError(
|
||||
f"production pids CHANGED: before={before.pids}, after={self.pids}"
|
||||
)
|
||||
if set(self.published) != set(before.published):
|
||||
raise ProductionError(
|
||||
"production published ports CHANGED: "
|
||||
f"before={sorted(before.published)}, after={sorted(self.published)}"
|
||||
)
|
||||
|
||||
|
||||
def production_state() -> ProductionState:
|
||||
"""Resolve production's current pids and published ports, proving every
|
||||
production container is running."""
|
||||
pids: dict[int, str] = {}
|
||||
published: dict[int, str] = {}
|
||||
for container in PROD_CONTAINERS:
|
||||
status = _inspect(container, "{{.State.Status}}")
|
||||
if status != "running":
|
||||
raise ProductionError(
|
||||
f"production container {container} is {status!r}, not running"
|
||||
)
|
||||
pid = int(_inspect(container, "{{.State.Pid}}") or 0)
|
||||
if pid <= 0:
|
||||
raise ProductionError(
|
||||
f"production container {container} is running but reports no pid"
|
||||
)
|
||||
pids[pid] = f"prod {container}"
|
||||
ports = _inspect(
|
||||
container,
|
||||
"{{range $port, $bindings := .NetworkSettings.Ports}}"
|
||||
"{{range $bindings}}{{.HostPort}} {{end}}{{end}}",
|
||||
)
|
||||
for field in ports.split():
|
||||
published[int(field)] = container
|
||||
return ProductionState(pids, published)
|
||||
@@ -10,10 +10,12 @@ ones. So there is no pattern matching here at all:
|
||||
* before any signal, /proc/<pid>/cmdline is read and MUST contain the staging
|
||||
directory -- production's cmdline never can, because staging runs binaries
|
||||
copied into that directory;
|
||||
* the known production pids are refused explicitly, as a second gate;
|
||||
* production's CURRENT pids, resolved from the container runtime, are refused
|
||||
explicitly as a second gate;
|
||||
* only the process GROUP the up script created (pgid == pid, via
|
||||
start_new_session) is signalled, so a responder thread/child cannot be orphaned;
|
||||
* afterwards every staging port is proven free and production is proven alive.
|
||||
* afterwards every staging port is proven free, and production is proven to be
|
||||
the same running containers serving the same ports as before.
|
||||
|
||||
python3 scripts/sold-staging-down.py
|
||||
python3 scripts/sold-staging-down.py --purge # also delete the staging dir
|
||||
@@ -29,20 +31,20 @@ import signal
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from openfut_production import ( # noqa: E402
|
||||
PROD_PORTS,
|
||||
ProductionError,
|
||||
listening_ports,
|
||||
production_state,
|
||||
)
|
||||
|
||||
DEFAULT_STAGING_DIR = "/home/alex/openfut-sold-staging"
|
||||
|
||||
FORBIDDEN_PATHS = ("/home/alex/openfut-promotion/state",)
|
||||
# Production processes that MUST be alive before and after this script runs. These
|
||||
# two are the ones the batch contract names, and they live in the host pid view.
|
||||
PROD_PIDS = {3631953: "prod utas-host", 3374264: "prod Core"}
|
||||
# Reported but not gated: container pids change when the operator restarts the
|
||||
# container, and a stale entry here would turn a successful teardown into a FATAL.
|
||||
PROD_PIDS_INFO = {2090886: "prod blaze", 2091170: "prod python oracle",
|
||||
2090888: "prod pow"}
|
||||
PROD_PORTS = (8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216, 8080, 8081, 8094)
|
||||
|
||||
|
||||
class Fatal(RuntimeError):
|
||||
class Fatal(ProductionError):
|
||||
pass
|
||||
|
||||
|
||||
@@ -87,37 +89,17 @@ def cmdline_of(pid: int) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def listening_ports() -> set[int]:
|
||||
"""Ports in state LISTEN, from the kernel socket table. A trial bind() would
|
||||
report EADDRINUSE for a stopped server's TIME_WAIT sockets and wrongly claim the
|
||||
teardown failed."""
|
||||
ports: set[int] = set()
|
||||
for path in ("/proc/net/tcp", "/proc/net/tcp6"):
|
||||
try:
|
||||
with open(path) as fh:
|
||||
next(fh, None) # header
|
||||
for line in fh:
|
||||
fields = line.split()
|
||||
if len(fields) < 4 or fields[3] != "0A": # TCP_LISTEN
|
||||
continue
|
||||
ports.add(int(fields[1].rsplit(":", 1)[1], 16))
|
||||
except OSError:
|
||||
continue
|
||||
return ports
|
||||
|
||||
|
||||
def port_free(port: int) -> bool:
|
||||
return port not in listening_ports()
|
||||
|
||||
|
||||
def stop_one(rec: dict, staging_dir: str) -> str:
|
||||
def stop_one(rec: dict, staging_dir: str, prod_pids: dict[int, str]) -> str:
|
||||
"""Stop exactly one recorded process. Returns a human-readable outcome."""
|
||||
name, pid = rec["name"], int(rec["pid"])
|
||||
|
||||
known_prod = {**PROD_PIDS, **PROD_PIDS_INFO}
|
||||
if pid in known_prod:
|
||||
if pid in prod_pids:
|
||||
raise Fatal(
|
||||
f"manifest entry {name} names PRODUCTION pid {pid} ({known_prod[pid]}). "
|
||||
f"manifest entry {name} names PRODUCTION pid {pid} ({prod_pids[pid]}). "
|
||||
"REFUSING to signal anything from this manifest."
|
||||
)
|
||||
if not pid_alive(pid):
|
||||
@@ -192,8 +174,14 @@ def main() -> int:
|
||||
)
|
||||
step(f"variant : {manifest.get('variant')}")
|
||||
|
||||
# Resolved BEFORE anything is signalled: the refusal gate below is only
|
||||
# meaningful if it knows production's pids as they are right now.
|
||||
before = production_state()
|
||||
for line in before.describe():
|
||||
step(f"production : {line}")
|
||||
|
||||
for rec in manifest.get("processes", []):
|
||||
ok(stop_one(rec, staging_dir))
|
||||
ok(stop_one(rec, staging_dir, before.pids))
|
||||
|
||||
banner("PROVE STAGING IS GONE")
|
||||
ports = manifest.get("ports", {})
|
||||
@@ -214,16 +202,13 @@ def main() -> int:
|
||||
ok("no recorded staging process is alive")
|
||||
|
||||
banner("PROVE PRODUCTION IS STILL UP")
|
||||
dead = [f"{what} pid {pid}" for pid, what in PROD_PIDS.items()
|
||||
if not pid_alive(pid)]
|
||||
for pid, what in PROD_PIDS.items():
|
||||
if pid_alive(pid):
|
||||
ok(f"{what} pid {pid} alive")
|
||||
if dead:
|
||||
raise Fatal("production process(es) NOT alive: " + ", ".join(dead))
|
||||
for pid, what in PROD_PIDS_INFO.items():
|
||||
state = "alive" if pid_alive(pid) else "not found (informational only)"
|
||||
step(f"{what} pid {pid} {state}")
|
||||
after = production_state()
|
||||
for line in after.describe():
|
||||
ok(f"{line} alive")
|
||||
after.assert_unchanged(before)
|
||||
after.assert_serving()
|
||||
ok(f"all {len(after.published)} published production ports still listening: "
|
||||
+ ", ".join(str(port) for port in sorted(after.published)))
|
||||
|
||||
if args.purge:
|
||||
shutil.rmtree(safe_path(staging_dir), ignore_errors=True)
|
||||
@@ -243,7 +228,7 @@ def main() -> int:
|
||||
print()
|
||||
print(" then RELAUNCH the FIFA 17 client. See docs/SOLD_STAGING_RUNBOOK.md.")
|
||||
return 0
|
||||
except Fatal as exc:
|
||||
except ProductionError as exc:
|
||||
print(f"\nFATAL: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
@@ -21,11 +21,20 @@ answers `{"id": 0}` and does NOT echo the squad.
|
||||
|
||||
python3 scripts/sold-staging-seed-squad.py # seed, then verify
|
||||
python3 scripts/sold-staging-seed-squad.py --show # read-only
|
||||
|
||||
This is a FIXTURE tool. `PUT /squad/0` is a FULL REPLACEMENT, so running it against
|
||||
the operator's real imported club would overwrite that club's own squad with an
|
||||
auto-picked XI -- a destructive, unrecoverable-in-place edit of real data. It
|
||||
therefore refuses to run when the staging manifest says the real club is installed,
|
||||
unless `--force` is given. `--show` stays available in every mode.
|
||||
"""
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
STAGING_MANIFEST = "/home/alex/openfut-sold-staging/manifest.json"
|
||||
|
||||
HOST, PORT = "127.0.0.1", 8299
|
||||
FORBIDDEN = {8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216}
|
||||
HEADERS = {"X-OpenFUT-Game": "fifa17", "Content-Type": "application/json"}
|
||||
@@ -153,11 +162,37 @@ def show():
|
||||
return len(filled)
|
||||
|
||||
|
||||
def refuse_on_real_club():
|
||||
"""`PUT /squad/0` replaces the whole squad. Against the real club that destroys
|
||||
the operator's own lineup, so the fixture seeder must not be runnable there by
|
||||
accident -- the staging manifest records which club is installed."""
|
||||
if "--force" in sys.argv:
|
||||
print(" --force: seeding over the installed club as instructed")
|
||||
return
|
||||
try:
|
||||
with open(STAGING_MANIFEST) as fh:
|
||||
manifest = json.load(fh)
|
||||
except FileNotFoundError:
|
||||
return # no manifest: nothing claims a real club is installed
|
||||
if manifest.get("club") != "real":
|
||||
return
|
||||
club = manifest.get("real_club") or {}
|
||||
raise SystemExit(
|
||||
"REFUSING: staging is running the operator's REAL club "
|
||||
f"({club.get('username')}, {club.get('owned_cards')} items) and "
|
||||
"PUT /squad/0 is a FULL REPLACEMENT -- this would overwrite the real squad "
|
||||
f"({club.get('squad_players')} players) with an auto-picked XI.\n"
|
||||
" Read it instead: python3 scripts/sold-staging-seed-squad.py --show\n"
|
||||
" Override only if you mean it: --force"
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
print("=== staging squad, before ===")
|
||||
before = show()
|
||||
if "--show" in sys.argv:
|
||||
return 0
|
||||
refuse_on_real_club()
|
||||
players = club_players()
|
||||
print(f"=== owned players available: {len(players)} ===")
|
||||
chosen, missing = pick_xi(players)
|
||||
|
||||
+713
-95
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Attribute captured UTAS requests to labelled UI actions, and diff them.
|
||||
|
||||
utas-filter-diff.py --session <dir> [--baseline NO_FILTER]
|
||||
|
||||
The My Squad filter investigation needs to answer "which wire field changed
|
||||
when I changed exactly one thing in the UI". That is a diff between labelled
|
||||
groups of requests, so this tool needs both halves:
|
||||
|
||||
raw/utas.ofcap what the client sent
|
||||
labels.txt MARKER <label> <HH:MM:SS> UTC lines, written when the human
|
||||
said a search was done
|
||||
|
||||
Requests are attributed to the label whose marker most recently PRECEDES them.
|
||||
Anything before the first marker is 'boot'.
|
||||
|
||||
Why a separate tool: the observer must stay a dumb, byte-faithful tee. Anything
|
||||
that interprets traffic belongs outside it, so a mistake here can never affect
|
||||
what was recorded.
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import collections
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from urllib.parse import parse_qsl
|
||||
|
||||
|
||||
def load(session):
|
||||
"""Rebuild per-connection streams, keeping the wall time of each chunk."""
|
||||
path = os.path.join(session, "raw", "utas.ofcap")
|
||||
conns = collections.defaultdict(
|
||||
lambda: {"c2s": bytearray(), "s2c": bytearray(), "marks": []}
|
||||
)
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
r = json.loads(line)
|
||||
if "b64" not in r:
|
||||
continue
|
||||
c = conns[r["conn"]]
|
||||
data = base64.b64decode(r["b64"])
|
||||
if r["dir"] == "c2s":
|
||||
c["marks"].append((len(c["c2s"]), r["unix"]))
|
||||
c[r["dir"]].extend(data)
|
||||
return conns
|
||||
|
||||
|
||||
def labels(session):
|
||||
out = []
|
||||
p = os.path.join(session, "labels.txt")
|
||||
if not os.path.exists(p):
|
||||
return out
|
||||
import datetime
|
||||
for line in open(p):
|
||||
m = re.match(r"MARKER\s+(\S+)\s+(\d\d):(\d\d):(\d\d)\s+UTC", line.strip())
|
||||
if m:
|
||||
name, h, mi, s = m.group(1), *map(int, m.groups()[1:])
|
||||
out.append((name, h * 3600 + mi * 60 + s))
|
||||
return out
|
||||
|
||||
|
||||
def split_requests(buf):
|
||||
"""(offset, method, target, headers, body) per request in a stream."""
|
||||
out, i = [], 0
|
||||
while True:
|
||||
sep = buf.find(b"\r\n\r\n", i)
|
||||
if sep < 0:
|
||||
break
|
||||
head = bytes(buf[i:sep]).decode("latin1")
|
||||
line0 = head.split("\r\n")[0]
|
||||
m = re.match(r"(\S+)\s+(\S+)\s+HTTP/", line0)
|
||||
if not m:
|
||||
break
|
||||
hdrs = [h.split(":", 1) for h in head.split("\r\n")[1:] if ":" in h]
|
||||
hdrs = [(k.strip(), v.strip()) for k, v in hdrs]
|
||||
cl = next((int(v) for k, v in hdrs if k.lower() == "content-length" and v.isdigit()), 0)
|
||||
start = sep + 4
|
||||
body = bytes(buf[start:start + cl])
|
||||
out.append((i, m.group(1), m.group(2), hdrs, body))
|
||||
i = start + cl
|
||||
return out
|
||||
|
||||
|
||||
def split_responses(buf):
|
||||
out, i = [], 0
|
||||
while True:
|
||||
sep = buf.find(b"\r\n\r\n", i)
|
||||
if sep < 0:
|
||||
break
|
||||
head = bytes(buf[i:sep]).decode("latin1")
|
||||
st = re.match(r"HTTP/\d\.\d\s+(\d+)", head)
|
||||
cl = re.search(r"(?im)^content-length:\s*(\d+)\s*$", head)
|
||||
te = re.search(r"(?im)^transfer-encoding:.*chunked", head)
|
||||
start = sep + 4
|
||||
if te:
|
||||
j, body = start, bytearray()
|
||||
while True:
|
||||
nl = buf.find(b"\r\n", j)
|
||||
if nl < 0:
|
||||
return out
|
||||
try:
|
||||
n = int(bytes(buf[j:nl]).split(b";")[0], 16)
|
||||
except ValueError:
|
||||
return out
|
||||
j = nl + 2
|
||||
if n == 0:
|
||||
j = buf.find(b"\r\n", j)
|
||||
j = j + 2 if j >= 0 else len(buf)
|
||||
break
|
||||
body.extend(buf[j:j + n])
|
||||
j += n + 2
|
||||
out.append((int(st.group(1)) if st else None, bytes(body)))
|
||||
i = j
|
||||
elif cl:
|
||||
n = int(cl.group(1))
|
||||
out.append((int(st.group(1)) if st else None, bytes(buf[start:start + n])))
|
||||
i = start + n
|
||||
else:
|
||||
out.append((int(st.group(1)) if st else None, b""))
|
||||
i = start
|
||||
return out
|
||||
|
||||
|
||||
def time_at(marks, off):
|
||||
t = marks[0][1] if marks else 0
|
||||
for pos, unix in marks:
|
||||
if pos > off:
|
||||
break
|
||||
t = unix
|
||||
return t
|
||||
|
||||
|
||||
def item_ids(body):
|
||||
"""Item ids in a response, if it looks like an item list."""
|
||||
try:
|
||||
d = json.loads(body)
|
||||
except Exception:
|
||||
return None
|
||||
ids = []
|
||||
|
||||
def walk(o):
|
||||
if isinstance(o, dict):
|
||||
if "id" in o and isinstance(o["id"], int):
|
||||
ids.append(o["id"])
|
||||
for v in o.values():
|
||||
walk(v)
|
||||
elif isinstance(o, list):
|
||||
for v in o:
|
||||
walk(v)
|
||||
|
||||
walk(d)
|
||||
return ids
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--session", required=True)
|
||||
ap.add_argument("--baseline", default="NO_FILTER")
|
||||
a = ap.parse_args()
|
||||
|
||||
import datetime
|
||||
conns = load(a.session)
|
||||
marks = labels(a.session)
|
||||
|
||||
rows = []
|
||||
for cid in sorted(conns):
|
||||
c = conns[cid]
|
||||
reqs = split_requests(c["c2s"])
|
||||
resps = split_responses(c["s2c"])
|
||||
for i, (off, method, target, hdrs, body) in enumerate(reqs):
|
||||
unix = time_at(c["marks"], off)
|
||||
secs = datetime.datetime.utcfromtimestamp(unix)
|
||||
secs = secs.hour * 3600 + secs.minute * 60 + secs.second
|
||||
label = "boot"
|
||||
for name, at in marks:
|
||||
if at <= secs:
|
||||
label = name
|
||||
status, rbody = resps[i] if i < len(resps) else (None, b"")
|
||||
path, _, query = target.partition("?")
|
||||
ids = item_ids(rbody)
|
||||
rows.append({
|
||||
"label": label,
|
||||
"time": datetime.datetime.utcfromtimestamp(unix).strftime("%H:%M:%S"),
|
||||
"conn": cid, "method": method, "path": path,
|
||||
"query": dict(parse_qsl(query, keep_blank_values=True)) if query else {},
|
||||
"query_raw": query,
|
||||
"req_body": body.decode("latin1") if len(body) < 2000 else "<%dB>" % len(body),
|
||||
"status": status,
|
||||
"resp_len": len(rbody),
|
||||
"resp_sha": hashlib.sha256(rbody).hexdigest()[:12],
|
||||
"item_count": len(ids) if ids is not None else None,
|
||||
"item_ids": ids[:40] if ids else None,
|
||||
})
|
||||
|
||||
print("=== requests by label ===")
|
||||
for r in rows:
|
||||
if r["label"] == "boot":
|
||||
continue
|
||||
print("%-18s %s conn%-3d %-5s %-46s %s %5dB sha=%s items=%s"
|
||||
% (r["label"], r["time"], r["conn"], r["method"], r["path"][:46],
|
||||
r["status"], r["resp_len"], r["resp_sha"],
|
||||
r["item_count"] if r["item_count"] is not None else "-"))
|
||||
if r["query"]:
|
||||
print("%-18s query: %s" % ("", r["query"]))
|
||||
if r["req_body"].strip():
|
||||
print("%-18s body : %s" % ("", r["req_body"][:200]))
|
||||
|
||||
# Field-level diff against the baseline label.
|
||||
base = [r for r in rows if r["label"] == a.baseline]
|
||||
if not base:
|
||||
print("\n(no %s rows yet; skipping diff)" % a.baseline)
|
||||
return 0
|
||||
print("\n=== query-field diff vs %s ===" % a.baseline)
|
||||
bq = base[-1]["query"]
|
||||
bpath = base[-1]["path"]
|
||||
seen = set()
|
||||
for r in rows:
|
||||
if r["label"] in ("boot", a.baseline) or r["label"] in seen:
|
||||
continue
|
||||
seen.add(r["label"])
|
||||
added = {k: v for k, v in r["query"].items() if bq.get(k) != v}
|
||||
removed = {k: v for k, v in bq.items() if k not in r["query"]}
|
||||
note = "" if r["path"] == bpath else " PATH DIFFERS: %s" % r["path"]
|
||||
print(" %-18s +%s -%s%s" % (r["label"], added or "{}", removed or "{}", note))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,5 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# OpenFUT Setup Script
|
||||
# OpenFUT Setup Script — ⚠️ LEGACY (FIFA 23 core+bridge lineage)
|
||||
#
|
||||
# SUPERSEDED. The working target is FIFA 17. This script builds/starts the old
|
||||
# FIFA 23 core+bridge stack and redirects EA domains for FIFA 23. Do NOT use it
|
||||
# for the current server. Canonical server bring-up (FIFA 17):
|
||||
# cd fifa17-recon/docker/fifa17-python && docker compose up -d
|
||||
# Status: docs/PROJECT_STATE.md · Runbook: fifa17-recon/FUT-RUNBOOK.md
|
||||
#
|
||||
# Builds, configures, and starts the OpenFUT offline FUT emulator for FIFA 23.
|
||||
# Run as a regular user; the script will sudo only for hosts/cert/iptables steps.
|
||||
|
||||
@@ -407,6 +414,11 @@ EOF
|
||||
}
|
||||
|
||||
# ── Dispatch ───────────────────────────────────────────────────────────────────
|
||||
# --- Legacy guard: this is the superseded FIFA 23 flow (see header banner). ---
|
||||
echo "[LEGACY] setup.sh drives the superseded FIFA 23 core+bridge stack." >&2
|
||||
echo "[LEGACY] Canonical FIFA 17 server: cd fifa17-recon/docker/fifa17-python && docker compose up -d" >&2
|
||||
echo "[LEGACY] Status: docs/PROJECT_STATE.md · Runbook: fifa17-recon/FUT-RUNBOOK.md" >&2
|
||||
|
||||
case "${1:-help}" in
|
||||
quickstart) cmd_quickstart ;;
|
||||
build) cmd_build ;;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# OpenFUT FIFA 17 - native Windows client
|
||||
|
||||
The FIFA 17 client host (`10.10.0.105`, Windows 11 Pro) runs FIFA 17 **natively**
|
||||
(no Wine/Proton/umu). This directory holds the read-only preflight verifier and
|
||||
documents the native launch/routing/rollback model.
|
||||
|
||||
## Install layout (`C:\FIFA 17`)
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `FIFA17.exe` | retail game exe (sha256 `29C31CEF…`). **Never modify/patch.** ImageBase `0x140000000`. |
|
||||
| `_fifa17.exe` | native crack loader (Chemicalflood). This is what you launch. `asInvoker` manifest -> must be elevated externally. |
|
||||
| `version.dll` | **OpenFUT hook** (in-process via the version.dll load-order hijack). ImageBase `0x180000000`. |
|
||||
| `version.dll.stale-849k.bak` | **rollback** copy of the previous hook. |
|
||||
| `CardsDLL_Win64_retail.dll` | FUT card/SBC/kit logic. ImageBase `0x180000000`. |
|
||||
| `powdll_Win64_retail.dll` | Pack-Opening-World (EASFC store). ImageBase `0x180000000`. |
|
||||
| `sysdll_Win64_retail.dll` | EA networking / ProtoSSL (cert, ea.com). |
|
||||
| `stp-origin_emu.dll` + `stp-origin_emu.ini` | Origin/LSX login emulator (in-process; opens LSX `:4216` locally at runtime). Persona configured in the `.ini`. |
|
||||
| `stp-selector.exe` | ssl/LSX selector companion. |
|
||||
| `openfut.cfg` | operator-facing routing override (see below). |
|
||||
|
||||
## Launch (native - there is NO launcher script by design)
|
||||
|
||||
Run `C:\FIFA 17\_fifa17.exe` **as Administrator**. The correct, reproducible way:
|
||||
|
||||
- Double-click the **"FIFA 17 (OpenFUT)"** shortcut (Desktop and Start Menu).
|
||||
It targets `_fifa17.exe`, working dir `C:\FIFA 17`, with the RunAsAdmin bit set.
|
||||
- `_fifa17.exe` is also flagged `RUNASADMIN` in
|
||||
`HKCU\Software\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Layers`,
|
||||
so any launch (Explorer double-click included) elevates via UAC.
|
||||
|
||||
On launch the Windows loader maps `version.dll` from the game directory (hijack),
|
||||
`stp-origin_emu.dll` emulates Origin login for the configured persona, and the
|
||||
hook redirects EA endpoints to the OpenFUT backend.
|
||||
|
||||
> Do **not** wrap the launch in a script. The elevation + shortcut is the
|
||||
> supported mechanism. FIFA under native Windows also ignores synthetic input,
|
||||
> so in-game steps are performed by the operator one at a time.
|
||||
|
||||
### OpenFUT Launcher (GUI)
|
||||
|
||||
The `openfut-launcher` egui app runs natively on Windows (built for
|
||||
`x86_64-pc-windows-gnu`; the Linux Proton path is `cfg`-gated out). It is the
|
||||
one-button front end: it checks the backend, reconciles `openfut.cfg` from its
|
||||
settings, and starts the game.
|
||||
|
||||
- Binary: `C:\OpenFUT\openfut-launcher.exe`; config: `%APPDATA%\openfut-launcher\config.json`.
|
||||
- Launch it from the **"OpenFUT Launcher"** shortcut (Desktop / Start Menu). The
|
||||
launcher itself is flagged `RUNASADMIN`, so it elevates once at start; the game
|
||||
it spawns inherits that elevation (no second UAC prompt, no crack loader
|
||||
"run as admin" failure).
|
||||
- On Windows the launcher does NOT spawn LSX/autopatch (they are in-process:
|
||||
`stp-origin_emu.dll` + the `version.dll` hook) and does NOT arm the host
|
||||
(routing is purely `openfut.cfg`, which it writes into `C:\FIFA 17`).
|
||||
- Rebuild from Linux: `cargo build -p openfut-launcher --release --target x86_64-pc-windows-gnu`.
|
||||
|
||||
## Routing (`openfut.cfg`)
|
||||
|
||||
```
|
||||
host=10.10.0.120
|
||||
https_port=8443
|
||||
blaze_redirector_port=42127
|
||||
blaze_main_port=42130
|
||||
```
|
||||
|
||||
The hook carries `10.10.0.120` as its baked-in default; `openfut.cfg` is the
|
||||
override. `10.10.0.120` hosts **both** production and staging.
|
||||
|
||||
> **Production safety:** `blaze_main_port=42130` is the **production** Blaze.
|
||||
> Before any match/economy exercise, repoint `blaze_main_port` (and the matching
|
||||
> UTAS/HTTPS route) to the staging port so no traffic reaches the prod container.
|
||||
> The preflight raises a WARN whenever `42130` is configured.
|
||||
|
||||
## Rollback
|
||||
|
||||
The hook is a single file swap; no installer state.
|
||||
|
||||
```powershell
|
||||
# disable OpenFUT hook (restore previous DLL)
|
||||
Copy-Item 'C:\FIFA 17\version.dll' 'C:\FIFA 17\version.dll.disabled.bak' -Force
|
||||
Copy-Item 'C:\FIFA 17\version.dll.stale-849k.bak' 'C:\FIFA 17\version.dll' -Force
|
||||
# re-arm: copy the desired hook build over version.dll again
|
||||
```
|
||||
|
||||
Always keep a `*.bak` of the live hook before redeploying (the preflight checks
|
||||
that a rollback backup exists and differs from the live DLL).
|
||||
|
||||
## Preflight
|
||||
|
||||
`openfut-client-preflight.ps1` is **read-only**: it never launches the game,
|
||||
never elevates, never writes game files, never mutates economy state. It verifies
|
||||
the retail exe hash, companion DLLs, hook + rollback, routing + backend
|
||||
reachability, login persona, launcher elevation, and the RE toolchain
|
||||
(x64dbg, cargo). Exit 0 = OK, 1 = blocking failure.
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File .\openfut-client-preflight.ps1
|
||||
```
|
||||
|
||||
## Runtime RE (x64dbg)
|
||||
|
||||
See the Vault note **`02 Reverse Engineering/FIFA 17/Windows Client Runtime & x64dbg.md`**
|
||||
for the attach workflow and the RVA<->VA (ASLR) math. In short: these modules'
|
||||
preferred ImageBase is `0x180000000` (`0x140000000` for `FIFA17.exe`); in x64dbg
|
||||
a module name evaluates to its runtime (ASLR) base, so a Ghidra address maps to a
|
||||
breakpoint as `bp CardsDLL_Win64_retail.dll+<RVA>` where
|
||||
`RVA = ghidra_addr - 0x180000000`.
|
||||
@@ -0,0 +1,129 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
OpenFUT FIFA 17 Windows client preflight - READ ONLY.
|
||||
|
||||
.DESCRIPTION
|
||||
Non-destructive verification of the native Windows FIFA 17 OpenFUT client on
|
||||
this machine. It NEVER launches the game, never elevates, never writes to
|
||||
game files, and never mutates any economy state. It only reads files,
|
||||
registry, and performs TCP connect probes to the configured backend.
|
||||
|
||||
Exit code 0 = all PASS/WARN, 1 = one or more FAIL.
|
||||
|
||||
Native launch model (there is NO launcher script by design):
|
||||
run C:\FIFA 17\_fifa17.exe as Administrator
|
||||
(use the "FIFA 17 (OpenFUT)" shortcut, which carries the RunAsAdmin bit).
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$FifaRoot = 'C:\FIFA 17'
|
||||
)
|
||||
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
|
||||
# --- known-good fingerprints -------------------------------------------------
|
||||
# Retail executable MUST NOT be modified/patched (Denuvo + anti-cheat sensitive).
|
||||
$EXPECT_FIFA17_EXE_SHA256 = '29C31CEF12B0C3C2A7305220617C7B4FA139AB76B8C857851BDBE88987962899'
|
||||
# Currently deployed OpenFUT hook (base-supply + VEH build). Update on redeploy.
|
||||
$EXPECT_HOOK_SHA256 = '8844A6BCEE7BE37DD55B989246B312AF52FA711CCBC1220D25FEC64CBAD077D8'
|
||||
$ROLLBACK_BAK = Join-Path $FifaRoot 'version.dll.stale-849k.bak'
|
||||
|
||||
$script:fail = 0
|
||||
function Say([string]$level, [string]$msg) {
|
||||
switch ($level) {
|
||||
'PASS' { $c = 'Green' }
|
||||
'WARN' { $c = 'Yellow' }
|
||||
'FAIL' { $c = 'Red'; $script:fail++ }
|
||||
default { $c = 'Gray' }
|
||||
}
|
||||
Write-Host ('[{0}] {1}' -f $level, $msg) -ForegroundColor $c
|
||||
}
|
||||
function Sha([string]$p) { if (Test-Path $p) { (Get-FileHash $p -Algorithm SHA256).Hash } else { $null } }
|
||||
|
||||
Write-Host '=== OpenFUT FIFA 17 Windows client preflight (read-only) ===' -ForegroundColor Cyan
|
||||
Say 'INFO' ("host={0} user={1} {2}" -f $env:COMPUTERNAME, $env:USERNAME, (Get-CimInstance Win32_OperatingSystem).Caption)
|
||||
|
||||
# --- 1. FIFA install + retail exe integrity ---------------------------------
|
||||
$exe = Join-Path $FifaRoot 'FIFA17.exe'
|
||||
$loader = Join-Path $FifaRoot '_fifa17.exe'
|
||||
if (Test-Path $exe) {
|
||||
$h = Sha $exe
|
||||
if ($h -eq $EXPECT_FIFA17_EXE_SHA256) { Say 'PASS' "FIFA17.exe present and unmodified ($($h.Substring(0,16))...)" }
|
||||
else { Say 'FAIL' "FIFA17.exe hash MISMATCH - retail exe changed! got $($h.Substring(0,16))... expected $($EXPECT_FIFA17_EXE_SHA256.Substring(0,16))..." }
|
||||
} else { Say 'FAIL' "FIFA17.exe missing at $exe" }
|
||||
if (Test-Path $loader) { Say 'PASS' "native loader _fifa17.exe present ($((Sha $loader).Substring(0,16))...)" }
|
||||
else { Say 'FAIL' "_fifa17.exe (native loader) missing - cannot launch" }
|
||||
|
||||
# --- 2. companion DLLs -------------------------------------------------------
|
||||
$companions = 'CardsDLL_Win64_retail.dll','powdll_Win64_retail.dll','sysdll_Win64_retail.dll',
|
||||
'FootballCompEng_Win64_retail.dll','stp-origin_emu.dll','stp-selector.exe'
|
||||
foreach ($c in $companions) {
|
||||
$p = Join-Path $FifaRoot $c
|
||||
if (Test-Path $p) { Say 'PASS' "companion present: $c" } else { Say 'FAIL' "companion MISSING: $c" }
|
||||
}
|
||||
|
||||
# --- 3. OpenFUT hook (version.dll) + rollback backup ------------------------
|
||||
$hook = Join-Path $FifaRoot 'version.dll'
|
||||
if (Test-Path $hook) {
|
||||
$hh = Sha $hook
|
||||
if ($hh -eq $EXPECT_HOOK_SHA256) { Say 'PASS' "hook version.dll deployed (expected build $($hh.Substring(0,16))...)" }
|
||||
else { Say 'WARN' "hook version.dll present but hash differs from recorded build ($($hh.Substring(0,16))...) - may be a newer/older hook" }
|
||||
$bytes = [IO.File]::ReadAllBytes($hook); $ascii = [Text.Encoding]::ASCII.GetString($bytes)
|
||||
$markers = @('OpenFUT','fifa17','CardsDLL','SBC_DISPATCH') | Where-Object { $ascii -match [regex]::Escape($_) }
|
||||
if ($markers.Count -ge 3) { Say 'PASS' "hook markers found: $($markers -join ', ')" }
|
||||
else { Say 'WARN' "hook markers thin: $($markers -join ', ') - is this the OpenFUT hook?" }
|
||||
} else { Say 'FAIL' "hook version.dll NOT deployed - client will run vanilla (no OpenFUT)" }
|
||||
if (Test-Path $ROLLBACK_BAK) {
|
||||
if ((Sha $ROLLBACK_BAK) -ne (Sha $hook)) { Say 'PASS' "rollback backup present and differs from live: $(Split-Path $ROLLBACK_BAK -Leaf)" }
|
||||
else { Say 'WARN' "rollback backup equals live version.dll - rollback would be a no-op" }
|
||||
} else { Say 'WARN' "no rollback backup ($(Split-Path $ROLLBACK_BAK -Leaf)) - keep one before redeploying the hook" }
|
||||
|
||||
# --- 4. routing config + backend reachability -------------------------------
|
||||
$cfg = Join-Path $FifaRoot 'openfut.cfg'
|
||||
$host120 = $null; $ports = @()
|
||||
if (Test-Path $cfg) {
|
||||
$kv = @{}; foreach ($l in Get-Content $cfg) { if ($l -match '^\s*([^=#]+)=(.+)$') { $kv[$matches[1].Trim()] = $matches[2].Trim() } }
|
||||
$host120 = $kv['host']
|
||||
Say 'PASS' "openfut.cfg routing: host=$($kv['host']) https=$($kv['https_port']) redirector=$($kv['blaze_redirector_port']) blaze=$($kv['blaze_main_port'])"
|
||||
foreach ($k in 'https_port','blaze_redirector_port','blaze_main_port') { if ($kv[$k]) { $ports += [int]$kv[$k] } }
|
||||
if ($kv['blaze_main_port'] -eq '42130') { Say 'WARN' 'blaze_main_port=42130 targets PRODUCTION - repoint to a staging port before match/economy testing' }
|
||||
} else { Say 'WARN' "openfut.cfg absent - hook uses its baked-in default host" }
|
||||
if ($host120) {
|
||||
foreach ($p in $ports) {
|
||||
$t = New-Object Net.Sockets.TcpClient
|
||||
try {
|
||||
$ar = $t.BeginConnect($host120, $p, $null, $null)
|
||||
if ($ar.AsyncWaitHandle.WaitOne(2500) -and $t.Connected) { Say 'PASS' "backend reachable ${host120}:$p" }
|
||||
else { Say 'FAIL' "backend UNREACHABLE ${host120}:$p" }
|
||||
} catch { Say 'FAIL' "backend probe error ${host120}:$p - $($_.Exception.Message)" } finally { $t.Close() }
|
||||
}
|
||||
}
|
||||
|
||||
# --- 5. login persona (stp origin emulator) ---------------------------------
|
||||
$ini = Join-Path $FifaRoot 'stp-origin_emu.ini'
|
||||
if (Test-Path $ini) {
|
||||
$persona = (Get-Content $ini | Select-String 'PersonaId|PersonaName') -join ' '
|
||||
Say 'PASS' "login emulator config: $persona"
|
||||
} else { Say 'FAIL' "stp-origin_emu.ini missing - no login persona" }
|
||||
|
||||
# --- 6. native launcher elevation setup -------------------------------------
|
||||
$lk = 'HKCU:\Software\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Layers'
|
||||
$layer = (Get-ItemProperty $lk).$loader
|
||||
if ($layer -match 'RUNASADMIN') { Say 'PASS' "_fifa17.exe RUNASADMIN flag set ($layer)" }
|
||||
else { Say 'WARN' '_fifa17.exe has no RUNASADMIN flag - launch must be manual "Run as administrator"' }
|
||||
$sc = Join-Path $env:USERPROFILE 'Desktop\FIFA 17 (OpenFUT).lnk'
|
||||
if (Test-Path $sc) { Say 'PASS' "desktop launcher shortcut present: $(Split-Path $sc -Leaf)" }
|
||||
else { Say 'WARN' 'no desktop launcher shortcut' }
|
||||
|
||||
# --- 7. RE toolchain ---------------------------------------------------------
|
||||
$x = Get-ChildItem "$env:LOCALAPPDATA\Microsoft\WinGet\Packages" -Recurse -Depth 4 -Include x64dbg.exe -Attributes !ReparsePoint -EA SilentlyContinue | Select-Object -First 1 -Expand FullName
|
||||
if (-not $x) { $x = (Get-Command x64dbg.exe -EA SilentlyContinue).Source }
|
||||
if ($x) { Say 'PASS' "x64dbg present: $x (v$((Get-Item $x).VersionInfo.FileVersion))" } else { Say 'WARN' 'x64dbg not located - install for runtime RE' }
|
||||
$cargo = (Get-Command cargo -EA SilentlyContinue).Source
|
||||
if ($cargo) { Say 'PASS' "rust toolchain: $cargo" } else { Say 'WARN' 'cargo not found - needed to rebuild the hook natively' }
|
||||
|
||||
# --- summary -----------------------------------------------------------------
|
||||
Write-Host ''
|
||||
if ($script:fail -eq 0) { Write-Host 'PREFLIGHT: PASS (no blocking failures)' -ForegroundColor Green; exit 0 }
|
||||
else { Write-Host "PREFLIGHT: FAIL ($script:fail blocking issue(s))" -ForegroundColor Red; exit 1 }
|
||||
Reference in New Issue
Block a user