Compare commits
172 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e14d3cd063 | |||
| f4fc832ace | |||
| 6aab279244 | |||
| d3451be17b | |||
| 0300af3333 | |||
| 2c572e918f | |||
| f608dbc438 | |||
| 43c460741b | |||
| 5eed124b85 | |||
| e3ed8c298e | |||
| 5181e103dc | |||
| 433a9b22dd | |||
| 0701ac94e1 | |||
| 025122ec9a | |||
| b91e707a7e | |||
| c3d0e56f69 | |||
| e7893a0162 | |||
| a2b0c32a70 | |||
| e49c1f211c | |||
| 96f24e799a | |||
| f315f16e8e | |||
| ead0426ea0 | |||
| 74768693ec | |||
| 6bbc0eaf4f | |||
| 09bb2dc306 | |||
| 42229e5782 | |||
| d929efdffe | |||
| 98f30931a0 | |||
| a5e5628039 | |||
| 0e200758f0 | |||
| 2fc335c37d | |||
| 25b7089c54 | |||
| 8983998707 | |||
| 96610ccb16 | |||
| 704482be84 | |||
| 0997dd3b60 | |||
| 743b20b00b | |||
| e48d659bce | |||
| a86d21ec79 | |||
| f40e8587ac | |||
| 5bf2c7ddc1 | |||
| d146f9c3fc | |||
| d4a39ba75d | |||
| 9cc5188e5c | |||
| 6baa673252 | |||
| eefa98c961 | |||
| 88ae754b0f | |||
| e0f1e379b7 | |||
| 6d89d62e41 | |||
| 40e53ed02c | |||
| b55dd16a46 | |||
| 11b7991d81 | |||
| e18304d365 | |||
| 8614acff57 | |||
| 3ff09ae62c | |||
| 34be38260d | |||
| 1e0124c197 | |||
| 286a44461d | |||
| 8c353c6c66 | |||
| 3a038fe406 | |||
| 3bb6b4fc9d | |||
| a9bac8be8e | |||
| 3c28b0d1af | |||
| 4936654f84 | |||
| 4156dc5810 | |||
| fc1fdcc5ab | |||
| 1e8d46b258 | |||
| 8ae432223a | |||
| 9026220533 | |||
| f0c6dcf238 | |||
| 6c97bc4e2b | |||
| 3c67fea074 | |||
| 2a9507cb6a | |||
| 5b8bee286c | |||
| ba19954ffb | |||
| 88b4cad780 | |||
| a4c6aeed49 | |||
| 97498c560e | |||
| 8cb2a0f9c6 | |||
| 9f445904a5 | |||
| ce5d4204ac | |||
| 6ca735749e | |||
| 739228efdb | |||
| db5fb37980 | |||
| 0a7c4e129c | |||
| a96d06dbc0 | |||
| 06d94bb37d | |||
| f371349dd5 | |||
| 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 | |||
| e8d1c1ddac | |||
| f9740f640d |
@@ -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
@@ -3210,6 +3210,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tower 0.5.3",
|
||||
|
||||
@@ -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:
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,934 @@
|
||||
k|J|
|
||||
tz^WU
|
||||
Gb\X[
|
||||
,j|L
|
||||
cXXXX
|
||||
gzW[rp
|
||||
)l``b`
|
||||
c^^^^
|
||||
zrbnz
|
||||
r--)
|
||||
&jzzx
|
||||
Jl```
|
||||
c^^^\
|
||||
----
|
||||
k|X\^_
|
||||
c\Xxx
|
||||
K|bjk
|
||||
cxxxx
|
||||
---%
|
||||
{xx|
|
||||
cxxxz
|
||||
Frxz
|
||||
{VVVW
|
||||
cpxz~
|
||||
gr*:
|
||||
sTVUU
|
||||
cxz^W
|
||||
[5555
|
||||
px~M
|
||||
cUUU5
|
||||
cUU-
|
||||
gz((+
|
||||
&rX`
|
||||
&kVX
|
||||
cUUUx
|
||||
&r^\
|
||||
%%%%
|
||||
&kUW
|
||||
f[UUW
|
||||
gcE[
|
||||
$gcE[
|
||||
cUU%
|
||||
UUWT
|
||||
xxxx
|
||||
FsUU^
|
||||
$ecF[
|
||||
icD[
|
||||
T\Rb
|
||||
sUW|
|
||||
UUU\
|
||||
VUUU
|
||||
BIGF
|
||||
L286
|
||||
Apt Data:1:7:8
|
||||
game/globalComponents/globalComponents
|
||||
game.globalComponents.ImageLoader
|
||||
game/components/SelectTeam
|
||||
game.components.SelectTeam
|
||||
Coins
|
||||
TournamentData
|
||||
BackingFUT
|
||||
VersusFUT
|
||||
external.ion_fut.screens.futSelectTeam
|
||||
__Packages.external.ion_fut.screens.futSelectTeam
|
||||
__Packages.ion.manager.HelpProperties
|
||||
EACondBold
|
||||
10.000
|
||||
Screen
|
||||
RtIL
|
||||
RYgO
|
||||
7uOO
|
||||
XsOY
|
||||
2sOY
|
||||
<@OY
|
||||
BG&Y
|
||||
3NuIL
|
||||
7NuI
|
||||
3NuI
|
||||
3NstY
|
||||
7uOY
|
||||
&v>Y
|
||||
&v>t
|
||||
3NYN
|
||||
7NYN
|
||||
tOYZ
|
||||
BG&v
|
||||
NZGO
|
||||
NZGOZu
|
||||
uOZu
|
||||
mcCup
|
||||
txtPrizeHeading
|
||||
txtCoins
|
||||
mcCoin
|
||||
mcBacking
|
||||
txtVs
|
||||
mcTournamentInfo
|
||||
mcSelectTeam
|
||||
mcVersusFUT
|
||||
publishObject
|
||||
dpID
|
||||
nHomeKitID
|
||||
nAwayKitID
|
||||
keyCode
|
||||
controllerId
|
||||
nSide
|
||||
arrKitIDs
|
||||
teamId
|
||||
kitToResolve
|
||||
side
|
||||
isUser
|
||||
arrKits
|
||||
objProperties
|
||||
Void
|
||||
nXPos
|
||||
DDS |
|
||||
NVTT
|
||||
DXT5
|
||||
8VTTT
|
||||
UUVT
|
||||
TTTU
|
||||
0TUVT
|
||||
TTUW
|
||||
$$r
|
||||
%UUU
|
||||
WUUU
|
||||
UUUSP
|
||||
UUUM
|
||||
UUUNK
|
||||
72Ib
|
||||
*72Ib
|
||||
U;8I
|
||||
WWWW?>I
|
||||
UIFI
|
||||
WWWWLKI
|
||||
WWWVQNI
|
||||
VVYVI
|
||||
`]IB
|
||||
daIB
|
||||
heIB
|
||||
VlhI
|
||||
vtI"I
|
||||
UU%!I
|
||||
*;8I
|
||||
U?>I
|
||||
ULKI
|
||||
Apt1
|
||||
_global
|
||||
external
|
||||
Object
|
||||
ion_fut
|
||||
screens
|
||||
futSelectTeam
|
||||
futSelectTeam::futSelectTeam()
|
||||
OnExitScreen
|
||||
cafe
|
||||
utility
|
||||
Delegate
|
||||
Create
|
||||
game
|
||||
globalClasses
|
||||
ScreenManager
|
||||
SetOnExitScreenCallback
|
||||
m_nFlowState
|
||||
EA_ZONE
|
||||
gScreenFlowManager
|
||||
getFlowState
|
||||
ION_Platform
|
||||
IsFinal
|
||||
CardNotification
|
||||
eState
|
||||
FUT_OFFLINE_DRAFT
|
||||
FUT_OFFLINE_TOURNAMENT
|
||||
FUT_OFFLINE_SEASON
|
||||
m_bAllowSelectAnyTeam
|
||||
FUT/ALLOW_ANY_CPU_TEAM
|
||||
ION_Customization
|
||||
GetAardvarkIntValue
|
||||
mcPanelHome
|
||||
mcPanelAway
|
||||
mcReadyHome
|
||||
mcReadyAway
|
||||
mcKitHome
|
||||
mcKitAway
|
||||
mcLockHome
|
||||
mcLockAway
|
||||
InitComponents
|
||||
InitializeScreen
|
||||
Initialize
|
||||
screen
|
||||
BaseScreen
|
||||
prototype
|
||||
futSelectTeam::InitializeScreen()
|
||||
_visible
|
||||
HOME_SIDE
|
||||
GameServices
|
||||
eTeamSide
|
||||
SIDE_HOME
|
||||
AWAY_SIDE
|
||||
SIDE_AWAY
|
||||
NEUTRAL_SIDE
|
||||
SIDE_NEUTRAL
|
||||
m_arrPanelData
|
||||
Array
|
||||
m_arrKitPanelData
|
||||
futSelectTeam::InitComponents()
|
||||
InitializeKitConfig
|
||||
InitializeTeamConfig
|
||||
SetTeamAndKitConfigs
|
||||
UIFDataProviderList
|
||||
FUT_USER_CLUB_DATA_DP
|
||||
UIFUtility
|
||||
RegisterDataProvider
|
||||
FUT_OPPONENT_CLUBS_LIST_DP
|
||||
FUT_OPPONENTS_SQUADS_LIST_DP
|
||||
FUT_OPPONENT_SQUAD_LINEUP_DP
|
||||
FUT_USER_SQUAD_LINEUP_DP
|
||||
FUT_CREATE_MATCH_DP
|
||||
FUT_GET_MATCH_KITS_DP
|
||||
SetupReadyTexts
|
||||
initSideInfo
|
||||
SetPanels
|
||||
m_arrPanels
|
||||
m_arrKitPanels
|
||||
KitSelectDP
|
||||
TeamSetupDP
|
||||
AnimateIn
|
||||
AnimateInComplete
|
||||
BeginAnimateIn
|
||||
futSelectTeam::AnimateInComplete()
|
||||
m_bHasAnimatedIn
|
||||
checkForDisconnect
|
||||
gScreenNotAborted
|
||||
LocalEventHandler
|
||||
InputManager
|
||||
AddLocalEventHandler
|
||||
SetHandlerId
|
||||
refreshCurrentConnectionStatus
|
||||
HelpManager
|
||||
Update
|
||||
futSelectTeam::OnExitScreen()
|
||||
AnimationManager
|
||||
ClearAnimations
|
||||
UnregisterDataProvider
|
||||
INJURY_POPUP_ID
|
||||
PopupManager
|
||||
DeletePopup
|
||||
TOTW_BELOW_MIN_POPUP_ID
|
||||
USER_BELOW_MIN_POPUP_ID
|
||||
OPP_BELOW_MIN_POPUP_ID
|
||||
OPP_HAS_NO_VALID_SQUADS_ID
|
||||
Shutdown
|
||||
ClearSavedOpponentData
|
||||
SQUAD_ID
|
||||
UUID_UPPER
|
||||
UUID_LOWER
|
||||
UIFActionList
|
||||
ACTION_SAVE_OPPONENT_DATA
|
||||
SendActionObj
|
||||
Publish
|
||||
futSelectTeam::Publish()
|
||||
header
|
||||
USER_CLUB_DATA
|
||||
SetUserClubData
|
||||
initVersusFUTComponents
|
||||
OPPONENT_CLUBS
|
||||
m_arrOpponentClubs
|
||||
data
|
||||
MATCH_CREATED
|
||||
FUT_PAFC_GAME
|
||||
GetCurrentCountryIndex
|
||||
SQUADS
|
||||
GetCurrentLeagueIndex
|
||||
ACTION_ADVANCE
|
||||
SendAction
|
||||
eSoundEvent
|
||||
PRIMARY_SELECT
|
||||
playSound
|
||||
m_bShouldWaitForPublish
|
||||
OPP_SQUADS_LIST
|
||||
SetOpponentSquadListData
|
||||
SQUAD_LINEUP_LOADED
|
||||
IS_USER
|
||||
SetSquadLineup
|
||||
KITS_AVAILABLE
|
||||
LENGTH
|
||||
KIT_
|
||||
push
|
||||
futSelectTeam::InitializeKitConfig()
|
||||
SetupTeamsInfo
|
||||
GetHomeTeamId
|
||||
ACTION_MATCHDAY_HOME_TEAM_CHANGE
|
||||
GetAwayTeamId
|
||||
ACTION_MATCHDAY_AWAY_TEAM_CHANGE
|
||||
ACTION_MATCHDAY_ADVANCE_KIT_SETUP
|
||||
SetReadyStatus
|
||||
FadeOut
|
||||
GetKitArrayForFUT
|
||||
HOME_KIT_ID
|
||||
AWAY_KIT_ID
|
||||
ION_Uniform
|
||||
IsKitSelectCreated
|
||||
EnterKitSelect
|
||||
IsAlternatingMode
|
||||
GetUnhighlightedSide
|
||||
SetKitUnReady
|
||||
InitializeKitsFromArray
|
||||
Unhighlight
|
||||
SetDisabled
|
||||
SetHighlightedSide
|
||||
GetHighlightedSide
|
||||
Highlight
|
||||
futSelectTeam::InitializeTeamConfig()
|
||||
LEAGUE_ID
|
||||
components
|
||||
TeamSetupControl
|
||||
TEAM_TOGGLE
|
||||
GetUserSideForFUT
|
||||
m_isInFUT
|
||||
InitData
|
||||
GetToggleValue
|
||||
UpdateTeamInfo
|
||||
m_bOpponentTeamInvalid
|
||||
m_OppHasSquads
|
||||
SetChemistryValue
|
||||
ResetTeamInfo
|
||||
FadeIn
|
||||
SetupMouseSupport
|
||||
SetWomenTeamsOnlyFilter
|
||||
SetMenTeamsOnlyFilter
|
||||
DeactivateReady
|
||||
futSelectTeam::SetupTeamsInfo()
|
||||
USER_TEAM_ID
|
||||
ION_GameSetup
|
||||
GetTeam
|
||||
SetHomeTeamId
|
||||
SetAwayTeamId
|
||||
setCustomSelectionArray
|
||||
Team
|
||||
eAttribute
|
||||
ION_Team
|
||||
GetAttributes
|
||||
futSelectTeam::LocalEventHandler()
|
||||
WARNING: Preventing the user to move until a Publish occurs.
|
||||
IsInTransition
|
||||
Stop spamming buttons, the team select screen is in a transition.
|
||||
GetUserControllerSide
|
||||
GetScreenState
|
||||
DataProviders
|
||||
STATE_TEAM
|
||||
InputCodes
|
||||
LEFT
|
||||
RIGHT
|
||||
GetReadyStatus
|
||||
DOWN
|
||||
BACK
|
||||
ADVANCE
|
||||
OPTION_TOP
|
||||
OPTION_LEFT
|
||||
IsSwitchSidesActive
|
||||
STATE_KIT
|
||||
SetUniform
|
||||
ExitKitSelect
|
||||
RemoveKitLocks
|
||||
ACTION_BACKOUT
|
||||
CANCEL
|
||||
SetKit
|
||||
SaveKitsForMatch
|
||||
FUT_TOTW_GAME
|
||||
SetGoingToKickoffHub
|
||||
SetHomeKitId
|
||||
SetAwayKitId
|
||||
GetHomeKitId
|
||||
GetAwayKitId
|
||||
ACTION_CREATE_MATCH
|
||||
SetReady
|
||||
SetKitReady
|
||||
FUT_OPP_HAS_NO_VALID_SQUADS
|
||||
PopupData
|
||||
Okay_abbr2
|
||||
AddButton
|
||||
ShowPopup
|
||||
ValidateFullLineUp
|
||||
m_sInjuryOrSuspendedWarning
|
||||
m_bConceptPlayersInSquad
|
||||
FUT_DB_Players_Not_Playable
|
||||
FUT_TOTW_BELOW_MIN_PLAYERS
|
||||
FUT_BELOW_MIN_PLAYERS
|
||||
FUT_OPP_BELOW_MIN_PLAYERS
|
||||
COUNTRY_TOGGLE
|
||||
LEAGUE_TOGGLE
|
||||
ACTION_GET_USER_SQUAD_LINEUP
|
||||
ACTION_GET_OPPONENT_SQUAD_LINEUP
|
||||
GoToViewSquad
|
||||
PlatformManager
|
||||
IsMicrosoft
|
||||
USER_NAME
|
||||
length
|
||||
gEaso
|
||||
showGamercard
|
||||
getHelpContext
|
||||
futSelectTeam::getHelpContext()
|
||||
STATE_INVALID
|
||||
FUT_VIEW_SQUAD_HOME
|
||||
ltxt
|
||||
manager
|
||||
HelpItem
|
||||
CreateHelpItem
|
||||
FUT_VIEW_SQUAD_AWAY
|
||||
ViewGamerCard
|
||||
CreateHelpTickerItem
|
||||
futSelectTeam::InitializeKitsFromArray()
|
||||
GetAllAttributes
|
||||
TYPE_UPPER
|
||||
ITEM_NAME
|
||||
ITEM_ID
|
||||
ASSET_ID
|
||||
StyleManager
|
||||
FONT_TILE_HS
|
||||
SetTitleTextFormat
|
||||
SetToggleOffset
|
||||
globalComponents
|
||||
BasePanel
|
||||
STYLE_FIFTEEN
|
||||
SetBasePanelStyle
|
||||
KIT_SCALE
|
||||
kits
|
||||
ToggleWithImage
|
||||
STYLE_TOGGLE
|
||||
SetStrokeVisibility
|
||||
CheckIsKitLocked
|
||||
futSelectTeam::GetKitArrayForFUT()
|
||||
GetNonConflictingUniformID
|
||||
eSortType
|
||||
SORT_ASCENDING
|
||||
Uniform
|
||||
eSortColumn
|
||||
SORT_NONE
|
||||
eFilter
|
||||
FILTER_UNFILTERED
|
||||
GetIDs
|
||||
LOCKED
|
||||
NAME
|
||||
shift
|
||||
futSelectTeam::initVersusFUTComponents()
|
||||
text
|
||||
Versus_abbr
|
||||
_height
|
||||
FUT_Tournament
|
||||
GetOfflineActiveTournamentId
|
||||
GetOfflineTournamentInfo
|
||||
TROPHY_ID
|
||||
trophy
|
||||
getArtAssetPath
|
||||
SCALE_ASPECT_CENTER
|
||||
setScaling
|
||||
setSize
|
||||
setImage
|
||||
FUT_UC_TOURNAMENT_BONUS
|
||||
PRIZE_FINAL
|
||||
ION_Localization
|
||||
LocalizeInteger
|
||||
_width
|
||||
textWidth
|
||||
FUT_COINS_OFFSET
|
||||
futSelectTeam::GoToViewSquad()
|
||||
isUserTeam
|
||||
CLUB_NAME
|
||||
BADGE_TEAM_ID
|
||||
SQUAD_NAME
|
||||
RATING
|
||||
SQUAD_RATING
|
||||
CHEMISTRY
|
||||
SQUAD_CHEMISTRY
|
||||
SHOW_CHEM_LINE
|
||||
SCREEN
|
||||
VIEW_SQUADS
|
||||
setContextDataObject
|
||||
loadOverlayScreen
|
||||
futSelectTeam::SetUserClubData()
|
||||
m_arrUserClubs
|
||||
PUBLIC
|
||||
CLUB_ABBR
|
||||
EST_DATE
|
||||
ACTIVE_SQUAD_ID
|
||||
SIDE_NAME
|
||||
Away_Side
|
||||
Home_Side
|
||||
futSelectTeam::SetOpponentSquadListData()
|
||||
split
|
||||
FUT_NO_VALID_SQUADS
|
||||
futSelectTeam::SetSquadLineup()
|
||||
SetTeam
|
||||
futSelectTeam::GetCurrentCountryIndex()
|
||||
futSelectTeam::GetCurrentLeagueIndex()
|
||||
futSelectTeam::GetUserSideForFUT()
|
||||
bIsDemo
|
||||
GetLockRules
|
||||
SIDE_LOCK
|
||||
futSelectTeam::ValidateFullLineUp()
|
||||
FUT_SquadManagement
|
||||
GetOpponentSquadLineup
|
||||
GetSquadLineup
|
||||
FUT_NUM_PLAYERS_IN_SQUAD
|
||||
CARD_ID
|
||||
ION_Card
|
||||
GetPlayerCardInfo
|
||||
IS_DREAM_PLAYER
|
||||
FUT_NUM_PLAYERS_IN_SQUAD_EXTENDED
|
||||
gFutHelpers
|
||||
GetInjuryOrSuspendedSquadWarning
|
||||
futSelectTeam::SaveKitsForMatch()
|
||||
SIDE
|
||||
NUM_KITS
|
||||
ACTION_SAVE_MATCH_KIT
|
||||
FUT_TOURNAMENT_CUP_SCALE
|
||||
INJURY_OR_SUSPENDED_POPUP
|
||||
TOTW_NUM_PLAYERS_BELOW_MIN_POPUP
|
||||
USER_NUM_PLAYERS_BELOW_MIN_POPUP
|
||||
OPP_NUM_PLAYERS_BELOW_MIN_POPUP
|
||||
OPP_HAS_NO_VALID_SQUADS
|
||||
SCALE_NONE
|
||||
SCALE_ASPECT
|
||||
SCALE_ABSOLUTE
|
||||
ASSetPropFlags
|
||||
HelpProperties
|
||||
mXPos
|
||||
GetXPos
|
||||
SetXPos
|
||||
registerClass
|
||||
hj\W
|
||||
hj/U
|
||||
UUUV
|
||||
'Z`XV
|
||||
j`XVW
|
||||
b$9^
|
||||
UW^}
|
||||
hb>x
|
||||
DA__\X
|
||||
UWW^
|
||||
HbCA
|
||||
hjCA/
|
||||
+{dA
|
||||
b#9X7*
|
||||
I^^|x
|
||||
(Z``pP
|
||||
Zxp`
|
||||
\j~X
|
||||
&j\xp
|
||||
jXXXX
|
||||
Fn%Vb=
|
||||
xxxp
|
||||
xh``
|
||||
WWWW
|
||||
r\XXX
|
||||
xxz_
|
||||
{XPpr
|
||||
Hb__^\
|
||||
`x|x
|
||||
``xX
|
||||
]{jp
|
||||
xxhh
|
||||
X\\\
|
||||
U\T_
|
||||
`pz~
|
||||
_^^^
|
||||
cq,6
|
||||
-/+*+
|
||||
jjjj
|
||||
jJJj
|
||||
_^Xp
|
||||
WV\p
|
||||
TTWU
|
||||
```h
|
||||
pWUU
|
||||
\UUU
|
||||
$I">
|
||||
x^UU
|
||||
/UUU
|
||||
$IR`m
|
||||
$IL#
|
||||
cAxW
|
||||
dI/U
|
||||
&b%W
|
||||
|UWV\
|
||||
j_uyQ
|
||||
|UUVT
|
||||
|WT\\
|
||||
j`ppp
|
||||
|\\\\
|
||||
bpppp
|
||||
)---
|
||||
||x||
|
||||
bzzzz
|
||||
s"#)5
|
||||
K{&R
|
||||
D(tFR```
|
||||
j5555
|
||||
){zxh`
|
||||
hlUU_`
|
||||
$Ithd
|
||||
Ithd
|
||||
hlUWVT
|
||||
s(ljj
|
||||
HR{O
|
||||
IBww
|
||||
@Bbb
|
||||
Hl\\Xx
|
||||
zzhh
|
||||
@`pP
|
||||
Htxxxx
|
||||
hlHd
|
||||
Ht%%%5
|
||||
ZZ\\
|
||||
H|xxxx
|
||||
Hthd
|
||||
xxxX
|
||||
TV_]
|
||||
H|hd
|
||||
Xxx`
|
||||
_\|p
|
||||
pppP
|
||||
H|xxxz
|
||||
i|%%%5
|
||||
pX\T
|
||||
H|xzzz
|
||||
(dHt
|
||||
H|`xz_
|
||||
UU^p
|
||||
$G|(t
|
||||
G|(t
|
||||
(tG\
|
||||
VTTT
|
||||
kUUU5
|
||||
(pXxx
|
||||
XXXX
|
||||
KOKK
|
||||
'cUU^
|
||||
hs'c
|
||||
$Gk(c
|
||||
Gk(c
|
||||
~ZZX
|
||||
GkUWx
|
||||
GkUUU\
|
||||
'Gk(c
|
||||
p``H
|
||||
zUU~
|
||||
X`pxZ
|
||||
sUWx
|
||||
c```
|
||||
@@@@
|
||||
WVT\
|
||||
Vw~U
|
||||
_^_j
|
||||
\XPp
|
||||
^|~^
|
||||
c``pX\
|
||||
````
|
||||
UUUU
|
||||
\\\\
|
||||
????
|
||||
p~UU
|
||||
Ib'b
|
||||
X\WU
|
||||
A*++
|
||||
UUUX
|
||||
VWUU
|
||||
z^VW
|
||||
W^x
|
||||
\\\\j
|
||||
TWVV
|
||||
\^xx
|
||||
W^~
|
||||
VWVt
|
||||
cI^xxp
|
||||
k$)WWVT
|
||||
)W_VT
|
||||
$1VTVT
|
||||
(\\\\
|
||||
\\\\"
|
||||
$1\\XX
|
||||
pr`z
|
||||
AXPp`
|
||||
yU^r^
|
||||
$I2,r
|
||||
PZrC
|
||||
U{Bz
|
||||
{||Z
|
||||
kkki
|
||||
c`p^
|
||||
cx6l
|
||||
\\\\]
|
||||
dIb`@@
|
||||
pvv]
|
||||
'z@@
|
||||
XVUU
|
||||
e9`p
|
||||
UVVV
|
||||
(.-5
|
||||
JJJJ
|
||||
cQxxx
|
||||
(%-)+
|
||||
VVVV
|
||||
#9ZZxx
|
||||
i-)-
|
||||
1U_|
|
||||
#9=*
|
||||
VVTT
|
||||
T\\X
|
||||
X^__
|
||||
5-)+
|
||||
$I"'r
|
||||
rrbJ
|
||||
8)-%5
|
||||
ZZZZ
|
||||
jjjk
|
||||
1^UUU
|
||||
g1G)^
|
||||
!XX\V
|
||||
BIGF0
|
||||
Apt Data:1:5:8
|
||||
U555
|
||||
Urpp
|
||||
~B'j
|
||||
5555
|
||||
m*((
|
||||
;RRRR
|
||||
m***
|
||||
:RRRR
|
||||
`15555
|
||||
sZPPP
|
||||
`95555
|
||||
Apppp
|
||||
95555
|
||||
{PPPR
|
||||
RRRR
|
||||
rrp_
|
||||
&j2'
|
||||
pppp
|
||||
Ns%!U
|
||||
%)%%%%
|
||||
f)%!
|
||||
` 6dC.kE!
|
||||
Z%)70
|
||||
1E!W
|
||||
1E!U
|
||||
f1E!
|
||||
F1Xp*
|
||||
9f)U
|
||||
xUU\
|
||||
JV~No
|
||||
(n{$!
|
||||
iJPPpp
|
||||
<W\^
|
||||
AqUW
|
||||
{Cq_
|
||||
U]P\
|
||||
Pppp
|
||||
TTTT
|
||||
PPPp
|
||||
,(;k
|
||||
z^\x
|
||||
\^VT
|
||||
???/
|
||||
btTVV
|
||||
M{-/75
|
||||
x~_^
|
||||
TUWW
|
||||
%555
|
||||
(^xp`
|
||||
UUWV
|
||||
jR\T\\
|
||||
1xp``
|
||||
b\\\X
|
||||
b557/
|
||||
jZ'5
|
||||
WWWh
|
||||
^XPZ
|
||||
zxxxx
|
||||
1UWVT
|
||||
h4Vb%
|
||||
\^U?
|
||||
\\\X
|
||||
I*.$
|
||||
Hb'A
|
||||
9UU\
|
||||
i--+
|
||||
Wka@
|
||||
P|WWW
|
||||
UW^x
|
||||
@PW^
|
||||
czXX
|
||||
++-5
|
||||
`x@p
|
||||
brp`
|
||||
U%%%
|
||||
\VUW
|
||||
XPXX
|
||||
WTTV
|
||||
PPXX
|
||||
zc9~^z
|
||||
I"1-+
|
||||
!*+*
|
||||
TTVT
|
||||
----Y
|
||||
73 &
|
||||
Av|z
|
||||
`^UJ
|
||||
xx~p
|
||||
&jB1_
|
||||
Y444$
|
||||
xWU0
|
||||
$_nO
|
||||
G1BBBB
|
||||
xxx^
|
||||
xxz~
|
||||
---=
|
||||
***J
|
||||
O"'@
|
||||
7 '>
|
||||
U`X\Y
|
||||
h035^
|
||||
Lw!f
|
||||
&T@a
|
||||
]8RR
|
||||
[OAq
|
||||
D/Oz%F
|
||||
+1.^-
|
||||
_,_Y
|
||||
..^O
|
||||
CG|!@
|
||||
;}>|
|
||||
nHT*
|
||||
a8Nj
|
||||
?'Un70
|
||||
^[zM2Bj @
|
||||
6nd[N
|
||||
Z)MBc
|
||||
wY=A
|
||||
8p(a
|
||||
:m"D
|
||||
[dbt
|
||||
E'0S
|
||||
nT+bJuZ
|
||||
V-:t
|
||||
v)(n
|
||||
(*s?p
|
||||
cc?r
|
||||
B%{r
|
||||
-4Yi
|
||||
sci,Iy
|
||||
|3;=
|
||||
<KB6
|
||||
cCFVJ
|
||||
J|jg
|
||||
4VvVV6
|
||||
p$$e&
|
||||
4%1{
|
||||
~%ew==_.
|
||||
EFGFFED
|
||||
[FFB}9
|
||||
$"dOz%^-
|
||||
mw77
|
||||
ct3r
|
||||
ecGB
|
||||
*\JV
|
||||
c&WV
|
||||
w6GMq
|
||||
13aB
|
||||
$X~_
|
||||
mMx%
|
||||
;11sZR
|
||||
'&'n
|
||||
q&##>o
|
||||
+:Z/Y
|
||||
]:AY
|
||||
$(+~
|
||||
,^:(,
|
||||
kp>C
|
||||
luqYql
|
||||
wf_q
|
||||
XYX\
|
||||
@p77
|
||||
--X&q
|
||||
{ cf
|
||||
waF,
|
||||
znrn;
|
||||
VRwCE
|
||||
5=#&
|
||||
/J"}
|
||||
_A4Z
|
||||
gnB7%
|
||||
q`Y
|
||||
Q|!+
|
||||
[MMA
|
||||
**rV
|
||||
)~U(w*,)m
|
||||
Z*SVd
|
||||
$#&qi
|
||||
qmUW=
|
||||
F"MN
|
||||
HaA%e%
|
||||
(T!]5(\
|
||||
IK#k
|
||||
v wQ
|
||||
C(\M
|
||||
];%P
|
||||
7f&=kJ
|
||||
%(oRtK
|
||||
gRr?
|
||||
+rq_
|
||||
/==7
|
||||
,IUk
|
||||
D?t(zC,
|
||||
\}oe
|
||||
'^YY
|
||||
nwzu
|
||||
jdf,
|
||||
i5hFc
|
||||
z@xOrFp
|
||||
aqgv
|
||||
y^oT+
|
||||
dZ13
|
||||
d{g~
|
||||
ttzi
|
||||
p,@B
|
||||
upqH
|
||||
1{pl0
|
||||
J4)~
|
||||
&W<;@
|
||||
p77Es
|
||||
:aPw
|
||||
`>(^&
|
||||
lnW|
|
||||
~+w8
|
||||
@@ -0,0 +1,278 @@
|
||||
# FIFA17.exe runtime command/event id -> name registry
|
||||
# Recovered 2026-08-24 from live pid 44405 (Denuvo-decrypted, /proc/PID/mem, read-only).
|
||||
# CardsDLL live base 0x6ffffc0f0000; registration loop at live 0x147dd0000-0x147df8000.
|
||||
# NOTE: this is a DIFFERENT namespace from CardsDLL's DataProvider id table.
|
||||
# the same numeric id has a different name in each, matching the APT's split
|
||||
# between game.uif.UIFDataProviderList and the action/command list.
|
||||
#
|
||||
0x0207 %d
|
||||
0x0bb9 back
|
||||
0x0bbb preScreenSucceeded
|
||||
0x0bbc preScreenFailed
|
||||
0x0bc0 clearTeamSheets
|
||||
0x0be7 selectTab
|
||||
0x0c15 optionSelected
|
||||
0x0c2a leaveGameGroup
|
||||
0x0c2c quitToHub
|
||||
0x0dac UpdateStadiumCrests
|
||||
0x0dac startStoryMode
|
||||
0x2713 matchdayFixtureChange
|
||||
0x271a evt_set_matchDay_offline_fixture
|
||||
0x271b evt_team_setup_state
|
||||
0x271c advanceDefault
|
||||
0x271d advanceDefaultWithTeam
|
||||
0x271e advancePran
|
||||
0x271f feInitialized
|
||||
0x2720 skipBootflow
|
||||
0x2721 startBootflow
|
||||
0x2722 bootflowStarted
|
||||
0x2723 bootflowFinished
|
||||
0x2724 bootflowSaveLoadFailed
|
||||
0x2725 returnToPressStart
|
||||
0x2726 showPressStart
|
||||
0x2727 evt_load_personal_settings
|
||||
0x2728 evt_settings_load_complete
|
||||
0x2729 assetUpdate
|
||||
0x272a pranUpload
|
||||
0x272b pranDownload
|
||||
0x272c controllerConfig
|
||||
0x272d activateGameModeIntro
|
||||
0x272e ActivateFullGame
|
||||
0x272f startIntroFlow
|
||||
0x2730 offlineEulaProfileSuccess
|
||||
0x2731 offlineEulaProfileFail
|
||||
0x2732 startIntroMatch
|
||||
0x2733 abortIntroMatch
|
||||
0x2735 setCareerType
|
||||
0x2736 exitTitle
|
||||
0x2737 evt_set_fullscreen
|
||||
0x273e enterSubPanel
|
||||
0x273f exitSubPanel
|
||||
0x2742 evt_invite_accepted
|
||||
0x2743 profileSignOut
|
||||
0x2744 profilePrepareForSave
|
||||
0x2745 logTelemetry
|
||||
0x2746 enterPracticeArena
|
||||
0x2748 navigationBackoutStart
|
||||
0x2749 navigationBackoutContinue
|
||||
0x274a navigationBackoutComplete
|
||||
0x274b checkSpeechData
|
||||
0x274c newsSharingSettings
|
||||
0x274d leaveBootFlow
|
||||
0x274e mainMenuProfileCreationDone
|
||||
0x274f nonLeadProfileCreation
|
||||
0x2750 nonLeadProfileLoad
|
||||
0x2755 teamSheetAction
|
||||
0x2758 evt_set_lead_profile
|
||||
0x2759 evt_sign_out
|
||||
0x275a notifySignOut
|
||||
0x275b notifySignOutReady
|
||||
0x275c notifySignOutTitleScreen
|
||||
0x275d evt_sign_out_flow_ready
|
||||
0x275e evt_sign_out_flow_not_ready
|
||||
0x275f showSignOutPopup
|
||||
0x2760 showSignOutTitleScreenPopup
|
||||
0x2761 evt_dismiss_sign_out_popup
|
||||
0x2762 evt_show_account_picker
|
||||
0x2763 evt_lead_profile_recovered
|
||||
0x2764 triggerSignOut
|
||||
0x2765 checkLeadProfilePairing
|
||||
0x2766 evt_lead_profile_paired
|
||||
0x2767 evt_lead_profile_unpaired
|
||||
0x2768 evt_lead_profile_controller_changed
|
||||
0x2769 beginProfileCheck
|
||||
0x276a endProfileCheck
|
||||
0x276b evt_controller_disconnect
|
||||
0x276c evt_notify_controller_disconnect
|
||||
0x276d evt_controller_disconnect_flow_ready
|
||||
0x276e evt_controller_disconnect_flow_not_ready
|
||||
0x276f showLoadPersonalSettingsPopup
|
||||
0x2770 showSavePersonalSettingsPopup
|
||||
0x2771 feRenderInGame
|
||||
0x2772 pvProfilerStart
|
||||
0x2773 pvProfilerStop
|
||||
0x2775 enterMatchDayTab
|
||||
0x2776 exitMatchDayTab
|
||||
0x2777 restartWithNewTeams
|
||||
0x2778 playSecondLegFixture
|
||||
0x2779 setupSecondLegFixture
|
||||
0x277a welcomeToMatchDayLive
|
||||
0x277b exitMatchDayLivePanel
|
||||
0x277c enableAardvark
|
||||
0x277d disableAardvark
|
||||
0x277e conditionAardvark
|
||||
0x2780 adaptiveDifficultyDetectedPopup
|
||||
0x2781 adaptiveDifficultyUpPopup
|
||||
0x2782 adaptiveDifficultyDownPopup
|
||||
0x2783 adaptiveDifficultyDetected
|
||||
0x2784 adaptiveDifficultyUp
|
||||
0x2785 adaptiveDifficultyDown
|
||||
0x2786 adaptiveDifficultyDisable
|
||||
0x2787 adaptiveDifficultyReset
|
||||
0x2788 adaptiveDifficultyKeep
|
||||
0x2789 adaptiveDifficultyOverride
|
||||
0x278c evt_countdown_done
|
||||
0x278d evt_countdown_restart
|
||||
0x278e evt_start_stadium_change
|
||||
0x278f evt_wait_for_stadium_change
|
||||
0x2790 evt_wait_for_stadium_change_bootflow
|
||||
0x2791 evt_advance_to_wait_popup
|
||||
0x2792 evt_advance_to_wait
|
||||
0x2793 evt_stadium_background_loaded
|
||||
0x2795 setupTournament
|
||||
0x2796 createTournament
|
||||
0x2797 createWomenTournament
|
||||
0x2799 setWomenTournament
|
||||
0x279a evt_sl_operation_started
|
||||
0x279b evt_sl_operation_complete
|
||||
0x279c evt_sl_operation_load
|
||||
0x279d evt_sl_operation_boot_load
|
||||
0x279e evt_sl_operation_save
|
||||
0x279f evt_sl_operation_delete
|
||||
0x27a0 FUTLoginComplete
|
||||
0x27a1 requestDownload
|
||||
0x27a2 backendEnter
|
||||
0x27a3 backendExit
|
||||
0x27a4 onlineLoginToEaPopup
|
||||
0x27a5 onlineBootLoginToEaPopup
|
||||
0x27a6 evt_onlineAlertPopup
|
||||
0x27a7 evt_onlineBootLoginFailurePopup
|
||||
0x27a8 evt_onlineLoginFailurePopup
|
||||
0x27a9 onlineLoginPopupHide
|
||||
0x27aa onlineLoginPopupShow
|
||||
0x27ab evt_invite_flow_ready
|
||||
0x27ac evt_invite_flow_not_ready
|
||||
0x27ad inviteFlowAbortSaveLoad
|
||||
0x27ae evt_verify_invite_nav_cleanup
|
||||
0x27af downloadComplete
|
||||
0x27b0 downloadFailed
|
||||
0x27b1 spevnetNotAvailable
|
||||
0x27b2 spevnetNotRegistered
|
||||
0x27b3 spevnetNotRegisteredBeta
|
||||
0x27b4 userBanned
|
||||
0x27b5 showExitConfirmPopup
|
||||
0x27b6 hideExitConfirmPopup
|
||||
0x27b7 confirmExit
|
||||
0x27b8 showRegisterConfirmPopup
|
||||
0x27b9 hideRegisterConfirmPopup
|
||||
0x27ba setStadiumPosition
|
||||
0x27bb liveCompCountryDecision
|
||||
0x27bc liveCompAllCountriesSelect
|
||||
0x27bd liveCompLimitedCountriesSelect
|
||||
0x27be liveCompAdvanceToTeamSelect
|
||||
0x27bf liveCompRegistrationConfirm
|
||||
0x27c0 liveCompEventListSuccess
|
||||
0x27c1 liveCompEventListFail
|
||||
0x27c2 postMatchHighlightExit
|
||||
0x27c3 postMatchHighlightComplete
|
||||
0x27c4 postMatchHighlightSelect
|
||||
0x27c5 postMatchHighlightReelSelect
|
||||
0x27c6 postMatchHighlightIRSelect
|
||||
0x27cf leaveUpsell
|
||||
0x27d0 purchase
|
||||
0x27d1 advanceFromPMA
|
||||
0x27d2 evt_transitionToPMADone
|
||||
0x27d3 cutSceneCommand
|
||||
0x27d4 cutScenePlay
|
||||
0x27d5 loadCutScenesSubLevel
|
||||
0x27d6 unloadCutScenesSubLevel
|
||||
0x27d7 evt_enable_skip_cutscene
|
||||
0x27d8 gmCutSceneStarted
|
||||
0x27d9 gmCutSceneEnded
|
||||
0x27da gmCutScenesSublevelLoaded
|
||||
0x27db gmCutScenesSublevelUnloaded
|
||||
0x27dc gmAirlockToGameplayEnded
|
||||
0x27dd gmAirlockLoadComplete
|
||||
0x27de evt_quit_to_training_hub
|
||||
0x27e0 evt_training_allow_advance_to_game
|
||||
0x27e1 checkOriginConnected
|
||||
0x27e2 OriginIsOnline
|
||||
0x27e3 OriginIsOffline
|
||||
0x27e4 OIGOpened
|
||||
0x27e5 OIGClosed
|
||||
0x27e6 overrideOnlineStadium
|
||||
0x27e7 smLoadFEStadium
|
||||
0x27e8 smActivateFreeRoam
|
||||
0x27e9 smGameOver
|
||||
0x27ea smScenePrime
|
||||
0x27eb smScenePrimeAndPrep
|
||||
0x27ec smScenePause
|
||||
0x27ed smSceneResume
|
||||
0x27ee smMoment
|
||||
0x27ef smMomentRepeat
|
||||
0x27f0 smMomentComplete
|
||||
0x27f1 smExitMomentState
|
||||
0x27f2 smOnPlayScene
|
||||
0x27f3 smConversation
|
||||
0x27f4 smConversationComplete
|
||||
0x27f5 smConversationNotification
|
||||
0x27f6 smConversationNotificationComplete
|
||||
0x27f7 smGameplayStartLoad
|
||||
0x27f8 smGameplayLoadOver
|
||||
0x27f9 smGameplayStart
|
||||
0x27fa smGameplayOver
|
||||
0x27fb smGameplayPause
|
||||
0x27fc smGameplayResume
|
||||
0x27ff smTweetConsume
|
||||
0x2800 smHeroLoanedOut
|
||||
0x2801 smSetupAcademyMatch
|
||||
0x2802 smSetupAcademyTeams
|
||||
0x2803 smStartIntroFlow
|
||||
0x2804 smStartSeason
|
||||
0x2805 smPlayMatch
|
||||
0x2806 smEndMatch
|
||||
0x2807 smGetTrainingSet
|
||||
0x2808 smEnterTrainingTeamHub
|
||||
0x2809 smEnterTraining
|
||||
0x280a smPlayTrainingSessionVO
|
||||
0x280b smPrepareTraining
|
||||
0x280c smPlayTraining
|
||||
0x280d smStopTraining
|
||||
0x280e smStartSkillGame
|
||||
0x280f smSimTraining
|
||||
0x2810 smEndTraining
|
||||
0x2811 smSave
|
||||
0x2812 smAutoSave
|
||||
0x2814 smLoad
|
||||
0x2815 smSetScreenFlowLocation
|
||||
0x2816 smGetScreenFlowLocation
|
||||
0x2817 smGetHomeHubLocation
|
||||
0x2818 smGetHeroLeague
|
||||
0x2819 smCompleteMatchday
|
||||
0x281a smEndInterviewPeriod
|
||||
0x281b smHeroRemovedFromMatch
|
||||
0x281c smEpisodicUploadCheck
|
||||
0x281d smRetryEpisodicUpload
|
||||
0x281e smNotifyMatchNotPlayed
|
||||
0x281f matchFlowStart
|
||||
0x2820 matchFlowHalftime
|
||||
0x2821 matchFlowPostgame
|
||||
0x2822 matchFlowEnd
|
||||
0x2823 enterGameplay
|
||||
0x2824 leaveGameplay
|
||||
0x2825 forfeitMatch
|
||||
0x2826 matchSetType
|
||||
0x2827 simMatch
|
||||
0x2828 simStarted
|
||||
0x2829 simStopped
|
||||
0x282a fbStartFlowEvent
|
||||
0x282b stopSavedInput
|
||||
0x282c changeSonyStoreBrowseMode
|
||||
0x282d trialCheck
|
||||
0x282e gotoTrialUpsell
|
||||
0x754d retrieveManagerQuestData
|
||||
0x7560 futWidgetShow
|
||||
0x7561 futWidgetHide
|
||||
0x7562 futWidgetLoad
|
||||
0x7563 futWidgetUnload
|
||||
0x7567 inviteAcceptedFUT
|
||||
0x7568 futAddCriticalSection
|
||||
0x7569 futRemoveCriticalSection
|
||||
0x7572 exitDraftMode
|
||||
0x7579 useSavedMatchData
|
||||
0x757a useSavedMatchKits
|
||||
0x7580 exitSbcMode
|
||||
0x7587 setFUTServerEnvironment
|
||||
0x9cc1 discardTeamSheet
|
||||
0x9cc1 resetReady
|
||||
0x9cd0 showKeyboard
|
||||
@@ -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,95 @@ 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. The pre-match kit selector gates on `+0x60 == 4` at `0x1801c34f2`, and no
|
||||
instruction in CardsDLL stores that constant immediately (29 stores, constants
|
||||
`{-2,0,1,908,0x3f800000}`), nor does FIFA17.exe across 79 MB.
|
||||
Tool: `fifa17-recon/tools/kit_gate_probe.py`.
|
||||
|
||||
**CORRECTED 2026-08-23 (live, pid 8793, read-only `/proc/PID/mem`).** The
|
||||
2026-08-21 entry went on to call the kit selector "a client dead end, not a
|
||||
missing wire field", on the grounds that "every OTHER input to that gate is
|
||||
already served". That conclusion is WITHDRAWN. It rested on two mistakes.
|
||||
|
||||
1. **`+0x60 == 4` does occur.** A live record reached the art-clone driver
|
||||
`FUN_1801c3480` holding `+0x4c == 2`, `+0x60 == 4`. So the value arrives by
|
||||
some path the immediate-store scan cannot see (register copy or computed),
|
||||
and "nothing can ever satisfy the gate" is false. What the static scan
|
||||
actually licenses is the narrower claim above.
|
||||
2. **cardtype 7 was never verified to be produced at all.** The probe annotates
|
||||
`cmp [rdi+0x4c], 7` with "<- we produce this". Nothing measured that. Its own
|
||||
live half showed `{1: players, 0: staff}` -- i.e. zero cardtype-7 records --
|
||||
and that was read as "the only thing missing is +0x60".
|
||||
|
||||
**What is actually measured now.** With the client parked on the kit selector,
|
||||
scanning all 3047 MiB of readable process memory for the exact u32 values the
|
||||
server sent:
|
||||
|
||||
```
|
||||
resident (record-shaped, sane fields):
|
||||
player resourceId 83906881 -> cardtype 1, itemState 1, teamid 243, +0x60 1
|
||||
staff resourceId 9000081 -> cardtype 2
|
||||
staff resourceId 3000083 -> cardtype 4, subtype 8
|
||||
staff resourceId 1000509 -> cardtype 2, subtype 4, teamid 241
|
||||
NOT resident, by resourceId AND by instance id, zero hits each:
|
||||
kit 6300006 / 100004874 (cardsubtypeid 9)
|
||||
kit 6400003 / 100004873 (cardsubtypeid 9)
|
||||
badge 6000005 / 100004875 (cardsubtypeid 11)
|
||||
stadium 6200000 / 100004876 (cardsubtypeid 10)
|
||||
```
|
||||
|
||||
The client fetched `?type=kit` at 17:50:09 this session and the host logged
|
||||
`total=2 emitted=2`. Both kits were delivered and NEITHER produced a record.
|
||||
Every cardtype-7 family is absent while cardtype 1/2/4 are resident.
|
||||
|
||||
So the blocker is upstream of the `+0x60` gate: no cardtype-7 record is ever
|
||||
created, therefore the club scan `FUN_1800d73d0` (`+0x4c==7 && +0x50==9 &&
|
||||
`+0x5c in {101,102}`) has nothing to match, `KIT_DESC` never fires, and
|
||||
`KITS_AVAILABLE` reads 0. Whether that is a bad wire shape (the cardtype-7 parse
|
||||
arm wants `name`/`localizedName`/`description`, which OpenFUT does not send) or
|
||||
cardtype-7 items being transient by design is NOT yet settled -- do not record
|
||||
either as fact.
|
||||
|
||||
**Method note.** `kit_gate_probe.py`'s live half is unreliable as written: on
|
||||
pid 8793 it printed "CardsDb is empty (no FUT session loaded)" while a byte scan
|
||||
found 1966 resident players. Its structural chain is stale, so its record counts
|
||||
(including the original "27 resident records") understate reality. Prefer the
|
||||
value scan until the chain is re-derived.
|
||||
|
||||
**`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,406 @@
|
||||
# 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.
|
||||
|
||||
## FUT task vocabulary (2026-08-21, live)
|
||||
|
||||
The client drives UTAS through named TASKS, not just URLs. The task-name table
|
||||
lives in CardsDLL `.rdata` as 0x20-byte inline slots holding MixedCase/UPPERCASE
|
||||
pairs (`tools/apply_route_search.py`, controls `tradePile`/`ut/%s/item`/`squad`
|
||||
all FOUND):
|
||||
|
||||
```
|
||||
ViewCards AssingCard(sic) ApplyCard ApplyCardByRes
|
||||
ActivateCard ConsumeCard DiscardCard DiscardCardByRes
|
||||
DiscardACard MoveCard MoveCardByRes SwapCard
|
||||
CreateMatch MatchReady DestroyMatch PlayGame ResetMatch KeepAlive
|
||||
LoadCategoryDetails LoadSetChallenges StartChallenge LoadSquadChallenge
|
||||
SaveSquadChallenge SubmitChallenge TagSets SetSbcData
|
||||
TournamentList TournamentTeams SetUserInfo GetHistorical SetTutData ...
|
||||
```
|
||||
|
||||
A descriptor table in `.data` pairs each name with a task id and a small setter
|
||||
thunk, e.g. `ApplyCard` id **0x0d** at `0x1802cb170`, `ApplyCardByRes` id **0x0e**
|
||||
at `0x1802cb1a0`. The thunks are `mov [rip+flag], cl; ret` (a per-task flag), NOT
|
||||
request builders, so the request is assembled elsewhere keyed by task id.
|
||||
|
||||
**So consumable application IS a first-class client action (`ApplyCard` /
|
||||
`ApplyCardByRes` / `ConsumeCard`), even though no `/apply` URL exists.** It
|
||||
therefore rides an existing route. Which one is a one-capture question, and the
|
||||
host now names every unclaimed request:
|
||||
|
||||
```
|
||||
utas-host owner=PYTHON route=passthrough method=GET path=/ut/... body_len=N
|
||||
```
|
||||
|
||||
## CONSUMABLE APPLY — LIVE_PROVEN (2026-08-21)
|
||||
|
||||
Captured end to end on staging, operator applying a bronze player contract:
|
||||
|
||||
```
|
||||
POST /ut/game/fifa17/item/resource/5001004
|
||||
{"apply":[{"id":100000003}]}
|
||||
```
|
||||
|
||||
| element | value | where |
|
||||
|---|---|---|
|
||||
| source consumable | resource id `5001004` (player contract, subtype 201) | **path** |
|
||||
| target item(s) | wire instance `100000003` (= squad slot 0 GK, resourceId 200389) | **body**, `apply[]` |
|
||||
| verb | `POST` | |
|
||||
|
||||
**There is no `/apply` endpoint** — the apply re-uses `ut/%s/item/resource`, which
|
||||
we already serve for **GET** (item-definition lookup). The **POST** verb on that
|
||||
path is the mutation, and nothing claimed it, so it fell through to Python. This
|
||||
is the wire form of the `ApplyCardByRes` task (id `0x0e`) -- "apply card **by
|
||||
res**ource" -- which is why the source is a definition id rather than an instance
|
||||
id.
|
||||
|
||||
`apply` is an ARRAY, so one consumable resource can name several targets in a
|
||||
single request. Whether the client ever batches is unobserved.
|
||||
|
||||
Corroborating UI evidence from the same session: applying to a PLAYER offered
|
||||
only the subtype-201 card and withheld both subtype-202 manager contracts,
|
||||
independently confirming the `201 = player_contract / 202 = manager_contract`
|
||||
split.
|
||||
|
||||
Fail-closed confirmed: with the upstream dead the request 502s and Core is left
|
||||
EXACTLY unchanged (coins, owned count, and the source card all identical).
|
||||
|
||||
### Not yet known
|
||||
* the **response shape** the client expects on success;
|
||||
* the **effect** -- how many matches a contract grants. Our own catalog carries
|
||||
`contract: 7` for `5001004`, documented as "the number of matches the card
|
||||
grants", but that is observed profile data, i.e. INFERRED, not reversed. No
|
||||
effect is implemented on that basis.
|
||||
|
||||
## Consumables category `development` is unmapped (client really asks)
|
||||
|
||||
The new passthrough/route logging caught the client requesting
|
||||
|
||||
```
|
||||
GET /ut/game/fifa17/club/consumables/development -> outcome=unknown_category emitted=0
|
||||
```
|
||||
|
||||
`consumable_families_for_category` has no `development` arm, so the screen is
|
||||
served empty. The client demonstrably asks for it, which is exactly the condition
|
||||
that function's own doc says should add an arm. Which families it should map to
|
||||
is NOT guessed here.
|
||||
|
||||
### Success contract — STATIC_REVERSED (2026-08-22)
|
||||
|
||||
The apply completion handler is `0x180035520`:
|
||||
|
||||
```asm
|
||||
0x180035529 mov ecx,DWORD PTR [rdx+0x1c] ; the ONLY field tested
|
||||
0x18003552c test ecx,ecx
|
||||
0x18003552e jne 0x18003555c ; nonzero -> FAILURE
|
||||
0x18003553c lea rdx,[EVENT_CARDS_APPLY_CARD_SUCCESS] ; 0x1801f37f0
|
||||
0x180035569 lea rdx,[EVENT_CARDS_APPLY_CARD_FAILURE] ; 0x1801f3810
|
||||
```
|
||||
|
||||
It tests exactly one 32-bit field — the transport code — and **never inspects
|
||||
the body**. `EVENT_CARDS_APPLY_CARD_SUCCESS` has precisely one reference in the
|
||||
module, so this is the whole verdict path.
|
||||
|
||||
This does NOT resemble the move ack (`0x180128600`), which builds per-item
|
||||
verdict records and reports FAILURE on an EMPTY vector. The "`{}` is
|
||||
known-broken" precedent is specific to that route and does not transfer here.
|
||||
|
||||
Supporting structure: the response object's constructor `0x1800a4ce0` installs
|
||||
vtable `0x1801fb5b0` and initialises its record vector at `+0x50`/`+0x58`/`+0x60`
|
||||
EMPTY (0x20-byte elements); `0x1800682b0` is the matching destructor, freeing
|
||||
that range with a 0x20 stride. An empty result is therefore a legal parsed state
|
||||
for this response, unlike the move.
|
||||
|
||||
Registration site: `0x1800357da` installs the completion handler and
|
||||
`0x1800357e5` the response factory, back to back.
|
||||
|
||||
**Probe response**: `{"itemData":[]}` — an object root (matching how the oracle's
|
||||
method-agnostic `item/resource` route answers this path) containing an empty
|
||||
vector (legal per the constructor). Labelled a PROBE. The client's SUCCESS only
|
||||
requires transport code 0.
|
||||
|
||||
## Consumables categories — nine, not seven (2026-08-22)
|
||||
|
||||
Correcting the earlier claim that the two formation-modifier families "have no
|
||||
group code, so no segment can reach them — the client's own gap". The client's
|
||||
own switch says otherwise. Literal table at `0x1801f5a38` (under
|
||||
`MyClubAdapterClass` / `CONSUMABLE_TYPE`); switch at `0x180048820` indexing by
|
||||
`enum + 1` through the byte table at `0x180048a90` into the case table at
|
||||
`0x180048a6c`:
|
||||
|
||||
| CONSUMABLE_TYPE | segment |
|
||||
|---|---|
|
||||
| **-1 (unset)** | `development` |
|
||||
| 1, 2 | `contracts` |
|
||||
| 3 | `healing` |
|
||||
| 4 | `fitness` |
|
||||
| **16** | `formation` |
|
||||
| 17 | `position` |
|
||||
| 23 | `playStyle` |
|
||||
| 24 | `managerLeagueModifier` |
|
||||
| 0, 5..15, 18..22 | `training` (switch default) |
|
||||
|
||||
`formation` was a SERVER gap, not a client one. `development` is the type-unset
|
||||
bucket — index 0 of an `enum + 1` table — i.e. the unfiltered view; the eight
|
||||
typed segments already reach all thirteen families exactly once, so it owns no
|
||||
family privately and maps to their union.
|
||||
|
||||
## Contract effect — the `contract: 7` inference is REFUTED at the source
|
||||
|
||||
Do not implement a contract effect from the catalog's `contract: 7`.
|
||||
|
||||
`fifa17-recon/tools/fut_store.py:232` — the generic `_item()` factory that builds
|
||||
EVERY item the oracle serves — hardcodes:
|
||||
|
||||
```python
|
||||
"playStyle": 250,
|
||||
"contract": 7,
|
||||
"fitness": 99,
|
||||
```
|
||||
|
||||
These are blanket placeholders on every item, players and consumables alike. The
|
||||
staging squad's GK reads back `contract 7 / fitness 99 / playStyle 250`: the same
|
||||
three constants. So the `contract: 7` carried in the production catalog for
|
||||
resource 5001004 is **our own oracle placeholder round-tripped through an
|
||||
observed profile**, not an EA value. Its evidence level is not INFERRED; it is
|
||||
KNOWN-BOGUS as a source of the effect.
|
||||
|
||||
### What the client's own table does say
|
||||
|
||||
`fcc_contractcards` (13 rows) is NOT amount-less, contrary to an earlier note
|
||||
here. Columns: `carddbid, cardsubtype, weightrare, cardassetid, gold, rating,
|
||||
bronze, silver`.
|
||||
|
||||
| rating | player (201) | manager (202) | gold | silver | bronze |
|
||||
|---|---|---|---|---|---|
|
||||
| 50 | 5001001 | 5001007 | 1 | 2 | 8 |
|
||||
| 65 | 5001002 | 5001008 | 8 | 10 | 10 / 8 |
|
||||
| 80 | 5001003 | 5001009 | 13 | 11 | 15 / 11 |
|
||||
| 60 | 5001004 | 5001010 | 3 | 6 | 15 |
|
||||
| 70 | 5001005 | 5001011 | 18 | 24 | 20 / 18 |
|
||||
| 90 | 5001006 | 5001012 | 28 | 24 | 28 / 24 |
|
||||
| 90 | 5001013 | — | 99 | 99 | 99 |
|
||||
|
||||
Compare the sibling `fcc_healingcards`, which shares `carddbid, cardsubtype,
|
||||
weightrare, cardassetid, rating` and differs only by carrying a single `amount`.
|
||||
So `weightrare` is the drop weight and the differing column(s) are the effect
|
||||
payload — which would make gold/silver/bronze a per-target-tier amount.
|
||||
|
||||
AGAINST that reading: the values are not monotonic across tiers (5001005 is gold
|
||||
18, silver 24, bronze 20; 5001003 is gold 13, silver 11, bronze 15), which is
|
||||
odd for an amount and unremarkable for a weight. Note also that **no column of
|
||||
5001004 equals 7**, so nothing here explains the placeholder either way.
|
||||
|
||||
Unresolved, and NOT to be guessed: the fcc tables are loaded by `FIFA17.exe`, not
|
||||
CardsDLL (the table-name and column literals are absent from the DLL), so the
|
||||
reader that would settle amount-vs-weight lives in the EXE. Status stays
|
||||
**EFFECT_UNKNOWN**.
|
||||
|
||||
## Post-ACK behaviour — OUTCOME B, LIVE_PROVEN (2026-08-22)
|
||||
|
||||
Captured with the staging probe answering `200 {"itemData":[]}` and mutating
|
||||
nothing:
|
||||
|
||||
```
|
||||
T0 POST /ut/game/fifa17/item/resource/5001004 {"apply":[{"id":100000003}]}
|
||||
T1 200 {"itemData":[]}
|
||||
T2 callback -> SUCCESS (no failure event; ZERO ut/delete/auth; session alive)
|
||||
T4 GET club/consumables/contracts <- refresh of the SOURCE list
|
||||
T5 GET club/consumables/development
|
||||
T6 GET squad/active <- refresh of the TARGET
|
||||
T7 no second mutation of any kind
|
||||
```
|
||||
|
||||
So of the candidate protocols:
|
||||
|
||||
```
|
||||
B) POST resource -> ACK -> client performs GET refresh
|
||||
-> the SERVER is expected to have mutated state
|
||||
```
|
||||
|
||||
Ruled out by observation: (A) the response carries the modified state — the body
|
||||
was empty and the client was satisfied; (C) a follow-up generic PUT/item — none
|
||||
was sent; (D) another route performs the mutation — nothing else was called.
|
||||
|
||||
Three consequences.
|
||||
|
||||
1. **The success verdict is transport-only, confirmed live.** The static read of
|
||||
`0x180035520` said the body is never inspected; an empty `itemData` produced a
|
||||
clean success and a surviving session, which is that prediction holding.
|
||||
2. **The server owns the effect entirely.** The client does not compute one; it
|
||||
re-reads. This is the good failure mode: a wrong server-side effect cannot be
|
||||
masked by client-side optimism, and the refresh will always show server truth.
|
||||
Here the refresh correctly showed `contracts copies=3` and an unchanged squad,
|
||||
because the probe consumed nothing.
|
||||
3. **There is no client-side amount to harvest.** Since the client never renders
|
||||
an optimistic "+N games" of its own, the live path cannot reveal the grant
|
||||
size. The number the client DISPLAYS on a contract card comes from the wire
|
||||
`contract` atom (0xb8 -> record+0x8c; see `fut_consumables.py`, which notes
|
||||
categories 2 and 3 ignore `amount` and read `contract`) — i.e. the server
|
||||
tells the client what the card is worth.
|
||||
|
||||
That last point matters for honesty: our oracle has been sending the placeholder
|
||||
`7` for that atom, so every contract card this project has ever shown a player
|
||||
said "7" because WE said 7. Recovering EA's real value is not reachable from the
|
||||
client's behaviour; it needs the `FIFA17.exe` reader of `fcc_contractcards`, or
|
||||
it becomes an explicit design decision. Status: **EFFECT_UNKNOWN**.
|
||||
|
||||
### Boundary status
|
||||
|
||||
| aspect | status |
|
||||
|---|---|
|
||||
| route, method, source encoding, target encoding | LIVE_PROVEN |
|
||||
| success condition (`[obj+0x1c] == 0`, body ignored) | STATIC_REVERSED + LIVE_CONFIRMED |
|
||||
| response shape accepted by the client | LIVE_PROVEN (`{"itemData":[]}`, session survived) |
|
||||
| post-ACK protocol | LIVE_PROVEN — outcome B |
|
||||
| batching | UNPROVEN — refused, never guessed |
|
||||
| contract effect / grant size | UNKNOWN (placeholder source refuted) |
|
||||
| source instance selection with multiple copies | UNDETERMINED (only 1 copy owned) |
|
||||
|
||||
## Consumable QUICK-SELL is PUT item/resource — LIVE_PROVEN (2026-08-22)
|
||||
|
||||
Captured on staging when the operator quick-sold a Position Modifier from the
|
||||
consumables screen:
|
||||
|
||||
```
|
||||
PUT /ut/game/fifa17/item/resource/5003068 body_len=0
|
||||
```
|
||||
|
||||
So `ut/<sku>/item/resource/<resourceId>` carries THREE verbs, and this is the
|
||||
third:
|
||||
|
||||
| verb | meaning |
|
||||
|---|---|
|
||||
| `GET` | item-definition lookup (`defs_route` parity) |
|
||||
| `POST` | apply the consumable (`ApplyCardByRes`, body `{"apply":[{"id":N}]}`) |
|
||||
| `PUT` | **quick-sell the consumable**, EMPTY body |
|
||||
|
||||
Note it is keyed by **resourceId**, i.e. the STACK, not by an owned instance
|
||||
id — unlike the player quick-sell, which is `DELETE ut/<sku>/item/<instanceId>`
|
||||
and is retail-proven in production. That asymmetry follows the consumables
|
||||
screen's own model: the UI entity there is a stack, not a card.
|
||||
|
||||
Neither stack has ever served this route. The Python oracle maps
|
||||
`item/resource` method-agnostically to `defs_route`, so a PUT would get a
|
||||
definition list and HTTP 200 while nothing was sold — the client would believe
|
||||
the sale succeeded. On staging the oracle is deliberately dead, so it 502'd and
|
||||
Core was left untouched (coins 29843976, owned 1993, consumables 17).
|
||||
|
||||
### Consequence for production
|
||||
|
||||
Production's oracle IS alive, so today a consumable quick-sell there would reach
|
||||
Python, return 200 from `defs_route`, and mutate nothing — the client would show
|
||||
a successful sale that never happened. That is a second, independent reason not
|
||||
to quick-sell consumables in production until this route is implemented in Rust.
|
||||
|
||||
### UNKNOWN, not to be guessed
|
||||
|
||||
* Does an empty-body PUT sell ONE copy or the WHOLE stack? The request carries no
|
||||
quantity, and both readings fit. A stack of 2 at 38 is either +38 or +76.
|
||||
* Which owned instance is consumed when several share the resourceId.
|
||||
* What response the client requires (the player path's ack shape may not apply).
|
||||
@@ -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.**
|
||||
@@ -920,16 +939,96 @@ freezes any of these — GAPs are "feature missing", not "crash".
|
||||
| 4 | FutViewCards | `0x1801293d0` | GET `ut/%s/item` | `itemData`(0x16b) → **array[card-item]** via `0x18013fe00` [FREEZE-RISK] | HANDLED (utas `/item` `defs_route` serves `itemData`) | HIGH |
|
||||
| 5 | FutActivateCard | `0x1801642c0` | PUT `ut/%s/item` (FUT_CLUB_ACTIVATE_ITEM_DP) | **none** (immediate `ret`) | ack — `{}` fine | HIGH |
|
||||
| 6 | FutApplyCard | `0x18012a710` | PUT `ut/%s/item` (apply by itemId) | `itemData`(0x16b) → **array[updated card-item]** via `0x18013fe00` [FREEZE-RISK] | GAP | HIGH |
|
||||
| 7 | FutApplyCardByRes | `0x18012ad10` | PUT `ut/%s/item` (apply by resourceId) | `itemData`(0x16b) → **array[updated card-item]** [FREEZE-RISK] | GAP | HIGH |
|
||||
| 7 | FutApplyCardByRes | `0x18012ad10` | **POST** `ut/%s/item/resource/<rid>` (apply by resourceId) | `itemData`(0x16b) → **array[updated card-item]** [FREEZE-RISK] | **SERVED** (Rust host, contracts + attribute training) | HIGH |
|
||||
|
||||
> **Rows 6 and 7 are NOT the same route.** `ApplyCardByRes` carries urlIndex
|
||||
> `0x0e`, which resolves to `ut/%s/item/resource` — not `ut/%s/item`
|
||||
> (`plan-2026-08-05-pack-opening.md:505-506`, shared with `DiscardCardByRes` and
|
||||
> `MoveCardByRes`). The verb is **POST**, live-proven by a real-client capture:
|
||||
> `POST /ut/game/fifa17/item/resource/5001004` `{"apply":[{"id":100000003}]}`.
|
||||
> This row previously read `PUT ut/%s/item` for both, and that conflation is what
|
||||
> kept the "apply must ride `PUT ut/%s/item`" hypothesis alive
|
||||
> (`CLIENT_ROUTE_SURFACE.md:104-106`) until the POST capture settled it — every
|
||||
> observed `PUT ut/%s/item` is a pile MOVE, never an apply.
|
||||
|
||||
| 8 | FutDiscardCard | `0x180127300` | DELETE `ut/delete/%s/item` (CardsDiscardCard) | `items`(0x171) → **array[int ids]** [FREEZE-RISK]; `totalCredits`(0x326) → int; `id`(0x15c) → int | GAP | HIGH |
|
||||
| 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.
|
||||
|
||||
Executable
+697
@@ -0,0 +1,697 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Decoder for EA APT (compiled ActionScript) as shipped in FIFA 17.
|
||||
|
||||
Clean-room implementation. The byte-level format facts (opcode numbers, operand
|
||||
widths, alignment rule, branch base, DefineFunction2 field order) were taken from
|
||||
a written specification derived from OpenSAGE, which is GPL-3.0 with EA
|
||||
additional terms. No OpenSAGE code was copied or transliterated; only the format
|
||||
description -- an interface specification -- was used. Reference read at
|
||||
OpenSAGE/OpenSAGE commit 588ac477367a0022adf29f20a084e8873014e6ce and
|
||||
OpenSAGE/AptEditor commit 09f73c655c45a781f883b623a93d2e8f5b065a6c.
|
||||
|
||||
FIFA 17 ships a 64-BIT variant of the format. Differences from the 32-bit SAGE
|
||||
layout described by the reference, all established by measurement against
|
||||
futSelectTeam and asserted by --selftest:
|
||||
|
||||
* Container pointers and counts are u64, not u32.
|
||||
* Parameterised instructions align their operand block to 8 bytes, not 4.
|
||||
Proven by the ConstantPool at 0xd38: aligning to 4 yields garbage, aligning
|
||||
to 8 yields count=401 with an index array that ends exactly on the
|
||||
parameter-list region.
|
||||
* The constant pool lives in a separate "Apt1" container member rather than a
|
||||
".const" sibling file. Entries are 16 bytes: {u64 type, u64 value}; type 1
|
||||
is a string whose value is an absolute offset inside that same member.
|
||||
* DefineFunction2's operand block is 48 bytes rather than 28, and the
|
||||
0x1234567898765432 trailer is stored as two u64 halves.
|
||||
* Branch displacements remain i32 and remain relative to the end of the
|
||||
branch record, exactly as in the 32-bit format.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import struct
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
APT1_MAGIC = b"Apt1"
|
||||
APTDATA_MAGIC = b"Apt Data:1:7:8\x1a\x00"
|
||||
|
||||
# Trailer sentinel on DefineFunction/DefineFunction2, stored as two u64 halves.
|
||||
FUNC_SENTINEL_LO = 0x98765432
|
||||
FUNC_SENTINEL_HI = 0x12345678
|
||||
|
||||
ALIGN = 8
|
||||
|
||||
# Operand kinds.
|
||||
NONE = "none" # no operand block
|
||||
U8REG = "u8reg" # 1 raw byte, register index
|
||||
U8CONST = "u8const" # 1 raw byte, constant-pool index
|
||||
U16CONST = "u16const" # 2 raw bytes, constant-pool index
|
||||
U8LIT = "u8lit" # 1 raw byte, literal integer
|
||||
U16LIT = "u16lit" # 2 raw bytes, literal integer
|
||||
BRANCH = "branch" # aligned i32, relative to end of record
|
||||
U32 = "u32" # aligned u32
|
||||
F32 = "f32" # aligned f32
|
||||
STR64 = "str64" # aligned u64 absolute offset to NUL-terminated string
|
||||
POOL = "pool" # aligned u64 count + u64 array offset (array of u64 ids)
|
||||
FUNC2 = "func2" # aligned DefineFunction2 record
|
||||
FUNC1 = "func1" # aligned DefineFunction record
|
||||
|
||||
# opcode -> (mnemonic, operand kind)
|
||||
OPCODES: dict[int, tuple[str, str]] = {
|
||||
0x00: ("End", NONE),
|
||||
0x04: ("NextFrame", NONE),
|
||||
0x06: ("Play", NONE),
|
||||
0x07: ("Stop", NONE),
|
||||
0x0A: ("Add", NONE),
|
||||
0x0B: ("Subtract", NONE),
|
||||
0x0C: ("Multiply", NONE),
|
||||
0x0D: ("Divide", NONE),
|
||||
0x12: ("Not", NONE),
|
||||
0x13: ("StringEquals", NONE),
|
||||
0x17: ("Pop", NONE),
|
||||
0x18: ("ToInteger", NONE),
|
||||
0x1C: ("GetVariable", NONE),
|
||||
0x1D: ("SetVariable", NONE),
|
||||
0x21: ("StringConcat", NONE),
|
||||
0x22: ("GetProperty", NONE),
|
||||
0x23: ("SetProperty", NONE),
|
||||
0x26: ("Trace", NONE),
|
||||
0x30: ("Random", NONE),
|
||||
0x3A: ("Delete", NONE),
|
||||
0x3B: ("Delete2", NONE),
|
||||
0x3C: ("DefineLocal", NONE),
|
||||
0x3D: ("CallFunction", NONE),
|
||||
0x3E: ("Return", NONE),
|
||||
0x3F: ("Modulo", NONE),
|
||||
0x40: ("NewObject", NONE),
|
||||
0x41: ("Var", NONE),
|
||||
0x42: ("InitArray", NONE),
|
||||
0x43: ("InitObject", NONE),
|
||||
0x44: ("TypeOf", NONE),
|
||||
0x47: ("Add2", NONE),
|
||||
0x48: ("LessThan2", NONE),
|
||||
0x49: ("Equals2", NONE),
|
||||
0x4A: ("ToNumber", NONE),
|
||||
0x4B: ("ToString", NONE),
|
||||
0x4C: ("PushDuplicate", NONE),
|
||||
0x4E: ("GetMember", NONE),
|
||||
0x4F: ("SetMember", NONE),
|
||||
0x50: ("Increment", NONE),
|
||||
0x51: ("Decrement", NONE),
|
||||
0x52: ("CallMethod", NONE),
|
||||
# 0x53 appears in the reference enum as NewMethod but the reference never
|
||||
# parses it. Standard AVM1 ActionNewMethod carries no operand block;
|
||||
# decoding it as zero-length keeps this artifact synchronised with every
|
||||
# branch still landing on an instruction boundary, which is the check that
|
||||
# would break first if the width were wrong.
|
||||
0x53: ("NewMethod", NONE),
|
||||
0x54: ("InstanceOf", NONE),
|
||||
0x55: ("Enumerate2", NONE),
|
||||
0x56: ("PushThis", NONE),
|
||||
0x59: ("PushZero", NONE),
|
||||
0x5A: ("PushOne", NONE),
|
||||
0x5B: ("CallFuncPop", NONE),
|
||||
0x5C: ("CallFunc", NONE),
|
||||
0x5D: ("CallMethodPop", NONE),
|
||||
0x62: ("BitwiseXOr", NONE),
|
||||
0x66: ("StrictEqual", NONE),
|
||||
0x67: ("Greater", NONE),
|
||||
0x69: ("Extends", NONE),
|
||||
0x70: ("PushThisVar", NONE),
|
||||
0x71: ("PushGlobalVar", NONE),
|
||||
0x72: ("ZeroVar", NONE),
|
||||
0x73: ("PushTrue", NONE),
|
||||
0x74: ("PushFalse", NONE),
|
||||
0x75: ("PushNull", NONE),
|
||||
0x76: ("PushUndefined", NONE),
|
||||
0x87: ("SetRegister", U32),
|
||||
0x88: ("ConstantPool", POOL),
|
||||
0x8C: ("GotoLabel", STR64),
|
||||
0x8E: ("DefineFunction2", FUNC2),
|
||||
0x96: ("PushData", POOL),
|
||||
0x99: ("BranchAlways", BRANCH),
|
||||
0x9B: ("DefineFunction", FUNC1),
|
||||
0x9D: ("BranchIfTrue", BRANCH),
|
||||
0x9F: ("GotoFrame2", U32),
|
||||
0xA1: ("PushString", STR64),
|
||||
0xA2: ("PushConstantByte", U8CONST),
|
||||
0xA3: ("PushConstantWord", U16CONST),
|
||||
0xA4: ("GetStringVar", STR64),
|
||||
0xA5: ("GetStringMember", STR64),
|
||||
0xA6: ("SetStringVar", STR64),
|
||||
0xA7: ("SetStringMember", STR64),
|
||||
0xAE: ("PushValueOfVar", U8CONST),
|
||||
0xAF: ("GetNamedMember", U8CONST),
|
||||
0xB0: ("CallNamedFuncPop", U8CONST),
|
||||
0xB1: ("CallNamedFunc", U8CONST),
|
||||
0xB2: ("CallNamedMethodPop", U8CONST),
|
||||
0xB3: ("CallNamedMethod", U8CONST),
|
||||
0xB4: ("PushFloat", F32),
|
||||
0xB5: ("PushByte", U8LIT),
|
||||
0xB6: ("PushShort", U16LIT),
|
||||
0xB8: ("BranchIfFalse", BRANCH),
|
||||
0xB9: ("PushRegister", U8REG),
|
||||
}
|
||||
|
||||
ALIGNED_KINDS = {BRANCH, U32, F32, STR64, POOL, FUNC2, FUNC1}
|
||||
|
||||
|
||||
class DecodeError(Exception):
|
||||
"""Raised when the stream cannot be decoded without guessing."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Instr:
|
||||
offset: int
|
||||
opcode: int
|
||||
mnemonic: str
|
||||
length: int # opcode byte through end of operand block, incl. padding
|
||||
operands: dict
|
||||
raw: bytes
|
||||
target: int | None = None # resolved branch destination
|
||||
comment: str = ""
|
||||
|
||||
def render(self, width: int = 22) -> str:
|
||||
ops = self.comment or ""
|
||||
return f" {self.offset:#07x} {self.mnemonic:<{width}} {ops}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Function:
|
||||
name: str
|
||||
record_offset: int # offset of the DefineFunction* opcode byte
|
||||
body_start: int
|
||||
body_end: int
|
||||
n_params: int
|
||||
n_registers: int
|
||||
flags: int
|
||||
params: list = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def anonymous(self) -> bool:
|
||||
return not self.name
|
||||
|
||||
|
||||
PRELOAD_FLAGS = [
|
||||
(0x010000, "PreloadExtern"),
|
||||
(0x008000, "PreloadParent"),
|
||||
(0x004000, "PreloadRoot"),
|
||||
(0x002000, "SupressSuper"),
|
||||
(0x001000, "PreloadSuper"),
|
||||
(0x000800, "SupressArguments"),
|
||||
(0x000400, "PreloadArguments"),
|
||||
(0x000200, "SupressThis"),
|
||||
(0x000100, "PreloadThis"),
|
||||
(0x000001, "PreloadGlobal"),
|
||||
]
|
||||
|
||||
# Registers preloaded by the VM, in flag order, starting at index 1.
|
||||
PRELOAD_ORDER = [
|
||||
(0x000100, "this"),
|
||||
(0x000400, "arguments"),
|
||||
(0x001000, "super"),
|
||||
(0x004000, "_root"),
|
||||
(0x008000, "_parent"),
|
||||
(0x000001, "_global"),
|
||||
(0x010000, "extern"),
|
||||
]
|
||||
|
||||
|
||||
def flag_names(flags: int) -> str:
|
||||
got = [n for bit, n in PRELOAD_FLAGS if flags & bit]
|
||||
return "|".join(got) if got else "0"
|
||||
|
||||
|
||||
def register_map(fn: Function) -> dict[int, str]:
|
||||
"""Reproduce the VM's register preload order, then bound parameters."""
|
||||
regs: dict[int, str] = {}
|
||||
idx = 1
|
||||
for bit, name in PRELOAD_ORDER:
|
||||
if fn.flags & bit:
|
||||
regs[idx] = name
|
||||
idx += 1
|
||||
for reg, pname in fn.params:
|
||||
if reg:
|
||||
regs[reg] = pname
|
||||
return regs
|
||||
|
||||
|
||||
class ConstPool:
|
||||
"""The 'Apt1' container member: header, 16-byte entries, string table."""
|
||||
|
||||
def __init__(self, data: bytes):
|
||||
if data[:4] != APT1_MAGIC:
|
||||
raise DecodeError(f"not an Apt1 member: {data[:4]!r}")
|
||||
self.data = data
|
||||
self.count = struct.unpack_from("<Q", data, 0x20)[0]
|
||||
self.first = struct.unpack_from("<Q", data, 0x28)[0]
|
||||
self.entries: list[tuple[int, int, str | None]] = []
|
||||
for i in range(self.count):
|
||||
off = self.first + i * 16
|
||||
if off + 16 > len(data):
|
||||
raise DecodeError(f"const entry {i} at {off:#x} runs past end")
|
||||
etype, value = struct.unpack_from("<QQ", data, off)
|
||||
text = None
|
||||
if etype == 1:
|
||||
if not (0 < value < len(data)):
|
||||
raise DecodeError(
|
||||
f"const entry {i}: string offset {value:#x} outside member"
|
||||
)
|
||||
end = data.find(b"\0", value)
|
||||
if end < 0:
|
||||
raise DecodeError(f"const entry {i}: unterminated string")
|
||||
text = data[value:end].decode("latin1")
|
||||
self.entries.append((etype, value, text))
|
||||
|
||||
def string(self, index: int) -> str:
|
||||
if not (0 <= index < len(self.entries)):
|
||||
raise DecodeError(f"const index {index} out of range (0..{len(self.entries)-1})")
|
||||
etype, _, text = self.entries[index]
|
||||
if etype != 1 or text is None:
|
||||
raise DecodeError(f"const index {index} is type {etype}, not a string")
|
||||
return text
|
||||
|
||||
def find(self, needle: str) -> list[int]:
|
||||
return [i for i, (_, _, t) in enumerate(self.entries) if t == needle]
|
||||
|
||||
|
||||
class AptData:
|
||||
"""The 'Apt Data' container member: movie structures plus action streams."""
|
||||
|
||||
def __init__(self, data: bytes, pool: ConstPool):
|
||||
if not data.startswith(APTDATA_MAGIC[:8]):
|
||||
raise DecodeError(f"not an Apt Data member: {data[:16]!r}")
|
||||
self.data = data
|
||||
self.pool = pool
|
||||
self.scope: list[str] = [] # installed by ConstantPool
|
||||
self.functions: list[Function] = []
|
||||
|
||||
# -- helpers ---------------------------------------------------------
|
||||
def cstr(self, off: int) -> str:
|
||||
if not (0 <= off < len(self.data)):
|
||||
raise DecodeError(f"string offset {off:#x} outside Apt Data")
|
||||
end = self.data.find(b"\0", off)
|
||||
if end < 0:
|
||||
raise DecodeError(f"unterminated string at {off:#x}")
|
||||
return self.data[off:end].decode("latin1")
|
||||
|
||||
def const(self, index: int) -> str:
|
||||
"""Resolve through the scope pool installed by the most recent 0x88."""
|
||||
if self.scope:
|
||||
if not (0 <= index < len(self.scope)):
|
||||
raise DecodeError(
|
||||
f"scope-pool index {index} out of range (0..{len(self.scope)-1})"
|
||||
)
|
||||
return self.scope[index]
|
||||
return self.pool.string(index)
|
||||
|
||||
def install_pool(self, ids: list[int]) -> None:
|
||||
self.scope = [self.pool.string(i) for i in ids]
|
||||
|
||||
# -- instruction decoding --------------------------------------------
|
||||
def decode_one(self, pos: int) -> Instr:
|
||||
d = self.data
|
||||
if pos >= len(d):
|
||||
raise DecodeError(f"position {pos:#x} past end of stream")
|
||||
op = d[pos]
|
||||
entry = OPCODES.get(op)
|
||||
if entry is None:
|
||||
raise DecodeError(
|
||||
f"unknown opcode {op:#04x} at {pos:#07x} "
|
||||
f"(raw {d[pos:pos+8].hex(' ')}) - refusing to guess its length"
|
||||
)
|
||||
mnem, kind = entry
|
||||
p = pos + 1
|
||||
if kind in ALIGNED_KINDS:
|
||||
p = (p + ALIGN - 1) & ~(ALIGN - 1)
|
||||
|
||||
ops: dict = {}
|
||||
comment = ""
|
||||
target = None
|
||||
|
||||
def need(n: int) -> None:
|
||||
if p + n > len(d):
|
||||
raise DecodeError(f"{mnem} at {pos:#07x} truncated: needs {n} bytes")
|
||||
|
||||
if kind == NONE:
|
||||
pass
|
||||
elif kind in (U8REG, U8LIT):
|
||||
need(1)
|
||||
ops["value"] = d[p]
|
||||
p += 1
|
||||
comment = f"r{ops['value']}" if kind == U8REG else str(ops["value"])
|
||||
elif kind == U8CONST:
|
||||
need(1)
|
||||
ops["index"] = d[p]
|
||||
p += 1
|
||||
comment = f"{ops['index']:#04x} -> {self.const(ops['index'])!r}"
|
||||
elif kind == U16CONST:
|
||||
need(2)
|
||||
ops["index"] = struct.unpack_from("<H", d, p)[0]
|
||||
p += 2
|
||||
comment = f"{ops['index']:#06x} -> {self.const(ops['index'])!r}"
|
||||
elif kind == U16LIT:
|
||||
need(2)
|
||||
ops["value"] = struct.unpack_from("<H", d, p)[0]
|
||||
p += 2
|
||||
comment = str(ops["value"])
|
||||
elif kind == U32:
|
||||
need(4)
|
||||
ops["value"] = struct.unpack_from("<I", d, p)[0]
|
||||
p += 4
|
||||
comment = str(ops["value"])
|
||||
elif kind == F32:
|
||||
need(4)
|
||||
ops["value"] = struct.unpack_from("<f", d, p)[0]
|
||||
p += 4
|
||||
comment = repr(ops["value"])
|
||||
elif kind == BRANCH:
|
||||
need(4)
|
||||
disp = struct.unpack_from("<i", d, p)[0]
|
||||
p += 4
|
||||
ops["displacement"] = disp
|
||||
target = p + disp # base = end of record
|
||||
comment = f"{disp:+d} -> {target:#07x}"
|
||||
elif kind == STR64:
|
||||
need(8)
|
||||
off = struct.unpack_from("<Q", d, p)[0]
|
||||
p += 8
|
||||
ops["offset"] = off
|
||||
ops["text"] = self.cstr(off)
|
||||
comment = f"{ops['text']!r}"
|
||||
elif kind == POOL:
|
||||
need(16)
|
||||
count, arr = struct.unpack_from("<QQ", d, p)
|
||||
p += 16
|
||||
if arr + count * 8 > len(d):
|
||||
raise DecodeError(f"{mnem} at {pos:#07x}: array {arr:#x}[{count}] overruns")
|
||||
ids = list(struct.unpack_from(f"<{count}Q", d, arr))
|
||||
ops["count"], ops["array"], ops["ids"] = count, arr, ids
|
||||
comment = f"count={count} array={arr:#x}"
|
||||
elif kind in (FUNC2, FUNC1):
|
||||
if kind == FUNC2:
|
||||
need(48)
|
||||
name_off, n_params = struct.unpack_from("<QI", d, p)
|
||||
n_reg = d[p + 12]
|
||||
flags = int.from_bytes(d[p + 13:p + 16], "little")
|
||||
plist, body = struct.unpack_from("<QQ", d, p + 16)
|
||||
lo, hi = struct.unpack_from("<QQ", d, p + 32)
|
||||
p += 48
|
||||
else:
|
||||
need(40)
|
||||
name_off, n_params, plist, body = struct.unpack_from("<QQQQ", d, p)
|
||||
n_reg, flags = 4, 0
|
||||
lo, hi = struct.unpack_from("<QQ", d, p + 32)
|
||||
p += 40
|
||||
if (lo, hi) != (FUNC_SENTINEL_LO, FUNC_SENTINEL_HI):
|
||||
raise DecodeError(
|
||||
f"{mnem} at {pos:#07x}: bad trailer {lo:#x}/{hi:#x}, "
|
||||
"record layout is wrong"
|
||||
)
|
||||
name = self.cstr(name_off)
|
||||
params = []
|
||||
for i in range(n_params):
|
||||
e = plist + i * 16
|
||||
if e + 16 > len(d):
|
||||
raise DecodeError(f"{mnem} at {pos:#07x}: param {i} overruns")
|
||||
reg, pn = struct.unpack_from("<QQ", d, e)
|
||||
params.append((reg, self.cstr(pn)))
|
||||
ops.update(name=name, n_params=n_params, n_registers=n_reg,
|
||||
flags=flags, params=params, body_size=body)
|
||||
comment = (f"{name or '<anonymous>'}({', '.join(n for _, n in params)}) "
|
||||
f"nRegs={n_reg} flags={flag_names(flags)} bodySize={body}")
|
||||
ops["body_start"] = p
|
||||
ops["body_end"] = p + body
|
||||
else:
|
||||
raise DecodeError(f"internal: unhandled kind {kind}")
|
||||
|
||||
return Instr(pos, op, mnem, p - pos, ops, d[pos:p], target, comment)
|
||||
|
||||
def decode_stream(self, start: int, limit: int | None = None) -> list[Instr]:
|
||||
"""Linear decode using the reference termination rule.
|
||||
|
||||
Stops when the last instruction was End AND we are past every branch
|
||||
destination seen so far. A stream may legitimately continue past an End.
|
||||
"""
|
||||
out: list[Instr] = []
|
||||
pos = start
|
||||
furthest = start
|
||||
while True:
|
||||
if limit is not None and pos >= limit:
|
||||
break
|
||||
ins = self.decode_one(pos)
|
||||
out.append(ins)
|
||||
if ins.target is not None:
|
||||
furthest = max(furthest, ins.target)
|
||||
if ins.mnemonic == "ConstantPool":
|
||||
self.install_pool(ins.operands["ids"])
|
||||
if ins.mnemonic in ("DefineFunction2", "DefineFunction"):
|
||||
fn = Function(
|
||||
name=ins.operands["name"],
|
||||
record_offset=ins.offset,
|
||||
body_start=ins.operands["body_start"],
|
||||
body_end=ins.operands["body_end"],
|
||||
n_params=ins.operands["n_params"],
|
||||
n_registers=ins.operands["n_registers"],
|
||||
flags=ins.operands["flags"],
|
||||
params=ins.operands["params"],
|
||||
)
|
||||
self.functions.append(fn)
|
||||
furthest = max(furthest, fn.body_end)
|
||||
pos = ins.offset + ins.length
|
||||
if ins.mnemonic == "End" and pos > furthest:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def load(apt1_path: str, aptdata_path: str) -> tuple[ConstPool, AptData]:
|
||||
pool = ConstPool(open(apt1_path, "rb").read())
|
||||
movie = AptData(open(aptdata_path, "rb").read(), pool)
|
||||
return pool, movie
|
||||
|
||||
|
||||
def find_streams(movie: AptData) -> list[int]:
|
||||
"""Seed stream starts: every ConstantPool record that validates."""
|
||||
seeds = []
|
||||
d = movie.data
|
||||
for p in range(len(d)):
|
||||
if d[p] != 0x88:
|
||||
continue
|
||||
try:
|
||||
ins = movie.decode_one(p)
|
||||
except DecodeError:
|
||||
continue
|
||||
if ins.operands.get("count", 0) and ins.operands["ids"] == list(
|
||||
range(ins.operands["count"])
|
||||
):
|
||||
seeds.append(p)
|
||||
return seeds
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--apt1", default="fifa17-recon/data/apt/futSelectTeam_Apt1.bin")
|
||||
ap.add_argument("--aptdata", default="fifa17-recon/data/apt/futSelectTeam_AptData.bin")
|
||||
ap.add_argument("--stream", type=lambda s: int(s, 0), help="decode one stream at offset")
|
||||
ap.add_argument("--function", help="decode the named function's body")
|
||||
ap.add_argument("--list-functions", action="store_true")
|
||||
ap.add_argument("--report", action="store_true", help="structural validation report")
|
||||
ap.add_argument("--strings", action="store_true", help="dump the constant pool")
|
||||
ap.add_argument("--selftest", action="store_true")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
pool, movie = load(args.apt1, args.aptdata)
|
||||
|
||||
if args.selftest:
|
||||
return selftest(pool, movie)
|
||||
|
||||
if args.strings:
|
||||
for i, (t, v, s) in enumerate(pool.entries):
|
||||
print(f" #{i:3d} type={t} @{v:#07x} {s!r}")
|
||||
return 0
|
||||
|
||||
seeds = find_streams(movie)
|
||||
if args.stream is not None:
|
||||
seeds = [args.stream]
|
||||
|
||||
all_instrs: list[Instr] = []
|
||||
for s in seeds:
|
||||
all_instrs.extend(movie.decode_stream(s))
|
||||
|
||||
if args.list_functions:
|
||||
for fn in movie.functions:
|
||||
regs = register_map(fn)
|
||||
rs = " ".join(f"r{k}={v}" for k, v in sorted(regs.items()))
|
||||
print(f" {fn.body_start:#07x}-{fn.body_end:#07x} "
|
||||
f"{fn.name or '<anonymous>':<34} {rs}")
|
||||
return 0
|
||||
|
||||
if args.function:
|
||||
for fn in movie.functions:
|
||||
if fn.name == args.function:
|
||||
print(f"; {fn.name} body {fn.body_start:#x}..{fn.body_end:#x} "
|
||||
f"flags={flag_names(fn.flags)} nRegs={fn.n_registers}")
|
||||
regs = register_map(fn)
|
||||
for k, v in sorted(regs.items()):
|
||||
print(f"; r{k} = {v}")
|
||||
for ins in movie.decode_stream(fn.body_start, fn.body_end):
|
||||
print(ins.render())
|
||||
return 0
|
||||
print(f"function {args.function!r} not found", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.report:
|
||||
return report(movie, seeds, all_instrs)
|
||||
|
||||
for ins in all_instrs:
|
||||
print(ins.render())
|
||||
return 0
|
||||
|
||||
|
||||
def report(movie: AptData, seeds: list[int], instrs: list[Instr]) -> int:
|
||||
import collections
|
||||
hist = collections.Counter(i.mnemonic for i in instrs)
|
||||
covered = set()
|
||||
for i in instrs:
|
||||
covered.update(range(i.offset, i.offset + i.length))
|
||||
branches = [i for i in instrs if i.target is not None]
|
||||
boundaries = {i.offset for i in instrs}
|
||||
bad = [i for i in branches if i.target not in boundaries]
|
||||
print(f" streams decoded : {len(seeds)} {[hex(s) for s in seeds]}")
|
||||
print(f" instructions : {len(instrs)}")
|
||||
print(f" bytes covered : {len(covered)} of {len(movie.data)}")
|
||||
print(f" functions : {len(movie.functions)}")
|
||||
print(f" branches : {len(branches)}")
|
||||
print(f" invalid branch targets: {len(bad)}")
|
||||
for i in bad[:10]:
|
||||
print(f" {i.offset:#07x} {i.mnemonic} -> {i.target:#07x}")
|
||||
print(f" distinct opcodes : {len(hist)}")
|
||||
for m, n in hist.most_common():
|
||||
print(f" {m:<22} {n}")
|
||||
return 1 if bad else 0
|
||||
|
||||
|
||||
def selftest(pool: ConstPool, movie: AptData) -> int:
|
||||
"""Assertions that pin the measured format facts."""
|
||||
ok = True
|
||||
|
||||
def check(label: str, cond: bool, detail: str = "") -> None:
|
||||
nonlocal ok
|
||||
print(f" [{'PASS' if cond else 'FAIL'}] {label}{(' - ' + detail) if detail else ''}")
|
||||
ok = ok and cond
|
||||
|
||||
check("Apt1 entry count", pool.count == 414, f"{pool.count}")
|
||||
check("Apt1 all entries are strings",
|
||||
all(t == 1 for t, _, _ in pool.entries))
|
||||
check("Apt1 entry array abuts string table",
|
||||
pool.first + pool.count * 16 == min(v for t, v, _ in pool.entries if t == 1))
|
||||
|
||||
# Phase 3: exact pointer -> string resolution for known symbols.
|
||||
for name in ("CheckIsKitLocked", "KITS_AVAILABLE", "FUT_GET_MATCH_KITS_DP",
|
||||
"mcLockHome"):
|
||||
idx = pool.find(name)
|
||||
check(f"string resolves: {name}", len(idx) == 1 and pool.string(idx[0]) == name,
|
||||
f"index {idx}")
|
||||
|
||||
# Bad pointers must raise, not fuzzy-match.
|
||||
for bad in (-1, 10 ** 6):
|
||||
try:
|
||||
pool.string(bad)
|
||||
check(f"bad const index {bad} rejected", False)
|
||||
except DecodeError:
|
||||
check(f"bad const index {bad} rejected", True)
|
||||
|
||||
# Phase 4 fixtures for the two EA opcodes.
|
||||
movie.scope = ["alpha", "beta"] + [f"c{i}" for i in range(2, 300)]
|
||||
fixtures = [
|
||||
(bytes([0xB9, 0x00]), "PushRegister", 2, "r0"),
|
||||
(bytes([0xB9, 0x05]), "PushRegister", 2, "r5"),
|
||||
(bytes([0xB9, 0xFF]), "PushRegister", 2, "r255"),
|
||||
(bytes([0xAF, 0x00]), "GetNamedMember", 2, "'alpha'"),
|
||||
(bytes([0xAF, 0x01]), "GetNamedMember", 2, "'beta'"),
|
||||
(bytes([0xA2, 0x01]), "PushConstantByte", 2, "'beta'"),
|
||||
]
|
||||
for raw, mnem, length, needle in fixtures:
|
||||
probe = AptData(APTDATA_MAGIC + raw.ljust(16, b"\0"), pool)
|
||||
probe.scope = movie.scope
|
||||
ins = probe.decode_one(16)
|
||||
check(f"fixture {raw.hex()} -> {mnem}",
|
||||
ins.mnemonic == mnem and ins.length == length and needle in ins.comment,
|
||||
f"{ins.mnemonic} len={ins.length} {ins.comment}")
|
||||
|
||||
# Truncated records must fail closed.
|
||||
for raw in (bytes([0xB9]), bytes([0xAF]), bytes([0xA3, 0x01])):
|
||||
probe = AptData(APTDATA_MAGIC + raw, pool)
|
||||
probe.scope = movie.scope
|
||||
try:
|
||||
probe.decode_one(16)
|
||||
check(f"truncated {raw.hex()} fails closed", False)
|
||||
except DecodeError:
|
||||
check(f"truncated {raw.hex()} fails closed", True)
|
||||
|
||||
# Out-of-range pool index must fail closed, not silently clamp.
|
||||
probe = AptData(APTDATA_MAGIC + bytes([0xAF, 0x10]), pool)
|
||||
probe.scope = ["only-one"]
|
||||
try:
|
||||
probe.decode_one(16)
|
||||
check("out-of-range scope index rejected", False)
|
||||
except DecodeError:
|
||||
check("out-of-range scope index rejected", True)
|
||||
|
||||
# Unknown opcode must refuse rather than resynchronise.
|
||||
probe = AptData(APTDATA_MAGIC + bytes([0xEE, 0x00]), pool)
|
||||
try:
|
||||
probe.decode_one(16)
|
||||
check("unknown opcode refuses to guess length", False)
|
||||
except DecodeError as e:
|
||||
check("unknown opcode refuses to guess length", "refusing to guess" in str(e))
|
||||
|
||||
# Whole-artifact decode.
|
||||
movie.scope = []
|
||||
movie.functions = []
|
||||
seeds = find_streams(movie)
|
||||
instrs: list[Instr] = []
|
||||
try:
|
||||
for s in seeds:
|
||||
instrs.extend(movie.decode_stream(s))
|
||||
check("whole artifact decodes", True, f"{len(instrs)} instructions")
|
||||
except DecodeError as e:
|
||||
check("whole artifact decodes", False, str(e))
|
||||
return 1
|
||||
|
||||
boundaries = {i.offset for i in instrs}
|
||||
bad = [i for i in instrs if i.target is not None and i.target not in boundaries]
|
||||
check("every branch lands on an instruction boundary", not bad,
|
||||
f"{len(bad)} bad")
|
||||
|
||||
# CheckIsKitLocked is CALLED here, never defined here: it is a method on the
|
||||
# mcSelectTeam child clip, whose class lives in another asset. Assert the
|
||||
# call site is bound exactly, and that this asset defines no such function.
|
||||
called = [i for i in instrs if i.comment and "CheckIsKitLocked" in i.comment]
|
||||
check("CheckIsKitLocked referenced exactly once", len(called) == 1,
|
||||
f"{[hex(i.offset) for i in called]}")
|
||||
check("CheckIsKitLocked reference is PushConstantWord (pool index > u8)",
|
||||
bool(called) and called[0].mnemonic == "PushConstantWord")
|
||||
check("CheckIsKitLocked is not defined in this asset",
|
||||
"CheckIsKitLocked" not in {f.name for f in movie.functions})
|
||||
|
||||
# The gate contract the native DP builder must satisfy.
|
||||
gate = [i for i in instrs if i.comment and "KITS_AVAILABLE" in i.comment]
|
||||
check("KITS_AVAILABLE read exactly once", len(gate) == 1)
|
||||
check("KITS_AVAILABLE read via GetNamedMember on the DP header",
|
||||
bool(gate) and gate[0].mnemonic == "GetNamedMember")
|
||||
|
||||
# 8-byte alignment is load-bearing: prove 4 would break the pool record.
|
||||
p4 = (0xD38 + 1 + 3) & ~3
|
||||
c4 = struct.unpack_from("<Q", movie.data, p4)[0]
|
||||
check("alignment is 8 not 4", c4 != 401, f"align4 count would be {c4:#x}")
|
||||
|
||||
print(f"\n {'ALL PASS' if ok else 'FAILURES PRESENT'}")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+587
@@ -0,0 +1,587 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Interpret FIFA 17's atom -> field-id dispatch functions instead of pattern-scanning them.
|
||||
|
||||
WHY THIS EXISTS
|
||||
---------------
|
||||
CardsDLL turns a JSON key into an "atom index" (a position in the string-pointer
|
||||
table at .data 0x1802d2760), then a per-response-family mapper converts that index
|
||||
into an internal field id with a chain of integer compares and jump tables.
|
||||
|
||||
A previous attempt to recover each mapper's accepted atoms by scanning for
|
||||
`sub ecx,K` / `cmp ecx,L` / `ja` patterns produced a confidently wrong answer: it
|
||||
reported that no mapper accepts atom 424 (`manager`), while a live client plainly
|
||||
holds a resident manager record. Pattern scanning cannot see control flow, so it
|
||||
cannot tell which compares are actually reachable.
|
||||
|
||||
This module executes the mappers instead. The modelled subset is exactly what these
|
||||
functions use: the resolver call, integer cmp/sub/add/dec, conditional and computed
|
||||
jumps, jump-table loads out of the image, lea, movsxd, and `mov eax,imm; ret`.
|
||||
Anything outside that subset raises Unsupported, so a wrong field id is never
|
||||
returned silently.
|
||||
|
||||
TWO DECODER TRAPS THIS MODULE IS REQUIRED TO HANDLE
|
||||
---------------------------------------------------
|
||||
1. ModRM rm==5 with mod!=0 is [rbp+disp], NOT RIP-relative. Only mod==0 with rm==5
|
||||
is RIP-relative. Treating all rm==5 as RIP-relative hides rbp-based DTO accesses.
|
||||
Covered by test_rbp_relative_is_not_rip_relative.
|
||||
2. A constant frequently arrives in a register (`mov r8d,0x4` ... later stored), so
|
||||
searching for an immediate-to-memory store misses it. The interpreter tracks
|
||||
register values, so propagated constants are followed.
|
||||
Covered by test_constant_propagated_through_register.
|
||||
|
||||
Run `--selftest` to execute the positive controls. Negative results from this tool
|
||||
are only admissible when the selftest passes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import bisect
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REGS = ("rax", "rcx", "rdx", "rbx", "rsp", "rbp", "rsi", "rdi",
|
||||
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15")
|
||||
|
||||
ATOM_TABLE_BASE = 0x1802D2760 # validated against 6 known anchors, see anchors()
|
||||
ATOM_RESOLVER = 0x180180D00 # key string -> atom index, returns in eax
|
||||
ITEM_MAPPER = 0x18012FD40 # the DTO/item mapper: atom 568 'players' -> 1
|
||||
|
||||
|
||||
class Unsupported(Exception):
|
||||
"""The mapper used an instruction or address outside the modelled subset."""
|
||||
|
||||
|
||||
def s32(v: int) -> int:
|
||||
v &= 0xFFFFFFFF
|
||||
return v - 0x100000000 if v & 0x80000000 else v
|
||||
|
||||
|
||||
class Image:
|
||||
"""A parsed PE, with VA<->file mapping and .pdata function bounds."""
|
||||
|
||||
def __init__(self, path: Path):
|
||||
self.buf = path.read_bytes()
|
||||
b = self.buf
|
||||
pe = struct.unpack_from("<I", b, 0x3C)[0]
|
||||
if b[pe:pe + 4] != b"PE\0\0":
|
||||
raise ValueError(f"{path} is not a PE image")
|
||||
nsec = struct.unpack_from("<H", b, pe + 6)[0]
|
||||
optsz = struct.unpack_from("<H", b, pe + 20)[0]
|
||||
self.base = struct.unpack_from("<Q", b, pe + 24 + 24)[0]
|
||||
self.sections = []
|
||||
for i in range(nsec):
|
||||
o = pe + 24 + optsz + 40 * i
|
||||
name = b[o:o + 8].rstrip(b"\0").decode(errors="replace")
|
||||
vsz, va, rsz, raw = struct.unpack_from("<IIII", b, o + 8)
|
||||
self.sections.append((name, va, vsz, raw, rsz))
|
||||
self._funcs = None
|
||||
|
||||
def va2off(self, va: int):
|
||||
rva = va - self.base
|
||||
for _name, sva, vsz, raw, rsz in self.sections:
|
||||
if sva <= rva < sva + max(vsz, rsz):
|
||||
off = raw + (rva - sva)
|
||||
if off < len(self.buf):
|
||||
return off
|
||||
return None
|
||||
|
||||
def rd8(self, va: int) -> int:
|
||||
o = self.va2off(va)
|
||||
if o is None:
|
||||
raise Unsupported(f"unmapped byte read 0x{va:x}")
|
||||
return self.buf[o]
|
||||
|
||||
def rd32(self, va: int) -> int:
|
||||
o = self.va2off(va)
|
||||
if o is None:
|
||||
raise Unsupported(f"unmapped dword read 0x{va:x}")
|
||||
return struct.unpack_from("<I", self.buf, o)[0]
|
||||
|
||||
def cstr(self, va: int, maxlen: int = 96):
|
||||
o = self.va2off(va)
|
||||
if o is None:
|
||||
return None
|
||||
end = self.buf.find(b"\0", o, o + maxlen)
|
||||
if end < 0:
|
||||
return None
|
||||
try:
|
||||
return self.buf[o:end].decode("ascii")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
# ---- .pdata gives exact function bounds; never guess a prologue ----
|
||||
def functions(self):
|
||||
if self._funcs is None:
|
||||
sec = next(s for s in self.sections if s[0] == ".pdata")
|
||||
_n, _va, vsz, raw, _rsz = sec
|
||||
out = []
|
||||
for i in range(vsz // 12):
|
||||
beg, end, _unw = struct.unpack_from("<III", self.buf, raw + 12 * i)
|
||||
if beg or end:
|
||||
out.append((self.base + beg, self.base + end))
|
||||
out.sort()
|
||||
self._funcs = out
|
||||
return self._funcs
|
||||
|
||||
def function_of(self, va: int):
|
||||
fs = self.functions()
|
||||
starts = [f[0] for f in fs]
|
||||
i = bisect.bisect_right(starts, va) - 1
|
||||
if i >= 0 and fs[i][0] <= va < fs[i][1]:
|
||||
return fs[i]
|
||||
return None
|
||||
|
||||
def atom(self, index: int):
|
||||
ptr = struct.unpack_from("<Q", self.buf, self.va2off(ATOM_TABLE_BASE) + 8 * index)[0]
|
||||
return self.cstr(ptr)
|
||||
|
||||
def atom_index(self, name: str):
|
||||
off = self.va2off(ATOM_TABLE_BASE)
|
||||
for i in range(4096):
|
||||
ptr = struct.unpack_from("<Q", self.buf, off + 8 * i)[0]
|
||||
if self.cstr(ptr) == name:
|
||||
return i
|
||||
return None
|
||||
|
||||
|
||||
class Mapper:
|
||||
"""Executes one dispatch function for a given atom index."""
|
||||
|
||||
def __init__(self, image: Image, resolver: int = ATOM_RESOLVER):
|
||||
self.img = image
|
||||
self.resolver = resolver
|
||||
|
||||
def _ea(self, k: int, rex: int, r: dict):
|
||||
"""Decode ModRM[+SIB][+disp].
|
||||
|
||||
Returns (nbytes, dst_reg, addr, src_reg). addr is an int, or the marker
|
||||
("rip", disp) which the caller resolves once it knows the instruction
|
||||
length, or None for a register-form operand.
|
||||
|
||||
TRAP 1: rm==5 is RIP-relative ONLY when mod==0. With mod 1 or 2 it is
|
||||
[rbp+disp] and must be resolved from rbp.
|
||||
"""
|
||||
b = self.img.buf
|
||||
modrm = b[k]
|
||||
mod, rm = modrm >> 6, modrm & 7
|
||||
dst = REGS[(((modrm >> 3) & 7) | ((rex & 4) << 1)) & 15]
|
||||
n = 1
|
||||
if mod == 3:
|
||||
return n, dst, None, REGS[(rm | ((rex & 1) << 3)) & 15]
|
||||
base_v = idx_v = disp = 0
|
||||
if rm == 4:
|
||||
sib = b[k + 1]
|
||||
n += 1
|
||||
scale = 1 << (sib >> 6)
|
||||
ir = ((sib >> 3) & 7) | ((rex & 2) << 2)
|
||||
br = (sib & 7) | ((rex & 1) << 3)
|
||||
if (ir & 15) != 4:
|
||||
idx_v = r[REGS[ir & 15]] * scale
|
||||
if (sib & 7) == 5 and mod == 0:
|
||||
disp = struct.unpack_from("<i", b, k + n)[0]
|
||||
n += 4
|
||||
else:
|
||||
base_v = r[REGS[br & 15]]
|
||||
elif rm == 5 and mod == 0:
|
||||
disp = struct.unpack_from("<i", b, k + 1)[0]
|
||||
return n + 4, dst, ("rip", disp), None
|
||||
else:
|
||||
base_v = r[REGS[(rm | ((rex & 1) << 3)) & 15]]
|
||||
if mod == 1:
|
||||
disp = struct.unpack_from("<b", b, k + n)[0]
|
||||
n += 1
|
||||
elif mod == 2:
|
||||
disp = struct.unpack_from("<i", b, k + n)[0]
|
||||
n += 4
|
||||
return n, dst, (base_v + idx_v + disp) & 0xFFFFFFFFFFFFFFFF, None
|
||||
|
||||
@staticmethod
|
||||
def _cond(cc: int, last) -> bool:
|
||||
a, b = last
|
||||
sa, sb = s32(a), s32(b)
|
||||
ua, ub = a & 0xFFFFFFFF, b & 0xFFFFFFFF
|
||||
if cc == 0x4: return sa == sb
|
||||
if cc == 0x5: return sa != sb
|
||||
if cc == 0xF: return sa > sb
|
||||
if cc == 0xD: return sa >= sb
|
||||
if cc == 0xC: return sa < sb
|
||||
if cc == 0xE: return sa <= sb
|
||||
if cc == 0x7: return ua > ub
|
||||
if cc == 0x3: return ua >= ub
|
||||
if cc == 0x2: return ua < ub
|
||||
if cc == 0x6: return ua <= ub
|
||||
if cc == 0x8: return sa < sb
|
||||
if cc == 0x9: return sa >= sb
|
||||
raise Unsupported(f"condition code 0x{cc:x}")
|
||||
|
||||
def run(self, start: int, atom: int, limit: int = 5000) -> int:
|
||||
b = self.img.buf
|
||||
r = {k: 0 for k in REGS}
|
||||
last = (0, 0)
|
||||
va = start
|
||||
for _ in range(limit):
|
||||
i0 = self.img.va2off(va)
|
||||
if i0 is None:
|
||||
raise Unsupported(f"pc unmapped 0x{va:x}")
|
||||
j = i0
|
||||
while b[j] in (0x66, 0x67, 0xF2, 0xF3):
|
||||
j += 1
|
||||
rex = 0
|
||||
if 0x40 <= b[j] <= 0x4F:
|
||||
rex = b[j]
|
||||
j += 1
|
||||
op = b[j]
|
||||
pre = j - i0
|
||||
|
||||
if op == 0xC3:
|
||||
return r["rax"] & 0xFFFFFFFF
|
||||
if op == 0xCC:
|
||||
raise Unsupported(f"int3 at 0x{va:x}: ran off the end of the function")
|
||||
if op == 0xE8:
|
||||
tgt = va + pre + 5 + struct.unpack_from("<i", b, j + 1)[0]
|
||||
if tgt != self.resolver:
|
||||
raise Unsupported(f"call to non-resolver 0x{tgt:x} at 0x{va:x}")
|
||||
r["rax"] = atom & 0xFFFFFFFF # resolver returns the atom index
|
||||
va += pre + 5
|
||||
continue
|
||||
if op == 0xE9:
|
||||
va += pre + 5 + struct.unpack_from("<i", b, j + 1)[0]
|
||||
continue
|
||||
if op == 0xEB:
|
||||
va += pre + 2 + struct.unpack_from("<b", b, j + 1)[0]
|
||||
continue
|
||||
if 0x70 <= op <= 0x7F:
|
||||
nxt = va + pre + 2
|
||||
rel = struct.unpack_from("<b", b, j + 1)[0]
|
||||
va = nxt + rel if self._cond(op & 0xF, last) else nxt
|
||||
continue
|
||||
if op == 0x0F and 0x80 <= b[j + 1] <= 0x8F:
|
||||
nxt = va + pre + 6
|
||||
rel = struct.unpack_from("<i", b, j + 2)[0]
|
||||
va = nxt + rel if self._cond(b[j + 1] & 0xF, last) else nxt
|
||||
continue
|
||||
if 0xB8 <= op <= 0xBF:
|
||||
r[REGS[((op - 0xB8) | ((rex & 1) << 3)) & 15]] = struct.unpack_from("<I", b, j + 1)[0]
|
||||
va += pre + 5
|
||||
continue
|
||||
if op in (0x05, 0x2D, 0x3D):
|
||||
# accumulator short forms: add/sub/cmp eax, imm32
|
||||
imm = struct.unpack_from("<i", b, j + 1)[0]
|
||||
cur = r["rax"] & 0xFFFFFFFF
|
||||
if op == 0x3D:
|
||||
last = (cur, imm & 0xFFFFFFFF)
|
||||
elif op == 0x2D:
|
||||
r["rax"] = (cur - imm) & 0xFFFFFFFF
|
||||
last = (r["rax"], 0)
|
||||
else:
|
||||
r["rax"] = (cur + imm) & 0xFFFFFFFF
|
||||
last = (r["rax"], 0)
|
||||
va += pre + 5
|
||||
continue
|
||||
if op in (0x81, 0x83):
|
||||
w = 4 if op == 0x81 else 1
|
||||
modrm = b[j + 1]
|
||||
if modrm >> 6 != 3:
|
||||
raise Unsupported(f"{op:02x} memory form at 0x{va:x}")
|
||||
reg = REGS[((modrm & 7) | ((rex & 1) << 3)) & 15]
|
||||
imm = struct.unpack_from("<i" if w == 4 else "<b", b, j + 2)[0]
|
||||
ext = (modrm >> 3) & 7
|
||||
cur = r[reg] & 0xFFFFFFFF
|
||||
if ext == 7:
|
||||
last = (cur, imm & 0xFFFFFFFF)
|
||||
elif ext == 5:
|
||||
r[reg] = (cur - imm) & 0xFFFFFFFF
|
||||
last = (r[reg], 0)
|
||||
elif ext == 0:
|
||||
r[reg] = (cur + imm) & 0xFFFFFFFF
|
||||
last = (r[reg], 0)
|
||||
else:
|
||||
raise Unsupported(f"{op:02x} /{ext} at 0x{va:x}")
|
||||
va += pre + 2 + w
|
||||
continue
|
||||
if op == 0xFF and b[j + 1] >> 6 == 3:
|
||||
ext = (b[j + 1] >> 3) & 7
|
||||
reg = REGS[((b[j + 1] & 7) | ((rex & 1) << 3)) & 15]
|
||||
if ext == 1:
|
||||
r[reg] = (r[reg] - 1) & 0xFFFFFFFF
|
||||
last = (r[reg], 0)
|
||||
va += pre + 2
|
||||
continue
|
||||
if ext == 4:
|
||||
va = r[reg]
|
||||
continue
|
||||
raise Unsupported(f"ff /{ext} at 0x{va:x}")
|
||||
if op == 0x0F and b[j + 1] == 0xB6:
|
||||
n, dst, addr, src = self._ea(j + 2, rex, r)
|
||||
end = va + pre + 2 + n
|
||||
if isinstance(addr, tuple):
|
||||
addr = end + addr[1]
|
||||
r[dst] = self.img.rd8(addr) if addr is not None else r[src] & 0xFF
|
||||
va = end
|
||||
continue
|
||||
if op in (0x8B, 0x8D):
|
||||
n, dst, addr, src = self._ea(j + 1, rex, r)
|
||||
end = va + pre + 1 + n
|
||||
if isinstance(addr, tuple):
|
||||
addr = end + addr[1]
|
||||
if op == 0x8D:
|
||||
if addr is None:
|
||||
raise Unsupported(f"lea with register operand at 0x{va:x}")
|
||||
r[dst] = addr
|
||||
else:
|
||||
if addr is None:
|
||||
# register form: mov r32, r32 (e.g. 8b c8 = mov ecx,eax)
|
||||
r[dst] = r[src] if rex & 8 else r[src] & 0xFFFFFFFF
|
||||
else:
|
||||
r[dst] = self.img.rd32(addr)
|
||||
va = end
|
||||
continue
|
||||
if op == 0x89:
|
||||
modrm = b[j + 1]
|
||||
if modrm >> 6 != 3:
|
||||
raise Unsupported(f"89 memory store at 0x{va:x}")
|
||||
src = REGS[((((modrm >> 3) & 7) | ((rex & 4) << 1))) & 15]
|
||||
dst = REGS[((modrm & 7) | ((rex & 1) << 3)) & 15]
|
||||
r[dst] = r[src] if rex & 8 else r[src] & 0xFFFFFFFF
|
||||
va += pre + 2
|
||||
continue
|
||||
if op == 0x63:
|
||||
modrm = b[j + 1]
|
||||
if modrm >> 6 != 3:
|
||||
raise Unsupported(f"63 memory form at 0x{va:x}")
|
||||
src = REGS[((modrm & 7) | ((rex & 1) << 3)) & 15]
|
||||
dst = REGS[((((modrm >> 3) & 7) | ((rex & 4) << 1))) & 15]
|
||||
r[dst] = s32(r[src]) & 0xFFFFFFFFFFFFFFFF
|
||||
va += pre + 2
|
||||
continue
|
||||
if op in (0x01, 0x03, 0x29, 0x2B, 0x39, 0x3B,
|
||||
0x09, 0x0B, 0x21, 0x23, 0x31, 0x33, 0x85):
|
||||
modrm = b[j + 1]
|
||||
if modrm >> 6 != 3:
|
||||
raise Unsupported(f"{op:02x} memory form at 0x{va:x}")
|
||||
a = REGS[((modrm & 7) | ((rex & 1) << 3)) & 15]
|
||||
c = REGS[((((modrm >> 3) & 7) | ((rex & 4) << 1))) & 15]
|
||||
m = 0xFFFFFFFFFFFFFFFF if rex & 8 else 0xFFFFFFFF
|
||||
if op == 0x01:
|
||||
r[a] = (r[a] + r[c]) & m
|
||||
elif op == 0x03:
|
||||
r[c] = (r[c] + r[a]) & m
|
||||
elif op == 0x29:
|
||||
r[a] = (r[a] - r[c]) & m
|
||||
last = (r[a] & 0xFFFFFFFF, 0)
|
||||
elif op == 0x2B:
|
||||
r[c] = (r[c] - r[a]) & m
|
||||
last = (r[c] & 0xFFFFFFFF, 0)
|
||||
elif op in (0x09, 0x0B, 0x21, 0x23, 0x31, 0x33):
|
||||
fn = {0x09: lambda x, y: x | y, 0x0B: lambda x, y: x | y,
|
||||
0x21: lambda x, y: x & y, 0x23: lambda x, y: x & y,
|
||||
0x31: lambda x, y: x ^ y, 0x33: lambda x, y: x ^ y}[op]
|
||||
if op in (0x09, 0x21, 0x31):
|
||||
r[a] = fn(r[a], r[c]) & m
|
||||
last = (r[a] & 0xFFFFFFFF, 0)
|
||||
else:
|
||||
r[c] = fn(r[c], r[a]) & m
|
||||
last = (r[c] & 0xFFFFFFFF, 0)
|
||||
elif op == 0x85:
|
||||
last = ((r[a] & r[c]) & 0xFFFFFFFF, 0)
|
||||
elif op == 0x39:
|
||||
last = (r[a] & 0xFFFFFFFF, r[c] & 0xFFFFFFFF)
|
||||
else:
|
||||
last = (r[c] & 0xFFFFFFFF, r[a] & 0xFFFFFFFF)
|
||||
va += pre + 2
|
||||
continue
|
||||
if op == 0x90:
|
||||
va += pre + 1
|
||||
continue
|
||||
if op == 0x0F and b[j + 1] == 0x1F:
|
||||
n, _d, _a, _s = self._ea(j + 2, rex, r)
|
||||
va += pre + 2 + n
|
||||
continue
|
||||
raise Unsupported(f"opcode {op:02x} at 0x{va:x}")
|
||||
raise Unsupported("instruction limit reached")
|
||||
|
||||
|
||||
def find_mappers(img: Image, resolver: int = ATOM_RESOLVER):
|
||||
"""Every function containing a direct call to the atom resolver."""
|
||||
sec = next(s for s in img.sections if s[0] == ".text")
|
||||
_n, tva, _vsz, traw, trsz = sec
|
||||
out = {}
|
||||
for i in range(traw, traw + trsz - 5):
|
||||
if img.buf[i] != 0xE8:
|
||||
continue
|
||||
va = img.base + tva + (i - traw)
|
||||
if va + 5 + struct.unpack_from("<i", img.buf, i + 1)[0] == resolver:
|
||||
f = img.function_of(va)
|
||||
if f:
|
||||
out.setdefault(f[0], []).append(va)
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# selftest: the two decoder traps plus the live-verified positive controls
|
||||
# --------------------------------------------------------------------------
|
||||
def test_atom_anchors(img: Image) -> list:
|
||||
"""The atom table base must reproduce known anchors, or every index is wrong."""
|
||||
anchors = {11: "actives", 363: "itemData", 376: "kicktakers",
|
||||
424: "manager", 568: "players", 718: "squadActives"}
|
||||
fails = []
|
||||
for idx, want in anchors.items():
|
||||
got = img.atom(idx)
|
||||
if got != want:
|
||||
fails.append(f"atom[{idx}] = {got!r}, expected {want!r}")
|
||||
return fails
|
||||
|
||||
|
||||
def test_rbp_relative_is_not_rip_relative(img: Image) -> list:
|
||||
"""TRAP 1. mod!=0 with rm==5 must resolve as [rbp+disp], not RIP-relative.
|
||||
|
||||
Encoding under test: 8b 4d 20 == mov ecx,[rbp+0x20] (mod=01, rm=101).
|
||||
A decoder that treats rm==5 as RIP-relative computes a wildly different
|
||||
address and silently reads the wrong memory.
|
||||
"""
|
||||
m = Mapper(img)
|
||||
r = {k: 0 for k in REGS}
|
||||
r["rbp"] = 0x140000000
|
||||
saved = img.buf
|
||||
try:
|
||||
img.buf = bytes.fromhex("8b4d20")
|
||||
n, dst, addr, _src = m._ea(1, 0, r)
|
||||
finally:
|
||||
img.buf = saved
|
||||
fails = []
|
||||
if isinstance(addr, tuple):
|
||||
fails.append("mod=01 rm=101 decoded as RIP-relative; must be [rbp+disp]")
|
||||
elif addr != 0x140000020:
|
||||
fails.append(f"[rbp+0x20] resolved to 0x{addr:x}, expected 0x140000020")
|
||||
if dst != "rcx":
|
||||
fails.append(f"destination decoded as {dst}, expected rcx")
|
||||
if n != 2:
|
||||
fails.append(f"modrm+disp8 consumed {n} bytes, expected 2")
|
||||
return fails
|
||||
|
||||
|
||||
def test_constant_propagated_through_register(img: Image) -> list:
|
||||
"""TRAP 2. A constant reaching a use through a register must be followed.
|
||||
|
||||
Program: mov eax,0; mov r8d,4; mov eax,r8d; ret -> must yield 4, which is
|
||||
only observable if register values propagate. Scanning for an immediate
|
||||
store would see nothing.
|
||||
"""
|
||||
m = Mapper(img)
|
||||
saved = img.buf
|
||||
prog = bytes.fromhex("b800000000" "41b804000000" "4489c0" "c3")
|
||||
try:
|
||||
img.buf = prog
|
||||
img_va2off = img.va2off
|
||||
img.va2off = lambda va: va if 0 <= va < len(prog) else None
|
||||
got = m.run(0, 0)
|
||||
finally:
|
||||
img.buf = saved
|
||||
img.va2off = img_va2off
|
||||
return [] if got == 4 else [f"register-propagated constant yielded {got}, expected 4"]
|
||||
|
||||
|
||||
def test_item_mapper_controls(img: Image) -> list:
|
||||
"""Live/disassembly-verified behaviour of the item mapper."""
|
||||
m = Mapper(img)
|
||||
fails = []
|
||||
got = m.run(ITEM_MAPPER, 568)
|
||||
if got != 1:
|
||||
fails.append(f"item mapper atom 568 'players' -> {got}, expected 1")
|
||||
got = m.run(ITEM_MAPPER, 11)
|
||||
if got != 0:
|
||||
fails.append(f"item mapper atom 11 'actives' -> {got}, expected 0")
|
||||
return fails
|
||||
|
||||
|
||||
def test_manager_424_is_accepted_somewhere(img: Image) -> list:
|
||||
"""MANDATORY control. A live client holds a resident manager record, so some
|
||||
mapper must map atom 424 to a non-zero field id. The previous pattern-scan
|
||||
method failed exactly here, and any replacement must not."""
|
||||
m = Mapper(img)
|
||||
accepting = []
|
||||
for start in find_mappers(img):
|
||||
try:
|
||||
if m.run(start, 424):
|
||||
accepting.append(start)
|
||||
except Unsupported:
|
||||
continue
|
||||
if not accepting:
|
||||
return ["no mapper maps atom 424 'manager' to a non-zero field id, "
|
||||
"which contradicts the live resident manager record"]
|
||||
return []
|
||||
|
||||
|
||||
def selftest(img: Image) -> int:
|
||||
checks = [
|
||||
("atom table anchors", test_atom_anchors),
|
||||
("trap 1: rbp-relative modrm", test_rbp_relative_is_not_rip_relative),
|
||||
("trap 2: constant via register", test_constant_propagated_through_register),
|
||||
("item mapper positive controls", test_item_mapper_controls),
|
||||
("mandatory: manager atom 424 accepted", test_manager_424_is_accepted_somewhere),
|
||||
]
|
||||
bad = 0
|
||||
for name, fn in checks:
|
||||
try:
|
||||
fails = fn(img)
|
||||
except Exception as exc: # noqa: BLE001 - report, don't mask
|
||||
fails = [f"raised {type(exc).__name__}: {exc}"]
|
||||
if fails:
|
||||
bad += 1
|
||||
print(f" FAIL {name}")
|
||||
for f in fails:
|
||||
print(f" {f}")
|
||||
else:
|
||||
print(f" ok {name}")
|
||||
print("\n ALL PASS" if not bad else f"\n {bad} CHECK(S) FAILED - negative results are NOT admissible")
|
||||
return 1 if bad else 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("image", type=Path, help="CardsDLL_Win64_retail.dll")
|
||||
ap.add_argument("--selftest", action="store_true")
|
||||
ap.add_argument("--atom", type=int, action="append", default=[],
|
||||
help="atom index to resolve through every mapper")
|
||||
ap.add_argument("--name", action="append", default=[],
|
||||
help="atom name to resolve through every mapper")
|
||||
args = ap.parse_args()
|
||||
img = Image(args.image)
|
||||
|
||||
if args.selftest:
|
||||
return selftest(img)
|
||||
|
||||
atoms = list(args.atom)
|
||||
for nm in args.name:
|
||||
idx = img.atom_index(nm)
|
||||
if idx is None:
|
||||
print(f" atom {nm!r} not found in the table")
|
||||
return 2
|
||||
atoms.append(idx)
|
||||
if not atoms:
|
||||
ap.error("give --atom/--name, or --selftest")
|
||||
|
||||
m = Mapper(img)
|
||||
mappers = find_mappers(img)
|
||||
print(f" {len(mappers)} mapper function(s) found\n")
|
||||
for a in atoms:
|
||||
print(f" === atom {a} ({img.atom(a)!r}) ===")
|
||||
rows, unsup = [], 0
|
||||
for start in sorted(mappers):
|
||||
try:
|
||||
fid = m.run(start, a)
|
||||
except Unsupported:
|
||||
unsup += 1
|
||||
continue
|
||||
if fid:
|
||||
rows.append((start, fid))
|
||||
for start, fid in rows:
|
||||
print(f" mapper 0x{start:x} -> field id {fid} (0x{fid:x})")
|
||||
print(f" {len(rows)} mapper(s) accept it; {unsup} not modelled\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+190
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Canonical FIFA 17 kit map, joined from the extracted client tables.
|
||||
|
||||
Authority for every kit question that a table can answer, so nobody has to
|
||||
reverse a binary for a fact that is sitting in a JSON row. Reads only:
|
||||
|
||||
fifa17-recon/data/tables/fcc_kitcards.json the FUT KIT CARD definitions
|
||||
fifa17-recon/data/tables/teamkits.json the ENGINE kit rows
|
||||
|
||||
Everything printed is TABLE_PROVEN unless the line says otherwise: it is a
|
||||
direct count over the full table, not a sample.
|
||||
|
||||
Usage:
|
||||
python3 audit_fifa17_kits.py human report
|
||||
python3 audit_fifa17_kits.py --json machine-readable, for tests/tools
|
||||
python3 audit_fifa17_kits.py --team 21 drill into one team
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
TABLES = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "tables")
|
||||
|
||||
# TABLE_PROVEN, established by this script's own discriminating test (see
|
||||
# category_type_evidence): a kit CARD's `category` selects the engine kit ROW's
|
||||
# `teamkittypetechid` at the same (team, year).
|
||||
CATEGORY_TO_KIT_TYPE = {2: 0, 3: 1, 5: 2}
|
||||
KIT_TYPE_NAME = {0: "HOME", 1: "AWAY", 2: "THIRD", 3: "FOURTH", 5: "GK", 6: "SPECIAL6", 7: "SPECIAL7"}
|
||||
|
||||
|
||||
def load(name):
|
||||
with open(os.path.join(TABLES, name), "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
return data if isinstance(data, list) else data.get("rows", data)
|
||||
|
||||
|
||||
def band(carddbid: int) -> int:
|
||||
"""The 6_300_000 / 6_400_000 id band."""
|
||||
return (carddbid // 100_000) * 100_000
|
||||
|
||||
|
||||
def category_type_evidence(cards, kits):
|
||||
"""The DISCRIMINATING test behind CATEGORY_TO_KIT_TYPE.
|
||||
|
||||
Asserting "category 3 means away" because away kits usually exist is not
|
||||
evidence -- types 0/1/2 are present for most teams, so the claim is true by
|
||||
construction. What discriminates is the teams that LACK a type: if category 3
|
||||
really means type 1, then no category-3 card may exist for a (team, year)
|
||||
that has no type-1 row. Same for category 5 and type 2.
|
||||
"""
|
||||
kits_by = defaultdict(set)
|
||||
for r in kits:
|
||||
kits_by[(r["teamtechid"], r["year"])].add(r["teamkittypetechid"])
|
||||
cards_by = defaultdict(list)
|
||||
for r in cards:
|
||||
cards_by[(r["teamid"], r["year"])].append(r)
|
||||
|
||||
out = {}
|
||||
for cat, want in CATEGORY_TO_KIT_TYPE.items():
|
||||
# keys that HAVE teamkits rows but not the wanted type
|
||||
lacking = [k for k, t in kits_by.items() if t and want not in t]
|
||||
counterexamples = [
|
||||
r["carddbid"] for k in lacking for r in cards_by.get(k, []) if r["category"] == cat
|
||||
]
|
||||
out[cat] = {
|
||||
"kit_type": want,
|
||||
"name": KIT_TYPE_NAME[want],
|
||||
"keys_lacking_type": len(lacking),
|
||||
"counterexamples": counterexamples,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def audit():
|
||||
cards = load("fcc_kitcards.json")
|
||||
kits = load("teamkits.json")
|
||||
|
||||
kits_by = defaultdict(list)
|
||||
for r in kits:
|
||||
kits_by[(r["teamtechid"], r["year"])].append(r)
|
||||
|
||||
rows = []
|
||||
for c in cards:
|
||||
key = (c["teamid"], c["year"])
|
||||
want = CATEGORY_TO_KIT_TYPE.get(c["category"])
|
||||
match = next((k for k in kits_by.get(key, []) if k["teamkittypetechid"] == want), None)
|
||||
rows.append(
|
||||
{
|
||||
"carddbid": c["carddbid"],
|
||||
"band": band(c["carddbid"]),
|
||||
"teamid": c["teamid"],
|
||||
"year": c["year"],
|
||||
"category": c["category"],
|
||||
"kit_type": want,
|
||||
"kit_type_name": KIT_TYPE_NAME.get(want, "?"),
|
||||
"assetid": c["assetid"],
|
||||
"cardassetid": c["cardassetid"],
|
||||
"value": c["value"],
|
||||
"weightrare": c["weightrare"],
|
||||
# These are BYTE OFFSETS into the table's string blob, not ids.
|
||||
# The blob is not among the extracted tables, so a kit's own
|
||||
# name string is NOT recoverable from data/tables alone.
|
||||
"name_offset": c["name"],
|
||||
"header_offset": c["header"],
|
||||
"description_offset": c["description"],
|
||||
"teamkitid": match["teamkitid"] if match else None,
|
||||
"teamkit_islocked": match["islocked"] if match else None,
|
||||
"teamkit_embargoed": match["isembargoed"] if match else None,
|
||||
}
|
||||
)
|
||||
|
||||
dupes = [k for k, n in Counter((r["teamid"], r["year"], r["category"]) for r in rows).items() if n > 1]
|
||||
|
||||
return {
|
||||
"counts": {"fcc_kitcards": len(cards), "teamkits": len(kits)},
|
||||
"bands": dict(sorted(Counter(r["band"] for r in rows).items())),
|
||||
"band_x_assetid": {f"{b}/{a}": n for (b, a), n in
|
||||
sorted(Counter((r["band"], r["assetid"]) for r in rows).items())},
|
||||
"band_x_category": {f"{b}/{c}": n for (b, c), n in
|
||||
sorted(Counter((r["band"], r["category"]) for r in rows).items())},
|
||||
"category_counts": dict(sorted(Counter(r["category"] for r in rows).items())),
|
||||
"cardassetid": sorted({r["cardassetid"] for r in rows}),
|
||||
"category_type_evidence": category_type_evidence(cards, kits),
|
||||
"unmatched": [r["carddbid"] for r in rows if r["teamkitid"] is None],
|
||||
"duplicate_team_year_category": dupes,
|
||||
"teamkits_islocked": dict(Counter(r["islocked"] for r in kits)),
|
||||
"teamkits_embargoed": dict(Counter(r["isembargoed"] for r in kits)),
|
||||
"teamkits_types": dict(sorted(Counter(r["teamkittypetechid"] for r in kits).items())),
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--json", action="store_true")
|
||||
ap.add_argument("--team", type=int)
|
||||
args = ap.parse_args()
|
||||
|
||||
a = audit()
|
||||
if args.json:
|
||||
json.dump(a, sys.stdout, indent=2)
|
||||
return
|
||||
|
||||
print("FIFA 17 kit map — TABLE_PROVEN from the extracted client tables")
|
||||
print(f" fcc_kitcards rows : {a['counts']['fcc_kitcards']}")
|
||||
print(f" teamkits rows : {a['counts']['teamkits']}")
|
||||
|
||||
print("\nid bands")
|
||||
for b, n in a["bands"].items():
|
||||
print(f" {b}: {n}")
|
||||
print("\nband/assetid (assetid is fully determined by band)")
|
||||
for k, n in a["band_x_assetid"].items():
|
||||
print(f" {k}: {n}")
|
||||
print("\nband/category")
|
||||
for k, n in a["band_x_category"].items():
|
||||
print(f" {k}: {n}")
|
||||
print(f"\ncardassetid values: {a['cardassetid']} (the FUT card frame, not the kit art)")
|
||||
|
||||
print("\ncategory -> engine kit type, with the discriminating test")
|
||||
for cat, ev in a["category_type_evidence"].items():
|
||||
verdict = "HOLDS" if not ev["counterexamples"] else f"FAILS ({len(ev['counterexamples'])})"
|
||||
print(f" category {cat} -> type {ev['kit_type']} {ev['name']:6s} "
|
||||
f"| {ev['keys_lacking_type']:4d} (team,year) keys lack that type, "
|
||||
f"{len(ev['counterexamples'])} counterexample(s) -> {verdict}")
|
||||
|
||||
print("\nengine kit types present in teamkits")
|
||||
for t, n in a["teamkits_types"].items():
|
||||
print(f" type {t} {KIT_TYPE_NAME.get(t,'?'):8s}: {n}")
|
||||
|
||||
print(f"\nteamkits islocked : {a['teamkits_islocked']} <- every row, so NOT the selector lock")
|
||||
print(f"teamkits embargoed : {a['teamkits_embargoed']}")
|
||||
|
||||
print(f"\nanomalies")
|
||||
print(f" cards with no matching teamkits row : {len(a['unmatched'])}")
|
||||
print(f" duplicate (team,year,category) : {len(a['duplicate_team_year_category'])}")
|
||||
|
||||
if args.team is not None:
|
||||
print(f"\n=== team {args.team} ===")
|
||||
print(f" {'carddbid':10s} {'cat':4s} {'type':7s} {'year':6s} {'assetid':8s} {'teamkitid':10s} locked")
|
||||
for r in sorted((r for r in a["rows"] if r["teamid"] == args.team), key=lambda r: r["carddbid"]):
|
||||
print(f" {r['carddbid']:<10} {r['category']:<4} {r['kit_type_name']:<7} {r['year']:<6} "
|
||||
f"{r['assetid']:<8} {str(r['teamkitid']):<10} {r['teamkit_islocked']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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),
|
||||
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Recover the kit caption/localisation vocabulary from the UNPACKED CardsDLL.
|
||||
|
||||
Why CardsDLL and not FIFA17.exe: CardsDLL is not packed, so a MISS here is
|
||||
meaningful. FIFA17.exe is Denuvo-packed and only partially readable -- a hit
|
||||
there is useful, a miss proves nothing. Every run therefore prints a positive
|
||||
control first; if the control fails, the run is void and no negative may be
|
||||
quoted from it.
|
||||
|
||||
Usage: python3 cardsdll_kit_strings.py [path-to-CardsDLL]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
DEFAULT = os.path.expanduser(
|
||||
"~/.cache/openfut-investigation/bin/CardsDLL_Win64_retail.dll"
|
||||
)
|
||||
|
||||
# Strings that MUST be present. If any is missing the search is broken.
|
||||
CONTROLS = [b"activeHomeKit", b"cardsubtypeid", b"resourceId", b"activeAwayKit"]
|
||||
|
||||
# The kit caption vocabulary this project has referred to, plus neighbours worth
|
||||
# knowing about either way.
|
||||
PROBES = [
|
||||
b"FUT_UC_KITS", b"TeamName_Abbr15_", b"TeamName_Abbr15", b"TeamName_",
|
||||
b"FUT_UC_", b"StadiumName_", b"Badge", b"Stadium",
|
||||
b"activeBadge", b"activeBall", b"activeStadium",
|
||||
b"kit", b"Kit", b"KIT",
|
||||
b"home", b"Home", b"HOME", b"away", b"Away", b"AWAY",
|
||||
b"locked", b"Locked", b"LOCKED", b"unlock",
|
||||
b"category", b"year", b"teamid", b"teamId",
|
||||
b"DataProvider", b"itemData", b"itemType", b"itemState",
|
||||
]
|
||||
|
||||
|
||||
def ascii_strings(data, minlen=4):
|
||||
for m in re.finditer(rb"[ -~]{%d,}" % minlen, data):
|
||||
yield m.start(), m.group()
|
||||
|
||||
|
||||
def main():
|
||||
path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT
|
||||
data = open(path, "rb").read()
|
||||
print(f"{os.path.basename(path)} {len(data)} bytes")
|
||||
|
||||
print("\n-- positive control (a miss voids every negative below) --")
|
||||
ok = True
|
||||
for c in CONTROLS:
|
||||
n = data.count(c)
|
||||
print(f" {c.decode():16s} {n}")
|
||||
if n == 0:
|
||||
ok = False
|
||||
if not ok:
|
||||
print(" CONTROL FAILED — do not quote negatives from this run.")
|
||||
return 1
|
||||
|
||||
print("\n-- probe counts --")
|
||||
for p in PROBES:
|
||||
print(f" {p.decode():18s} {data.count(p)}")
|
||||
|
||||
# Whole-string table: every standalone string containing kit-ish substrings.
|
||||
print("\n-- standalone strings matching kit/team/caption vocabulary --")
|
||||
pat = re.compile(rb"(?i)(kit|teamname|abbr|stadiumname|fut_uc|locked|unlock)")
|
||||
seen = set()
|
||||
for off, s in ascii_strings(data, 5):
|
||||
if pat.search(s) and s not in seen:
|
||||
seen.add(s)
|
||||
print(f" @{off:#08x} {s.decode('latin1')[:110]}")
|
||||
print(f" ({len(seen)} distinct)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+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
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Classify call sites of the 130000/130001 provider stubs.
|
||||
|
||||
A call whose result is COMPARED implements a predicate ("is this the FUT custom
|
||||
club?"). Only a call whose result is STORED can assign a team id. This turns an
|
||||
unreadable 81-site list into the handful that could actually introduce 130000
|
||||
into a struct.
|
||||
|
||||
classify_calls.py <asmfile> <target_va_hex> [more_targets...]
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
|
||||
asm = sys.argv[1]
|
||||
targets = [t.lower().lstrip("0x") for t in sys.argv[2:]]
|
||||
|
||||
lines = []
|
||||
for l in open(asm, errors="replace"):
|
||||
m = re.match(r"\s*([0-9a-f]+):\s+((?:[0-9a-f]{2} )+)\s*(.*)", l)
|
||||
if m:
|
||||
lines.append((int(m.group(1), 16), m.group(3).strip()))
|
||||
idx = {a: i for i, (a, _t) in enumerate(lines)}
|
||||
|
||||
STORE = re.compile(r"^mov\s+(?:DWORD PTR |QWORD PTR )?\[[^\]]+\],(eax|rax)\b")
|
||||
CMP = re.compile(r"^(cmp|sub|test)\b.*\b(eax|rax)\b")
|
||||
MOVREG = re.compile(r"^mov\s+(e[a-z]{2}|r\d+d|r[a-z]{2}),(eax|rax)\b")
|
||||
|
||||
for tgt in targets:
|
||||
print(f"\n ===== callers of 0x{tgt} =====")
|
||||
stores, cmps, other = [], [], []
|
||||
for i, (a, txt) in enumerate(lines):
|
||||
if not txt.startswith("call") or tgt not in txt:
|
||||
continue
|
||||
# look at the next few instructions for the fate of eax
|
||||
window = [lines[j][1] for j in range(i + 1, min(i + 7, len(lines)))]
|
||||
verdict, detail = "other", window[0] if window else ""
|
||||
for w in window:
|
||||
if STORE.match(w):
|
||||
verdict, detail = "STORE", w
|
||||
break
|
||||
if CMP.match(w):
|
||||
verdict, detail = "compare", w
|
||||
break
|
||||
if MOVREG.match(w):
|
||||
verdict, detail = "movreg", w
|
||||
break
|
||||
rec = (a, detail)
|
||||
(stores if verdict == "STORE" else cmps if verdict == "compare" else other).append(rec)
|
||||
print(f" STORE (can assign) : {len(stores)}")
|
||||
for a, d in stores:
|
||||
print(f" 0x{a:x} {d}")
|
||||
print(f" compare (predicate) : {len(cmps)}")
|
||||
print(f" other/moved to reg : {len(other)}")
|
||||
for a, d in other[:14]:
|
||||
print(f" 0x{a:x} {d}")
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only probe v3: discriminate "kits never ingested" from "ingested then freed".
|
||||
|
||||
Staff was refetched by the client at 18:40:38, four minutes before the scan, and
|
||||
players are resident. If staff/badge/stadium records are resident but the two
|
||||
kits are not, the kits are being dropped specifically.
|
||||
"""
|
||||
import re
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
NEEDLES = {
|
||||
"PLAYER resourceId 83906881 (control, resident)": 83906881,
|
||||
"STAFF resourceId 9000081 (headcoach-ish)": 9000081,
|
||||
"STAFF resourceId 3000083 (x2)": 3000083,
|
||||
"STAFF resourceId 1000509": 1000509,
|
||||
"STAFF instance 100004870": 100004870,
|
||||
"BADGE resourceId 6000005": 6000005,
|
||||
"BADGE instance 100004875": 100004875,
|
||||
"STADIUM resourceId 6200000": 6200000,
|
||||
"STADIUM instance 100004876": 100004876,
|
||||
"KIT resourceId 6300006 (home)": 6300006,
|
||||
"KIT resourceId 6400003 (away)": 6400003,
|
||||
"KIT instance 100004874 (home)": 100004874,
|
||||
"KIT instance 100004873 (away)": 100004873,
|
||||
"KIT cardassetid 35": 35,
|
||||
}
|
||||
|
||||
|
||||
def find_pid():
|
||||
out = subprocess.run(["pgrep", "-f", "FIFA17.exe"], capture_output=True, text=True).stdout.split()
|
||||
for p in out:
|
||||
try:
|
||||
with open(f"/proc/{p}/maps") as fh:
|
||||
if "CardsDLL" in fh.read():
|
||||
return int(p)
|
||||
except OSError:
|
||||
continue
|
||||
return int(out[0]) if out else None
|
||||
|
||||
|
||||
def main():
|
||||
pid = find_pid()
|
||||
if not pid:
|
||||
sys.exit("FIFA17.exe not running")
|
||||
print(f"pid={pid}")
|
||||
|
||||
regs = []
|
||||
with open(f"/proc/{pid}/maps") as fh:
|
||||
for line in fh:
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) (\S{4}) \S+ \S+ \S+\s*(.*)", line)
|
||||
if not m:
|
||||
continue
|
||||
lo, hi, perms, path = int(m.group(1), 16), int(m.group(2), 16), m.group(3), m.group(4)
|
||||
if "r" in perms and not path.startswith("/dev/") and (hi - lo) <= (512 << 20):
|
||||
regs.append((lo, hi))
|
||||
|
||||
hits = {k: [] for k in NEEDLES}
|
||||
pats = {k: struct.pack("<I", v) for k, v in NEEDLES.items()}
|
||||
mib = 0
|
||||
|
||||
with open(f"/proc/{pid}/mem", "rb", buffering=0) as mem:
|
||||
for lo, hi in regs:
|
||||
try:
|
||||
mem.seek(lo)
|
||||
buf = mem.read(hi - lo)
|
||||
except (OSError, ValueError, OverflowError):
|
||||
continue
|
||||
if not buf:
|
||||
continue
|
||||
mib += len(buf)
|
||||
for k, needle in pats.items():
|
||||
start = 0
|
||||
while len(hits[k]) < 5000:
|
||||
i = buf.find(needle, start)
|
||||
if i < 0:
|
||||
break
|
||||
hits[k].append(lo + i)
|
||||
start = i + 4
|
||||
|
||||
print(f"read {mib/(1<<20):.0f} MiB\n" + "=" * 66)
|
||||
|
||||
def rd(base, off, size=4):
|
||||
try:
|
||||
mem.seek(base + off)
|
||||
raw = mem.read(size)
|
||||
return int.from_bytes(raw, "little") if len(raw) == size else None
|
||||
except (OSError, ValueError, OverflowError):
|
||||
return None
|
||||
|
||||
for k in NEEDLES:
|
||||
addrs = hits[k]
|
||||
# count how many look like real item records (plausible cardtype)
|
||||
recs = []
|
||||
for a in addrs[:3000]:
|
||||
base = a - 0x18
|
||||
ct = rd(base, 0x4C)
|
||||
if ct in (1, 2, 3, 4, 5, 6, 7, 9):
|
||||
recs.append((base, ct))
|
||||
flag = "" if addrs else " <-- ZERO"
|
||||
print(f" {len(addrs):6d} raw / {len(recs):4d} record-shaped {k}{flag}")
|
||||
for base, ct in recs[:3]:
|
||||
print(f" @{base:#x} cardtype={ct} subtype={rd(base,0x50)} "
|
||||
f"itemState={rd(base,0x5c)} +0x60={rd(base,0x60)} "
|
||||
f"teamid={rd(base,0x94)} cat={rd(base,0xb8)} year={rd(base,0xba,2)}")
|
||||
print("=" * 66)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+332
@@ -0,0 +1,332 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trace FIFA17 Screen event 0x30 through command 0x128 and ScenarioModeStart.
|
||||
|
||||
The generated GDB program uses hardware breakpoints, only reads registers and
|
||||
client memory, logs, and continues. Seven breakpoints are rotated so no more
|
||||
than four are enabled. It never calls client functions, writes client memory,
|
||||
emits events, or drives input.
|
||||
|
||||
command_128_trace.py [pid] [--output PATH]
|
||||
command_128_trace.py --selftest
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import match_advance_trace as advance
|
||||
import match_transition_trace as transition
|
||||
|
||||
MANAGER_SELECT_ACTION_SOURCE_RVA = 0x0705A620
|
||||
MANAGER_SELECT_ACTION_RESULT_RVA = 0x07CDC4A6
|
||||
SCREEN_EVENT_CHANNEL_ROUTER_RVA = 0x080CE230
|
||||
SCREEN_EVENT_DISPATCH_RVA = 0x080CF790
|
||||
SKILL_INSTRUCTIONS_SCREEN_RVA = 0x07DCA400
|
||||
GAMEPLAY_COMMAND_DISPATCH_RVA = 0x07A8F6C0
|
||||
FREE_ROAM_COMMAND_128_RVA = 0x07A92B0F
|
||||
SCENARIO_SCHEDULER_RVA = 0x07AC3A40
|
||||
SCENARIO_MANAGER_START_RVA = 0x07B1C2B0
|
||||
MODE_ZERO_SCENARIO_START_RVA = 0x07B1C190
|
||||
GAMEPLAY_GLOBAL_RVA = 0x04BFB910
|
||||
SCREEN_VTABLE_RVA = 0x03B3ECC0
|
||||
FREE_ROAM_VTABLE_RVA = 0x03AEDF58
|
||||
MODE_ZERO_CHILD_VTABLE_RVA = 0x03AE9C00
|
||||
|
||||
|
||||
def addresses(base: int) -> dict[str, int]:
|
||||
return {
|
||||
"manager_select_source": base + MANAGER_SELECT_ACTION_SOURCE_RVA,
|
||||
"manager_select_action": base + MANAGER_SELECT_ACTION_RESULT_RVA,
|
||||
"screen_event_router": base + SCREEN_EVENT_CHANNEL_ROUTER_RVA,
|
||||
"screen_event_dispatch": base + SCREEN_EVENT_DISPATCH_RVA,
|
||||
"instructions_screen": base + SKILL_INSTRUCTIONS_SCREEN_RVA,
|
||||
"command_dispatch": base + GAMEPLAY_COMMAND_DISPATCH_RVA,
|
||||
"free_roam_case": base + FREE_ROAM_COMMAND_128_RVA,
|
||||
"scheduler": base + SCENARIO_SCHEDULER_RVA,
|
||||
"manager_start": base + SCENARIO_MANAGER_START_RVA,
|
||||
"scenario_start": base + MODE_ZERO_SCENARIO_START_RVA,
|
||||
"gameplay_global": base + GAMEPLAY_GLOBAL_RVA,
|
||||
"screen_vtable": base + SCREEN_VTABLE_RVA,
|
||||
"free_roam_vtable": base + FREE_ROAM_VTABLE_RVA,
|
||||
"mode_zero_child_vtable": base + MODE_ZERO_CHILD_VTABLE_RVA,
|
||||
}
|
||||
|
||||
|
||||
def gdb_prelude(pid: int, output: str) -> str:
|
||||
if any(character in output for character in "\n\r"):
|
||||
raise ValueError("output path cannot contain a newline")
|
||||
return f"""set pagination off
|
||||
set confirm off
|
||||
set print thread-events off
|
||||
set breakpoint always-inserted off
|
||||
set logging file {output}
|
||||
set logging overwrite on
|
||||
set logging redirect off
|
||||
set logging enabled on
|
||||
handle SIGSEGV nostop noprint pass
|
||||
handle SIGILL nostop noprint pass
|
||||
handle SIGFPE nostop noprint pass
|
||||
handle SIGPIPE nostop noprint pass
|
||||
handle SIGALRM nostop noprint pass
|
||||
handle SIGUSR1 nostop noprint pass
|
||||
handle SIGUSR2 nostop noprint pass
|
||||
|
||||
attach {pid}
|
||||
"""
|
||||
|
||||
|
||||
def build_script(pid: int, fifa_base: int, output: str) -> str:
|
||||
address = addresses(fifa_base)
|
||||
return (
|
||||
gdb_prelude(pid, output)
|
||||
+ f"""define snapshot_gameplay
|
||||
set $snap_gameplay_global = *(void**)0x{address['gameplay_global']:x}
|
||||
set $snap_listener_manager = 0
|
||||
set $snap_listener_table = 0
|
||||
set $snap_listener_index = -1
|
||||
set $snap_free_roam = 0
|
||||
set $snap_free_state = -1
|
||||
set $snap_free_111 = -1
|
||||
set $snap_free_112 = -1
|
||||
set $snap_free_124 = -1
|
||||
set $snap_selected = 0
|
||||
set $snap_selected_vtable = 0
|
||||
set $snap_selected_mode = -1
|
||||
if $snap_gameplay_global != 0
|
||||
set $snap_listener_manager = *(void**)($snap_gameplay_global+0x58)
|
||||
end
|
||||
if $snap_listener_manager != 0
|
||||
set $snap_listener_table = *(void**)$snap_listener_manager
|
||||
end
|
||||
if $snap_listener_table != 0
|
||||
set $snap_free_roam = *(void**)$snap_listener_table
|
||||
set $snap_listener_index = *(int*)($snap_listener_table+0x20)
|
||||
if $snap_listener_index >= 0 && $snap_listener_index < 3
|
||||
set $snap_selected = *(void**)($snap_listener_table+$snap_listener_index*8)
|
||||
end
|
||||
end
|
||||
if $snap_free_roam != 0
|
||||
set $snap_free_state = *(int*)($snap_free_roam+0x30)
|
||||
set $snap_free_111 = *(unsigned char*)($snap_free_roam+0x111)
|
||||
set $snap_free_112 = *(unsigned char*)($snap_free_roam+0x112)
|
||||
set $snap_free_124 = *(int*)($snap_free_roam+0x124)
|
||||
end
|
||||
if $snap_selected != 0
|
||||
set $snap_selected_vtable = *(void**)$snap_selected
|
||||
set $snap_selected_mode = *(int*)($snap_selected+0x18)
|
||||
end
|
||||
end
|
||||
|
||||
set $action_count = 0
|
||||
hbreak *0x{address['manager_select_action']:x}
|
||||
commands
|
||||
silent
|
||||
set $action_count = $action_count+1
|
||||
set $provider = $rbx
|
||||
snapshot_gameplay
|
||||
if $action_count <= 128
|
||||
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d MANAGER_SELECT_ACTION" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d ordinal=%d instruction=%p caller_return=%p provider=%p provider_vtable=%p action_id=%#x free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $action_count, $pc, *(void**)($rsp+0x58), $provider, *(void**)$provider, $eax, $snap_free_roam, $snap_free_state, $snap_free_111, $snap_free_112, $snap_free_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
end
|
||||
if $eax == 0x30
|
||||
bt 16
|
||||
end
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['instructions_screen']:x}
|
||||
condition 2 $edx == 0x30 && *(void**)$rcx == 0x{address['screen_vtable']:x}
|
||||
commands
|
||||
silent
|
||||
set $screen = $rcx
|
||||
set $screen_owner = *(void**)($screen+0x140)
|
||||
set $screen_owner_vtable = 0
|
||||
if $screen_owner != 0
|
||||
set $screen_owner_vtable = *(void**)$screen_owner
|
||||
end
|
||||
snapshot_gameplay
|
||||
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d INSTRUCTIONS_SCREEN_EVENT_30" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d handler=%p caller_return=%p screen=%p screen_vtable=%p event=%#x payload=%p allow_advance138=%d owner140=%p owner_vtable=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $screen, *(void**)$screen, $edx, $r8, *(int*)($screen+0x138), $screen_owner, $screen_owner_vtable, $snap_free_roam, $snap_free_state, $snap_free_111, $snap_free_112, $snap_free_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
bt 16
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['command_dispatch']:x}
|
||||
condition 3 $edx == 0x128
|
||||
commands
|
||||
silent
|
||||
set $command_dispatcher = $rcx
|
||||
set $command_table = *(void**)$command_dispatcher
|
||||
snapshot_gameplay
|
||||
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d GAMEPLAY_COMMAND_128" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d function=%p caller_return=%p dispatcher=%p command=%#x payload=%p arg_r9=%p table=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $command_dispatcher, $edx, $r8, $r9, $command_table, $snap_free_roam, $snap_free_state, $snap_free_111, $snap_free_112, $snap_free_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
disable 1
|
||||
disable 2
|
||||
disable 3
|
||||
enable 5
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['free_roam_case']:x}
|
||||
commands
|
||||
silent
|
||||
set $owner = $rbx
|
||||
snapshot_gameplay
|
||||
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d FREE_ROAM_COMMAND_128" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d callsite=%p caller_return=%p owner=%p owner_vtable=%p command=%#x payload=%p state=%d previous=%d free111=%d free112=%d free124=%d manager=%p selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $owner, *(void**)$owner, $esi, $rdi, *(int*)($owner+0x30), *(int*)($owner+0x34), *(unsigned char*)($owner+0x111), *(unsigned char*)($owner+0x112), *(int*)($owner+0x124), *(void**)($owner+0x168), $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['scheduler']:x}
|
||||
disable 5
|
||||
commands
|
||||
silent
|
||||
set $owner = $rcx
|
||||
set $manager = *(void**)($owner+0x168)
|
||||
set $manager_vtable = 0
|
||||
set $manager_mode = -1
|
||||
set $child = 0
|
||||
set $child_vtable = 0
|
||||
if $manager != 0
|
||||
set $manager_vtable = *(void**)$manager
|
||||
set $manager_mode = *(int*)($manager+0x50)
|
||||
set $child = *(void**)($manager+0x8)
|
||||
end
|
||||
if $child != 0
|
||||
set $child_vtable = *(void**)$child
|
||||
end
|
||||
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d SCENARIO_SCHEDULER" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d function=%p caller_return=%p owner=%p owner_vtable=%p free124=%d command=%#x payload=%p manager=%p manager_vtable=%p manager_mode=%d child=%p child_vtable=%p\\n", $_thread, $pc, *(void**)$rsp, $owner, *(void**)$owner, *(int*)($owner+0x124), $edx, $r8, $manager, $manager_vtable, $manager_mode, $child, $child_vtable
|
||||
disable 4
|
||||
disable 5
|
||||
enable 6
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['manager_start']:x}
|
||||
disable 6
|
||||
commands
|
||||
silent
|
||||
set $manager = $rcx
|
||||
set $child = *(void**)($manager+0x8)
|
||||
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d SCENARIO_MANAGER_START" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d function=%p caller_return=%p manager=%p manager_vtable=%p requested_countdown=%d mode=%d child=%p child_vtable=%p\\n", $_thread, $pc, *(void**)$rsp, $manager, *(void**)$manager, $rdx & 0xff, *(int*)($manager+0x50), $child, $child ? *(void**)$child : 0
|
||||
disable 6
|
||||
enable 7
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['scenario_start']:x}
|
||||
disable 7
|
||||
commands
|
||||
silent
|
||||
set $ctx = $rcx
|
||||
snapshot_gameplay
|
||||
python import time; print("COMMAND128 epoch_ns=%d mono_ns=%d MODE_ZERO_SCENARIO_START" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d function=%p caller_return=%p ctx=%p ctx_vtable=%p descriptor=%p scenario_index=%d requested_countdown=%d flag40_before=%d callback_owner78=%p callback_vtable48=%p dispatcher_vtable80=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, *(void**)$ctx, $rdx, $r8d, $r9 & 0xff, *(unsigned char*)($ctx+0x40), *(void**)($ctx+0x78), *(void**)($ctx+0x48), *(void**)($ctx+0x80), $snap_free_roam, $snap_free_state, $snap_free_111, $snap_free_112, $snap_free_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
disable 7
|
||||
continue
|
||||
end
|
||||
|
||||
printf "COMMAND128 ARMED pid={pid} action_id=0x{address['manager_select_action']:x} screen_handler=0x{address['instructions_screen']:x} command_dispatch=0x{address['command_dispatch']:x} free_roam=0x{address['free_roam_case']:x} scheduler=0x{address['scheduler']:x} manager=0x{address['manager_start']:x} scenario=0x{address['scenario_start']:x}\\n"
|
||||
continue
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def effective_environment(pid: int) -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
for item in Path(f"/proc/{pid}/environ").read_bytes().split(b"\0"):
|
||||
if not item.startswith(b"OPENFUT_FIFA17_"):
|
||||
continue
|
||||
key, _, value = item.decode("utf-8", errors="replace").partition("=")
|
||||
values[key] = value
|
||||
return values
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
address = addresses(0x140000000)
|
||||
script = build_script(1234, 0x140000000, "/tmp/command-128.log")
|
||||
assert address["manager_select_source"] == 0x14705A620
|
||||
assert address["manager_select_action"] == 0x147CDC4A6
|
||||
assert address["instructions_screen"] == 0x147DCA400
|
||||
assert address["command_dispatch"] == 0x147A8F6C0
|
||||
assert address["free_roam_case"] == 0x147A92B0F
|
||||
assert address["scheduler"] == 0x147AC3A40
|
||||
assert address["manager_start"] == 0x147B1C2B0
|
||||
assert address["scenario_start"] == 0x147B1C190
|
||||
assert script.count("hbreak *") == 7
|
||||
assert "set $action_count = 0" in script
|
||||
assert "MANAGER_SELECT_ACTION" in script
|
||||
assert "condition 2 $edx == 0x30" in script
|
||||
assert "condition 3 $edx == 0x128" in script
|
||||
assert "disable 4" in script
|
||||
assert "disable 5" in script and "enable 5" in script
|
||||
assert "disable 6" in script and "enable 6" in script
|
||||
assert "disable 7" in script and "enable 7" in script
|
||||
assert "set *(" not in script
|
||||
print("command_128_trace selftest: PASS")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pid", nargs="?", type=int)
|
||||
parser.add_argument("--output")
|
||||
parser.add_argument("--print-script", action="store_true")
|
||||
parser.add_argument("--selftest", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return 0
|
||||
|
||||
pid = args.pid or transition.find_pid()
|
||||
if not pid:
|
||||
print("FIFA17.exe not found", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
|
||||
advance.validate_file(
|
||||
fifa_path,
|
||||
advance.PINNED_FIFA_SHA256,
|
||||
advance.FIFA_MODULE,
|
||||
)
|
||||
cards_base = 0
|
||||
cards_path = "<not-loaded>"
|
||||
try:
|
||||
cards_base, cards_path = transition.cards_mapping(pid)
|
||||
except RuntimeError:
|
||||
pass
|
||||
else:
|
||||
transition.validate_cards(cards_path)
|
||||
output = args.output or f"/tmp/fifa17-command-128-{pid}.log"
|
||||
script = build_script(pid, fifa_base, output)
|
||||
environment = effective_environment(pid)
|
||||
print(
|
||||
"COMMAND128 PREPARED "
|
||||
f"pid={pid} fifa_base={fifa_base:#x} cards_base={cards_base:#x} "
|
||||
f"cards_path={cards_path} "
|
||||
f"team_compat={environment.get('OPENFUT_FIFA17_SEASON_TEAM_COMPAT', '<absent>')} "
|
||||
f"pma_fix={environment.get('OPENFUT_FIFA17_OFFLINE_SEASONS_PMA_FIX', '<absent>')}"
|
||||
)
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.print_script:
|
||||
print(script, end="")
|
||||
return 0
|
||||
if not shutil.which("gdb"):
|
||||
print("gdb not found", file=sys.stderr)
|
||||
return 2
|
||||
script_path = f"/tmp/fifa17-command-128-{pid}.gdb"
|
||||
Path(script_path).write_text(script, encoding="utf-8")
|
||||
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
|
||||
return 127
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(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
|
||||
|
||||
Executable
+86
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Find an APT/ActionScript symbol inside the FIFA 17 Frostbite .cas archives.
|
||||
|
||||
Frosty is a GUI-only tool and its Legacy Explorer is the documented way to reach
|
||||
these assets, but the chunks holding APT ActionScript are stored plainly enough to
|
||||
grep — so a screen can be identified, and its whole symbol table recovered,
|
||||
without driving the GUI at all.
|
||||
|
||||
ALWAYS passes a control first: `KitAssignmentPopup` is a string from an
|
||||
already-exported BIG, so if it misses, the archives are packed differently than
|
||||
assumed and no negative from this tool may be quoted.
|
||||
|
||||
python3 find_apt_in_cas.py FUT_GET_MATCH_KITS_DP
|
||||
python3 find_apt_in_cas.py --dump 0x3707ecd7 fifa_installpackage_01/cas_01.cas
|
||||
"""
|
||||
import argparse
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
ROOT = "/mnt/games/FIFA 17"
|
||||
CONTROL = b"KitAssignmentPopup"
|
||||
|
||||
|
||||
def cas_files():
|
||||
return sorted(glob.glob(os.path.join(ROOT, "**", "*.cas"), recursive=True))
|
||||
|
||||
|
||||
def find(needle: bytes):
|
||||
control_total = 0
|
||||
hits = []
|
||||
for p in cas_files():
|
||||
d = open(p, "rb").read()
|
||||
control_total += d.count(CONTROL)
|
||||
start = 0
|
||||
while True:
|
||||
i = d.find(needle, start)
|
||||
if i < 0:
|
||||
break
|
||||
hits.append((p, i))
|
||||
start = i + 1
|
||||
return control_total, hits
|
||||
|
||||
|
||||
def dump(path, off, span=90000):
|
||||
with open(path, "rb") as f:
|
||||
f.seek(max(0, off - span // 2))
|
||||
d = f.read(span)
|
||||
seen = []
|
||||
for m in re.finditer(rb"[ -~]{4,}", d):
|
||||
t = m.group().decode("latin1")
|
||||
if t not in seen:
|
||||
seen.append(t)
|
||||
return seen
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("needle", nargs="?")
|
||||
ap.add_argument("--dump", metavar="OFFSET")
|
||||
ap.add_argument("--file")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.dump:
|
||||
path = args.file if os.path.isabs(args.file or "") else os.path.join(
|
||||
ROOT, "Data/Win32/superbundlelayout", args.file or "")
|
||||
for s in dump(path, int(args.dump, 0)):
|
||||
print(s)
|
||||
return 0
|
||||
|
||||
if not args.needle:
|
||||
ap.error("needle required")
|
||||
ctl, hits = find(args.needle.encode())
|
||||
print(f"control {CONTROL.decode()}: {ctl} hit(s)")
|
||||
if ctl == 0:
|
||||
print("CONTROL FAILED — archives not greppable this way; no negative is valid.")
|
||||
return 1
|
||||
print(f"{args.needle}: {len(hits)} hit(s)")
|
||||
for p, i in hits[:20]:
|
||||
print(f" {os.path.relpath(p, ROOT)} @ {i:#x}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Hardware-only trace of the engine-local overwrite wrapper entry.
|
||||
|
||||
Breaks before the prologue of FUN_147ce47e0, where [rsp] is the exact direct
|
||||
caller return address and R8D is the team ID later written to the final match
|
||||
record. This closes the one frame Wine PE unwinding could not recover.
|
||||
|
||||
No INT3/software breakpoints. No client memory writes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import gdb
|
||||
|
||||
WRAPPER_VA = 0x147CE47E0
|
||||
_STATE = None
|
||||
|
||||
|
||||
def _reg(name: str) -> int:
|
||||
return int(gdb.parse_and_eval(f"${name}"))
|
||||
|
||||
|
||||
def _read(address: int, size: int) -> bytes | None:
|
||||
if not address or address < 0:
|
||||
return None
|
||||
try:
|
||||
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||
except gdb.error:
|
||||
return None
|
||||
|
||||
|
||||
def _u64(address: int) -> int | None:
|
||||
data = _read(address, 8)
|
||||
return struct.unpack("<Q", data)[0] if data else None
|
||||
|
||||
|
||||
def _thread() -> dict:
|
||||
thread = gdb.selected_thread()
|
||||
if thread is None:
|
||||
return {}
|
||||
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||
|
||||
|
||||
def _registers() -> dict:
|
||||
names = (
|
||||
"rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rbp", "rsp",
|
||||
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15", "rip",
|
||||
)
|
||||
return {name: _reg(name) for name in names}
|
||||
|
||||
|
||||
class State:
|
||||
def __init__(self, path: str):
|
||||
self.path = path
|
||||
self.index = 0
|
||||
|
||||
def log(self, kind: str, **payload):
|
||||
self.index += 1
|
||||
thread = _thread()
|
||||
event = {
|
||||
"event": kind,
|
||||
"event_index": self.index,
|
||||
"time_unix": time.time(),
|
||||
"thread": thread,
|
||||
**payload,
|
||||
}
|
||||
with open(self.path, "a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
|
||||
class WrapperBreakpoint(gdb.Breakpoint):
|
||||
def __init__(self, state: State):
|
||||
self.state = state
|
||||
super().__init__(
|
||||
f"*0x{WRAPPER_VA:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False
|
||||
)
|
||||
self.silent = True
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
stack = _reg("rsp")
|
||||
caller_return = _u64(stack)
|
||||
self.state.log(
|
||||
"engine_overwrite_wrapper_entry",
|
||||
wrapper_va=WRAPPER_VA,
|
||||
caller_return_address=caller_return,
|
||||
source_team_id=_reg("r8") & 0xFFFFFFFF,
|
||||
side_argument=_reg("rdx") & 0xFFFFFFFF,
|
||||
registers=_registers(),
|
||||
caller_disassembly=(
|
||||
gdb.execute(f"x/12i 0x{caller_return - 32:x}", to_string=True)
|
||||
if caller_return else None
|
||||
),
|
||||
backtrace=gdb.execute("bt 32", to_string=True),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log(
|
||||
"trace_error", where="engine_overwrite_wrapper", error=str(exc),
|
||||
traceback=traceback.format_exc()
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _on_exit(event):
|
||||
if _STATE is not None:
|
||||
_STATE.log("inferior_exited", detail=str(event))
|
||||
|
||||
|
||||
def start_trace(log_path: str, _cards_base: int):
|
||||
global _STATE
|
||||
open(log_path, "w", encoding="utf-8").close()
|
||||
_STATE = State(log_path)
|
||||
breakpoint = WrapperBreakpoint(_STATE)
|
||||
gdb.events.exited.connect(_on_exit)
|
||||
_STATE.log(
|
||||
"trace_armed",
|
||||
breakpoints={"engine_overwrite_wrapper": {"number": breakpoint.number, "va": WRAPPER_VA}},
|
||||
hardware_only=True,
|
||||
client_memory_writes=False,
|
||||
)
|
||||
@@ -0,0 +1,226 @@
|
||||
"""GDB payload for the LIVE-PROVEN engine match-team +0x14 writer.
|
||||
|
||||
READ-ONLY hardware debug only:
|
||||
|
||||
0x147c652ce mov dword [rdx + rcx + 0x44], r8d
|
||||
|
||||
At the first team-like source value, derives both fixed-stride record fields
|
||||
from live RCX and arms 4-byte WRITE watchpoints on:
|
||||
|
||||
teamId A = rcx + 0x44
|
||||
teamId B = rcx + 0x44 + 0x45c
|
||||
|
||||
The execute breakpoint records the intended source value before every call. The
|
||||
watchpoints then capture both the expected write and any later overwrite, even
|
||||
if the overwrite comes from a different function.
|
||||
|
||||
No INT3/software breakpoints. No client memory writes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import gdb
|
||||
|
||||
WRITER_VA = 0x147C652CE
|
||||
POST_WRITER_VA = 0x147C652D3
|
||||
SIDE_STRIDE = 0x45C
|
||||
TEAM_FIELD_OFF = 0x44
|
||||
RECORD_FIELD_OFF = 0x14
|
||||
TEAM_LIKE = {73, 240, 241, 243, 130000, 130001}
|
||||
|
||||
_STATE = None
|
||||
|
||||
|
||||
def _reg(name: str) -> int:
|
||||
return int(gdb.parse_and_eval(f"${name}"))
|
||||
|
||||
|
||||
def _thread() -> dict:
|
||||
thread = gdb.selected_thread()
|
||||
if thread is None:
|
||||
return {}
|
||||
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||
|
||||
|
||||
def _read(address: int, size: int) -> bytes | None:
|
||||
if not address or address < 0:
|
||||
return None
|
||||
try:
|
||||
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||
except gdb.error:
|
||||
return None
|
||||
|
||||
|
||||
def _i32(address: int) -> int | None:
|
||||
data = _read(address, 4)
|
||||
return struct.unpack("<i", data)[0] if data else None
|
||||
|
||||
|
||||
def _registers() -> dict:
|
||||
names = (
|
||||
"rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rbp", "rsp",
|
||||
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15", "rip",
|
||||
)
|
||||
return {name: _reg(name) for name in names}
|
||||
|
||||
|
||||
class State:
|
||||
def __init__(self, log_path: str):
|
||||
self.log_path = log_path
|
||||
self.event_index = 0
|
||||
self.engine_base = None
|
||||
self.watch_a = None
|
||||
self.watch_b = None
|
||||
|
||||
def log(self, kind: str, **payload):
|
||||
self.event_index += 1
|
||||
event = {
|
||||
"event": kind,
|
||||
"event_index": self.event_index,
|
||||
"time_unix": time.time(),
|
||||
"thread": _thread(),
|
||||
**payload,
|
||||
}
|
||||
with open(self.log_path, "a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
def arm_fields(self, engine_base: int):
|
||||
if self.engine_base == engine_base and self.watch_a and self.watch_b:
|
||||
return
|
||||
for watchpoint in (self.watch_a, self.watch_b):
|
||||
if watchpoint is not None:
|
||||
try:
|
||||
watchpoint.delete()
|
||||
except gdb.error:
|
||||
pass
|
||||
self.engine_base = engine_base
|
||||
self.watch_a = TeamFieldWatchpoint(self, 0, engine_base + TEAM_FIELD_OFF)
|
||||
self.watch_b = TeamFieldWatchpoint(
|
||||
self, 1, engine_base + TEAM_FIELD_OFF + SIDE_STRIDE
|
||||
)
|
||||
self.log(
|
||||
"team_field_watchpoints_armed",
|
||||
engine_base=engine_base,
|
||||
team_id_a_address=self.watch_a.address,
|
||||
team_id_b_address=self.watch_b.address,
|
||||
watchpoint_a=self.watch_a.number,
|
||||
watchpoint_b=self.watch_b.number,
|
||||
)
|
||||
|
||||
|
||||
class TeamFieldWatchpoint(gdb.Breakpoint):
|
||||
def __init__(self, state: State, side: int, address: int):
|
||||
self.state = state
|
||||
self.side = side
|
||||
self.address = address
|
||||
super().__init__(
|
||||
f"*(int*)0x{address:x}",
|
||||
type=gdb.BP_WATCHPOINT,
|
||||
wp_class=gdb.WP_WRITE,
|
||||
internal=False,
|
||||
)
|
||||
self.silent = True
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
pc = _reg("rip")
|
||||
writer = WRITER_VA if pc == POST_WRITER_VA else None
|
||||
record_start = self.address - RECORD_FIELD_OFF
|
||||
record = _read(record_start, 0x7C)
|
||||
self.state.log(
|
||||
"final_team_field_write_post",
|
||||
side=self.side,
|
||||
watch_address=self.address,
|
||||
value=_i32(self.address),
|
||||
stopped_pc=pc,
|
||||
writer_va=writer,
|
||||
record_start=record_start,
|
||||
record_hex=record.hex() if record else None,
|
||||
registers=_registers(),
|
||||
disassembly=gdb.execute("x/12i $pc-32", to_string=True),
|
||||
backtrace=gdb.execute("bt 32", to_string=True),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log(
|
||||
"trace_error",
|
||||
where="team_field_watchpoint",
|
||||
error=str(exc),
|
||||
traceback=traceback.format_exc(),
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
class FinalWriterBreakpoint(gdb.Breakpoint):
|
||||
def __init__(self, state: State):
|
||||
self.state = state
|
||||
super().__init__(
|
||||
f"*0x{WRITER_VA:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False
|
||||
)
|
||||
self.silent = True
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
engine_base = _reg("rcx")
|
||||
side_offset = _reg("rdx")
|
||||
source_value = _reg("r8") & 0xFFFFFFFF
|
||||
if source_value in TEAM_LIKE:
|
||||
self.state.arm_fields(engine_base)
|
||||
destination = engine_base + side_offset + TEAM_FIELD_OFF
|
||||
side = side_offset // SIDE_STRIDE if side_offset in (0, SIDE_STRIDE) else None
|
||||
self.state.log(
|
||||
"final_writer_pre",
|
||||
instruction_va=WRITER_VA,
|
||||
engine_base=engine_base,
|
||||
side_offset=side_offset,
|
||||
side=side,
|
||||
destination=destination,
|
||||
record_start=destination - RECORD_FIELD_OFF,
|
||||
source_register="r8d",
|
||||
source_value=source_value,
|
||||
prior_value=_i32(destination),
|
||||
team_id_a_address=engine_base + TEAM_FIELD_OFF,
|
||||
team_id_b_address=engine_base + TEAM_FIELD_OFF + SIDE_STRIDE,
|
||||
team_id_a_before=_i32(engine_base + TEAM_FIELD_OFF),
|
||||
team_id_b_before=_i32(engine_base + TEAM_FIELD_OFF + SIDE_STRIDE),
|
||||
registers=_registers(),
|
||||
disassembly=gdb.execute("x/6i $pc", to_string=True),
|
||||
backtrace=gdb.execute("bt 32", to_string=True),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log(
|
||||
"trace_error",
|
||||
where="final_writer",
|
||||
error=str(exc),
|
||||
traceback=traceback.format_exc(),
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _on_exit(event):
|
||||
if _STATE is not None:
|
||||
_STATE.log("inferior_exited", detail=str(event))
|
||||
|
||||
|
||||
def start_trace(log_path: str, _cards_base: int):
|
||||
global _STATE
|
||||
open(log_path, "w", encoding="utf-8").close()
|
||||
_STATE = State(log_path)
|
||||
writer = FinalWriterBreakpoint(_STATE)
|
||||
gdb.events.exited.connect(_on_exit)
|
||||
_STATE.log(
|
||||
"trace_armed",
|
||||
breakpoints={
|
||||
"final_writer": {"number": writer.number, "va": WRITER_VA},
|
||||
},
|
||||
side_stride=SIDE_STRIDE,
|
||||
team_field_offset=TEAM_FIELD_OFF,
|
||||
hardware_only=True,
|
||||
client_memory_writes=False,
|
||||
)
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Hardware-only origin trace for the exact SetTeam team context.
|
||||
|
||||
Matches the typed integer context pointer selected by SetTeam to the constructor
|
||||
invocation that produced it. No client memory writes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import gdb
|
||||
|
||||
CONTEXT_REUSE = 0x1477C17FC
|
||||
CONTEXT_ALLOCATED = 0x1477C18C1
|
||||
SET_TEAM_STUB = 0x147060A80
|
||||
LOCKED_SETTER_RETURN = 0x1477C2415
|
||||
CONTEXT_STACK_COUNT = 0x144BCEDA0
|
||||
CONTEXT_STACK_ARRAY = 0x144BCEDA8
|
||||
INTERESTING = {73, 130000, 130001}
|
||||
_STATE = None
|
||||
|
||||
|
||||
def _reg(name):
|
||||
return int(gdb.parse_and_eval(f"${name}"))
|
||||
|
||||
|
||||
def _read(address, size):
|
||||
if not address or address < 0:
|
||||
return None
|
||||
try:
|
||||
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||
except gdb.error:
|
||||
return None
|
||||
|
||||
|
||||
def _u64(address):
|
||||
data = _read(address, 8)
|
||||
return struct.unpack("<Q", data)[0] if data else None
|
||||
|
||||
|
||||
def _i32(address):
|
||||
data = _read(address, 4)
|
||||
return struct.unpack("<i", data)[0] if data else None
|
||||
|
||||
|
||||
def _thread():
|
||||
thread = gdb.selected_thread()
|
||||
if thread is None:
|
||||
return {}
|
||||
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||
|
||||
|
||||
class State:
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
self.index = 0
|
||||
self.total_constructor_hits = 0
|
||||
self.interesting_constructor_hits = 0
|
||||
self.pending_allocations = {}
|
||||
self.origins = deque(maxlen=4096)
|
||||
|
||||
def log(self, kind, **payload):
|
||||
self.index += 1
|
||||
event = {
|
||||
"event": kind,
|
||||
"event_index": self.index,
|
||||
"time_unix": time.time(),
|
||||
"thread": _thread(),
|
||||
**payload,
|
||||
}
|
||||
with open(self.path, "a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
def thread_key(self):
|
||||
return tuple(_thread().get("ptid", ()))
|
||||
|
||||
def remember_origin(self, context, origin):
|
||||
if context:
|
||||
self.origins.append({**origin, "context": context})
|
||||
|
||||
def find_origin(self, context):
|
||||
return next((origin for origin in reversed(self.origins)
|
||||
if origin["context"] == context), None)
|
||||
|
||||
|
||||
class HardwareBreakpoint(gdb.Breakpoint):
|
||||
def __init__(self, state, address):
|
||||
self.state = state
|
||||
self.address = address
|
||||
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
|
||||
self.silent = True
|
||||
|
||||
|
||||
class ContextReuseBreakpoint(HardwareBreakpoint):
|
||||
def stop(self):
|
||||
self.state.total_constructor_hits += 1
|
||||
try:
|
||||
value = _reg("rcx") & 0xFFFFFFFF
|
||||
if value not in INTERESTING:
|
||||
return False
|
||||
self.state.interesting_constructor_hits += 1
|
||||
rsp = _reg("rsp")
|
||||
direct_return = _u64(rsp + 0x28)
|
||||
origin = {
|
||||
"value": value,
|
||||
"direct_return_address": direct_return,
|
||||
"upstream_return_address": (
|
||||
_u64(rsp + 0x68)
|
||||
if direct_return == LOCKED_SETTER_RETURN
|
||||
else direct_return
|
||||
),
|
||||
"constructor_stack_hex": (_read(rsp, 0x100) or b"").hex(),
|
||||
"constructor_hit": self.state.total_constructor_hits,
|
||||
}
|
||||
context = _reg("rax")
|
||||
if context:
|
||||
self.state.remember_origin(context, origin)
|
||||
else:
|
||||
self.state.pending_allocations[self.state.thread_key()] = origin
|
||||
except Exception as exc:
|
||||
self.state.log(
|
||||
"trace_error",
|
||||
where="context_reuse",
|
||||
error=str(exc),
|
||||
traceback=traceback.format_exc(),
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
class ContextAllocatedBreakpoint(HardwareBreakpoint):
|
||||
def stop(self):
|
||||
try:
|
||||
origin = self.state.pending_allocations.pop(self.state.thread_key(), None)
|
||||
if origin is not None:
|
||||
self.state.remember_origin(_reg("rdx"), origin)
|
||||
except Exception as exc:
|
||||
self.state.log(
|
||||
"trace_error",
|
||||
where="context_allocated",
|
||||
error=str(exc),
|
||||
traceback=traceback.format_exc(),
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
class SetTeamStubBreakpoint(HardwareBreakpoint):
|
||||
def stop(self):
|
||||
try:
|
||||
count = _i32(CONTEXT_STACK_COUNT)
|
||||
array = _u64(CONTEXT_STACK_ARRAY)
|
||||
team_context = (
|
||||
_u64(array + (count - 2) * 8)
|
||||
if array and count is not None and count >= 2
|
||||
else None
|
||||
)
|
||||
side_context = (
|
||||
_u64(array + (count - 1) * 8)
|
||||
if array and count is not None and count >= 1
|
||||
else None
|
||||
)
|
||||
rsp = _reg("rsp")
|
||||
self.state.log(
|
||||
"set_team_stub_entry",
|
||||
context_stack_count=count,
|
||||
team_context=team_context,
|
||||
team_context_hex=(_read(team_context, 0x40) or b"").hex(),
|
||||
team_value=_i32(team_context + 0x10) if team_context else None,
|
||||
side_context=side_context,
|
||||
side_value=_i32(side_context + 0x10) if side_context else None,
|
||||
matched_origin=self.state.find_origin(team_context),
|
||||
caller_return_address=_u64(rsp),
|
||||
entry_registers={
|
||||
name: _reg(name)
|
||||
for name in ("rcx", "rdx", "r8", "r9")
|
||||
},
|
||||
backtrace=gdb.execute("bt 32", to_string=True),
|
||||
total_constructor_hits=self.state.total_constructor_hits,
|
||||
interesting_constructor_hits=self.state.interesting_constructor_hits,
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log(
|
||||
"trace_error",
|
||||
where="set_team_stub",
|
||||
error=str(exc),
|
||||
traceback=traceback.format_exc(),
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _on_exit(event):
|
||||
if _STATE is not None:
|
||||
_STATE.log(
|
||||
"inferior_exited",
|
||||
detail=str(event),
|
||||
total_constructor_hits=_STATE.total_constructor_hits,
|
||||
interesting_constructor_hits=_STATE.interesting_constructor_hits,
|
||||
)
|
||||
|
||||
|
||||
def start_trace(log_path, _cards_base):
|
||||
global _STATE
|
||||
open(log_path, "w", encoding="utf-8").close()
|
||||
_STATE = State(log_path)
|
||||
points = {
|
||||
"context_reuse": ContextReuseBreakpoint(_STATE, CONTEXT_REUSE),
|
||||
"context_allocated": ContextAllocatedBreakpoint(_STATE, CONTEXT_ALLOCATED),
|
||||
"set_team_stub": SetTeamStubBreakpoint(_STATE, SET_TEAM_STUB),
|
||||
}
|
||||
gdb.events.exited.connect(_on_exit)
|
||||
_STATE.log(
|
||||
"trace_armed",
|
||||
breakpoints={
|
||||
name: {"number": point.number, "va": point.address}
|
||||
for name, point in points.items()
|
||||
},
|
||||
hardware_only=True,
|
||||
client_memory_writes=False,
|
||||
matching="exact_context_pointer",
|
||||
)
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Hardware-only trace of engine game-setup context selection.
|
||||
|
||||
Captures the function that requests team/side, selector indices 1/0, selected
|
||||
transient context objects, and the typed value getter. No client writes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import gdb
|
||||
|
||||
DISPATCH = 0x147060D00
|
||||
SELECT_VALUE = 0x147572C50
|
||||
CONTEXT_SELECTED = 0x1477C845D
|
||||
_STATE = None
|
||||
|
||||
|
||||
def _reg(name: str) -> int:
|
||||
return int(gdb.parse_and_eval(f"${name}"))
|
||||
|
||||
|
||||
def _read(address: int, size: int) -> bytes | None:
|
||||
if not address or address < 0:
|
||||
return None
|
||||
try:
|
||||
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||
except gdb.error:
|
||||
return None
|
||||
|
||||
|
||||
def _u64(address: int) -> int | None:
|
||||
data = _read(address, 8)
|
||||
return struct.unpack("<Q", data)[0] if data else None
|
||||
|
||||
|
||||
def _i32(address: int) -> int | None:
|
||||
data = _read(address, 4)
|
||||
return struct.unpack("<i", data)[0] if data else None
|
||||
|
||||
|
||||
def _thread() -> dict:
|
||||
thread = gdb.selected_thread()
|
||||
if thread is None:
|
||||
return {}
|
||||
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||
|
||||
|
||||
def _printable_pointers(address: int, data: bytes) -> dict:
|
||||
found = {}
|
||||
for offset in range(0, len(data) - 7, 8):
|
||||
pointer = struct.unpack_from("<Q", data, offset)[0]
|
||||
raw = _read(pointer, 128)
|
||||
if not raw:
|
||||
continue
|
||||
value = raw.split(b"\0", 1)[0]
|
||||
try:
|
||||
text = value.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
if len(text) >= 3 and all(char.isprintable() for char in text):
|
||||
found[hex(offset)] = {"pointer": pointer, "text": text}
|
||||
return found
|
||||
|
||||
|
||||
class State:
|
||||
def __init__(self, path: str):
|
||||
self.path = path
|
||||
self.index = 0
|
||||
self.requested_indices = {}
|
||||
|
||||
def log(self, kind: str, **payload):
|
||||
self.index += 1
|
||||
event = {"event": kind, "event_index": self.index, "time_unix": time.time(),
|
||||
"thread": _thread(), **payload}
|
||||
with open(self.path, "a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||
handle.flush(); os.fsync(handle.fileno())
|
||||
|
||||
def key(self):
|
||||
return tuple(_thread().get("ptid", ()))
|
||||
|
||||
|
||||
class HardwareBreakpoint(gdb.Breakpoint):
|
||||
def __init__(self, state: State, address: int):
|
||||
self.state = state
|
||||
self.address = address
|
||||
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
|
||||
self.silent = True
|
||||
|
||||
|
||||
class DispatchBreakpoint(HardwareBreakpoint):
|
||||
def stop(self):
|
||||
try:
|
||||
rsp = _reg("rsp")
|
||||
caller = _u64(rsp)
|
||||
self.state.log(
|
||||
"game_setup_dispatch_entry",
|
||||
caller_return_address=caller,
|
||||
caller_disassembly=(gdb.execute(f"x/12i 0x{caller-32:x}", to_string=True)
|
||||
if caller else None),
|
||||
backtrace=gdb.execute("bt 24", to_string=True),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log("trace_error", where="dispatch", error=str(exc), traceback=traceback.format_exc())
|
||||
return False
|
||||
|
||||
|
||||
class SelectValueBreakpoint(HardwareBreakpoint):
|
||||
def stop(self):
|
||||
try:
|
||||
index = _reg("rcx") & 0xFFFFFFFF
|
||||
self.state.requested_indices[self.state.key()] = index
|
||||
self.state.log("context_value_request", index=index)
|
||||
except Exception as exc:
|
||||
self.state.log("trace_error", where="select_value", error=str(exc), traceback=traceback.format_exc())
|
||||
return False
|
||||
|
||||
|
||||
class ContextSelectedBreakpoint(HardwareBreakpoint):
|
||||
def stop(self):
|
||||
try:
|
||||
index = _reg("rdi") & 0xFFFFFFFF
|
||||
context = _reg("rbx")
|
||||
data = _read(context, 0x80) or b""
|
||||
self.state.log(
|
||||
"context_selected",
|
||||
requested_index=self.state.requested_indices.get(self.state.key()),
|
||||
selector_index=index,
|
||||
context=context,
|
||||
type_flags=_i32(context + 8),
|
||||
value_i32=_i32(context + 0x10),
|
||||
value_qword=_u64(context + 0x10),
|
||||
context_hex=data.hex(),
|
||||
printable_pointers=_printable_pointers(context, data),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log("trace_error", where="context_selected", error=str(exc), traceback=traceback.format_exc())
|
||||
return False
|
||||
|
||||
|
||||
|
||||
|
||||
def _on_exit(event):
|
||||
if _STATE is not None:
|
||||
_STATE.log("inferior_exited", detail=str(event))
|
||||
|
||||
|
||||
def start_trace(log_path: str, _cards_base: int):
|
||||
global _STATE
|
||||
open(log_path, "w", encoding="utf-8").close()
|
||||
_STATE = State(log_path)
|
||||
points = {
|
||||
"dispatch": DispatchBreakpoint(_STATE, DISPATCH),
|
||||
"select_value": SelectValueBreakpoint(_STATE, SELECT_VALUE),
|
||||
"context_selected": ContextSelectedBreakpoint(_STATE, CONTEXT_SELECTED),
|
||||
}
|
||||
gdb.events.exited.connect(_on_exit)
|
||||
_STATE.log(
|
||||
"trace_armed",
|
||||
breakpoints={name: {"number": bp.number, "va": bp.address} for name, bp in points.items()},
|
||||
hardware_only=True,
|
||||
client_memory_writes=False,
|
||||
)
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Hardware-only trace of CardsGameSetupAdapter query 13 and overwrite input.
|
||||
|
||||
Breakpoints:
|
||||
|
||||
FUN_180031340 entry incoming teamId/side/context
|
||||
0x18003148f pre-call query id, selector, output/count pointers
|
||||
0x180031495 post-call complete 48-byte records and count
|
||||
0x180031861 submit original incoming teamId sent to engine
|
||||
|
||||
This proves whether query 13 influences the overwrite. No INT3/software
|
||||
breakpoints, client writes, or game input.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import gdb
|
||||
|
||||
CARDS_IMAGE_BASE = 0x180000000
|
||||
ENTRY = 0x180031340
|
||||
QUERY_PRE = 0x18003148F
|
||||
QUERY_POST = 0x180031495
|
||||
SUBMIT = 0x180031861
|
||||
MAX_RECORDS = 100
|
||||
RECORD_SIZE = 48
|
||||
_STATE = None
|
||||
|
||||
|
||||
def _reg(name: str) -> int:
|
||||
return int(gdb.parse_and_eval(f"${name}"))
|
||||
|
||||
|
||||
def _read(address: int, size: int) -> bytes | None:
|
||||
if not address or address < 0 or size < 0:
|
||||
return None
|
||||
try:
|
||||
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||
except gdb.error:
|
||||
return None
|
||||
|
||||
|
||||
def _u64(address: int) -> int | None:
|
||||
data = _read(address, 8)
|
||||
return struct.unpack("<Q", data)[0] if data else None
|
||||
|
||||
|
||||
def _i32(address: int) -> int | None:
|
||||
data = _read(address, 4)
|
||||
return struct.unpack("<i", data)[0] if data else None
|
||||
|
||||
|
||||
def _thread() -> dict:
|
||||
thread = gdb.selected_thread()
|
||||
if thread is None:
|
||||
return {}
|
||||
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||
|
||||
|
||||
def _registers() -> dict:
|
||||
names = (
|
||||
"rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rbp", "rsp",
|
||||
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15", "rip",
|
||||
)
|
||||
return {name: _reg(name) for name in names}
|
||||
|
||||
|
||||
def _printable_pointer(pointer: int) -> str | None:
|
||||
data = _read(pointer, 96)
|
||||
if not data:
|
||||
return None
|
||||
raw = data.split(b"\0", 1)[0]
|
||||
if len(raw) < 3:
|
||||
return None
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
return text if all(char.isprintable() for char in text) else None
|
||||
|
||||
|
||||
def _decode_record(data: bytes, address: int) -> dict:
|
||||
words = list(struct.unpack("<12i", data))
|
||||
qwords = list(struct.unpack("<6Q", data))
|
||||
strings = {}
|
||||
for index, pointer in enumerate(qwords):
|
||||
text = _printable_pointer(pointer)
|
||||
if text:
|
||||
strings[f"qword_{index}"] = {"pointer": pointer, "text": text}
|
||||
interesting = {
|
||||
str(value): [index * 4 for index, word in enumerate(words) if word == value]
|
||||
for value in (73, 240, 241, 243, 130000, 130001)
|
||||
if value in words
|
||||
}
|
||||
return {
|
||||
"address": address,
|
||||
"hex": data.hex(),
|
||||
"i32": words,
|
||||
"u32": [value & 0xFFFFFFFF for value in words],
|
||||
"f32": list(struct.unpack("<12f", data)),
|
||||
"qwords": qwords,
|
||||
"strings": strings,
|
||||
"interesting_values": interesting,
|
||||
}
|
||||
|
||||
|
||||
class State:
|
||||
def __init__(self, path: str, cards_base: int):
|
||||
self.path = path
|
||||
self.cards_base = cards_base
|
||||
self.index = 0
|
||||
self.calls = {}
|
||||
|
||||
def log(self, kind: str, **payload):
|
||||
self.index += 1
|
||||
event = {
|
||||
"event": kind,
|
||||
"event_index": self.index,
|
||||
"time_unix": time.time(),
|
||||
"thread": _thread(),
|
||||
**payload,
|
||||
}
|
||||
with open(self.path, "a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
def thread_key(self):
|
||||
return tuple(_thread().get("ptid", ()))
|
||||
|
||||
|
||||
class HardwareBreakpoint(gdb.Breakpoint):
|
||||
def __init__(self, state: State, image_va: int):
|
||||
self.state = state
|
||||
self.image_va = image_va
|
||||
address = state.cards_base + (image_va - CARDS_IMAGE_BASE)
|
||||
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
|
||||
self.silent = True
|
||||
|
||||
|
||||
class EntryBreakpoint(HardwareBreakpoint):
|
||||
def stop(self):
|
||||
try:
|
||||
self.state.log(
|
||||
"game_setup_entry",
|
||||
incoming_context=_reg("rcx"),
|
||||
incoming_side=_reg("rdx") & 0xFFFFFFFF,
|
||||
incoming_team_id=_reg("r8") & 0xFFFFFFFF,
|
||||
incoming_r9=_reg("r9"),
|
||||
registers=_registers(),
|
||||
backtrace=gdb.execute("bt 24", to_string=True),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log("trace_error", where="entry", error=str(exc), traceback=traceback.format_exc())
|
||||
return False
|
||||
|
||||
|
||||
class QueryPreBreakpoint(HardwareBreakpoint):
|
||||
def stop(self):
|
||||
try:
|
||||
rsp = _reg("rsp")
|
||||
adapter = _reg("rcx")
|
||||
vtable = _u64(adapter)
|
||||
count_pointer = _u64(rsp + 0x20)
|
||||
state = {
|
||||
"adapter": adapter,
|
||||
"adapter_vtable": vtable,
|
||||
"query_target": _u64(vtable + 0xE0) if vtable else None,
|
||||
"query_id": _reg("rdx") & 0xFFFFFFFF,
|
||||
"selector": _reg("r8") & 0xFFFFFFFF,
|
||||
"output_buffer": _reg("r9"),
|
||||
"count_pointer": count_pointer,
|
||||
"sixth_argument": _u64(rsp + 0x28),
|
||||
"count_before": _i32(count_pointer) if count_pointer else None,
|
||||
"saved_incoming_team_id": _i32(rsp + 0x34),
|
||||
"saved_side": _i32(rsp + 0x50),
|
||||
"saved_engine_context": _u64(rsp + 0x68),
|
||||
"adapter_prefix_hex": (_read(adapter, 0x100) or b"").hex(),
|
||||
}
|
||||
self.state.calls[self.state.thread_key()] = state
|
||||
self.state.log(
|
||||
"query13_pre",
|
||||
**state,
|
||||
registers=_registers(),
|
||||
backtrace=gdb.execute("bt 24", to_string=True),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log("trace_error", where="query_pre", error=str(exc), traceback=traceback.format_exc())
|
||||
return False
|
||||
|
||||
|
||||
class QueryPostBreakpoint(HardwareBreakpoint):
|
||||
def stop(self):
|
||||
try:
|
||||
state = self.state.calls.get(self.state.thread_key(), {})
|
||||
count_pointer = state.get("count_pointer")
|
||||
output = state.get("output_buffer")
|
||||
count = _i32(count_pointer) if count_pointer else None
|
||||
safe_count = min(max(count or 0, 0), MAX_RECORDS)
|
||||
records = []
|
||||
for index in range(safe_count):
|
||||
address = output + index * RECORD_SIZE
|
||||
data = _read(address, RECORD_SIZE)
|
||||
if data and len(data) == RECORD_SIZE:
|
||||
records.append(_decode_record(data, address))
|
||||
self.state.log(
|
||||
"query13_post",
|
||||
query_state=state,
|
||||
count_after=count,
|
||||
records=records,
|
||||
saved_incoming_team_id_after=_i32(_reg("rsp") + 0x34),
|
||||
saved_side_after=_i32(_reg("rsp") + 0x50),
|
||||
registers=_registers(),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log("trace_error", where="query_post", error=str(exc), traceback=traceback.format_exc())
|
||||
return False
|
||||
|
||||
|
||||
class SubmitBreakpoint(HardwareBreakpoint):
|
||||
def stop(self):
|
||||
try:
|
||||
rsp = _reg("rsp")
|
||||
self.state.log(
|
||||
"game_setup_submit",
|
||||
submitted_team_id=_reg("r8") & 0xFFFFFFFF,
|
||||
submitted_side=_reg("rdx") & 0xFFFFFFFF,
|
||||
engine_context=_reg("rcx"),
|
||||
saved_incoming_team_id=_i32(rsp + 0x34),
|
||||
saved_side=_i32(rsp + 0x50),
|
||||
registers=_registers(),
|
||||
backtrace=gdb.execute("bt 24", to_string=True),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log("trace_error", where="submit", error=str(exc), traceback=traceback.format_exc())
|
||||
return False
|
||||
|
||||
|
||||
def _on_exit(event):
|
||||
if _STATE is not None:
|
||||
_STATE.log("inferior_exited", detail=str(event))
|
||||
|
||||
|
||||
def start_trace(log_path: str, cards_base: int):
|
||||
global _STATE
|
||||
open(log_path, "w", encoding="utf-8").close()
|
||||
_STATE = State(log_path, cards_base)
|
||||
points = {
|
||||
"entry": EntryBreakpoint(_STATE, ENTRY),
|
||||
"query_pre": QueryPreBreakpoint(_STATE, QUERY_PRE),
|
||||
"query_post": QueryPostBreakpoint(_STATE, QUERY_POST),
|
||||
"submit": SubmitBreakpoint(_STATE, SUBMIT),
|
||||
}
|
||||
gdb.events.exited.connect(_on_exit)
|
||||
_STATE.log(
|
||||
"trace_armed",
|
||||
breakpoints={name: {"number": bp.number, "image_va": bp.image_va} for name, bp in points.items()},
|
||||
record_size=RECORD_SIZE,
|
||||
hardware_only=True,
|
||||
client_memory_writes=False,
|
||||
)
|
||||
@@ -0,0 +1,388 @@
|
||||
"""GDB Python payload for read-only FIFA17 match-team writer tracing.
|
||||
|
||||
Loaded by trace_match_team_writer.py. Uses hardware execute breakpoints and a
|
||||
4-byte hardware WRITE watchpoint only; never inserts INT3 and never writes game
|
||||
memory.
|
||||
|
||||
Breakpoints (CardsDLL image VAs):
|
||||
|
||||
* FUN_1800fc500 entry -- derives output pair from RDX and arms *(int*)(rdx+4).
|
||||
* 0x1800fc595 -- pre-write opponent lookup into pair[1].
|
||||
* 0x1800fc5b8 -- mirrored pre-write opponent lookup into pair[0].
|
||||
|
||||
The dynamic watchpoint catches the exact write establishing pair[1], whether it
|
||||
is the opponent lookup at 0x1800fc595 or the own-club store at 0x1800fc5a0.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import gdb
|
||||
|
||||
CARDS_IMAGE_BASE = 0x180000000
|
||||
ENTRY_RVA = 0x0FC500
|
||||
LOOKUP_TO_TEAM1_RVA = 0x0FC595
|
||||
LOOKUP_TO_TEAM0_RVA = 0x0FC5B8
|
||||
TEAM1_POST_PC_TO_WRITER = {
|
||||
0x1800FC599: 0x1800FC595, # mov [r14+4],ecx
|
||||
0x1800FC5A4: 0x1800FC5A0, # mov [r14+4],eax
|
||||
}
|
||||
|
||||
_STATE = None
|
||||
|
||||
|
||||
def _reg(name: str) -> int:
|
||||
return int(gdb.parse_and_eval(f"${name}"))
|
||||
|
||||
|
||||
def _thread() -> dict:
|
||||
thread = gdb.selected_thread()
|
||||
if thread is None:
|
||||
return {}
|
||||
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||
|
||||
|
||||
def _read(address: int, size: int) -> bytes | None:
|
||||
if not address or address < 0 or size < 0:
|
||||
return None
|
||||
try:
|
||||
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||
except gdb.error:
|
||||
return None
|
||||
|
||||
|
||||
def _u8(address: int) -> int | None:
|
||||
data = _read(address, 1)
|
||||
return data[0] if data else None
|
||||
|
||||
|
||||
def _u32(address: int) -> int | None:
|
||||
data = _read(address, 4)
|
||||
return struct.unpack("<I", data)[0] if data else None
|
||||
|
||||
|
||||
def _i32(address: int) -> int | None:
|
||||
data = _read(address, 4)
|
||||
return struct.unpack("<i", data)[0] if data else None
|
||||
|
||||
|
||||
def _u64(address: int) -> int | None:
|
||||
data = _read(address, 8)
|
||||
return struct.unpack("<Q", data)[0] if data else None
|
||||
|
||||
|
||||
def _cstring(address: int, maximum: int = 256) -> str | None:
|
||||
data = _read(address, maximum)
|
||||
if not data:
|
||||
return None
|
||||
return data.split(b"\0", 1)[0].decode("utf-8", "replace")
|
||||
|
||||
|
||||
def _rtti_name(vtable: int, cards_base: int) -> str | None:
|
||||
"""MSVC x64 RTTI name from vtable[-1] CompleteObjectLocator.
|
||||
|
||||
PE RVAs in the locator are module-relative. Failure is evidence-free and is
|
||||
logged as null; no pointer is named from an offset coincidence.
|
||||
"""
|
||||
locator = _u64(vtable - 8) if vtable else None
|
||||
if not locator:
|
||||
return None
|
||||
raw = _read(locator, 24)
|
||||
if not raw:
|
||||
return None
|
||||
_signature, _offset, _cd_offset, type_rva, _hier_rva, self_rva = struct.unpack(
|
||||
"<IIIiii", raw
|
||||
)
|
||||
if not (0 <= type_rva < 0x10000000 and 0 <= self_rva < 0x10000000):
|
||||
return None
|
||||
image_base = locator - self_rva
|
||||
if abs(image_base - cards_base) > 0x100000:
|
||||
return None
|
||||
return _cstring(image_base + type_rva + 16)
|
||||
|
||||
|
||||
def _object(address: int, cards_base: int) -> dict:
|
||||
vtable = _u64(address) if address else None
|
||||
return {
|
||||
"address": address,
|
||||
"vtable": vtable,
|
||||
"vtable_image_va": (
|
||||
CARDS_IMAGE_BASE + (vtable - cards_base)
|
||||
if vtable and cards_base <= vtable < cards_base + 0x400000
|
||||
else None
|
||||
),
|
||||
"rtti": _rtti_name(vtable, cards_base) if vtable else None,
|
||||
}
|
||||
|
||||
|
||||
def _registers() -> dict:
|
||||
names = (
|
||||
"rax",
|
||||
"rbx",
|
||||
"rcx",
|
||||
"rdx",
|
||||
"rsi",
|
||||
"rdi",
|
||||
"rbp",
|
||||
"rsp",
|
||||
"r8",
|
||||
"r9",
|
||||
"r10",
|
||||
"r11",
|
||||
"r12",
|
||||
"r13",
|
||||
"r14",
|
||||
"r15",
|
||||
"rip",
|
||||
)
|
||||
return {name: _reg(name) for name in names}
|
||||
|
||||
|
||||
def _provenance(state, destination: int | None = None) -> dict:
|
||||
"""Recover the candidate's live input chain without naming the objects."""
|
||||
regs = _registers()
|
||||
context = regs["rbx"]
|
||||
output_pair = regs["r14"]
|
||||
obj = regs["rbp"]
|
||||
nested = _u64(obj + 0xB0) if obj else None
|
||||
field_2e8 = nested + 0x2E8 if nested else None
|
||||
source_base = _u64(field_2e8) if field_2e8 else None
|
||||
participant_holder = regs["r12"]
|
||||
participant = _u64(participant_holder) if participant_holder else None
|
||||
index_70 = _u8(participant + 0x70) if participant else None
|
||||
source_address = (
|
||||
source_base + index_70 * 16
|
||||
if source_base is not None and index_70 is not None
|
||||
else None
|
||||
)
|
||||
source_bytes = _read(source_address, 16) if source_address else None
|
||||
decoded = None
|
||||
if source_bytes and len(source_bytes) == 16:
|
||||
team_id, byte4, byte5, pad, word8, wordc = struct.unpack("<iBBHii", source_bytes)
|
||||
decoded = {
|
||||
"team_id": team_id,
|
||||
"byte_4": byte4,
|
||||
"byte_5": byte5,
|
||||
"pad_6": pad,
|
||||
"word_8": word8,
|
||||
"word_c": wordc,
|
||||
}
|
||||
pair_bytes = _read(output_pair, 8) if output_pair else None
|
||||
return {
|
||||
"destination": destination,
|
||||
"context": _object(context, state.cards_base),
|
||||
"entry_context": _object(state.current_entry.get("context", 0), state.cards_base),
|
||||
"output_pair": output_pair,
|
||||
"entry_output_pair": state.current_entry.get("output_pair"),
|
||||
"output_pair_bytes": pair_bytes.hex() if pair_bytes else None,
|
||||
"output_team_id_0": _i32(output_pair) if output_pair else None,
|
||||
"output_team_id_1": _i32(output_pair + 4) if output_pair else None,
|
||||
"obj": _object(obj, state.cards_base),
|
||||
"nested_at_obj_plus_b0": _object(nested or 0, state.cards_base),
|
||||
"field_plus_2e8_address": field_2e8,
|
||||
"source_array_base": source_base,
|
||||
"participant_holder": participant_holder,
|
||||
"participant": _object(participant or 0, state.cards_base),
|
||||
"participant_plus_70": index_70,
|
||||
"source_record_address": source_address,
|
||||
"source_record_hex": source_bytes.hex() if source_bytes else None,
|
||||
"source_record": decoded,
|
||||
"registers": regs,
|
||||
}
|
||||
|
||||
|
||||
class State:
|
||||
def __init__(self, log_path: str, cards_base: int):
|
||||
self.log_path = log_path
|
||||
self.cards_base = cards_base
|
||||
self.current_entry: dict = {}
|
||||
self.watchpoint = None
|
||||
self.event_index = 0
|
||||
|
||||
def log(self, kind: str, **payload):
|
||||
self.event_index += 1
|
||||
event = {
|
||||
"event": kind,
|
||||
"event_index": self.event_index,
|
||||
"time_unix": time.time(),
|
||||
"thread": _thread(),
|
||||
**payload,
|
||||
}
|
||||
with open(self.log_path, "a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
|
||||
class Team1Watchpoint(gdb.Breakpoint):
|
||||
def __init__(self, state: State, address: int):
|
||||
self.state = state
|
||||
self.address = address
|
||||
super().__init__(
|
||||
f"*(int*)0x{address:x}",
|
||||
type=gdb.BP_WATCHPOINT,
|
||||
wp_class=gdb.WP_WRITE,
|
||||
internal=False,
|
||||
)
|
||||
self.silent = True
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
pc = _reg("rip")
|
||||
image_pc = CARDS_IMAGE_BASE + (pc - self.state.cards_base)
|
||||
writer = TEAM1_POST_PC_TO_WRITER.get(image_pc)
|
||||
source_value = None
|
||||
if writer == 0x1800FC595:
|
||||
source_value = _reg("rcx") & 0xFFFFFFFF
|
||||
elif writer == 0x1800FC5A0:
|
||||
source_value = _reg("rax") & 0xFFFFFFFF
|
||||
self.state.log(
|
||||
"team1_write_post",
|
||||
watch_address=self.address,
|
||||
value=_i32(self.address),
|
||||
stopped_pc=pc,
|
||||
stopped_image_va=image_pc,
|
||||
writer_image_va=writer,
|
||||
source_value=source_value,
|
||||
disassembly=gdb.execute("x/10i $pc-32", to_string=True),
|
||||
backtrace=gdb.execute("bt 24", to_string=True),
|
||||
provenance=_provenance(self.state, self.address),
|
||||
)
|
||||
if writer is not None:
|
||||
# The output pair is a short-lived stack buffer. Leaving the
|
||||
# watchpoint active after the candidate's exact write produced
|
||||
# 114k unrelated events when that stack memory was reused.
|
||||
# The two hardware lookup breakpoints remain armed, so disabling
|
||||
# only this completed one-shot watch loses no provenance.
|
||||
self.enabled = False
|
||||
self.state.log(
|
||||
"team1_watchpoint_disabled",
|
||||
watch_address=self.address,
|
||||
reason="candidate exact write captured",
|
||||
)
|
||||
except Exception as exc: # GDB must continue even if evidence rendering fails.
|
||||
self.state.log("trace_error", where="team1_watchpoint", error=str(exc),
|
||||
traceback=traceback.format_exc())
|
||||
return False
|
||||
|
||||
|
||||
class EntryBreakpoint(gdb.Breakpoint):
|
||||
def __init__(self, state: State, address: int):
|
||||
self.state = state
|
||||
super().__init__(
|
||||
f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False
|
||||
)
|
||||
self.silent = True
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
context, output_pair = _reg("rcx"), _reg("rdx")
|
||||
self.state.current_entry = {
|
||||
"context": context,
|
||||
"output_pair": output_pair,
|
||||
"entry_thread": _thread(),
|
||||
}
|
||||
if self.state.watchpoint is not None:
|
||||
try:
|
||||
self.state.watchpoint.delete()
|
||||
except gdb.error:
|
||||
pass
|
||||
initial = _i32(output_pair + 4)
|
||||
self.state.watchpoint = Team1Watchpoint(self.state, output_pair + 4)
|
||||
self.state.log(
|
||||
"candidate_entry",
|
||||
entry_image_va=0x1800FC500,
|
||||
context=_object(context, self.state.cards_base),
|
||||
output_pair=output_pair,
|
||||
team_id_1_address=output_pair + 4,
|
||||
team_id_1_initial=initial,
|
||||
watchpoint_number=self.state.watchpoint.number,
|
||||
backtrace=gdb.execute("bt 24", to_string=True),
|
||||
registers=_registers(),
|
||||
)
|
||||
self.state.log(
|
||||
"team1_watchpoint_armed",
|
||||
watch_address=output_pair + 4,
|
||||
watchpoint_number=self.state.watchpoint.number,
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log("trace_error", where="candidate_entry", error=str(exc),
|
||||
traceback=traceback.format_exc())
|
||||
return False
|
||||
|
||||
|
||||
class LookupStoreBreakpoint(gdb.Breakpoint):
|
||||
def __init__(self, state: State, address: int, image_va: int, destination_offset: int):
|
||||
self.state = state
|
||||
self.image_va = image_va
|
||||
self.destination_offset = destination_offset
|
||||
super().__init__(
|
||||
f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False
|
||||
)
|
||||
self.silent = True
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
destination = _reg("r14") + self.destination_offset
|
||||
self.state.log(
|
||||
"opponent_lookup_store_pre",
|
||||
writer_image_va=self.image_va,
|
||||
destination=destination,
|
||||
destination_offset=self.destination_offset,
|
||||
source_register="ecx",
|
||||
source_value=_reg("rcx") & 0xFFFFFFFF,
|
||||
disassembly=gdb.execute("x/5i $pc", to_string=True),
|
||||
backtrace=gdb.execute("bt 24", to_string=True),
|
||||
provenance=_provenance(self.state, destination),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log("trace_error", where="lookup_store", error=str(exc),
|
||||
traceback=traceback.format_exc())
|
||||
return False
|
||||
|
||||
|
||||
def _on_exit(event):
|
||||
if _STATE is not None:
|
||||
_STATE.log("inferior_exited", detail=str(event))
|
||||
|
||||
|
||||
def start_trace(log_path: str, cards_base: int):
|
||||
"""Called from the supervisor's gdb command file after attach."""
|
||||
global _STATE
|
||||
open(log_path, "w", encoding="utf-8").close()
|
||||
_STATE = State(log_path, cards_base)
|
||||
entry = EntryBreakpoint(_STATE, cards_base + ENTRY_RVA)
|
||||
lookup_team1 = LookupStoreBreakpoint(
|
||||
_STATE,
|
||||
cards_base + LOOKUP_TO_TEAM1_RVA,
|
||||
0x1800FC595,
|
||||
4,
|
||||
)
|
||||
lookup_team0 = LookupStoreBreakpoint(
|
||||
_STATE,
|
||||
cards_base + LOOKUP_TO_TEAM0_RVA,
|
||||
0x1800FC5B8,
|
||||
0,
|
||||
)
|
||||
gdb.events.exited.connect(_on_exit)
|
||||
_STATE.log(
|
||||
"trace_armed",
|
||||
cards_base=cards_base,
|
||||
breakpoints={
|
||||
"candidate_entry": {"number": entry.number, "image_va": 0x1800FC500},
|
||||
"lookup_to_team1": {
|
||||
"number": lookup_team1.number,
|
||||
"image_va": 0x1800FC595,
|
||||
},
|
||||
"lookup_to_team0": {
|
||||
"number": lookup_team0.number,
|
||||
"image_va": 0x1800FC5B8,
|
||||
},
|
||||
},
|
||||
hardware_only=True,
|
||||
client_memory_writes=False,
|
||||
)
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Hardware-only origin trace for CardsDLL team-pair submissions.
|
||||
|
||||
Distinguishes the three callers of the engine team-id service that can submit a
|
||||
full two-team pair, plus the mode-76 builder that prepares its pair:
|
||||
|
||||
0x1800c7583 correct fixture pair control
|
||||
0x1800c6c23 generic pair submitter
|
||||
0x1800c8dc1 mode-76 pair submitter
|
||||
0x1800c8bf0 mode-76 pair builder entry
|
||||
|
||||
No INT3/software breakpoints. No client memory writes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import gdb
|
||||
|
||||
CARDS_IMAGE_BASE = 0x180000000
|
||||
SITES = {
|
||||
0x1800C7583: ("fixture_pair_submit", "r14", "rsi"),
|
||||
0x1800C6C23: ("generic_pair_submit", "r14", "rsi"),
|
||||
0x1800C8DC1: ("mode76_pair_submit", "r15", "rbp"),
|
||||
}
|
||||
MODE76_BUILDER = 0x1800C8BF0
|
||||
_STATE = None
|
||||
|
||||
|
||||
def _reg(name: str) -> int:
|
||||
return int(gdb.parse_and_eval(f"${name}"))
|
||||
|
||||
|
||||
def _thread() -> dict:
|
||||
thread = gdb.selected_thread()
|
||||
if thread is None:
|
||||
return {}
|
||||
return {"name": thread.name, "ptid": list(thread.ptid), "global_num": thread.global_num}
|
||||
|
||||
|
||||
def _read(address: int, size: int) -> bytes | None:
|
||||
if not address or address < 0:
|
||||
return None
|
||||
try:
|
||||
return bytes(gdb.selected_inferior().read_memory(address, size))
|
||||
except gdb.error:
|
||||
return None
|
||||
|
||||
|
||||
def _pair(address: int) -> list[int] | None:
|
||||
data = _read(address, 8)
|
||||
return list(struct.unpack("<2i", data)) if data else None
|
||||
|
||||
|
||||
def _registers() -> dict:
|
||||
names = (
|
||||
"rax", "rbx", "rcx", "rdx", "rsi", "rdi", "rbp", "rsp",
|
||||
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15", "rip",
|
||||
)
|
||||
return {name: _reg(name) for name in names}
|
||||
|
||||
|
||||
class State:
|
||||
def __init__(self, log_path: str, cards_base: int):
|
||||
self.log_path = log_path
|
||||
self.cards_base = cards_base
|
||||
self.event_index = 0
|
||||
|
||||
def log(self, kind: str, **payload):
|
||||
self.event_index += 1
|
||||
event = {
|
||||
"event": kind,
|
||||
"event_index": self.event_index,
|
||||
"time_unix": time.time(),
|
||||
"thread": _thread(),
|
||||
**payload,
|
||||
}
|
||||
with open(self.log_path, "a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
|
||||
class PairSubmitBreakpoint(gdb.Breakpoint):
|
||||
def __init__(self, state: State, image_va: int, name: str, pointer_reg: str, index_reg: str):
|
||||
self.state = state
|
||||
self.image_va = image_va
|
||||
self.name = name
|
||||
self.pointer_reg = pointer_reg
|
||||
self.index_reg = index_reg
|
||||
address = state.cards_base + (image_va - CARDS_IMAGE_BASE)
|
||||
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
|
||||
self.silent = True
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
pointer = _reg(self.pointer_reg)
|
||||
index = _reg(self.index_reg) & 0xFFFFFFFF
|
||||
pair_base = pointer - index * 4
|
||||
self.state.log(
|
||||
self.name,
|
||||
instruction_image_va=self.image_va,
|
||||
source_value=_reg("r8") & 0xFFFFFFFF,
|
||||
side=_reg("rdx") & 0xFF,
|
||||
engine_base=_reg("rcx"),
|
||||
pair_pointer=pointer,
|
||||
pair_index=index,
|
||||
pair_base=pair_base,
|
||||
pair=_pair(pair_base),
|
||||
registers=_registers(),
|
||||
disassembly=gdb.execute("x/5i $pc", to_string=True),
|
||||
backtrace=gdb.execute("bt 24", to_string=True),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log(
|
||||
"trace_error", where=self.name, error=str(exc),
|
||||
traceback=traceback.format_exc()
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
class Mode76BuilderBreakpoint(gdb.Breakpoint):
|
||||
def __init__(self, state: State):
|
||||
self.state = state
|
||||
address = state.cards_base + (MODE76_BUILDER - CARDS_IMAGE_BASE)
|
||||
super().__init__(f"*0x{address:x}", type=gdb.BP_HARDWARE_BREAKPOINT, internal=False)
|
||||
self.silent = True
|
||||
|
||||
def stop(self):
|
||||
try:
|
||||
self.state.log(
|
||||
"mode76_builder_entry",
|
||||
instruction_image_va=MODE76_BUILDER,
|
||||
object=_reg("rcx"),
|
||||
registers=_registers(),
|
||||
backtrace=gdb.execute("bt 24", to_string=True),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.state.log(
|
||||
"trace_error", where="mode76_builder", error=str(exc),
|
||||
traceback=traceback.format_exc()
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _on_exit(event):
|
||||
if _STATE is not None:
|
||||
_STATE.log("inferior_exited", detail=str(event))
|
||||
|
||||
|
||||
def start_trace(log_path: str, cards_base: int):
|
||||
global _STATE
|
||||
open(log_path, "w", encoding="utf-8").close()
|
||||
_STATE = State(log_path, cards_base)
|
||||
breakpoints = {}
|
||||
for image_va, (name, pointer_reg, index_reg) in SITES.items():
|
||||
bp = PairSubmitBreakpoint(_STATE, image_va, name, pointer_reg, index_reg)
|
||||
breakpoints[name] = {"number": bp.number, "image_va": image_va}
|
||||
builder = Mode76BuilderBreakpoint(_STATE)
|
||||
breakpoints["mode76_builder"] = {"number": builder.number, "image_va": MODE76_BUILDER}
|
||||
gdb.events.exited.connect(_on_exit)
|
||||
_STATE.log(
|
||||
"trace_armed",
|
||||
breakpoints=breakpoints,
|
||||
hardware_only=True,
|
||||
client_memory_writes=False,
|
||||
)
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Find IMMEDIATE stores of a constant to a struct offset, in a live module.
|
||||
|
||||
immstore.py <imm_dec> [disp_hex|any] [--exe]
|
||||
|
||||
Only `C7 /0` (mov dword [reg+disp], imm32) can INTRODUCE a constant into a
|
||||
field; `89 /r` merely propagates one. Emits image VAs so they can be fed to
|
||||
ldis.py. Read-only.
|
||||
"""
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
|
||||
CARDS_IMG = 0x180000000
|
||||
EXE_IMG = 0x140000000
|
||||
|
||||
|
||||
def pid():
|
||||
for d in glob.glob("/proc/[0-9]*"):
|
||||
try:
|
||||
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
|
||||
return int(os.path.basename(d))
|
||||
except OSError:
|
||||
pass
|
||||
raise SystemExit("FIFA17.exe not running")
|
||||
|
||||
|
||||
P = pid()
|
||||
|
||||
|
||||
def module_base(n):
|
||||
for l in open(f"/proc/{P}/maps"):
|
||||
if n.lower() in l.lower():
|
||||
return int(l.split("-")[0], 16)
|
||||
raise SystemExit(f"{n} not mapped")
|
||||
|
||||
|
||||
def text_spans(base):
|
||||
out = []
|
||||
started = False
|
||||
for l in open(f"/proc/{P}/maps"):
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)", l)
|
||||
if not m:
|
||||
continue
|
||||
lo, hi, perms, path = int(m.group(1), 16), int(m.group(2), 16), m.group(3), m.group(4)
|
||||
if lo == base:
|
||||
started = True
|
||||
continue
|
||||
if started:
|
||||
if not path.strip() and "x" in perms:
|
||||
out.append((lo, hi))
|
||||
elif out:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
args = [a for a in sys.argv[1:] if a != "--exe"]
|
||||
exe = "--exe" in sys.argv
|
||||
imm = int(args[0], 0)
|
||||
want_disp = None if len(args) < 2 or args[1] == "any" else int(args[1], 16)
|
||||
img = EXE_IMG if exe else CARDS_IMG
|
||||
base = module_base("FIFA17.exe" if exe else "CardsDLL")
|
||||
|
||||
mem = open(f"/proc/{P}/mem", "rb", 0)
|
||||
immb = struct.pack("<i", imm)
|
||||
hits = 0
|
||||
for lo, hi in text_spans(base):
|
||||
mem.seek(lo)
|
||||
buf = mem.read(hi - lo)
|
||||
img_lo = img + (lo - base)
|
||||
i = buf.find(b"\xc7", 0)
|
||||
while i >= 0:
|
||||
modrm = buf[i + 1] if i + 1 < len(buf) else 0
|
||||
if (modrm & 0x38) == 0: # /0
|
||||
mod, rm = modrm >> 6, modrm & 7
|
||||
if mod == 1 and i + 7 <= len(buf): # disp8
|
||||
disp, ib = buf[i + 2], i + 3
|
||||
sz = 7
|
||||
elif mod == 2 and i + 10 <= len(buf): # disp32
|
||||
disp, ib = struct.unpack_from("<i", buf, i + 2)[0], i + 6
|
||||
sz = 10
|
||||
elif mod == 0 and rm not in (4, 5) and i + 6 <= len(buf):
|
||||
disp, ib = 0, i + 2
|
||||
sz = 6
|
||||
else:
|
||||
disp = None
|
||||
if disp is not None and buf[ib:ib + 4] == immb:
|
||||
if want_disp is None or disp == want_disp:
|
||||
print(f" image 0x{img_lo+i:x} mov dword [reg+0x{disp:x}], {imm} ({sz}B)")
|
||||
hits += 1
|
||||
i = buf.find(b"\xc7", i + 1)
|
||||
print(f" {hits} immediate store(s) of {imm}"
|
||||
+ (f" at +0x{want_disp:x}" if want_disp is not None else ""))
|
||||
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
+94
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only live disassembler for the FIFA17 client (CardsDLL / FIFA17.exe).
|
||||
|
||||
ldis.py <image_va_hex> [nbytes] [--exe] disassemble
|
||||
ldis.py --bytes <image_va_hex> [nbytes] hexdump
|
||||
ldis.py --map show module bases
|
||||
|
||||
CardsDLL image base 0x180000000; FIFA17.exe image base 0x140000000.
|
||||
Live address = module_base + (image_va - img_base). Sections map 1:1 for both,
|
||||
but this is recomputed and printed so the offset trap stays visible.
|
||||
"""
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
PID = None
|
||||
CARDS_IMG = 0x180000000
|
||||
EXE_IMG = 0x140000000
|
||||
|
||||
|
||||
def pid():
|
||||
global PID
|
||||
if PID is None:
|
||||
import glob, os
|
||||
for d in glob.glob("/proc/[0-9]*"):
|
||||
try:
|
||||
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
|
||||
PID = int(os.path.basename(d))
|
||||
break
|
||||
except OSError:
|
||||
pass
|
||||
if PID is None:
|
||||
raise SystemExit("FIFA17.exe not running")
|
||||
return PID
|
||||
|
||||
|
||||
def module_base(needle):
|
||||
"""Base = the NAMED PE-header mapping for the module (Wine maps the rest
|
||||
anonymously, so never trust the mapping that merely CONTAINS an address)."""
|
||||
for l in open(f"/proc/{pid()}/maps"):
|
||||
if needle.lower() in l.lower():
|
||||
return int(l.split("-")[0], 16)
|
||||
raise SystemExit(f"module {needle} not mapped")
|
||||
|
||||
|
||||
def live(va, exe=False):
|
||||
if exe:
|
||||
return module_base("FIFA17.exe") + (va - EXE_IMG)
|
||||
return module_base("CardsDLL") + (va - CARDS_IMG)
|
||||
|
||||
|
||||
def read(va, n, exe=False):
|
||||
la = live(va, exe)
|
||||
with open(f"/proc/{pid()}/mem", "rb", 0) as m:
|
||||
m.seek(la)
|
||||
return la, m.read(n)
|
||||
|
||||
|
||||
def main():
|
||||
a = sys.argv[1:]
|
||||
if not a or a[0] == "--map":
|
||||
print(f" pid = {pid()}")
|
||||
print(f" CardsDLL = 0x{module_base('CardsDLL'):x} (image 0x{CARDS_IMG:x})")
|
||||
print(f" FIFA17.exe = 0x{module_base('FIFA17.exe'):x} (image 0x{EXE_IMG:x})")
|
||||
return
|
||||
hexdump = a[0] == "--bytes"
|
||||
if hexdump:
|
||||
a = a[1:]
|
||||
exe = "--exe" in a
|
||||
a = [x for x in a if x != "--exe"]
|
||||
va = int(a[0], 16)
|
||||
n = int(a[1]) if len(a) > 1 else 160
|
||||
la, buf = read(va, n, exe)
|
||||
print(f" image 0x{va:x} -> live 0x{la:x} ({len(buf)} bytes)")
|
||||
if hexdump:
|
||||
for i in range(0, len(buf), 16):
|
||||
c = buf[i:i + 16]
|
||||
print(f" 0x{va+i:x}: {' '.join(f'{b:02x}' for b in c):<47} "
|
||||
+ "".join(chr(b) if 32 <= b < 127 else "." for b in c))
|
||||
return
|
||||
with tempfile.NamedTemporaryFile(suffix=".bin") as f:
|
||||
f.write(buf)
|
||||
f.flush()
|
||||
out = subprocess.run(
|
||||
["objdump", "-D", "-b", "binary", "-m", "i386:x86-64", "-M", "intel",
|
||||
f"--adjust-vma=0x{va:x}", f.name],
|
||||
capture_output=True, text=True).stdout
|
||||
for line in out.splitlines():
|
||||
if re.match(r"\s+[0-9a-f]+:", line):
|
||||
print(" " + line.strip())
|
||||
|
||||
|
||||
main()
|
||||
Executable
+114
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only census of FIFA 17's RESIDENT club-item vector.
|
||||
|
||||
Chain, every link from CardsDLL static RE:
|
||||
[CardsDLL+0x2e6398] -> owner object (FUN_18011a830)
|
||||
owner->vtable[0x4e8] -> getter returning mgr (call *0x4e8(%rdx))
|
||||
mgr+0x108 .. mgr+0x110 -> club-item vector, stride 24
|
||||
element+0x10 -> the item record pointer (FUN_1800d73d0)
|
||||
record+0x4c cardtype (derived from cardsubtypeid by FUN_1800d8330: 9/10/11 -> 7)
|
||||
record+0x50 cardsubtypeid
|
||||
record+0x5c itemState (101 activeHomeKit, 102 activeAwayKit)
|
||||
record+0x60 category (clone driver FUN_1801c3480 requires 4)
|
||||
record+0x94 teamid
|
||||
record+0xba teamkittypetechid (u16)
|
||||
Offsets not in that list are labelled UNVERIFIED and only dumped raw.
|
||||
No writes. Ever.
|
||||
"""
|
||||
import re, struct, sys, collections
|
||||
|
||||
PID = int(sys.argv[1]) if len(sys.argv) > 1 else 44405
|
||||
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
|
||||
|
||||
def rd(a, n):
|
||||
mem.seek(a); return mem.read(n)
|
||||
def q(a):
|
||||
return struct.unpack("<Q", rd(a, 8))[0]
|
||||
def i32(b, o):
|
||||
return struct.unpack_from("<i", b, o)[0]
|
||||
|
||||
# locate CardsDLL by its NEAREST PRECEDING NAMED mapping (Wine maps PE sections anon)
|
||||
named = []
|
||||
for ln in open(f"/proc/{PID}/maps"):
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
|
||||
if m: named.append((int(m.group(1),16), m.group(3).strip()))
|
||||
named.sort()
|
||||
base = None
|
||||
for s, p in named:
|
||||
if p.endswith("CardsDLL_Win64_retail.dll"):
|
||||
base = s; break
|
||||
if base is None:
|
||||
print(" CardsDLL mapping not found"); sys.exit(1)
|
||||
print(f" CardsDLL base = {base:#x}")
|
||||
def live(static): return base + (static - 0x180000000)
|
||||
|
||||
# sanity: the 0x7575 sender immediate must be where static RE says
|
||||
probe = rd(live(0x180026fea), 6)
|
||||
print(f" sanity @0x180026fea: {probe.hex(' ')} (expect ba 75 75 00 00)")
|
||||
if probe[:5] != bytes.fromhex("ba75750000"):
|
||||
print(" SANITY FAILED - base wrong, aborting"); sys.exit(1)
|
||||
|
||||
owner = q(live(0x1802e6398))
|
||||
print(f" owner object = {owner:#x}")
|
||||
vt = q(owner)
|
||||
getter = q(vt + 0x4e8)
|
||||
print(f" vtable = {vt:#x}")
|
||||
print(f" vtable[0x4e8] = {getter:#x} bytes: {rd(getter,12).hex(' ')}")
|
||||
# expect: mov rax,[rcx+off] ; ret -> 48 8b 81 off32 c3 or 48 8b 41 off8 c3
|
||||
b = rd(getter, 12)
|
||||
mgr = None
|
||||
if b[0:3] == bytes.fromhex("488d81"):
|
||||
off = struct.unpack_from("<I", b, 3)[0]; mgr = owner + off
|
||||
print(f" getter returns owner+{off:#x} (EMBEDDED subobject) -> mgr = {mgr:#x}")
|
||||
elif b[0:3] == bytes.fromhex("488d41"):
|
||||
off = b[3]; mgr = owner + off
|
||||
print(f" getter returns owner+{off:#x} (EMBEDDED subobject) -> mgr = {mgr:#x}")
|
||||
elif b[0:3] == bytes.fromhex("488b81"):
|
||||
off = struct.unpack_from("<I", b, 3)[0]; mgr = q(owner + off)
|
||||
print(f" getter returns [owner+{off:#x}] -> mgr = {mgr:#x}")
|
||||
elif b[0:3] == bytes.fromhex("488b41"):
|
||||
off = b[3]; mgr = q(owner + off)
|
||||
print(f" getter returns [owner+{off:#x}] -> mgr = {mgr:#x}")
|
||||
elif b[0:2] == bytes.fromhex("488b") and b[2] == 0xc1:
|
||||
mgr = owner; print(" getter returns owner itself")
|
||||
else:
|
||||
print(" getter shape unrecognised; trying owner as mgr")
|
||||
mgr = owner
|
||||
|
||||
for label, mgr_try in (("resolved", mgr), ("owner", owner)):
|
||||
try:
|
||||
beg, end = q(mgr_try + 0x108), q(mgr_try + 0x110)
|
||||
except OSError:
|
||||
print(f" [{label}] +0x108/0x110 unreadable"); continue
|
||||
if not (0 < beg <= end) or (end - beg) % 24 or (end - beg) > 24*100000:
|
||||
print(f" [{label}] vector implausible: {beg:#x}..{end:#x}")
|
||||
continue
|
||||
n = (end - beg) // 24
|
||||
print(f"\n === club-item vector via {label}: {beg:#x}..{end:#x} {n} slot(s) ===")
|
||||
hist = collections.Counter(); rows = []
|
||||
for k in range(n):
|
||||
try:
|
||||
rec = q(beg + k*24 + 0x10)
|
||||
except OSError:
|
||||
continue
|
||||
if not rec:
|
||||
hist[("<null slot>", None)] += 1; continue
|
||||
try:
|
||||
r = rd(rec, 0xC0)
|
||||
except OSError:
|
||||
continue
|
||||
if len(r) < 0xC0: continue
|
||||
ct, sub, st, cat = i32(r,0x4c), i32(r,0x50), i32(r,0x5c), i32(r,0x60)
|
||||
team = i32(r,0x94); kt = struct.unpack_from("<H", r, 0xba)[0]
|
||||
hist[(ct, sub)] += 1
|
||||
rows.append((rec, ct, sub, st, cat, team, kt))
|
||||
print(f" (cardtype, cardsubtypeid) histogram:")
|
||||
for key, c in sorted(hist.items(), key=lambda x: -x[1]):
|
||||
tag = " <== KIT (selector needs this)" if key == (7, 9) else ""
|
||||
print(f" {str(key):<18} x{c}{tag}")
|
||||
print(f" cardtype 7 records: {sum(c for (ct,_),c in hist.items() if ct==7)}")
|
||||
print(f"\n first 12 records:")
|
||||
print(f" {'ptr':>14} {'ctype':>5} {'subtype':>7} {'state':>5} {'cat':>4} {'team':>5} {'kittype':>7}")
|
||||
for rec, ct, sub, st, cat, team, kt in rows[:12]:
|
||||
print(f" {rec:#14x} {ct:>5} {sub:>7} {st:>5} {cat:>4} {team:>5} {kt:>7}")
|
||||
break
|
||||
Executable
+137
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Byte-level diff of the two resident kit records in a live FIFA17 client.
|
||||
|
||||
The pre-match selector draws each kit from a clone query keyed on the record's
|
||||
own fields, so if both tiles render identically the question is precisely: which
|
||||
bytes of the home record differ from the away record? This prints every differing
|
||||
offset with the known field names attached, and dumps the fields the decoded
|
||||
clone query consumes.
|
||||
|
||||
Read-only. Never writes to the process.
|
||||
|
||||
Decoded query (FUN_1801c3480 -> FUN_1801c44b0):
|
||||
teamtechid == record+0x94
|
||||
teamkittypetechid == derived from itemState (101 -> 0 home, 102 -> 1 away)
|
||||
year == record+0xba
|
||||
"""
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
|
||||
PID = int(sys.argv[1])
|
||||
WANT = [int(a) for a in sys.argv[2:]] or [100004874, 100004873]
|
||||
|
||||
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
|
||||
|
||||
|
||||
def rd(a, n):
|
||||
mem.seek(a)
|
||||
return mem.read(n)
|
||||
|
||||
|
||||
def q(a):
|
||||
return struct.unpack("<Q", rd(a, 8))[0]
|
||||
|
||||
|
||||
named = []
|
||||
for ln in open(f"/proc/{PID}/maps"):
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
|
||||
if m:
|
||||
named.append((int(m.group(1), 16), m.group(3).strip()))
|
||||
named.sort()
|
||||
base = next((s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll")), None)
|
||||
if base is None:
|
||||
sys.exit("CardsDLL mapping not found")
|
||||
|
||||
|
||||
def live(static):
|
||||
return base + (static - 0x180000000)
|
||||
|
||||
|
||||
if rd(live(0x180026FEA), 5) != bytes.fromhex("ba75750000"):
|
||||
sys.exit("SANITY FAILED - wrong base")
|
||||
print(f" CardsDLL base = {base:#x} (sanity ok)")
|
||||
|
||||
owner = q(live(0x1802E6398))
|
||||
sentinel = owner + 0x160C8
|
||||
root = q(owner + 0x160D8)
|
||||
|
||||
# Known record fields, offset -> (name, width)
|
||||
FIELDS = {
|
||||
0x08: ("id", 8),
|
||||
0x18: ("resourceId/definitionId", 4),
|
||||
# Offsets per club_items.json `_record_map`, which is authoritative:
|
||||
# cardassetid is +0x1c and assetId is +0x20 — NOT the other way round.
|
||||
0x1C: ("cardassetid", 4),
|
||||
0x20: ("assetId", 4),
|
||||
0x38: ("discardValue", 4),
|
||||
0x4C: ("cardtype", 4),
|
||||
0x50: ("cardsubtypeid", 4),
|
||||
0x5C: ("itemState", 4),
|
||||
0x60: ("category(club slot)", 4),
|
||||
0x8C: ("contract", 4),
|
||||
0x94: ("teamid", 4),
|
||||
0xB4: ("rating", 4),
|
||||
0xB8: ("wire category", 1),
|
||||
0xBA: ("year", 2),
|
||||
0x148: ("nation", 4),
|
||||
0x154: ("leagueId", 4),
|
||||
}
|
||||
|
||||
|
||||
def walk(node, out):
|
||||
if not node or node == sentinel:
|
||||
return
|
||||
walk(q(node + 0x00), out)
|
||||
# The record is EMBEDDED at node+0x28 — NOT a pointer stored there.
|
||||
out.append((struct.unpack("<q", rd(node + 0x20, 8))[0], node + 0x28))
|
||||
walk(q(node + 0x08), out)
|
||||
|
||||
|
||||
nodes = []
|
||||
walk(root, nodes)
|
||||
recs = {k: v for k, v in nodes}
|
||||
|
||||
found = [(w, recs[w]) for w in WANT if w in recs]
|
||||
if len(found) < 2:
|
||||
sys.exit(f" need two resident kit records, found {[w for w, _ in found]}")
|
||||
|
||||
(id_a, ptr_a), (id_b, ptr_b) = found[0], found[1]
|
||||
a = rd(ptr_a, 0x180)
|
||||
b = rd(ptr_b, 0x180)
|
||||
print(f" A = {id_a} @ {ptr_a:#x}")
|
||||
print(f" B = {id_b} @ {ptr_b:#x}")
|
||||
|
||||
print("\n --- fields the clone query consumes ---")
|
||||
for off in (0x94, 0x5C, 0xBA):
|
||||
name = FIELDS[off][0]
|
||||
w = FIELDS[off][1]
|
||||
va = int.from_bytes(a[off : off + w], "little")
|
||||
vb = int.from_bytes(b[off : off + w], "little")
|
||||
flag = "" if va != vb else " <== IDENTICAL"
|
||||
print(f" +{off:#05x} {name:24} A={va:<12} B={vb:<12}{flag}")
|
||||
|
||||
print("\n --- every differing byte range ---")
|
||||
diffs = [i for i in range(0x180) if a[i] != b[i]]
|
||||
runs = []
|
||||
for i in diffs:
|
||||
if runs and i == runs[-1][1] + 1:
|
||||
runs[-1][1] = i
|
||||
else:
|
||||
runs.append([i, i])
|
||||
for s, e in runs:
|
||||
named_field = next(
|
||||
(n for o, (n, w) in FIELDS.items() if o <= s < o + w), "(unmapped)"
|
||||
)
|
||||
va = int.from_bytes(a[s : e + 1], "little")
|
||||
vb = int.from_bytes(b[s : e + 1], "little")
|
||||
print(f" +{s:#05x}..{e:#05x} {named_field:24} A={va:<12} B={vb}")
|
||||
print(f"\n {len(diffs)} differing bytes in {len(runs)} runs")
|
||||
|
||||
print("\n --- known fields, side by side ---")
|
||||
for off in sorted(FIELDS):
|
||||
name, w = FIELDS[off]
|
||||
va = int.from_bytes(a[off : off + w], "little")
|
||||
vb = int.from_bytes(b[off : off + w], "little")
|
||||
mark = " DIFFERS" if va != vb else ""
|
||||
print(f" +{off:#05x} {name:24} A={va:<12} B={vb:<12}{mark}")
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
#!/bin/sh
|
||||
# Remove the port-8081 DNAT rule that hijacks FIFA 17's roster/squad-update TLS.
|
||||
#
|
||||
# Why: the FUT squad update is https://winter15.gosredirector.ea.com:8081/fifa17/fut/rosterupdate.xml
|
||||
# (TLS on port 8081). A DNAT rule rewriting dport 8081 -> 8299 sends that TLS
|
||||
# handshake to the plain-HTTP staging UTAS host, which closes the connection.
|
||||
# Proven: a probe to 10.10.0.120:8081 from this box arrives at the server as
|
||||
# dport 8299. Result: "An error occurred downloading the FUT squad update."
|
||||
#
|
||||
# The rule also never redirected UTAS, which lives on :8443, not :8081.
|
||||
#
|
||||
# Read-only until it deletes; deletes only nat rules whose target port is 8299.
|
||||
set -u
|
||||
|
||||
echo "== nat OUTPUT rules mentioning 8081 or 8299 =="
|
||||
iptables -t nat -S OUTPUT 2>/dev/null | grep -E '8081|8299' || echo " (none)"
|
||||
|
||||
echo
|
||||
echo "== deleting DNAT rules that redirect to port 8299 =="
|
||||
removed=0
|
||||
# Delete by spec, repeatedly, until no matching rule remains.
|
||||
while :; do
|
||||
rule=$(iptables -t nat -S OUTPUT 2>/dev/null | grep -m1 -E '\-\-dport 8081 .*8299|to-destination [0-9.]+:8299')
|
||||
[ -z "$rule" ] && break
|
||||
spec=$(printf '%s' "$rule" | sed 's/^-A /-D /')
|
||||
# shellcheck disable=SC2086
|
||||
if iptables -t nat $spec 2>/dev/null; then
|
||||
echo " removed: $rule"
|
||||
removed=$((removed + 1))
|
||||
else
|
||||
echo " FAILED to remove: $rule" >&2
|
||||
break
|
||||
fi
|
||||
done
|
||||
[ "$removed" -eq 0 ] && echo " (no matching rule found)"
|
||||
|
||||
echo
|
||||
echo "== remaining nat OUTPUT rules mentioning 8081 or 8299 =="
|
||||
iptables -t nat -S OUTPUT 2>/dev/null | grep -E '8081|8299' || echo " (none)"
|
||||
|
||||
echo
|
||||
echo "== verifying the roster endpoint now presents the correct certificate =="
|
||||
python3 - <<'PY'
|
||||
import socket, ssl
|
||||
host, port, sni = "10.10.0.120", 8081, "winter15.gosredirector.ea.com"
|
||||
try:
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
with socket.create_connection((host, port), 8) as s:
|
||||
with ctx.wrap_socket(s, server_hostname=sni) as t:
|
||||
der = t.getpeercert(True)
|
||||
cn = dict(x[0] for x in t.getpeercert().get("subject", ()))
|
||||
print(f" PASS {host}:{port} sni={sni} {t.version()} der={len(der)}B subject={cn}")
|
||||
except Exception as e:
|
||||
print(f" FAIL {host}:{port} sni={sni} -> {type(e).__name__}: {e}")
|
||||
print(" The roster path is still broken; do not relaunch yet.")
|
||||
PY
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Hunt for specific wire instance ids anywhere in the client's writable memory.
|
||||
|
||||
Answers whether a served item was materialised into a record at all, versus
|
||||
materialised but not attached to a collection. A record is recognised by its
|
||||
established layout: id at +0x08, resourceId at +0x18, cardtype at +0x4c.
|
||||
|
||||
Read-only. Never writes.
|
||||
|
||||
usage: probe_hunt.py PID id [id ...]
|
||||
"""
|
||||
import re, struct, sys
|
||||
|
||||
PID = int(sys.argv[1])
|
||||
IDS = [int(a) for a in sys.argv[2:]]
|
||||
if not IDS:
|
||||
sys.exit("give at least one wire id")
|
||||
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
|
||||
|
||||
regions = []
|
||||
for ln in open(f"/proc/{PID}/maps"):
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) (\S{4}) \S+ \S+ \S+\s*(.*)", ln)
|
||||
if not m:
|
||||
continue
|
||||
lo, hi, perms, path = int(m.group(1), 16), int(m.group(2), 16), m.group(3), m.group(4).strip()
|
||||
if "w" not in perms:
|
||||
continue
|
||||
if path.startswith("/") and not path.endswith(".dll") and not path.endswith(".exe"):
|
||||
continue
|
||||
regions.append((lo, hi, perms, path))
|
||||
total = sum(hi - lo for lo, hi, _, _ in regions)
|
||||
print(f" {len(regions)} writable regions, {total/2**20:.0f} MiB to scan")
|
||||
|
||||
needles = {struct.pack("<I", i): i for i in IDS}
|
||||
hits = {i: [] for i in IDS}
|
||||
CHUNK = 8 << 20
|
||||
scanned = 0
|
||||
for lo, hi, perms, path in regions:
|
||||
a = lo
|
||||
while a < hi:
|
||||
n = min(CHUNK, hi - a)
|
||||
try:
|
||||
mem.seek(a)
|
||||
data = mem.read(n)
|
||||
except OSError:
|
||||
a += n
|
||||
continue
|
||||
if not data:
|
||||
a += n
|
||||
continue
|
||||
scanned += len(data)
|
||||
for nd, wid in needles.items():
|
||||
start = 0
|
||||
while True:
|
||||
j = data.find(nd, start)
|
||||
if j < 0:
|
||||
break
|
||||
start = j + 1
|
||||
va = a + j
|
||||
# a record would place this id at +0x08
|
||||
rec = va - 0x08
|
||||
try:
|
||||
mem.seek(rec)
|
||||
r = mem.read(0x100)
|
||||
except OSError:
|
||||
continue
|
||||
if len(r) < 0x100:
|
||||
continue
|
||||
ct = struct.unpack_from("<i", r, 0x4c)[0]
|
||||
res = struct.unpack_from("<I", r, 0x18)[0]
|
||||
sub = struct.unpack_from("<i", r, 0x50)[0]
|
||||
cat = struct.unpack_from("<i", r, 0x60)[0]
|
||||
looks = 0 <= ct <= 32 and res > 1000
|
||||
hits[wid].append((va, rec, ct, sub, cat, res, looks))
|
||||
a += n
|
||||
print(f" scanned {scanned/2**20:.0f} MiB\n")
|
||||
for wid in IDS:
|
||||
hs = hits[wid]
|
||||
recs = [h for h in hs if h[6]]
|
||||
print(f" id {wid}: {len(hs)} raw occurrence(s), {len(recs)} record-shaped")
|
||||
for va, rec, ct, sub, cat, res, _ in recs[:6]:
|
||||
print(f" record {rec:#x}: cardtype={ct} subtype={sub} category={cat} resourceId={res}")
|
||||
if not recs:
|
||||
print(" NOT MATERIALISED as a record anywhere in writable memory")
|
||||
Executable
+92
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Identify every resident record by its wire instance id.
|
||||
|
||||
Record layout established from known wire values:
|
||||
+0x08 id (wire instance) +0x18 resourceId +0x1c/+0x20 assetId
|
||||
+0x38 discardValue +0x4c cardtype +0x50 cardsubtypeid
|
||||
+0x5c itemState +0x60 category +0x94 teamid
|
||||
+0xb4 rating +0xba teamkittypetechid (u16)
|
||||
|
||||
Walks the contiguous 0x180-stride pool around the manager slot record so records
|
||||
that are resident but not in any collection are still seen. Read-only.
|
||||
|
||||
usage: probe_ids.py PID [expected_id ...]
|
||||
"""
|
||||
import re, struct, sys
|
||||
|
||||
PID = int(sys.argv[1])
|
||||
WANT = {int(a) for a in sys.argv[2:]}
|
||||
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
|
||||
|
||||
def rd(a, n):
|
||||
mem.seek(a); return mem.read(n)
|
||||
def q(a):
|
||||
return struct.unpack("<Q", rd(a, 8))[0]
|
||||
|
||||
named = []
|
||||
for ln in open(f"/proc/{PID}/maps"):
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
|
||||
if m:
|
||||
named.append((int(m.group(1), 16), m.group(3).strip()))
|
||||
named.sort()
|
||||
base = next(s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll"))
|
||||
live = lambda s: base + (s - 0x180000000)
|
||||
if rd(live(0x180026fea), 5) != bytes.fromhex("ba75750000"):
|
||||
sys.exit("SANITY FAILED")
|
||||
owner = q(live(0x1802e6398))
|
||||
mgr = owner + 0x1f9d8
|
||||
RECSZ = 0x180
|
||||
|
||||
def dec(rec):
|
||||
r = rd(rec, 0x180)
|
||||
g = lambda o: struct.unpack_from("<i", r, o)[0]
|
||||
return dict(id=struct.unpack_from("<I", r, 0x8)[0], res=struct.unpack_from("<I", r, 0x18)[0],
|
||||
ct=g(0x4c), sub=g(0x50), st=g(0x5c), cat=g(0x60), team=g(0x94),
|
||||
rating=struct.unpack_from("<I", r, 0xb4)[0],
|
||||
kt=struct.unpack_from("<H", r, 0xba)[0])
|
||||
|
||||
mgr_rec = q(mgr + 0xc0 + 0x10)
|
||||
print(f" manager-slot record = {mgr_rec:#x}")
|
||||
anchor = mgr_rec if mgr_rec else q(q(mgr + 0xd8) + 0x10)
|
||||
|
||||
# walk backwards to the start of the contiguous run, then forwards
|
||||
lo = anchor
|
||||
for _ in range(64):
|
||||
prev = lo - RECSZ
|
||||
try:
|
||||
d = dec(prev)
|
||||
except OSError:
|
||||
break
|
||||
if not (0 < d["ct"] < 64) or d["id"] == 0:
|
||||
break
|
||||
lo = prev
|
||||
|
||||
print(f" pool run starts at {lo:#x}\n")
|
||||
print(f" {'idx':>3} {'addr':>12} {'id':>10} {'resource':>9} {'ct':>3} {'sub':>4} "
|
||||
f"{'st':>3} {'cat':>4} {'team':>5} {'rate':>5} {'kt':>6}")
|
||||
found = {}
|
||||
k = 0
|
||||
addr = lo
|
||||
while k < 48:
|
||||
try:
|
||||
d = dec(addr)
|
||||
except OSError:
|
||||
break
|
||||
if d["id"] == 0 and d["ct"] == 0:
|
||||
break
|
||||
tag = ""
|
||||
if d["ct"] == 7:
|
||||
tag = " <== CARDTYPE 7"
|
||||
if d["id"] in WANT:
|
||||
tag += " <== WANTED"
|
||||
found[d["id"]] = addr
|
||||
slot = " [manager slot]" if addr == mgr_rec else ""
|
||||
print(f" {k:>3} {addr:#12x} {d['id']:>10} {d['res']:>9} {d['ct']:>3} {d['sub']:>4} "
|
||||
f"{d['st']:>3} {d['cat']:>4} {d['team']:>5} {d['rating']:>5} {d['kt']:>6}{tag}{slot}")
|
||||
addr += RECSZ
|
||||
k += 1
|
||||
|
||||
if WANT:
|
||||
print(f"\n wanted ids: {sorted(WANT)}")
|
||||
for w in sorted(WANT):
|
||||
print(f" {w}: {'FOUND at ' + hex(found[w]) if w in found else 'NOT RESIDENT'}")
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Map FIFA 17 resident record offsets using UNIQUE wire values as ground truth.
|
||||
|
||||
v2: identifies each record by its wire instance id (large, unique) and only
|
||||
accepts a field mapping when the value is distinctive (>= 16) and the same
|
||||
offset holds the right value for EVERY identified record. This avoids the v1
|
||||
failure where cardsubtypeid == 0 matched every zeroed field in the struct.
|
||||
|
||||
Read-only. Never writes.
|
||||
|
||||
usage: probe_layout2.py PID squad_active.json
|
||||
"""
|
||||
import re, struct, sys, json, collections
|
||||
|
||||
PID = int(sys.argv[1])
|
||||
SQUAD = json.load(open(sys.argv[2]))
|
||||
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
|
||||
|
||||
def rd(a, n):
|
||||
mem.seek(a); return mem.read(n)
|
||||
def q(a):
|
||||
return struct.unpack("<Q", rd(a, 8))[0]
|
||||
|
||||
named = []
|
||||
for ln in open(f"/proc/{PID}/maps"):
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
|
||||
if m:
|
||||
named.append((int(m.group(1), 16), m.group(3).strip()))
|
||||
named.sort()
|
||||
base = next(s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll"))
|
||||
live = lambda s: base + (s - 0x180000000)
|
||||
if rd(live(0x180026fea), 5) != bytes.fromhex("ba75750000"):
|
||||
sys.exit("SANITY FAILED")
|
||||
owner = q(live(0x1802e6398))
|
||||
mgr = owner + 0x1f9d8
|
||||
RECSZ = 0x180
|
||||
|
||||
beg, end = q(mgr + 0xd8), q(mgr + 0xe0)
|
||||
recs = [r for r in (q(beg + k*24 + 0x10) for k in range((end - beg)//24)) if r]
|
||||
|
||||
wire = {}
|
||||
for p in SQUAD["players"]:
|
||||
it = p.get("itemData") or {}
|
||||
if it.get("id"):
|
||||
wire[it["id"]] = it
|
||||
|
||||
# --- identify each record by its wire instance id ---
|
||||
ident = {}
|
||||
for rec in recs:
|
||||
r = rd(rec, RECSZ)
|
||||
for off in range(0, RECSZ - 4, 4):
|
||||
v = struct.unpack_from("<I", r, off)[0]
|
||||
if v in wire:
|
||||
ident.setdefault(rec, (v, off))
|
||||
break
|
||||
print(f" resident player records: {len(recs)}, identified: {len(ident)}")
|
||||
id_offs = collections.Counter(o for _, o in ident.values())
|
||||
print(f" wire-id offset candidates: {[(hex(o), c) for o, c in id_offs.most_common()]}")
|
||||
|
||||
FIELDS = ("id", "resourceId", "assetId", "definitionId", "cardassetid", "rating",
|
||||
"teamid", "nation", "leagueId", "contract", "fitness", "playStyle",
|
||||
"discardValue", "cardsubtypeid", "owners", "rareflag")
|
||||
# --- for every offset, does it hold field F for every identified record? ---
|
||||
consistent = {}
|
||||
for off in range(0, RECSZ - 4, 4):
|
||||
for f in FIELDS:
|
||||
ok = 0; total = 0; distinct = set()
|
||||
for rec, (wid, _) in ident.items():
|
||||
it = wire[wid]
|
||||
v = it.get(f)
|
||||
if not isinstance(v, int) or v < 16: # require distinctive values
|
||||
continue
|
||||
total += 1
|
||||
got = struct.unpack_from("<I", rd(rec, RECSZ), off)[0]
|
||||
if got == v:
|
||||
ok += 1; distinct.add(v)
|
||||
if total >= 5 and ok == total and len(distinct) >= 2:
|
||||
consistent.setdefault(off, []).append((f, total, len(distinct)))
|
||||
|
||||
print(f"\n === offsets consistently holding a distinctive wire field ===")
|
||||
for off in sorted(consistent):
|
||||
for f, total, nd in consistent[off]:
|
||||
print(f" +0x{off:<4x} {f:14s} (matched {total}/{total} records, {nd} distinct values)")
|
||||
|
||||
# --- dump the manager and the three club staff for comparison ---
|
||||
print(f"\n === cardtype-2 slot (manager) ===")
|
||||
h = q(mgr + 0xc0 + 0x10)
|
||||
if h:
|
||||
r = rd(h, RECSZ)
|
||||
for off in sorted(consistent):
|
||||
f = consistent[off][0][0]
|
||||
print(f" +0x{off:<4x} {f:14s} = {struct.unpack_from('<I', r, off)[0]}")
|
||||
for name, off, sz in (("cardtype", 0x4c, 4), ("cardsubtypeid", 0x50, 4),
|
||||
("itemState", 0x5c, 4), ("category", 0x60, 4)):
|
||||
print(f" +0x{off:<4x} {name:14s} = {struct.unpack_from('<i', r, off)[0]}")
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Enumerate FIFA 17's resident item map authoritatively.
|
||||
|
||||
Layout recovered from the lower_bound at 0x180119640:
|
||||
owner+0x160c8 sentinel / end marker
|
||||
owner+0x160d8 root
|
||||
owner+0x160e8 count
|
||||
node+0x00, node+0x08 children
|
||||
node+0x20 key = wire instance id (qword)
|
||||
node+0x28 the item record
|
||||
On miss the client returns the static sentinel 0x1802c2a28 whose +0x10 is NULL.
|
||||
|
||||
Read-only. usage: probe_map2.py PID [id ...]
|
||||
"""
|
||||
import re, struct, sys, collections
|
||||
|
||||
PID = int(sys.argv[1]); WANT = {int(a) for a in sys.argv[2:]}
|
||||
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
|
||||
def rd(a, n):
|
||||
mem.seek(a); return mem.read(n)
|
||||
def q(a): return struct.unpack("<Q", rd(a, 8))[0]
|
||||
named = []
|
||||
for ln in open(f"/proc/{PID}/maps"):
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
|
||||
if m: named.append((int(m.group(1), 16), m.group(3).strip()))
|
||||
named.sort()
|
||||
base = next(s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll"))
|
||||
if rd(base + (0x180026fea - 0x180000000), 5) != bytes.fromhex("ba75750000"):
|
||||
sys.exit("SANITY FAILED")
|
||||
owner = q(base + (0x1802e6398 - 0x180000000))
|
||||
SENT, ROOT, COUNT = owner + 0x160c8, q(owner + 0x160d8), q(owner + 0x160e8) & 0xffffffff
|
||||
print(f" owner={owner:#x} sentinel={SENT:#x} root={ROOT:#x} count={COUNT}")
|
||||
|
||||
nodes, seen, stack = [], set(), [ROOT]
|
||||
while stack:
|
||||
n = stack.pop()
|
||||
if not n or n == SENT or n in seen or len(seen) > 5000:
|
||||
continue
|
||||
seen.add(n)
|
||||
try:
|
||||
h = rd(n, 0x30)
|
||||
except OSError:
|
||||
continue
|
||||
if len(h) < 0x30:
|
||||
continue
|
||||
nodes.append(n)
|
||||
stack.append(struct.unpack_from("<Q", h, 0)[0])
|
||||
stack.append(struct.unpack_from("<Q", h, 8)[0])
|
||||
print(f" nodes reached: {len(nodes)} (count field says {COUNT})\n")
|
||||
|
||||
print(f" {'key':>11} {'record':>12} {'id':>10} {'resource':>10} {'ct':>3} {'sub':>4} {'st':>4} {'cat':>4}")
|
||||
hist = collections.Counter(); found = {}
|
||||
rows = []
|
||||
for n in nodes:
|
||||
key = q(n + 0x20)
|
||||
rec = n + 0x28
|
||||
try: r = rd(rec, 0x180)
|
||||
except OSError: continue
|
||||
if len(r) < 0x180: continue
|
||||
g = lambda o: struct.unpack_from("<i", r, o)[0]
|
||||
rid = struct.unpack_from("<I", r, 0x8)[0]
|
||||
res = struct.unpack_from("<I", r, 0x18)[0]
|
||||
ct, sub, st, cat = g(0x4c), g(0x50), g(0x5c), g(0x60)
|
||||
hist[ct] += 1
|
||||
if rid in WANT: found[rid] = rec
|
||||
rows.append((key, rec, rid, res, ct, sub, st, cat))
|
||||
for key, rec, rid, res, ct, sub, st, cat in sorted(rows):
|
||||
tag = " <== CARDTYPE 7" if ct == 7 else (" <== WANTED" if rid in WANT else "")
|
||||
print(f" {key:>11} {rec:#12x} {rid:>10} {res:>10} {ct:>3} {sub:>4} {st:>4} {cat:>4}{tag}")
|
||||
print(f"\n cardtype histogram: {dict(sorted(hist.items()))} total={sum(hist.values())}")
|
||||
for w in sorted(WANT):
|
||||
print(f" id {w}: {'RESIDENT' if w in found else 'ABSENT'}")
|
||||
Executable
+62
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only scan of the record pool embedded in the club-model owner object.
|
||||
|
||||
The 18 resident player records sit at a fixed stride of 0x180 inside the owner
|
||||
object, below the embedded manager subobject at owner+0x1f9d8. This walks that
|
||||
pool to see whether storage for the five club items exists and what it holds.
|
||||
Read-only. Never writes.
|
||||
"""
|
||||
import re, struct, sys
|
||||
|
||||
PID = int(sys.argv[1])
|
||||
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
|
||||
|
||||
def rd(a, n):
|
||||
mem.seek(a); return mem.read(n)
|
||||
def q(a):
|
||||
return struct.unpack("<Q", rd(a, 8))[0]
|
||||
|
||||
named = []
|
||||
for ln in open(f"/proc/{PID}/maps"):
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
|
||||
if m:
|
||||
named.append((int(m.group(1), 16), m.group(3).strip()))
|
||||
named.sort()
|
||||
base = next(s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll"))
|
||||
def live(s):
|
||||
return base + (s - 0x180000000)
|
||||
|
||||
owner = q(live(0x1802e6398))
|
||||
mgr = owner + 0x1f9d8
|
||||
beg, end = q(mgr + 0xd8), q(mgr + 0xe0)
|
||||
first = None
|
||||
for k in range((end - beg) // 24):
|
||||
r = q(beg + k * 24 + 0x10)
|
||||
if r:
|
||||
first = r; break
|
||||
if first is None:
|
||||
sys.exit("no populated player record to anchor the pool")
|
||||
|
||||
print(f" owner = {owner:#x} mgr = {mgr:#x} first record = {first:#x}")
|
||||
print(f" record - owner = {first - owner:#x} pool room to mgr = {(mgr - first) // 0x180} slots of 0x180")
|
||||
print()
|
||||
hdr = f" {'idx':>3} {'addr':>12} {'ctype':>6} {'subtyp':>6} {'state':>6} {'cat':>4} {'team':>5} {'kittyp':>6} set"
|
||||
print(hdr)
|
||||
n = (mgr - first) // 0x180
|
||||
for k in range(min(n, 40)):
|
||||
a = first + k * 0x180
|
||||
try:
|
||||
r = rd(a, 0xC0)
|
||||
except OSError:
|
||||
print(f" {k:>3} {a:#12x} unreadable"); break
|
||||
if len(r) < 0xC0:
|
||||
break
|
||||
ct, sub, st, cat, team = (struct.unpack_from("<i", r, o)[0] for o in (0x4c, 0x50, 0x5c, 0x60, 0x94))
|
||||
kt = struct.unpack_from("<H", r, 0xba)[0]
|
||||
nz = sum(1 for b in r if b)
|
||||
flag = ""
|
||||
if ct == 7:
|
||||
flag = " <== CARDTYPE 7"
|
||||
elif nz == 0:
|
||||
flag = " (all zero)"
|
||||
print(f" {k:>3} {a:#12x} {ct:>6} {sub:>6} {st:>6} {cat:>4} {team:>5} {kt:>6} {nz:>3}/192{flag}")
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only dump of RESIDENT record fields, for both the player and club-item vectors.
|
||||
|
||||
Purpose: the kit clone driver FUN_1801c3480 gates on record+0x60 (category) == 4.
|
||||
No instruction in CardsDLL writes immediate 4 there, so this reads what value a
|
||||
genuinely resident record actually carries. Read-only. Never writes.
|
||||
|
||||
mgr+0x0c0 cardtype-2 single slot
|
||||
mgr+0x0d8..0x0e0 cardtype-1 (player) vector
|
||||
mgr+0x108..0x110 club-item vector
|
||||
record+0x4c cardtype +0x50 cardsubtypeid +0x5c itemState
|
||||
record+0x60 category +0x94 teamid +0xba teamkittypetechid (u16)
|
||||
"""
|
||||
import re, struct, sys, collections
|
||||
|
||||
PID = int(sys.argv[1])
|
||||
mem = open(f"/proc/{PID}/mem", "rb", buffering=0)
|
||||
|
||||
def rd(a, n):
|
||||
mem.seek(a); return mem.read(n)
|
||||
def q(a):
|
||||
return struct.unpack("<Q", rd(a, 8))[0]
|
||||
def i32(b, o):
|
||||
return struct.unpack_from("<i", b, o)[0]
|
||||
|
||||
named = []
|
||||
for ln in open(f"/proc/{PID}/maps"):
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
|
||||
if m:
|
||||
named.append((int(m.group(1), 16), m.group(3).strip()))
|
||||
named.sort()
|
||||
base = next((s for s, p in named if p.endswith("CardsDLL_Win64_retail.dll")), None)
|
||||
if base is None:
|
||||
sys.exit("CardsDLL mapping not found")
|
||||
def live(static):
|
||||
return base + (static - 0x180000000)
|
||||
|
||||
if rd(live(0x180026fea), 5) != bytes.fromhex("ba75750000"):
|
||||
sys.exit("SANITY FAILED - wrong base")
|
||||
print(f" CardsDLL base = {base:#x} (sanity ok)")
|
||||
|
||||
owner = q(live(0x1802e6398))
|
||||
b = rd(q(owner) + 0x4e8, 12)
|
||||
b = rd(struct.unpack("<Q", struct.pack("<Q", q(q(owner) + 0x4e8)))[0], 12)
|
||||
getter = q(q(owner) + 0x4e8)
|
||||
gb = rd(getter, 12)
|
||||
if gb[0:3] == bytes.fromhex("488d81"):
|
||||
mgr = owner + struct.unpack_from("<I", gb, 3)[0]
|
||||
elif gb[0:3] == bytes.fromhex("488d41"):
|
||||
mgr = owner + gb[3]
|
||||
else:
|
||||
sys.exit(f"unexpected getter shape {gb.hex(' ')}")
|
||||
print(f" owner = {owner:#x} mgr = {mgr:#x}")
|
||||
|
||||
FIELDS = ("ctype", "subtype", "state", "cat", "team", "kittype")
|
||||
def decode(rec):
|
||||
r = rd(rec, 0xC0)
|
||||
if len(r) < 0xC0:
|
||||
return None
|
||||
return (i32(r, 0x4c), i32(r, 0x50), i32(r, 0x5c), i32(r, 0x60),
|
||||
i32(r, 0x94), struct.unpack_from("<H", r, 0xba)[0])
|
||||
|
||||
for label, vbeg, vend in (("players (cardtype 1)", mgr + 0xd8, mgr + 0xe0),
|
||||
("club items", mgr + 0x108, mgr + 0x110)):
|
||||
try:
|
||||
beg, end = q(vbeg), q(vend)
|
||||
except OSError:
|
||||
print(f"\n {label}: vector unreadable")
|
||||
continue
|
||||
span = end - beg
|
||||
print(f"\n === {label}: {beg:#x}..{end:#x} span={span} ===")
|
||||
if not (0 < beg <= end) or span > 24 * 100000:
|
||||
print(" implausible vector, skipping")
|
||||
continue
|
||||
# resolve stride: the element must contain a plausible heap pointer
|
||||
for stride, ptr_off in ((24, 0x10), (16, 0x08), (8, 0x00)):
|
||||
if span % stride:
|
||||
continue
|
||||
n = span // stride
|
||||
recs, nulls = [], []
|
||||
ok = True
|
||||
for k in range(n):
|
||||
try:
|
||||
rec = q(beg + k * stride + ptr_off)
|
||||
except OSError:
|
||||
ok = False; break
|
||||
if not rec:
|
||||
nulls.append(k); continue
|
||||
d = decode(rec)
|
||||
if d is None:
|
||||
ok = False; break
|
||||
recs.append((k, rec, d))
|
||||
if not ok:
|
||||
continue
|
||||
print(f" stride {stride} (ptr at +{ptr_off:#x}): {n} slots, {len(recs)} populated, {len(nulls)} null")
|
||||
if not recs and len(nulls) != n:
|
||||
continue
|
||||
hist = collections.Counter(d[0:2] for _, _, d in recs)
|
||||
for key, c in sorted(hist.items(), key=lambda x: -x[1]):
|
||||
print(f" (cardtype,subtype)={key} x{c}")
|
||||
# The SLOT INDEX is load-bearing evidence: the squad parser's `actives`
|
||||
# arm writes element i to slot `r15d + i`, and r15d is shared scratch
|
||||
# that other atom handlers clobber. Which slots are filled therefore
|
||||
# reveals the index the parse actually started from.
|
||||
print(f" {'slot':>4} {'ptr':>14} " + " ".join(f"{f:>8}" for f in FIELDS))
|
||||
for k, rec, d in recs[:8]:
|
||||
print(f" {k:>4} {rec:#14x} " + " ".join(f"{v:>8}" for v in d))
|
||||
if nulls:
|
||||
print(f" empty slots: {nulls[:16]}")
|
||||
cats = collections.Counter(d[3] for _, _, d in recs)
|
||||
if cats:
|
||||
print(f" CATEGORY (+0x60) distribution: {dict(cats)}")
|
||||
break
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/bin/sh
|
||||
# Native proof for the FIFA 17 kit milestone: does the client now hold resident
|
||||
# cardtype-7 records, and are the served kit ids among them?
|
||||
#
|
||||
# Auto-detects the live FIFA17.exe pid and walks the resident item map at
|
||||
# owner+0x160c8 (root +0x160d8, key = wire instance id at node+0x20, record at
|
||||
# node+0x28, count at owner+0x160e8). Read-only; never writes to the process.
|
||||
#
|
||||
# BEFORE this fix the map held 22 records with cardtype histogram {1:18, 2:1,
|
||||
# 4:2, 10:1} and both kit ids ABSENT.
|
||||
set -u
|
||||
|
||||
PID=$(for p in /proc/[0-9]*; do
|
||||
[ "$(cat "$p/comm" 2>/dev/null)" = "FIFA17.exe" ] && echo "${p#/proc/}"
|
||||
done | head -1)
|
||||
|
||||
if [ -z "$PID" ]; then
|
||||
echo " FIFA17.exe is not running - launch the game and enter FUT first"
|
||||
exit 1
|
||||
fi
|
||||
echo " live FIFA17 pid = $PID"
|
||||
echo
|
||||
|
||||
cd "$(dirname "$0")" || exit 1
|
||||
python3 probe_map2.py "$PID" 100004873 100004874 100004870
|
||||
|
||||
echo
|
||||
echo " ================ squad survival + slot indices ================"
|
||||
# The kit milestone is only real if the REST of the squad survives with it.
|
||||
# A populated `squad.actives` was once seen to leave the map holding just the
|
||||
# 2 kits with a fully null 23-slot player vector and an empty starting 11, so
|
||||
# the player-vector fill below is a PASS/FAIL gate, not decoration.
|
||||
#
|
||||
# The club-item slot indices are the other half: the parser writes element i to
|
||||
# slot r15d+i, and r15d is scratch other atom handlers clobber. Kits landing
|
||||
# somewhere other than slots 0 and 1 means the index did not start at zero.
|
||||
python3 probe_resident_fields.py "$PID"
|
||||
Executable
+225
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Watch FIFA 17's resident club-item store and log every change, with timestamps.
|
||||
|
||||
Read-only. Waits for FIFA17.exe to appear, re-resolves the store each tick (the
|
||||
manager is reallocated across logins), and appends one line per CHANGE so the
|
||||
output can be aligned against the staging host's route log by wall clock.
|
||||
|
||||
Purpose: answer "after which response does a resident club item first appear?"
|
||||
without reversing the constructor first. Pair with
|
||||
|
||||
journalctl -u openfut-staging-host --since <start> -o short-iso
|
||||
|
||||
and compare timestamps.
|
||||
|
||||
Usage: watch_residency.py [--interval 1.0] [--out /path/log] [--once]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
CARDS_DLL = "CardsDLL_Win64_retail.dll"
|
||||
OWNER_GLOBAL = 0x1802E6398 # FUN_18011a830: mov rax,[this]; ret
|
||||
SANITY_VA = 0x180026FEA # mov edx,0x7575
|
||||
SANITY_BYTES = bytes.fromhex("ba75750000")
|
||||
IMAGE_BASE = 0x180000000
|
||||
|
||||
# item-record offsets, all previously proven (see Vault: Kit Selector APT Decode)
|
||||
OFF = {"cardtype": 0x4C, "cardsubtypeid": 0x50, "itemState": 0x5C,
|
||||
"category": 0x60, "teamid": 0x94}
|
||||
OFF_KITTYPE_U16 = 0xBA
|
||||
|
||||
|
||||
class Target:
|
||||
"""One live FIFA17.exe, with the store chain resolved."""
|
||||
|
||||
def __init__(self, pid: int):
|
||||
self.pid = pid
|
||||
self.mem = open(f"/proc/{pid}/mem", "rb", buffering=0)
|
||||
self.base = self._cards_base()
|
||||
if self.base is None:
|
||||
raise RuntimeError("CardsDLL mapping not found")
|
||||
probe = self.rd(self.live(SANITY_VA), 5)
|
||||
if probe != SANITY_BYTES:
|
||||
raise RuntimeError(f"base sanity failed: {probe.hex(' ')}")
|
||||
owner = self.q(self.live(OWNER_GLOBAL))
|
||||
if not owner:
|
||||
raise RuntimeError("owner object is null (not logged in yet)")
|
||||
vt = self.q(owner)
|
||||
getter = self.q(vt + 0x4E8)
|
||||
b = self.rd(getter, 8)
|
||||
# lea rax,[rcx+imm32] ; ret / lea rax,[rcx+imm8] ; ret
|
||||
if b[0:3] == bytes.fromhex("488d81"):
|
||||
self.mgr = owner + struct.unpack_from("<I", b, 3)[0]
|
||||
elif b[0:3] == bytes.fromhex("488d41"):
|
||||
self.mgr = owner + b[3]
|
||||
elif b[0:3] == bytes.fromhex("488b81"):
|
||||
self.mgr = self.q(owner + struct.unpack_from("<I", b, 3)[0])
|
||||
else:
|
||||
raise RuntimeError(f"unrecognised getter: {b.hex(' ')}")
|
||||
|
||||
# -- raw access ------------------------------------------------------
|
||||
def rd(self, a: int, n: int) -> bytes:
|
||||
self.mem.seek(a)
|
||||
return self.mem.read(n)
|
||||
|
||||
def q(self, a: int) -> int:
|
||||
return struct.unpack("<Q", self.rd(a, 8))[0]
|
||||
|
||||
def live(self, static: int) -> int:
|
||||
return self.base + (static - IMAGE_BASE)
|
||||
|
||||
def _cards_base(self):
|
||||
named = []
|
||||
for ln in open(f"/proc/{self.pid}/maps"):
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+) \S{4} \S+ \S+ \S+\s+(.+)", ln)
|
||||
if m:
|
||||
named.append((int(m.group(1), 16), m.group(3).strip()))
|
||||
# NEAREST PRECEDING NAMED mapping: Wine maps PE sections anonymously and
|
||||
# the Wine heap is also rwx, so permissions cannot identify a module.
|
||||
for start, path in sorted(named):
|
||||
if path.endswith(CARDS_DLL):
|
||||
return start
|
||||
return None
|
||||
|
||||
# -- the store -------------------------------------------------------
|
||||
def vector(self, off_begin: int):
|
||||
beg, end = self.q(self.mgr + off_begin), self.q(self.mgr + off_begin + 8)
|
||||
if not (0 < beg <= end) or (end - beg) % 24 or (end - beg) > 24 * 200000:
|
||||
return None, 0
|
||||
return beg, (end - beg) // 24
|
||||
|
||||
def records(self, off_begin: int):
|
||||
beg, n = self.vector(off_begin)
|
||||
out = []
|
||||
if beg is None:
|
||||
return out
|
||||
for k in range(n):
|
||||
try:
|
||||
rec = self.q(beg + k * 24 + 0x10)
|
||||
except OSError:
|
||||
continue
|
||||
if not rec:
|
||||
out.append(None)
|
||||
continue
|
||||
try:
|
||||
r = self.rd(rec, 0xC0)
|
||||
except OSError:
|
||||
out.append(None)
|
||||
continue
|
||||
if len(r) < 0xC0:
|
||||
out.append(None)
|
||||
continue
|
||||
f = {k2: struct.unpack_from("<i", r, v)[0] for k2, v in OFF.items()}
|
||||
f["teamkittypetechid"] = struct.unpack_from("<H", r, OFF_KITTYPE_U16)[0]
|
||||
f["ptr"] = rec
|
||||
out.append(f)
|
||||
return out
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
club = self.records(0x108)
|
||||
players = self.records(0xD8)
|
||||
hist = collections.Counter(
|
||||
(r["cardtype"], r["cardsubtypeid"]) for r in club if r
|
||||
)
|
||||
return {
|
||||
"club_slots": len(club),
|
||||
"club_filled": sum(1 for r in club if r),
|
||||
"club_hist": dict(hist),
|
||||
"club_records": [r for r in club if r],
|
||||
"player_slots": len(players),
|
||||
"player_filled": sum(1 for r in players if r),
|
||||
}
|
||||
|
||||
|
||||
def find_pid() -> int | None:
|
||||
for d in os.listdir("/proc"):
|
||||
if not d.isdigit():
|
||||
continue
|
||||
try:
|
||||
with open(f"/proc/{d}/comm") as f:
|
||||
if f.read().strip() == "FIFA17.exe":
|
||||
return int(d)
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def fmt(snap: dict) -> str:
|
||||
parts = [
|
||||
f"club={snap['club_filled']}/{snap['club_slots']}",
|
||||
f"players={snap['player_filled']}/{snap['player_slots']}",
|
||||
]
|
||||
if snap["club_hist"]:
|
||||
parts.append("hist=" + ",".join(
|
||||
f"(ct{a},st{b})x{c}" for (a, b), c in sorted(snap["club_hist"].items())))
|
||||
for r in snap["club_records"]:
|
||||
parts.append(
|
||||
"KIT[" if (r["cardtype"], r["cardsubtypeid"]) == (7, 9) else "rec[")
|
||||
parts[-1] += (f"ptr={r['ptr']:#x} ct={r['cardtype']} st={r['cardsubtypeid']} "
|
||||
f"state={r['itemState']} cat={r['category']} "
|
||||
f"team={r['teamid']} kittype={r['teamkittypetechid']}]")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--interval", type=float, default=1.0)
|
||||
ap.add_argument("--out", default="/home/alex/openfut-live/residency.log")
|
||||
ap.add_argument("--once", action="store_true")
|
||||
a = ap.parse_args()
|
||||
|
||||
sink = sys.stdout if a.out == "-" else open(a.out, "a", buffering=1)
|
||||
|
||||
def emit(msg: str) -> None:
|
||||
line = f"{time.strftime('%Y-%m-%dT%H:%M:%S%z')} {msg}"
|
||||
print(line, file=sink)
|
||||
if sink is not sys.stdout:
|
||||
print(line, flush=True)
|
||||
|
||||
emit("watch: start")
|
||||
target = None
|
||||
last = None
|
||||
while True:
|
||||
if target is None:
|
||||
pid = find_pid()
|
||||
if pid is None:
|
||||
if a.once:
|
||||
emit("watch: no FIFA17.exe"); return 1
|
||||
time.sleep(a.interval); continue
|
||||
try:
|
||||
target = Target(pid)
|
||||
emit(f"watch: attached pid={pid} cardsdll={target.base:#x} "
|
||||
f"mgr={target.mgr:#x}")
|
||||
last = None
|
||||
except (OSError, RuntimeError) as e:
|
||||
# not logged in yet, or the process died mid-resolve
|
||||
if a.once:
|
||||
emit(f"watch: not ready: {e}"); return 1
|
||||
target = None
|
||||
time.sleep(a.interval); continue
|
||||
try:
|
||||
snap = target.snapshot()
|
||||
except (OSError, struct.error) as e:
|
||||
emit(f"watch: detached ({e})")
|
||||
target = None
|
||||
if a.once:
|
||||
return 1
|
||||
continue
|
||||
key = fmt(snap)
|
||||
if key != last:
|
||||
emit(key)
|
||||
last = key
|
||||
if a.once:
|
||||
return 0
|
||||
time.sleep(a.interval)
|
||||
|
||||
|
||||
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
+252
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Is the squad manager REGISTERED (not merely parsed) in a live FIFA17 client?
|
||||
|
||||
READ-ONLY. Opens /proc/<pid>/mem for reading and scans. Writes nothing, sends
|
||||
no input to the game, and never opens 'r+b'.
|
||||
|
||||
manager_coldproof.py [pid] [--manager-wire N] [--manager-resource N]
|
||||
[--control WIRE:RESOURCE ...]
|
||||
|
||||
Defaults describe the staging profile used to close the manager milestone; pass
|
||||
the flags for any other profile.
|
||||
|
||||
WHAT THIS DECIDES
|
||||
-----------------
|
||||
FIFA17's squad parser (FUN_18013d1f0) reaches the item parser FUN_18013fe00 by
|
||||
two different routes:
|
||||
|
||||
players : atom 568 -> per-element atoms 355 index / 363 itemData /
|
||||
378 kitNumber; the 363 arm at 0x18013d8d9 calls the item parser
|
||||
on the NESTED itemData object.
|
||||
manager : atom 424 -> array loop at 0x18013da29 calls that same item parser
|
||||
DIRECTLY on the array ELEMENT, into squad+0xC0. No itemData step.
|
||||
|
||||
So `squad.manager[]` elements must be BARE ITEM OBJECTS. When they were served
|
||||
as {id, itemData:{...}, dream} the parser read only the two keys that happen to
|
||||
be item atoms -- id and dream -- and left resourceId at 0. resourceId is the
|
||||
merge key, compared RAW against carddbid (fut_staff.py::manager_item, +0x18),
|
||||
so 0 resolves no manager: no name, no rating, no art, empty slot. Fixed in
|
||||
OpenFUT b91e707; see Vault "FIFA 17/Squad Manager Wire Shape.md".
|
||||
|
||||
CONTROLS
|
||||
--------
|
||||
manager wire id the instance id. Present even when BROKEN, because `id` is
|
||||
an item atom the parser reads at element level. Its
|
||||
presence proves the element was parsed and therefore proves
|
||||
nothing about registration -- do not use it as the verdict.
|
||||
manager resourceId THE VERDICT. Resident => the merge key survived the load.
|
||||
player wire id and positive controls. Players demonstrably render, so if their
|
||||
player resourceId resourceIds are absent the squad simply is not loaded yet
|
||||
and the run is INCONCLUSIVE, not a failure.
|
||||
|
||||
RESIDENT-MANAGER HIT
|
||||
--------------------
|
||||
A 4-byte-aligned little-endian i32 equal to the manager resourceId, anywhere in
|
||||
a readable private mapping. Corroborate with the record context printed below:
|
||||
a real item record carries resourceId eight words ahead of its wire id, which
|
||||
is the layout the player controls exhibit. Hits without that shape are usually
|
||||
id lists or unrelated integers -- the layout, not the raw count, is the proof.
|
||||
|
||||
LAYOUT ASSUMPTION (the only one)
|
||||
--------------------------------
|
||||
Item records place resourceId 0x20 bytes before the wire id. Measured, both
|
||||
sides:
|
||||
|
||||
before b91e707 (pid 126936) -- manager parsed, merge key absent
|
||||
player @0xb85dbf48: 83906881 1 0 0 0 0 0 0 | 100002878 0 | 7
|
||||
player @0xb85dbd68: 84053575 1 0 0 0 0 0 0 | 100003237 0 | 7
|
||||
manager @0xb85dc1b8: 0 0 0 0 0 0 0 0 | 100004870 0 | 7
|
||||
|
||||
after b91e707 (pid 134118) -- same layout, key present
|
||||
player @0xb8740fd8: 84053575 1 0 0 0 0 0 0 | 100003237 0 | 7 0
|
||||
player @0xb87411b8: 83906881 1 0 0 0 0 0 0 | 100002878 0 | 7 0
|
||||
manager @0xb8741428: 1000509 2 0 0 0 0 0 0 | 100004870 0 | 7 0
|
||||
|
||||
Addresses shift every session and are recorded only as provenance; nothing here
|
||||
depends on them. The tool re-derives everything by scanning.
|
||||
|
||||
EXIT CODES (fail-closed)
|
||||
------------------------
|
||||
0 PASS manager resourceId resident, controls present
|
||||
1 FAIL controls present, manager resourceId absent
|
||||
2 NO PROCESS no FIFA17.exe, or /proc/<pid>/mem unreadable
|
||||
3 INCONCLUSIVE controls absent -- squad not loaded yet; re-run at the
|
||||
squad screen. Deliberately NOT 0: absent controls mean the
|
||||
probe proved nothing.
|
||||
"""
|
||||
import argparse
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
|
||||
# Staging profile defaults (override on the command line).
|
||||
DEF_MANAGER_WIRE = 100004870
|
||||
DEF_MANAGER_RESOURCE = 1000509
|
||||
DEF_CONTROLS = [(100002878, 83906881), (100003237, 84053575)]
|
||||
|
||||
# Item record layout: resourceId sits this far BEFORE the wire id.
|
||||
RESOURCE_BACK_OFF = 0x20
|
||||
|
||||
|
||||
def find_pid():
|
||||
"""The Wine process whose comm is FIFA17.exe (same rule as memtool.py)."""
|
||||
for d in glob.glob("/proc/[0-9]*"):
|
||||
try:
|
||||
with open(os.path.join(d, "comm")) as fh:
|
||||
if fh.read().strip() == "FIFA17.exe":
|
||||
return int(os.path.basename(d))
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def regions(pid):
|
||||
"""Readable private mappings worth scanning.
|
||||
|
||||
Skips device/memfd mappings and anything over 512 MiB (the big reserved
|
||||
ranges are not where parsed records live and dominate the runtime).
|
||||
"""
|
||||
out = []
|
||||
with open(f"/proc/{pid}/maps") as fh:
|
||||
for line in fh:
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)", line)
|
||||
if not m:
|
||||
continue
|
||||
lo, hi = int(m.group(1), 16), int(m.group(2), 16)
|
||||
perms, path = m.group(3), m.group(4)
|
||||
if perms[0] != "r" or path.startswith(("/dev", "/memfd")):
|
||||
continue
|
||||
if hi - lo > 512 * 1024 * 1024:
|
||||
continue
|
||||
out.append((lo, hi))
|
||||
return out
|
||||
|
||||
|
||||
def scan(pid, needles, ctx_before=0x40, ctx_after=0x40):
|
||||
"""4-byte-aligned little-endian i32 search; keeps a window around each hit."""
|
||||
found = {n: [] for n in needles}
|
||||
pats = {n: struct.pack("<i", n) for n in needles}
|
||||
with open(f"/proc/{pid}/mem", "rb", 0) as mem:
|
||||
for lo, hi in regions(pid):
|
||||
try:
|
||||
mem.seek(lo)
|
||||
buf = mem.read(hi - lo)
|
||||
except (OSError, ValueError, OverflowError):
|
||||
continue # torn-down or unreadable mapping; not a failure
|
||||
for n, pat in pats.items():
|
||||
i = buf.find(pat)
|
||||
while i >= 0:
|
||||
if i % 4 == 0:
|
||||
found[n].append(
|
||||
(lo + i, buf[max(0, i - ctx_before): i + ctx_after], min(i, ctx_before))
|
||||
)
|
||||
i = buf.find(pat, i + 4)
|
||||
return found
|
||||
|
||||
|
||||
def words(blob, centre, before=8, after=4):
|
||||
cells = []
|
||||
for k in range(-before, after):
|
||||
o = centre + k * 4
|
||||
if 0 <= o <= len(blob) - 4:
|
||||
cells.append(str(struct.unpack_from("<i", blob, o)[0]))
|
||||
return " ".join(cells)
|
||||
|
||||
|
||||
def record_shaped(blob, centre, resource):
|
||||
"""True when resourceId sits RESOURCE_BACK_OFF before the id -- the real
|
||||
item-record layout, as opposed to an incidental integer match."""
|
||||
o = centre - RESOURCE_BACK_OFF
|
||||
if o < 0 or o > len(blob) - 4:
|
||||
return False
|
||||
return struct.unpack_from("<i", blob, o)[0] == resource
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="read-only manager registration probe")
|
||||
ap.add_argument("pid", nargs="?", type=int, help="FIFA17 pid (default: auto)")
|
||||
ap.add_argument("--manager-wire", type=int, default=DEF_MANAGER_WIRE)
|
||||
ap.add_argument("--manager-resource", type=int, default=DEF_MANAGER_RESOURCE)
|
||||
ap.add_argument(
|
||||
"--control",
|
||||
action="append",
|
||||
metavar="WIRE:RESOURCE",
|
||||
help="player positive control; repeatable (default: the staging pair)",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
controls = DEF_CONTROLS
|
||||
if args.control:
|
||||
try:
|
||||
controls = [tuple(int(x) for x in c.split(":", 1)) for c in args.control]
|
||||
except ValueError:
|
||||
print(" --control must be WIRE:RESOURCE", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
pid = args.pid or find_pid()
|
||||
if not pid:
|
||||
print(" NO FIFA17 PROCESS (comm == FIFA17.exe) -- is the client running?")
|
||||
return 2
|
||||
if not os.access(f"/proc/{pid}/mem", os.R_OK):
|
||||
print(f" /proc/{pid}/mem is not readable -- wrong user, or the process exited")
|
||||
return 2
|
||||
print(f" pid={pid}")
|
||||
|
||||
needles = [args.manager_wire, args.manager_resource]
|
||||
for w, r in controls:
|
||||
needles += [w, r]
|
||||
try:
|
||||
res = scan(pid, sorted(set(needles)))
|
||||
except OSError as e:
|
||||
print(f" cannot read /proc/{pid}/mem: {e}")
|
||||
return 2
|
||||
|
||||
print("\n ===== hit counts =====")
|
||||
print(f" {'manager wire (parsed?)':32} {args.manager_wire:<12} hits={len(res[args.manager_wire])}")
|
||||
print(f" {'manager resourceId (VERDICT)':32} {args.manager_resource:<12} "
|
||||
f"hits={len(res[args.manager_resource])}")
|
||||
for w, r in controls:
|
||||
print(f" {'player wire (control)':32} {w:<12} hits={len(res[w])}")
|
||||
print(f" {'player resourceId (control)':32} {r:<12} hits={len(res[r])}")
|
||||
|
||||
print("\n ===== record context (8 words before the id, then the id) =====")
|
||||
shaped = {"manager": 0}
|
||||
for tag, wire, resource in (
|
||||
[("manager", args.manager_wire, args.manager_resource)]
|
||||
+ [(f"player{i}", w, r) for i, (w, r) in enumerate(controls)]
|
||||
):
|
||||
marked = 0
|
||||
for addr, blob, centre in res[wire]:
|
||||
ok = record_shaped(blob, centre, resource)
|
||||
if ok:
|
||||
marked += 1
|
||||
if marked <= 2 or ok:
|
||||
print(f" {tag:8} @0x{addr:x}{' <- item-record layout' if ok else ''}: "
|
||||
f"{words(blob, centre)}")
|
||||
if marked >= 2:
|
||||
break
|
||||
shaped[tag] = marked
|
||||
|
||||
ctl_keys = sum(len(res[r]) for _w, r in controls)
|
||||
mgr_keys = len(res[args.manager_resource])
|
||||
|
||||
print("\n ===== verdict =====")
|
||||
if ctl_keys == 0:
|
||||
print(" INCONCLUSIVE: no player resourceId control is resident, so the squad")
|
||||
print(" is not loaded. Reach the squad screen and re-run. (Nothing proven.)")
|
||||
return 3
|
||||
if mgr_keys == 0:
|
||||
print(f" FAIL: manager resourceId {args.manager_resource} is absent while "
|
||||
f"{ctl_keys} player")
|
||||
print(" resourceId control hit(s) are resident -> PARSED_BUT_NOT_REGISTERED.")
|
||||
return 1
|
||||
print(f" PASS: manager resourceId {args.manager_resource} is resident "
|
||||
f"({mgr_keys} hits, {shaped['manager']} in item-record layout).")
|
||||
print(" The merge key survived the load; the broken projection had 0.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+189
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trace FIFA17 provider dispatch and ACTION_ADVANCE delivery boundaries.
|
||||
|
||||
This probe correlates the global UI dispatch of FUT_CREATE_MATCH_DP and
|
||||
FUT_GET_MATCH_KITS_DP, the subscribed CardsDLL provider, the internal 0x7546
|
||||
create-response callback that can replay FUT_CREATE_MATCH_DP, and the final
|
||||
native-to-UI bridge. At global dispatch, r8d is the provider ID and rdx is the
|
||||
payload; neither register is a screen key.
|
||||
|
||||
The generated GDB program uses hardware-assisted execution breakpoints only.
|
||||
It never writes client memory and never drives game input.
|
||||
|
||||
match_advance_trace.py [pid] [--output PATH]
|
||||
match_advance_trace.py --print-script [pid]
|
||||
match_advance_trace.py --selftest
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import match_transition_trace as transition
|
||||
|
||||
FIFA_MODULE = "FIFA17.exe"
|
||||
PINNED_FIFA_SHA256 = "29c31cef12b0c3c2a7305220617c7b4fa139ab76b8c857851bdbe88987962899"
|
||||
GLOBAL_UI_DISPATCH_RVA = 0x80D1070
|
||||
CREATE_MATCH_CONTROLLER_RVA = 0xBF950
|
||||
PROVIDER_BRIDGE_CALL_RVA = 0x1A4D41
|
||||
|
||||
|
||||
def module_mapping(pid: int, module: str) -> tuple[int, str]:
|
||||
with open(f"/proc/{pid}/maps", encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
fields = line.split(maxsplit=5)
|
||||
path = fields[5].rstrip() if len(fields) == 6 else ""
|
||||
if not path.endswith(module):
|
||||
continue
|
||||
return int(fields[0].split("-", 1)[0], 16), path
|
||||
raise RuntimeError(f"{module} is not mapped in PID {pid}")
|
||||
|
||||
|
||||
def validate_file(path: str, expected: str, label: str) -> None:
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
actual = digest.hexdigest()
|
||||
if actual != expected:
|
||||
raise RuntimeError(f"unsupported {label}: sha256={actual}; expected={expected}")
|
||||
|
||||
|
||||
def trace_addresses(cards_base: int, fifa_base: int) -> dict[str, int]:
|
||||
return {
|
||||
"provider": cards_base + transition.PROVIDER_DISPATCH_RVA,
|
||||
"global_dispatch": fifa_base + GLOBAL_UI_DISPATCH_RVA,
|
||||
"controller": cards_base + CREATE_MATCH_CONTROLLER_RVA,
|
||||
"bridge": cards_base + PROVIDER_BRIDGE_CALL_RVA,
|
||||
}
|
||||
|
||||
|
||||
def build_gdb_script(pid: int, cards_base: int, fifa_base: int, output: str) -> str:
|
||||
if any(character in output for character in "\n\r"):
|
||||
raise ValueError("output path cannot contain a newline")
|
||||
address = trace_addresses(cards_base, fifa_base)
|
||||
return f"""set pagination off
|
||||
set confirm off
|
||||
set print thread-events off
|
||||
set breakpoint always-inserted on
|
||||
set logging file {output}
|
||||
set logging overwrite on
|
||||
set logging redirect off
|
||||
set logging enabled on
|
||||
handle SIGSEGV nostop noprint pass
|
||||
handle SIGILL nostop noprint pass
|
||||
handle SIGFPE nostop noprint pass
|
||||
handle SIGPIPE nostop noprint pass
|
||||
handle SIGALRM nostop noprint pass
|
||||
handle SIGUSR1 nostop noprint pass
|
||||
handle SIGUSR2 nostop noprint pass
|
||||
|
||||
attach {pid}
|
||||
|
||||
hbreak *0x{address['provider']:x}
|
||||
condition 1 $edx == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edx == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d PROVIDER" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d provider=%#x payload=%p controller=%p caller=%p\\n", $_thread, $edx, $r8, $rcx, *(void**)$rsp
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['global_dispatch']:x}
|
||||
condition 2 $r8d == 0x{transition.FUT_CREATE_MATCH_DP:x} || $r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d GLOBAL_DISPATCH" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d provider=%#x payload=%p manager=%p caller=%p\\n", $_thread, $r8d, $rdx, $rcx, *(void**)$rsp
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['controller']:x}
|
||||
condition 3 $edx == 0x7546
|
||||
commands
|
||||
silent
|
||||
python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d CREATE_MATCH_CONTROLLER" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d event=%#x controller=%p caller=%p\\n", $_thread, $edx, $rcx, *(void**)$rsp
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['bridge']:x}
|
||||
condition 4 $edi == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("ADVTRACE epoch_ns=%d mono_ns=%d PROVIDER_BRIDGE" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d provider=%#x target=%p bridge=%p callback=%p\\n", $_thread, $edi, $rsi, $rbx, *(void**)(*(void**)$rbx+0x48)
|
||||
continue
|
||||
end
|
||||
|
||||
printf "ADVTRACE ARMED pid={pid} provider=0x{address['provider']:x} global=0x{address['global_dispatch']:x} controller=0x{address['controller']:x} bridge=0x{address['bridge']:x}\\n"
|
||||
continue
|
||||
"""
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
address = trace_addresses(0x180000000, 0x140000000)
|
||||
assert address == {
|
||||
"provider": 0x1801A4CD0,
|
||||
"global_dispatch": 0x1480D1070,
|
||||
"controller": 0x1800BF950,
|
||||
"bridge": 0x1801A4D41,
|
||||
}
|
||||
script = build_gdb_script(28804, 0x180000000, 0x140000000, "/tmp/advance.log")
|
||||
assert script.count("hbreak *") == 4
|
||||
assert f"$edx == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
|
||||
assert f"$edx == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
|
||||
assert f"$r8d == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
|
||||
assert f"$r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
|
||||
assert "CREATE_MATCH_CONTROLLER" in script
|
||||
assert "PROVIDER_BRIDGE" in script
|
||||
assert "set *(" not in script
|
||||
print("match_advance_trace selftest: PASS")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pid", nargs="?", type=int)
|
||||
parser.add_argument("--output")
|
||||
parser.add_argument("--print-script", action="store_true")
|
||||
parser.add_argument("--selftest", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return 0
|
||||
|
||||
pid = args.pid or transition.find_pid()
|
||||
if not pid:
|
||||
print("FIFA17.exe not found", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
cards_base, cards_path = transition.cards_mapping(pid)
|
||||
transition.validate_cards(cards_path)
|
||||
fifa_base, fifa_path = module_mapping(pid, FIFA_MODULE)
|
||||
validate_file(fifa_path, PINNED_FIFA_SHA256, FIFA_MODULE)
|
||||
output = args.output or f"/tmp/fifa17-match-advance-{pid}.log"
|
||||
script = build_gdb_script(pid, cards_base, fifa_base, output)
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.print_script:
|
||||
print(script, end="")
|
||||
return 0
|
||||
if not shutil.which("gdb"):
|
||||
print("gdb not found", file=sys.stderr)
|
||||
return 2
|
||||
script_path = f"/tmp/fifa17-match-advance-{pid}.gdb"
|
||||
with open(script_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(script)
|
||||
os.execvp("gdb", ["gdb", "-q", "-nx", "-x", script_path])
|
||||
return 127
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trace the FIFA17 ACTION_CREATE_MATCH-to-provider lifecycle.
|
||||
|
||||
The probe correlates:
|
||||
|
||||
* the select-team action handler for UIF action IDs 0x7574..0x757b;
|
||||
* DataManager's request dispatch for FutCreateMatchServerResponse (0x7546);
|
||||
* the concrete FutCreateMatchServerResponse data-source request method;
|
||||
* FIFA's global UI dispatch of providers 0x7563 and 0x7565.
|
||||
|
||||
Static decoding identifies action 0x7577 as the branch that constructs the
|
||||
create-match request and calls DataManager for source 0x7546. The trace proves
|
||||
whether that authentic trigger executes in the failing flow. It uses four
|
||||
hardware-assisted execution breakpoints, never writes client memory, and never
|
||||
drives game input.
|
||||
|
||||
match_create_action_trace.py [pid] [--output PATH]
|
||||
match_create_action_trace.py --print-script [pid]
|
||||
match_create_action_trace.py --selftest
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import match_advance_trace as advance
|
||||
import match_transition_trace as transition
|
||||
|
||||
SELECT_TEAM_ACTION_HANDLER_RVA = 0x0BFCC0
|
||||
DATA_MANAGER_REQUEST_RVA = 0x80D2340
|
||||
DATA_SOURCE_REQUEST_RVA = 0x120270
|
||||
GLOBAL_UI_DISPATCH_RVA = advance.GLOBAL_UI_DISPATCH_RVA
|
||||
FIRST_SELECT_TEAM_ACTION = 0x7574
|
||||
LAST_SELECT_TEAM_ACTION = 0x757B
|
||||
ACTION_CREATE_MATCH = 0x7577
|
||||
CREATE_DATA_SOURCE = 0x7546
|
||||
|
||||
|
||||
def trace_addresses(cards_base: int, fifa_base: int) -> dict[str, int]:
|
||||
return {
|
||||
"action_handler": cards_base + SELECT_TEAM_ACTION_HANDLER_RVA,
|
||||
"manager_request": fifa_base + DATA_MANAGER_REQUEST_RVA,
|
||||
"data_source_request": cards_base + DATA_SOURCE_REQUEST_RVA,
|
||||
"ui_dispatch": fifa_base + GLOBAL_UI_DISPATCH_RVA,
|
||||
}
|
||||
|
||||
|
||||
def build_gdb_script(
|
||||
pid: int, cards_base: int, fifa_base: int, output: str
|
||||
) -> str:
|
||||
if any(character in output for character in "\n\r"):
|
||||
raise ValueError("output path cannot contain a newline")
|
||||
address = trace_addresses(cards_base, fifa_base)
|
||||
return f"""set pagination off
|
||||
set confirm off
|
||||
set print thread-events off
|
||||
set breakpoint always-inserted on
|
||||
set logging file {output}
|
||||
set logging overwrite on
|
||||
set logging redirect off
|
||||
set logging enabled on
|
||||
handle SIGSEGV nostop noprint pass
|
||||
handle SIGILL nostop noprint pass
|
||||
handle SIGFPE nostop noprint pass
|
||||
handle SIGPIPE nostop noprint pass
|
||||
handle SIGALRM nostop noprint pass
|
||||
handle SIGUSR1 nostop noprint pass
|
||||
handle SIGUSR2 nostop noprint pass
|
||||
|
||||
attach {pid}
|
||||
set $create_action_seen = 0
|
||||
set $manager_request_seen = 0
|
||||
set $data_source_request_seen = 0
|
||||
|
||||
hbreak *0x{address['action_handler']:x}
|
||||
condition 1 $edx >= 0x{FIRST_SELECT_TEAM_ACTION:x} && $edx <= 0x{LAST_SELECT_TEAM_ACTION:x}
|
||||
commands
|
||||
silent
|
||||
if $edx == 0x{ACTION_CREATE_MATCH:x}
|
||||
set $create_action_seen = 1
|
||||
end
|
||||
python import time; print("ACTIONTRACE epoch_ns=%d mono_ns=%d SELECT_TEAM_ACTION" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d action=%#x is_create=%d controller=%p payload=%p create_seen=%d\\n", $_thread, $edx, $edx==0x{ACTION_CREATE_MATCH:x}, $rcx, $r8, $create_action_seen
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['manager_request']:x}
|
||||
condition 2 $edx == 0x{CREATE_DATA_SOURCE:x}
|
||||
commands
|
||||
silent
|
||||
set $manager_request_seen = 1
|
||||
set $tree_sentinel = $rcx + 0x10
|
||||
set $tree_cursor = *(void**)($rcx+0x20)
|
||||
set $data_node = $tree_sentinel
|
||||
while $tree_cursor != 0 && $tree_cursor != $tree_sentinel
|
||||
if *(unsigned int*)($tree_cursor+0x20) >= 0x{CREATE_DATA_SOURCE:x}
|
||||
set $data_node = $tree_cursor
|
||||
set $tree_cursor = *(void**)($tree_cursor+0x08)
|
||||
else
|
||||
set $tree_cursor = *(void**)$tree_cursor
|
||||
end
|
||||
end
|
||||
set $data_source = 0
|
||||
set $request_method = 0
|
||||
if $data_node != $tree_sentinel && *(unsigned int*)($data_node+0x20) == 0x{CREATE_DATA_SOURCE:x}
|
||||
set $data_source = *(void**)($data_node+0x28)
|
||||
if $data_source != 0
|
||||
set $request_method = *(void**)(*(void**)$data_source+0x18)
|
||||
end
|
||||
end
|
||||
python import time; print("ACTIONTRACE epoch_ns=%d mono_ns=%d MANAGER_REQUEST" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d source=%#x manager=%p request=%p node=%p data_source=%p request_method=%p create_seen=%d\\n", $_thread, $edx, $rcx, $r8, $data_node, $data_source, $request_method, $create_action_seen
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['data_source_request']:x}
|
||||
commands
|
||||
silent
|
||||
set $data_source_request_seen = 1
|
||||
python import time; print("ACTIONTRACE epoch_ns=%d mono_ns=%d DATA_SOURCE_REQUEST" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d response=%p data_source=%p request=%p ready_before=%#x create_seen=%d manager_seen=%d\\n", $_thread, $rcx-0x50, $rcx, $rdx, *(unsigned char*)($rcx+0x38), $create_action_seen, $manager_request_seen
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['ui_dispatch']:x}
|
||||
condition 4 $r8d == 0x{transition.FUT_CREATE_MATCH_DP:x} || $r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("ACTIONTRACE epoch_ns=%d mono_ns=%d UI_DISPATCH" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d provider=%#x payload=%p ui_manager=%p create_seen=%d manager_seen=%d data_source_seen=%d\\n", $_thread, $r8d, $rdx, $rcx, $create_action_seen, $manager_request_seen, $data_source_request_seen
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
printf "ACTIONTRACE ARMED pid={pid} action_handler=0x{address['action_handler']:x} manager_request=0x{address['manager_request']:x} data_source_request=0x{address['data_source_request']:x} ui_dispatch=0x{address['ui_dispatch']:x}\\n"
|
||||
continue
|
||||
"""
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
address = trace_addresses(0x180000000, 0x140000000)
|
||||
assert address == {
|
||||
"action_handler": 0x1800BFCC0,
|
||||
"manager_request": 0x1480D2340,
|
||||
"data_source_request": 0x180120270,
|
||||
"ui_dispatch": 0x1480D1070,
|
||||
}
|
||||
script = build_gdb_script(
|
||||
45949, 0x180000000, 0x140000000, "/tmp/create-action.log"
|
||||
)
|
||||
assert script.count("hbreak *") == 4
|
||||
assert f"$edx == 0x{ACTION_CREATE_MATCH:x}" in script
|
||||
assert f"$edx == 0x{CREATE_DATA_SOURCE:x}" in script
|
||||
assert f"$r8d == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
|
||||
assert f"$r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
|
||||
assert "request_method" in script
|
||||
assert "set *(" not in script
|
||||
print("match_create_action_trace selftest: PASS")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pid", nargs="?", type=int)
|
||||
parser.add_argument("--output")
|
||||
parser.add_argument("--print-script", action="store_true")
|
||||
parser.add_argument("--selftest", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return 0
|
||||
|
||||
pid = args.pid or transition.find_pid()
|
||||
if not pid:
|
||||
print("FIFA17.exe not found", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
cards_base, cards_path = transition.cards_mapping(pid)
|
||||
transition.validate_cards(cards_path)
|
||||
fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
|
||||
advance.validate_file(fifa_path, advance.PINNED_FIFA_SHA256, advance.FIFA_MODULE)
|
||||
output = args.output or f"/tmp/fifa17-match-create-action-{pid}.log"
|
||||
script = build_gdb_script(pid, cards_base, fifa_base, output)
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.print_script:
|
||||
print(script, end="")
|
||||
return 0
|
||||
if not shutil.which("gdb"):
|
||||
print("gdb not found", file=sys.stderr)
|
||||
return 2
|
||||
script_path = f"/tmp/fifa17-match-create-action-{pid}.gdb"
|
||||
with open(script_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(script)
|
||||
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
|
||||
return 127
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trace FIFA17 provider delivery lookup without heap-address assumptions.
|
||||
|
||||
The probe anchors the real CardsDLL call sequence in FUN_1801a4cd0 and the
|
||||
provider-specific FUT_CREATE_MATCH_DP readiness check in FUN_1800be500:
|
||||
|
||||
vslot +0x38 call -> create gate return -> returned target -> UI bridge
|
||||
|
||||
For FUT_CREATE_MATCH_DP and FUT_GET_MATCH_KITS_DP it records the live controller
|
||||
vtable, concrete lookup function, event service, readiness-gate implementation,
|
||||
every register input, returned target, and whether the native-to-UI bridge
|
||||
executes. No post-event object identity is used.
|
||||
|
||||
The generated GDB program uses hardware-assisted execution breakpoints only.
|
||||
It never writes client memory and never drives game input.
|
||||
|
||||
match_delivery_lifecycle_trace.py [pid] [--output PATH]
|
||||
match_delivery_lifecycle_trace.py --print-script [pid]
|
||||
match_delivery_lifecycle_trace.py --selftest
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import match_advance_trace as advance
|
||||
import match_transition_trace as transition
|
||||
|
||||
LOOKUP_CALL_RVA = transition.PROVIDER_DISPATCH_RVA + 0x2C
|
||||
LOOKUP_RETURN_RVA = transition.PROVIDER_DISPATCH_RVA + 0x2F
|
||||
BRIDGE_CALL_RVA = transition.PROVIDER_DISPATCH_RVA + 0x71
|
||||
CREATE_GATE_RETURN_RVA = 0x0BE647
|
||||
|
||||
|
||||
def trace_addresses(cards_base: int) -> dict[str, int]:
|
||||
return {
|
||||
"lookup_call": cards_base + LOOKUP_CALL_RVA,
|
||||
"gate_return": cards_base + CREATE_GATE_RETURN_RVA,
|
||||
"lookup_return": cards_base + LOOKUP_RETURN_RVA,
|
||||
"bridge": cards_base + BRIDGE_CALL_RVA,
|
||||
}
|
||||
|
||||
|
||||
def build_gdb_script(pid: int, cards_base: int, output: str) -> str:
|
||||
if any(character in output for character in "\n\r"):
|
||||
raise ValueError("output path cannot contain a newline")
|
||||
address = trace_addresses(cards_base)
|
||||
return f"""set pagination off
|
||||
set confirm off
|
||||
set print thread-events off
|
||||
set breakpoint always-inserted on
|
||||
set logging file {output}
|
||||
set logging overwrite on
|
||||
set logging redirect off
|
||||
set logging enabled on
|
||||
handle SIGSEGV nostop noprint pass
|
||||
handle SIGILL nostop noprint pass
|
||||
handle SIGFPE nostop noprint pass
|
||||
handle SIGPIPE nostop noprint pass
|
||||
handle SIGALRM nostop noprint pass
|
||||
handle SIGUSR1 nostop noprint pass
|
||||
handle SIGUSR2 nostop noprint pass
|
||||
|
||||
attach {pid}
|
||||
set $current_provider = 0
|
||||
set $current_payload = 0
|
||||
set $current_controller = 0
|
||||
set $current_vtable = 0
|
||||
set $current_lookup = 0
|
||||
set $current_service = 0
|
||||
set $current_service_vtable = 0
|
||||
set $current_gate = 0
|
||||
|
||||
hbreak *0x{address['lookup_call']:x}
|
||||
condition 1 $edi == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||
commands
|
||||
silent
|
||||
set $current_provider = $edi
|
||||
set $current_payload = $rbp
|
||||
set $current_controller = $rcx
|
||||
set $current_vtable = *(void**)$rcx
|
||||
set $current_lookup = *(void**)(*(void**)$rcx+0x38)
|
||||
set $current_service = *(void**)($rcx+0x18)
|
||||
set $current_service_vtable = *(void**)$current_service
|
||||
set $current_gate = *(void**)($current_service_vtable+0x58)
|
||||
python import time; print("LOOKUPTRACE epoch_ns=%d mono_ns=%d LOOKUP_CALL" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d provider=%#x this=%p outer_controller=%p payload=%p vtable=%p lookup_fn=%p service=%p service_vtable=%p gate_fn=%p controller_mode=%#x controller_flag=%#x rdx=%p r8=%p r9=%p state_rbx=%p state_rbp=%p\\n", $_thread, $edi, $rcx, $rbx, $rbp, $current_vtable, $current_lookup, $current_service, $current_service_vtable, $current_gate, *(unsigned int*)($rcx+0x140), *(unsigned char*)($rcx+0x152), $rdx, $r8, $r9, $rbx, $rbp
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['gate_return']:x}
|
||||
condition 2 $current_provider == 0x{transition.FUT_CREATE_MATCH_DP:x} && $rbx == $current_controller
|
||||
commands
|
||||
silent
|
||||
python import time; print("LOOKUPTRACE epoch_ns=%d mono_ns=%d CREATE_GATE_RETURN" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d provider=%#x controller=%p service=%p service_vtable=%p gate_fn=%p selector=0x7546 result_al=%#x\\n", $_thread, $current_provider, $current_controller, $current_service, $current_service_vtable, $current_gate, $al
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['lookup_return']:x}
|
||||
condition 3 $edi == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("LOOKUPTRACE epoch_ns=%d mono_ns=%d LOOKUP_RETURN" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d provider=%#x controller=%p payload=%p vtable=%p lookup_fn=%p result=%p\\n", $_thread, $edi, $rbx, $rbp, $current_vtable, $current_lookup, $rax
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['bridge']:x}
|
||||
condition 4 $edi == 0x{transition.FUT_CREATE_MATCH_DP:x} || $edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("LOOKUPTRACE epoch_ns=%d mono_ns=%d BRIDGE" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d provider=%#x controller=%p payload=%p target=%p bridge=%p callback=%p\\n", $_thread, $edi, $current_controller, $current_payload, $rsi, $rbx, *(void**)(*(void**)$rbx+0x48)
|
||||
continue
|
||||
end
|
||||
|
||||
printf "LOOKUPTRACE ARMED pid={pid} lookup_call=0x{address['lookup_call']:x} gate_return=0x{address['gate_return']:x} lookup_return=0x{address['lookup_return']:x} bridge=0x{address['bridge']:x}\\n"
|
||||
continue
|
||||
"""
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
address = trace_addresses(0x180000000)
|
||||
assert address == {
|
||||
"lookup_call": 0x1801A4CFC,
|
||||
"gate_return": 0x1800BE647,
|
||||
"lookup_return": 0x1801A4CFF,
|
||||
"bridge": 0x1801A4D41,
|
||||
}
|
||||
script = build_gdb_script(35632, 0x180000000, "/tmp/lookup.log")
|
||||
assert script.count("hbreak *") == 4
|
||||
assert f"$edi == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
|
||||
assert f"$edi == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
|
||||
assert "LOOKUP_CALL" in script
|
||||
assert "CREATE_GATE_RETURN" in script
|
||||
assert "LOOKUP_RETURN" in script
|
||||
assert "gate_fn" in script
|
||||
assert "BRIDGE" in script
|
||||
assert "set *(" not in script
|
||||
print("match_delivery_lifecycle_trace selftest: PASS")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pid", nargs="?", type=int)
|
||||
parser.add_argument("--output")
|
||||
parser.add_argument("--print-script", action="store_true")
|
||||
parser.add_argument("--selftest", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return 0
|
||||
|
||||
pid = args.pid or transition.find_pid()
|
||||
if not pid:
|
||||
print("FIFA17.exe not found", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
cards_base, cards_path = transition.cards_mapping(pid)
|
||||
transition.validate_cards(cards_path)
|
||||
_fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
|
||||
advance.validate_file(fifa_path, advance.PINNED_FIFA_SHA256, advance.FIFA_MODULE)
|
||||
output = args.output or f"/tmp/fifa17-match-provider-lookup-{pid}.log"
|
||||
script = build_gdb_script(pid, cards_base, output)
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.print_script:
|
||||
print(script, end="")
|
||||
return 0
|
||||
if not shutil.which("gdb"):
|
||||
print("gdb not found", file=sys.stderr)
|
||||
return 2
|
||||
script_path = f"/tmp/fifa17-match-provider-lookup-{pid}.gdb"
|
||||
with open(script_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(script)
|
||||
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
|
||||
return 127
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trace FIFA17's post-kit handoff into the gameplay loading state.
|
||||
|
||||
The probe anchors the second ACTION_SAVE_MATCH_KIT (0x7576), captures the
|
||||
select-team deleting destructor with its real caller, records entry to the
|
||||
Gameplay::ScenarioModeStart consumer with the state it would advance, and
|
||||
identifies the first TestingGame update after the boundary.
|
||||
|
||||
The generated GDB program uses four hardware-assisted execution breakpoints.
|
||||
It never writes client memory, calls client functions, drives input, emits
|
||||
actions, or changes timing deliberately.
|
||||
|
||||
match_drill_transition_trace.py [pid] [--output PATH]
|
||||
match_drill_transition_trace.py --print-script [pid]
|
||||
match_drill_transition_trace.py --selftest
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import match_advance_trace as advance
|
||||
import match_transition_trace as transition
|
||||
|
||||
SAVE_KIT_ACTION = 0x7576
|
||||
SAVE_ACTION_RVA = 0x0BFCC0
|
||||
SELECT_TEAM_DELETING_DESTRUCTOR_RVA = 0x0BE020
|
||||
TESTING_GAME_UPDATE_RVA = 0x05A410C8
|
||||
SCENARIO_MODE_START_HANDLER_RVA = 0x05A58EC0
|
||||
TESTING_GAME_VTABLE_RVA = 0x035C58A8
|
||||
TESTING_GAME_STATE_VTABLE_RVA = 0x035C2EE0
|
||||
|
||||
OWNER_STATE_OFFSET = 0x1958
|
||||
STATE_GAME_DATABASE_OFFSET = 0x17450
|
||||
STATE_PHASE_OFFSET = 0x27BEC
|
||||
STATE_SCENARIO_MODE_START_GATE_OFFSET = 0x359E8
|
||||
DATABASE_IS_SKILL_GAME_OFFSET = 0x7382
|
||||
DATABASE_TEAM_PAIR_OFFSET = 0x73C4
|
||||
|
||||
|
||||
def trace_addresses(cards_base: int, fifa_base: int) -> dict[str, int]:
|
||||
return {
|
||||
"save_action": cards_base + SAVE_ACTION_RVA,
|
||||
"deleting_destructor": cards_base + SELECT_TEAM_DELETING_DESTRUCTOR_RVA,
|
||||
"testing_game_update": fifa_base + TESTING_GAME_UPDATE_RVA,
|
||||
"scenario_mode_start_handler": fifa_base + SCENARIO_MODE_START_HANDLER_RVA,
|
||||
"testing_game_vtable": fifa_base + TESTING_GAME_VTABLE_RVA,
|
||||
"testing_game_state_vtable": fifa_base + TESTING_GAME_STATE_VTABLE_RVA,
|
||||
}
|
||||
|
||||
|
||||
def build_gdb_script(
|
||||
pid: int,
|
||||
cards_base: int,
|
||||
fifa_base: int,
|
||||
output: str,
|
||||
) -> str:
|
||||
if any(character in output for character in "\n\r"):
|
||||
raise ValueError("output path cannot contain a newline")
|
||||
address = trace_addresses(cards_base, fifa_base)
|
||||
return f"""set pagination off
|
||||
set confirm off
|
||||
set print thread-events off
|
||||
set breakpoint always-inserted on
|
||||
set logging file {output}
|
||||
set logging overwrite on
|
||||
set logging redirect off
|
||||
set logging enabled on
|
||||
handle SIGSEGV nostop noprint pass
|
||||
handle SIGILL nostop noprint pass
|
||||
handle SIGFPE nostop noprint pass
|
||||
handle SIGPIPE nostop noprint pass
|
||||
handle SIGALRM nostop noprint pass
|
||||
handle SIGUSR1 nostop noprint pass
|
||||
handle SIGUSR2 nostop noprint pass
|
||||
|
||||
attach {pid}
|
||||
set $save_count = 0
|
||||
set $current_controller = 0
|
||||
set $engine_seen = 0
|
||||
|
||||
hbreak *0x{address['save_action']:x}
|
||||
commands
|
||||
silent
|
||||
if $edx == 0x{SAVE_KIT_ACTION:x}
|
||||
set $save_count = $save_count + 1
|
||||
set $current_controller = $rcx
|
||||
python import time; print("DRILLTRACE epoch_ns=%d mono_ns=%d SAVE_ACTION" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d ordinal=%d action=%#x controller=%p payload=%p second_boundary=%d\\n", $_thread, $save_count, $edx, $rcx, $r8, $save_count==2
|
||||
if $save_count == 2
|
||||
disable 1
|
||||
end
|
||||
end
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['deleting_destructor']:x}
|
||||
condition 2 $save_count >= 2 && $rcx == $current_controller
|
||||
commands
|
||||
silent
|
||||
python import time; print("DRILLTRACE epoch_ns=%d mono_ns=%d SELECT_TEAM_DELETING_DESTRUCTOR" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d controller=%p delete_flags=%#x caller_return=%p vtable=%p\\n", $_thread, $rcx, $edx, *(void**)$rsp, *(void**)$rcx
|
||||
x/16gx $rsp
|
||||
bt 12
|
||||
disable 2
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['scenario_mode_start_handler']:x}
|
||||
commands
|
||||
silent
|
||||
set $scenario_wrapper = $rcx
|
||||
set $scenario_state = *(void**)($scenario_wrapper+0x30)
|
||||
set $scenario_payload = $r9
|
||||
python import time; print("DRILLTRACE epoch_ns=%d mono_ns=%d SCENARIO_MODE_START" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
if $scenario_state != 0
|
||||
set $scenario_database = *(void**)($scenario_state+0x{STATE_GAME_DATABASE_OFFSET:x})
|
||||
if $scenario_database != 0
|
||||
printf "thread=%d wrapper=%p state=%p payload=%p phase=%d alternate_gate=%d is_skill_game=%d caller_return=%p\\n", $_thread, $scenario_wrapper, $scenario_state, $scenario_payload, *(unsigned int*)($scenario_state+0x{STATE_PHASE_OFFSET:x}), *(unsigned char*)($scenario_state+0x{STATE_SCENARIO_MODE_START_GATE_OFFSET:x}), *(unsigned char*)($scenario_database+0x{DATABASE_IS_SKILL_GAME_OFFSET:x}), *(void**)$rsp
|
||||
else
|
||||
printf "thread=%d wrapper=%p state=%p payload=%p database=0 caller_return=%p\\n", $_thread, $scenario_wrapper, $scenario_state, $scenario_payload, *(void**)$rsp
|
||||
end
|
||||
else
|
||||
printf "thread=%d wrapper=%p state=0 payload=%p caller_return=%p\\n", $_thread, $scenario_wrapper, $scenario_payload, *(void**)$rsp
|
||||
end
|
||||
bt 12
|
||||
disable 3
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['testing_game_update']:x}
|
||||
condition 4 $save_count >= 2 && $engine_seen == 0
|
||||
commands
|
||||
silent
|
||||
set $owner = $rsi
|
||||
set $state = *(void**)($owner+0x{OWNER_STATE_OFFSET:x})
|
||||
if $state != 0 && *(void**)$owner == 0x{address['testing_game_vtable']:x} && *(void**)$state == 0x{address['testing_game_state_vtable']:x}
|
||||
set $database = *(void**)($state+0x{STATE_GAME_DATABASE_OFFSET:x})
|
||||
if $database != 0
|
||||
set $engine_seen = 1
|
||||
python import time; print("DRILLTRACE epoch_ns=%d mono_ns=%d ENGINE_HANDOFF" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d owner=%p owner_vtable=%p state=%p state_vtable=%p database=%p phase=%d is_skill_game=%d teams=%d,%d\\n", $_thread, $owner, *(void**)$owner, $state, *(void**)$state, $database, *(unsigned int*)($state+0x{STATE_PHASE_OFFSET:x}), *(unsigned char*)($database+0x{DATABASE_IS_SKILL_GAME_OFFSET:x}), *(unsigned int*)($database+0x{DATABASE_TEAM_PAIR_OFFSET:x}), *(unsigned int*)($database+0x{DATABASE_TEAM_PAIR_OFFSET + 4:x})
|
||||
bt 12
|
||||
disable 4
|
||||
end
|
||||
end
|
||||
continue
|
||||
end
|
||||
|
||||
printf "DRILLTRACE ARMED pid={pid} save_action=0x{address['save_action']:x} deleting_destructor=0x{address['deleting_destructor']:x} scenario_mode_start_handler=0x{address['scenario_mode_start_handler']:x} testing_game_update=0x{address['testing_game_update']:x}\\n"
|
||||
continue
|
||||
"""
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
address = trace_addresses(0x180000000, 0x140000000)
|
||||
assert address == {
|
||||
"save_action": 0x1800BFCC0,
|
||||
"deleting_destructor": 0x1800BE020,
|
||||
"testing_game_update": 0x145A410C8,
|
||||
"scenario_mode_start_handler": 0x145A58EC0,
|
||||
"testing_game_vtable": 0x1435C58A8,
|
||||
"testing_game_state_vtable": 0x1435C2EE0,
|
||||
}
|
||||
script = build_gdb_script(
|
||||
49938,
|
||||
0x180000000,
|
||||
0x140000000,
|
||||
"/tmp/drill-transition.log",
|
||||
)
|
||||
assert script.count("hbreak *") == 4
|
||||
assert "SELECT_TEAM_DELETING_DESTRUCTOR" in script
|
||||
assert "SCENARIO_MODE_START" in script
|
||||
assert "wrapper=%p state=%p payload=%p" in script
|
||||
assert "alternate_gate=%d" in script
|
||||
assert "skill_game_start_constructor" not in script
|
||||
assert "ENGINE_HANDOFF" in script
|
||||
assert "GameplayGameDatabase.IsSkillGame" not in script
|
||||
assert "set *(" not in script
|
||||
print("match_drill_transition_trace selftest: PASS")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pid", nargs="?", type=int)
|
||||
parser.add_argument("--output")
|
||||
parser.add_argument("--print-script", action="store_true")
|
||||
parser.add_argument("--selftest", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return 0
|
||||
|
||||
pid = args.pid or transition.find_pid()
|
||||
if not pid:
|
||||
print("FIFA17.exe not found", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
cards_base, cards_path = transition.cards_mapping(pid)
|
||||
transition.validate_cards(cards_path)
|
||||
fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
|
||||
advance.validate_file(
|
||||
fifa_path,
|
||||
advance.PINNED_FIFA_SHA256,
|
||||
advance.FIFA_MODULE,
|
||||
)
|
||||
output = args.output or f"/tmp/fifa17-match-drill-transition-{pid}.log"
|
||||
script = build_gdb_script(pid, cards_base, fifa_base, output)
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.print_script:
|
||||
print(script, end="")
|
||||
return 0
|
||||
if not shutil.which("gdb"):
|
||||
print("gdb not found", file=sys.stderr)
|
||||
return 2
|
||||
script_path = f"/tmp/fifa17-match-drill-transition-{pid}.gdb"
|
||||
with open(script_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(script)
|
||||
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
|
||||
return 127
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+185
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trace FIFA17's post-kit boundary without changing client behavior.
|
||||
|
||||
The probe anchors both ACTION_SAVE_MATCH_KIT (0x7576) actions, their concrete
|
||||
native save call, the action-handler return, and select-team provider teardown.
|
||||
The second 0x7576 action is the temporal boundary for later drill/game-loader
|
||||
instrumentation.
|
||||
|
||||
The generated GDB program uses four hardware-assisted execution breakpoints. It
|
||||
never writes client memory, calls client functions, drives input, emits actions,
|
||||
or alters timing deliberately.
|
||||
|
||||
match_post_kit_trace.py [pid] [--output PATH]
|
||||
match_post_kit_trace.py --print-script [pid]
|
||||
match_post_kit_trace.py --selftest
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import match_advance_trace as advance
|
||||
import match_transition_trace as transition
|
||||
|
||||
SAVE_KIT_ACTION = 0x7576
|
||||
ACTION_HANDLER_RVA = 0x0BFCC0
|
||||
SAVE_CALL_RVA = 0x0BFF25
|
||||
ACTION_RETURN_RVA = 0x0C00AE
|
||||
SELECT_TEAM_DESTRUCTOR_RVA = 0x0BDEC0
|
||||
|
||||
|
||||
def trace_addresses(cards_base: int) -> dict[str, int]:
|
||||
return {
|
||||
"action": cards_base + ACTION_HANDLER_RVA,
|
||||
"save_call": cards_base + SAVE_CALL_RVA,
|
||||
"action_return": cards_base + ACTION_RETURN_RVA,
|
||||
"destructor": cards_base + SELECT_TEAM_DESTRUCTOR_RVA,
|
||||
}
|
||||
|
||||
|
||||
def build_gdb_script(pid: int, cards_base: int, output: str) -> str:
|
||||
if any(character in output for character in "\n\r"):
|
||||
raise ValueError("output path cannot contain a newline")
|
||||
address = trace_addresses(cards_base)
|
||||
return f"""set pagination off
|
||||
set confirm off
|
||||
set print thread-events off
|
||||
set breakpoint always-inserted on
|
||||
set logging file {output}
|
||||
set logging overwrite on
|
||||
set logging redirect off
|
||||
set logging enabled on
|
||||
handle SIGSEGV nostop noprint pass
|
||||
handle SIGILL nostop noprint pass
|
||||
handle SIGFPE nostop noprint pass
|
||||
handle SIGPIPE nostop noprint pass
|
||||
handle SIGALRM nostop noprint pass
|
||||
handle SIGUSR1 nostop noprint pass
|
||||
handle SIGUSR2 nostop noprint pass
|
||||
|
||||
attach {pid}
|
||||
set $save_count = 0
|
||||
set $current_action = 0
|
||||
set $current_controller = 0
|
||||
set $current_payload = 0
|
||||
set $second_save_epoch = 0
|
||||
|
||||
hbreak *0x{address['action']:x}
|
||||
condition 1 $edx == 0x{SAVE_KIT_ACTION:x}
|
||||
commands
|
||||
silent
|
||||
set $save_count = $save_count + 1
|
||||
set $current_action = $edx
|
||||
set $current_controller = $rcx
|
||||
set $current_payload = $r8
|
||||
python import time, gdb; now = time.time_ns(); gdb.set_convenience_variable("event_epoch", now); print("POSTKIT epoch_ns=%d mono_ns=%d SAVE_ACTION" % (now, time.monotonic_ns()), end=" ")
|
||||
if $save_count == 2
|
||||
set $second_save_epoch = $event_epoch
|
||||
end
|
||||
printf "thread=%d ordinal=%d action=%#x controller=%p payload=%p payload_vtable=%p mode=%#x flags_150=%#x flags_151=%#x flags_152=%#x flags_155=%#x second_boundary=%d\\n", $_thread, $save_count, $edx, $rcx, $r8, *(void**)$r8, *(unsigned int*)($rcx+0x140), *(unsigned char*)($rcx+0x150), *(unsigned char*)($rcx+0x151), *(unsigned char*)($rcx+0x152), *(unsigned char*)($rcx+0x155), $save_count==2
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['save_call']:x}
|
||||
condition 2 $current_action == 0x{SAVE_KIT_ACTION:x}
|
||||
commands
|
||||
silent
|
||||
set $save_target = *(void**)(*(void**)$rcx+0x1d0)
|
||||
python import time; print("POSTKIT epoch_ns=%d mono_ns=%d NATIVE_SAVE_CALL" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d ordinal=%d central=%p central_vtable=%p target=%p side=%#x request=%p payload=%p\\n", $_thread, $save_count, $rcx, *(void**)$rcx, $save_target, $r8d, $rdx, $current_payload
|
||||
x/12gx $rdx
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['action_return']:x}
|
||||
condition 3 $current_action == 0x{SAVE_KIT_ACTION:x} && $rsi == $current_controller
|
||||
commands
|
||||
silent
|
||||
python import time; print("POSTKIT epoch_ns=%d mono_ns=%d ACTION_RETURN" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d ordinal=%d controller=%p handled=%#x mode=%#x flags_150=%#x flags_151=%#x flags_152=%#x flags_155=%#x\\n", $_thread, $save_count, $rsi, $al, *(unsigned int*)($rsi+0x140), *(unsigned char*)($rsi+0x150), *(unsigned char*)($rsi+0x151), *(unsigned char*)($rsi+0x152), *(unsigned char*)($rsi+0x155)
|
||||
set $current_action = 0
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['destructor']:x}
|
||||
condition 4 $save_count >= 2 && $rcx == $current_controller
|
||||
commands
|
||||
silent
|
||||
python import time; print("POSTKIT epoch_ns=%d mono_ns=%d SELECT_TEAM_DESTRUCTOR" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d controller=%p save_count=%d second_save_epoch=%lld vtable=%p mode=%#x\\n", $_thread, $rcx, $save_count, $second_save_epoch, *(void**)$rcx, *(unsigned int*)($rcx+0x140)
|
||||
bt 12
|
||||
continue
|
||||
end
|
||||
|
||||
printf "POSTKIT ARMED pid={pid} action=0x{address['action']:x} save_call=0x{address['save_call']:x} action_return=0x{address['action_return']:x} destructor=0x{address['destructor']:x}\\n"
|
||||
continue
|
||||
"""
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
address = trace_addresses(0x180000000)
|
||||
assert address == {
|
||||
"action": 0x1800BFCC0,
|
||||
"save_call": 0x1800BFF25,
|
||||
"action_return": 0x1800C00AE,
|
||||
"destructor": 0x1800BDEC0,
|
||||
}
|
||||
script = build_gdb_script(47872, 0x180000000, "/tmp/post-kit.log")
|
||||
assert script.count("hbreak *") == 4
|
||||
assert f"$edx == 0x{SAVE_KIT_ACTION:x}" in script
|
||||
assert "second_boundary" in script
|
||||
assert "NATIVE_SAVE_CALL" in script
|
||||
assert "SELECT_TEAM_DESTRUCTOR" in script
|
||||
assert "set *(" not in script
|
||||
print("match_post_kit_trace selftest: PASS")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pid", nargs="?", type=int)
|
||||
parser.add_argument("--output")
|
||||
parser.add_argument("--print-script", action="store_true")
|
||||
parser.add_argument("--selftest", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return 0
|
||||
|
||||
pid = args.pid or transition.find_pid()
|
||||
if not pid:
|
||||
print("FIFA17.exe not found", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
cards_base, cards_path = transition.cards_mapping(pid)
|
||||
transition.validate_cards(cards_path)
|
||||
_fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
|
||||
advance.validate_file(fifa_path, advance.PINNED_FIFA_SHA256, advance.FIFA_MODULE)
|
||||
output = args.output or f"/tmp/fifa17-match-post-kit-{pid}.log"
|
||||
script = build_gdb_script(pid, cards_base, output)
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.print_script:
|
||||
print(script, end="")
|
||||
return 0
|
||||
if not shutil.which("gdb"):
|
||||
print("gdb not found", file=sys.stderr)
|
||||
return 2
|
||||
script_path = f"/tmp/fifa17-match-post-kit-{pid}.gdb"
|
||||
with open(script_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(script)
|
||||
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
|
||||
return 127
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trace FIFA17 create-response readiness versus UI provider dispatch.
|
||||
|
||||
The probe correlates four concrete lifecycle boundaries:
|
||||
|
||||
* FutCreateMatchServerResponse data-source request;
|
||||
* the POST /match network response callback;
|
||||
* the response readiness/completion callback;
|
||||
* FIFA's global UI dispatch of providers 0x7563 and 0x7565.
|
||||
|
||||
This distinguishes network completion from the separate DataManager readiness
|
||||
lifecycle without assuming any screen or heap-object identity. The generated GDB
|
||||
program uses hardware-assisted execution breakpoints only. It never writes
|
||||
client memory and never drives game input.
|
||||
|
||||
match_provider_producer_trace.py [pid] [--output PATH]
|
||||
match_provider_producer_trace.py --print-script [pid]
|
||||
match_provider_producer_trace.py --selftest
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import match_advance_trace as advance
|
||||
import match_transition_trace as transition
|
||||
|
||||
DATA_SOURCE_REQUEST_RVA = 0x120270
|
||||
NETWORK_RESPONSE_RVA = transition.RESPONSE_CALLBACK_RVA
|
||||
CREATE_COMPLETE_RVA = 0x120000
|
||||
GLOBAL_UI_DISPATCH_RVA = advance.GLOBAL_UI_DISPATCH_RVA
|
||||
CREATE_RESPONSE_OFFSET = 0xA0
|
||||
CREATE_DATA_SOURCE_OFFSET = CREATE_RESPONSE_OFFSET + 0x50
|
||||
CREATE_READY_OFFSET = CREATE_RESPONSE_OFFSET + 0x88
|
||||
ACTIVE_CALLBACK_OFFSET = 0x47D0
|
||||
|
||||
|
||||
def trace_addresses(cards_base: int, fifa_base: int) -> dict[str, int]:
|
||||
return {
|
||||
"data_source_request": cards_base + DATA_SOURCE_REQUEST_RVA,
|
||||
"network_response": cards_base + NETWORK_RESPONSE_RVA,
|
||||
"create_complete": cards_base + CREATE_COMPLETE_RVA,
|
||||
"ui_dispatch": fifa_base + GLOBAL_UI_DISPATCH_RVA,
|
||||
}
|
||||
|
||||
|
||||
def build_gdb_script(
|
||||
pid: int, cards_base: int, fifa_base: int, output: str
|
||||
) -> str:
|
||||
if any(character in output for character in "\n\r"):
|
||||
raise ValueError("output path cannot contain a newline")
|
||||
address = trace_addresses(cards_base, fifa_base)
|
||||
return f"""set pagination off
|
||||
set confirm off
|
||||
set print thread-events off
|
||||
set breakpoint always-inserted on
|
||||
set logging file {output}
|
||||
set logging overwrite on
|
||||
set logging redirect off
|
||||
set logging enabled on
|
||||
handle SIGSEGV nostop noprint pass
|
||||
handle SIGILL nostop noprint pass
|
||||
handle SIGFPE nostop noprint pass
|
||||
handle SIGPIPE nostop noprint pass
|
||||
handle SIGALRM nostop noprint pass
|
||||
handle SIGUSR1 nostop noprint pass
|
||||
handle SIGUSR2 nostop noprint pass
|
||||
|
||||
attach {pid}
|
||||
set $last_central = 0
|
||||
set $last_response = 0
|
||||
set $last_data_source = 0
|
||||
set $last_descriptor = 0
|
||||
|
||||
hbreak *0x{address['data_source_request']:x}
|
||||
commands
|
||||
silent
|
||||
set $request_data_source = $rcx
|
||||
set $request_response = $rcx - 0x50
|
||||
python import time; print("RESPTRACE epoch_ns=%d mono_ns=%d DATA_SOURCE_REQUEST" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d response=%p data_source=%p request=%p ready_before=%#x callback_adapter=%p callback_context=%p callback_target=%p\\n", $_thread, $request_response, $request_data_source, $rdx, *(unsigned char*)($request_data_source+0x38), *(void**)($request_response+0x90), *(void**)($request_response+0x98), *(void**)($request_response+0xa0)
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['network_response']:x}
|
||||
commands
|
||||
silent
|
||||
set $last_central = $rcx
|
||||
set $last_response = $rcx + 0x{CREATE_RESPONSE_OFFSET:x}
|
||||
set $last_data_source = $rcx + 0x{CREATE_DATA_SOURCE_OFFSET:x}
|
||||
set $last_descriptor = $rdx
|
||||
python import time; print("RESPTRACE epoch_ns=%d mono_ns=%d NETWORK_RESPONSE" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
if $rdx == 0
|
||||
printf "thread=%d central=%p descriptor=(nil) status=UNKNOWN wire_payload=(nil) response=%p data_source=%p ready=%#x active_adapter=%p active_context=%p active_target=%p\\n", $_thread, $last_central, $last_response, $last_data_source, *(unsigned char*)($last_central+0x{CREATE_READY_OFFSET:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET + 8:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET + 16:x})
|
||||
else
|
||||
printf "thread=%d central=%p descriptor=%p status=%#x wire_payload=%p response=%p data_source=%p ready=%#x active_adapter=%p active_context=%p active_target=%p\\n", $_thread, $last_central, $rdx, *(unsigned int*)($rdx+0x1c), *(void**)($rdx+0x28), $last_response, $last_data_source, *(unsigned char*)($last_central+0x{CREATE_READY_OFFSET:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET + 8:x}), *(void**)($last_central+0x{ACTIVE_CALLBACK_OFFSET + 16:x})
|
||||
end
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['create_complete']:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("RESPTRACE epoch_ns=%d mono_ns=%d CREATE_COMPLETE" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
if $rdx == 0
|
||||
printf "thread=%d response=%p data_source=%p ready_before=%#x descriptor=(nil) status=UNKNOWN last_response=%p same_response=%d\\n", $_thread, $rcx, $rcx+0x50, *(unsigned char*)($rcx+0x88), $last_response, $rcx==$last_response
|
||||
else
|
||||
printf "thread=%d response=%p data_source=%p ready_before=%#x descriptor=%p status=%#x last_response=%p same_response=%d\\n", $_thread, $rcx, $rcx+0x50, *(unsigned char*)($rcx+0x88), $rdx, *(unsigned int*)($rdx+0x1c), $last_response, $rcx==$last_response
|
||||
end
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['ui_dispatch']:x}
|
||||
condition 4 $r8d == 0x{transition.FUT_CREATE_MATCH_DP:x} || $r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("RESPTRACE epoch_ns=%d mono_ns=%d UI_DISPATCH" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
if $last_response == 0
|
||||
printf "thread=%d provider=%#x payload=%p ui_manager=%p last_response=(nil) ready=UNKNOWN\\n", $_thread, $r8d, $rdx, $rcx
|
||||
else
|
||||
printf "thread=%d provider=%#x payload=%p ui_manager=%p last_response=%p data_source=%p ready=%#x descriptor=%p\\n", $_thread, $r8d, $rdx, $rcx, $last_response, $last_data_source, *(unsigned char*)($last_response+0x88), $last_descriptor
|
||||
end
|
||||
bt 10
|
||||
continue
|
||||
end
|
||||
|
||||
printf "RESPTRACE ARMED pid={pid} data_source_request=0x{address['data_source_request']:x} network_response=0x{address['network_response']:x} create_complete=0x{address['create_complete']:x} ui_dispatch=0x{address['ui_dispatch']:x}\\n"
|
||||
continue
|
||||
"""
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
address = trace_addresses(0x180000000, 0x140000000)
|
||||
assert address == {
|
||||
"data_source_request": 0x180120270,
|
||||
"network_response": 0x180114D90,
|
||||
"create_complete": 0x180120000,
|
||||
"ui_dispatch": 0x1480D1070,
|
||||
}
|
||||
script = build_gdb_script(
|
||||
38872, 0x180000000, 0x140000000, "/tmp/response-lifecycle.log"
|
||||
)
|
||||
assert script.count("hbreak *") == 4
|
||||
assert "DATA_SOURCE_REQUEST" in script
|
||||
assert "NETWORK_RESPONSE" in script
|
||||
assert "CREATE_COMPLETE" in script
|
||||
assert "UI_DISPATCH" in script
|
||||
assert f"$r8d == 0x{transition.FUT_CREATE_MATCH_DP:x}" in script
|
||||
assert f"$r8d == 0x{transition.FUT_GET_MATCH_KITS_DP:x}" in script
|
||||
assert "set *(" not in script
|
||||
print("match_provider_producer_trace selftest: PASS")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pid", nargs="?", type=int)
|
||||
parser.add_argument("--output")
|
||||
parser.add_argument("--print-script", action="store_true")
|
||||
parser.add_argument("--selftest", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return 0
|
||||
|
||||
pid = args.pid or transition.find_pid()
|
||||
if not pid:
|
||||
print("FIFA17.exe not found", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
cards_base, cards_path = transition.cards_mapping(pid)
|
||||
transition.validate_cards(cards_path)
|
||||
fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
|
||||
advance.validate_file(fifa_path, advance.PINNED_FIFA_SHA256, advance.FIFA_MODULE)
|
||||
output = args.output or f"/tmp/fifa17-match-response-lifecycle-{pid}.log"
|
||||
script = build_gdb_script(pid, cards_base, fifa_base, output)
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.print_script:
|
||||
print(script, end="")
|
||||
return 0
|
||||
if not shutil.which("gdb"):
|
||||
print("gdb not found", file=sys.stderr)
|
||||
return 2
|
||||
script_path = f"/tmp/fifa17-match-response-lifecycle-{pid}.gdb"
|
||||
with open(script_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(script)
|
||||
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
|
||||
return 127
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+233
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trace the FIFA17 create-match publish boundary with hardware breakpoints.
|
||||
|
||||
The tracer covers the client-local path after POST /match:
|
||||
|
||||
response callback -> deserializer -> controller event 0x7546
|
||||
-> FUT_CREATE_MATCH_DP 0x7563
|
||||
|
||||
FUT_GET_MATCH_KITS_DP 0x7565 is captured as the positive control through the
|
||||
same native dispatcher. The generated GDB program uses only hardware-assisted
|
||||
execution breakpoints. It never writes client memory and never drives game
|
||||
input.
|
||||
|
||||
match_transition_trace.py [pid] [--output PATH]
|
||||
match_transition_trace.py --print-script [pid]
|
||||
match_transition_trace.py --selftest
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
CARDS_MODULE = "CardsDLL_Win64_retail.dll"
|
||||
PINNED_CARDS_SHA256 = "4706a881ae1fc7b5769fd810b25a868d29d2b16a8e65a7513436327ef645573c"
|
||||
RESPONSE_CALLBACK_RVA = 0x114D90
|
||||
DESERIALIZE_SUCCESS_RVA = 0x118940
|
||||
CREATE_MATCH_CONTROLLER_RVA = 0xBF950
|
||||
PROVIDER_DISPATCH_RVA = 0x1A4CD0
|
||||
CREATE_MATCH_CONTROLLER_EVENT = 0x7546
|
||||
FUT_CREATE_MATCH_DP = 0x7563
|
||||
FUT_GET_MATCH_KITS_DP = 0x7565
|
||||
|
||||
|
||||
def find_pid() -> int | None:
|
||||
found = []
|
||||
for directory in glob.glob("/proc/[0-9]*"):
|
||||
try:
|
||||
with open(os.path.join(directory, "comm"), encoding="utf-8") as handle:
|
||||
if handle.read().strip() != "FIFA17.exe":
|
||||
continue
|
||||
pid = int(os.path.basename(directory))
|
||||
with open(os.path.join(directory, "statm"), encoding="utf-8") as handle:
|
||||
resident_pages = int(handle.read().split()[1])
|
||||
found.append((resident_pages, pid))
|
||||
except (OSError, ValueError, IndexError):
|
||||
continue
|
||||
return max(found)[1] if found else None
|
||||
|
||||
|
||||
def parse_cards_mapping(lines) -> tuple[int, str]:
|
||||
for line in lines:
|
||||
fields = line.split(maxsplit=5)
|
||||
path = fields[5].rstrip() if len(fields) == 6 else ""
|
||||
if not path.endswith(CARDS_MODULE):
|
||||
continue
|
||||
start = int(fields[0].split("-", 1)[0], 16)
|
||||
return start, path
|
||||
raise RuntimeError(f"{CARDS_MODULE} is not mapped")
|
||||
|
||||
|
||||
def cards_mapping(pid: int) -> tuple[int, str]:
|
||||
with open(f"/proc/{pid}/maps", encoding="utf-8") as handle:
|
||||
try:
|
||||
return parse_cards_mapping(handle)
|
||||
except RuntimeError as error:
|
||||
raise RuntimeError(f"{error} in PID {pid}") from error
|
||||
|
||||
|
||||
def sha256_file(path: str) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def validate_cards(path: str) -> None:
|
||||
actual = sha256_file(path)
|
||||
if actual != PINNED_CARDS_SHA256:
|
||||
raise RuntimeError(
|
||||
f"unsupported {CARDS_MODULE}: sha256={actual}; expected={PINNED_CARDS_SHA256}"
|
||||
)
|
||||
|
||||
|
||||
def trace_addresses(base: int) -> dict[str, int]:
|
||||
return {
|
||||
"response": base + RESPONSE_CALLBACK_RVA,
|
||||
"deserialize": base + DESERIALIZE_SUCCESS_RVA,
|
||||
"controller": base + CREATE_MATCH_CONTROLLER_RVA,
|
||||
"provider": base + PROVIDER_DISPATCH_RVA,
|
||||
}
|
||||
|
||||
|
||||
def build_gdb_script(pid: int, base: int, output: str) -> str:
|
||||
if any(character in output for character in "\n\r"):
|
||||
raise ValueError("output path cannot contain a newline")
|
||||
address = trace_addresses(base)
|
||||
return f"""set pagination off
|
||||
set confirm off
|
||||
set print thread-events off
|
||||
set breakpoint always-inserted on
|
||||
set logging file {output}
|
||||
set logging overwrite on
|
||||
set logging redirect off
|
||||
set logging enabled on
|
||||
handle SIGSEGV nostop noprint pass
|
||||
handle SIGILL nostop noprint pass
|
||||
handle SIGFPE nostop noprint pass
|
||||
handle SIGPIPE nostop noprint pass
|
||||
handle SIGALRM nostop noprint pass
|
||||
handle SIGUSR1 nostop noprint pass
|
||||
handle SIGUSR2 nostop noprint pass
|
||||
|
||||
attach {pid}
|
||||
|
||||
hbreak *0x{address['response']:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("HWTRACE epoch_ns=%d mono_ns=%d T3_RESPONSE_CALLBACK" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
if $rdx != 0
|
||||
printf "thread=%d manager=%p status_obj=%p status=%u wire_payload=%p caller=%p\\n", $_thread, $rcx, $rdx, *(unsigned int*)($rdx+0x1c), *(void**)($rdx+0x28), *(void**)$rsp
|
||||
else
|
||||
printf "thread=%d manager=%p status_obj=0 caller=%p\\n", $_thread, $rcx, *(void**)$rsp
|
||||
end
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['deserialize']:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("HWTRACE epoch_ns=%d mono_ns=%d T4_DESERIALIZE_SUCCESS" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d manager=%p payload=%p caller=%p\\n", $_thread, $rcx, $rdx, *(void**)$rsp
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['controller']:x}
|
||||
condition 3 $edx == 0x{CREATE_MATCH_CONTROLLER_EVENT:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("HWTRACE epoch_ns=%d mono_ns=%d T5_CREATE_MATCH_CONTROLLER" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d controller_subobject=%p event=%#x caller=%p\\n", $_thread, $rcx, $edx, *(void**)$rsp
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['provider']:x}
|
||||
condition 4 $edx == 0x{FUT_CREATE_MATCH_DP:x} || $edx == 0x{FUT_GET_MATCH_KITS_DP:x}
|
||||
commands
|
||||
silent
|
||||
python import time; print("HWTRACE epoch_ns=%d mono_ns=%d T6_PROVIDER_DISPATCH" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d controller=%p provider=%#x payload=%p callback=%p\\n", $_thread, $rcx, $edx, $r8, *(void**)$rsp
|
||||
continue
|
||||
end
|
||||
|
||||
printf "HWTRACE ARMED pid={pid} response=0x{address['response']:x} deserialize=0x{address['deserialize']:x} controller=0x{address['controller']:x} provider=0x{address['provider']:x}\\n"
|
||||
continue
|
||||
"""
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
base = 0x180000000
|
||||
address = trace_addresses(base)
|
||||
assert address == {
|
||||
"response": 0x180114D90,
|
||||
"deserialize": 0x180118940,
|
||||
"controller": 0x1800BF950,
|
||||
"provider": 0x1801A4CD0,
|
||||
}
|
||||
mapping = parse_cards_mapping(
|
||||
[
|
||||
"6ffffc0f0000-6ffffc0f1000 r--p 00000000 00:37 2941670 "
|
||||
"/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll\n"
|
||||
]
|
||||
)
|
||||
assert mapping == (
|
||||
0x6FFFFC0F0000,
|
||||
"/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll",
|
||||
)
|
||||
script = build_gdb_script(25718, base, "/tmp/match-transition.log")
|
||||
assert script.count("hbreak *") == 4
|
||||
assert f"$edx == 0x{CREATE_MATCH_CONTROLLER_EVENT:x}" in script
|
||||
assert f"$edx == 0x{FUT_CREATE_MATCH_DP:x}" in script
|
||||
assert f"$edx == 0x{FUT_GET_MATCH_KITS_DP:x}" in script
|
||||
assert "T3_RESPONSE_CALLBACK" in script
|
||||
assert "T4_DESERIALIZE_SUCCESS" in script
|
||||
assert "T5_CREATE_MATCH_CONTROLLER" in script
|
||||
assert "T6_PROVIDER_DISPATCH" in script
|
||||
assert "set *(" not in script
|
||||
print("match_transition_trace selftest: PASS")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pid", nargs="?", type=int)
|
||||
parser.add_argument("--output")
|
||||
parser.add_argument("--print-script", action="store_true")
|
||||
parser.add_argument("--selftest", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return 0
|
||||
|
||||
pid = args.pid or find_pid()
|
||||
if not pid:
|
||||
print("FIFA17.exe not found", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
base, cards_path = cards_mapping(pid)
|
||||
validate_cards(cards_path)
|
||||
output = args.output or f"/tmp/fifa17-match-transition-{pid}.log"
|
||||
script = build_gdb_script(pid, base, output)
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.print_script:
|
||||
print(script, end="")
|
||||
return 0
|
||||
if not shutil.which("gdb"):
|
||||
print("gdb not found", file=sys.stderr)
|
||||
return 2
|
||||
script_path = f"/tmp/fifa17-match-transition-{pid}.gdb"
|
||||
with open(script_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(script)
|
||||
os.execvp("gdb", ["gdb", "-q", "-nx", "-x", script_path])
|
||||
return 127
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+306
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only dynamic locator for FIFA17 Offline Seasons match state.
|
||||
|
||||
Never relies on heap addresses or allocator handles. It identifies:
|
||||
|
||||
* the 10 x 16-byte parsed fixture array from its complete wire-derived record
|
||||
sequence (teamId/difficulty/roundId/rewardMult/coins),
|
||||
* match-team records from the corrected invariant prefix (11,7,0,0,76), never
|
||||
from the transient +0x18 handle,
|
||||
* the match-config team pair from structural fields around it, not its team ids.
|
||||
|
||||
offline_match_locator.py [pid] [--fixture-index 0] [--json]
|
||||
offline_match_locator.py --selftest
|
||||
|
||||
READ-ONLY: /proc/<pid>/mem is opened 'rb'. No debugger and no game input.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
DEFAULT_TEAMS = (73, 240, 241, 243, 73, 240, 241, 243, 73, 240)
|
||||
MATCH_HEADER = struct.pack("<5i", 11, 7, 0, 0, 76)
|
||||
PARTICIPANT_PREFIX = struct.pack("<8i", -1, -2, -1, -2, -1, -2, -1, -2)
|
||||
F01 = 0x3DCCCCCD
|
||||
|
||||
|
||||
@dataclass
|
||||
class Fixture:
|
||||
address: int
|
||||
selected_address: int
|
||||
selected_index: int
|
||||
selected_team_id: int
|
||||
records: list[dict[str, int]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchTeam:
|
||||
address: int
|
||||
team_id: int
|
||||
marker_18: int
|
||||
marker_1c: int
|
||||
xi: list[int]
|
||||
substitutes: list[int]
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchConfig:
|
||||
pair_address: int
|
||||
team_id_0: int
|
||||
team_id_1: int
|
||||
player_count_0: int
|
||||
player_count_1: int
|
||||
|
||||
|
||||
def find_pids() -> list[int]:
|
||||
"""All live FIFA17.exe processes, largest resident set first.
|
||||
|
||||
The UMU/Proton launch chain briefly creates a small process with the same
|
||||
comm before the real game. Returning the first /proc glob match attached
|
||||
the trace supervisor to that short-lived process and missed the match.
|
||||
"""
|
||||
found = []
|
||||
for directory in glob.glob("/proc/[0-9]*"):
|
||||
try:
|
||||
with open(os.path.join(directory, "comm")) as handle:
|
||||
if handle.read().strip() != "FIFA17.exe":
|
||||
continue
|
||||
pid = int(os.path.basename(directory))
|
||||
with open(os.path.join(directory, "statm")) as handle:
|
||||
resident_pages = int(handle.read().split()[1])
|
||||
found.append((resident_pages, pid))
|
||||
except (OSError, ValueError, IndexError):
|
||||
continue
|
||||
return [pid for _resident, pid in sorted(found, reverse=True)]
|
||||
|
||||
|
||||
def find_pid() -> int | None:
|
||||
pids = find_pids()
|
||||
return pids[0] if pids else None
|
||||
|
||||
|
||||
def fixture_bytes(teams: tuple[int, ...] = DEFAULT_TEAMS) -> bytes:
|
||||
return b"".join(
|
||||
struct.pack("<iBBHii", team_id, 1, round_id, 0, 1, 400)
|
||||
for round_id, team_id in enumerate(teams)
|
||||
)
|
||||
|
||||
|
||||
def readable_regions(pid: int, *, writable_anon_only: bool = False):
|
||||
with open(f"/proc/{pid}/maps") as maps:
|
||||
for line in maps:
|
||||
match = re.match(
|
||||
r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)",
|
||||
line,
|
||||
)
|
||||
if not match:
|
||||
continue
|
||||
lo, hi = int(match.group(1), 16), int(match.group(2), 16)
|
||||
perms, path = match.group(3), match.group(4).strip()
|
||||
if perms[0] != "r" or path.startswith(("/dev", "/memfd")):
|
||||
continue
|
||||
if hi - lo > 512 * 1024 * 1024:
|
||||
continue
|
||||
if writable_anon_only and (perms[1] != "w" or path):
|
||||
continue
|
||||
yield lo, hi, perms, path
|
||||
|
||||
|
||||
def _i32(buf: bytes, offset: int) -> int:
|
||||
return struct.unpack_from("<i", buf, offset)[0]
|
||||
|
||||
|
||||
def scan_fixture_buffer(buf: bytes, base: int, selected_index: int) -> list[Fixture]:
|
||||
pattern = fixture_bytes()
|
||||
found = []
|
||||
offset = buf.find(pattern)
|
||||
while offset >= 0:
|
||||
records = []
|
||||
for round_id in range(len(DEFAULT_TEAMS)):
|
||||
at = offset + round_id * 16
|
||||
team_id, difficulty, parsed_round, _pad, reward_mult, coins = struct.unpack_from(
|
||||
"<iBBHii", buf, at
|
||||
)
|
||||
records.append(
|
||||
{
|
||||
"team_id": team_id,
|
||||
"difficulty": difficulty,
|
||||
"round_id": parsed_round,
|
||||
"reward_mult": reward_mult,
|
||||
"coins": coins,
|
||||
}
|
||||
)
|
||||
found.append(
|
||||
Fixture(
|
||||
address=base + offset,
|
||||
selected_address=base + offset + selected_index * 16,
|
||||
selected_index=selected_index,
|
||||
selected_team_id=records[selected_index]["team_id"],
|
||||
records=records,
|
||||
)
|
||||
)
|
||||
offset = buf.find(pattern, offset + 4)
|
||||
return found
|
||||
|
||||
|
||||
def scan_match_team_buffer(buf: bytes, base: int) -> list[MatchTeam]:
|
||||
found = []
|
||||
offset = buf.find(MATCH_HEADER)
|
||||
while offset >= 0:
|
||||
if offset + 0x7C <= len(buf):
|
||||
found.append(
|
||||
MatchTeam(
|
||||
address=base + offset,
|
||||
team_id=_i32(buf, offset + 0x14),
|
||||
marker_18=_i32(buf, offset + 0x18),
|
||||
marker_1c=_i32(buf, offset + 0x1C),
|
||||
xi=list(struct.unpack_from("<11i", buf, offset + 0x20)),
|
||||
substitutes=list(struct.unpack_from("<12i", buf, offset + 0x4C)),
|
||||
)
|
||||
)
|
||||
offset = buf.find(MATCH_HEADER, offset + 4)
|
||||
return found
|
||||
|
||||
|
||||
def _valid_config(buf: bytes, pair: int) -> bool:
|
||||
required = pair + 0x50
|
||||
if pair < 0 or required > len(buf):
|
||||
return False
|
||||
return (
|
||||
tuple(struct.unpack_from("<4I", buf, pair + 0x1C)) == (F01, F01, F01, F01)
|
||||
and _i32(buf, pair + 0x38) == 11
|
||||
and _i32(buf, pair + 0x3C) == 11
|
||||
and _i32(buf, pair + 0x40) == 0
|
||||
and _i32(buf, pair + 0x44) == 5
|
||||
)
|
||||
|
||||
|
||||
def scan_match_config_buffer(buf: bytes, base: int) -> list[MatchConfig]:
|
||||
found = []
|
||||
offset = buf.find(PARTICIPANT_PREFIX)
|
||||
while offset >= 0:
|
||||
pair = offset + len(PARTICIPANT_PREFIX)
|
||||
if _valid_config(buf, pair):
|
||||
found.append(
|
||||
MatchConfig(
|
||||
pair_address=base + pair,
|
||||
team_id_0=_i32(buf, pair),
|
||||
team_id_1=_i32(buf, pair + 4),
|
||||
player_count_0=_i32(buf, pair + 0x38),
|
||||
player_count_1=_i32(buf, pair + 0x3C),
|
||||
)
|
||||
)
|
||||
offset = buf.find(PARTICIPANT_PREFIX, offset + 4)
|
||||
return found
|
||||
|
||||
|
||||
def scan_process(
|
||||
pid: int,
|
||||
selected_index: int,
|
||||
*,
|
||||
include_fixture: bool = True,
|
||||
writable_anon_only: bool = False,
|
||||
) -> dict[str, list]:
|
||||
result: dict[str, list] = {"fixtures": [], "match_teams": [], "match_configs": []}
|
||||
with open(f"/proc/{pid}/mem", "rb", 0) as memory:
|
||||
for lo, hi, _perms, _path in readable_regions(
|
||||
pid, writable_anon_only=writable_anon_only
|
||||
):
|
||||
try:
|
||||
memory.seek(lo)
|
||||
buf = memory.read(hi - lo)
|
||||
except (OSError, ValueError, OverflowError):
|
||||
continue
|
||||
if include_fixture:
|
||||
result["fixtures"].extend(scan_fixture_buffer(buf, lo, selected_index))
|
||||
result["match_teams"].extend(scan_match_team_buffer(buf, lo))
|
||||
result["match_configs"].extend(scan_match_config_buffer(buf, lo))
|
||||
return result
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
fixture = fixture_bytes()
|
||||
team = bytearray(0x7C)
|
||||
team[:20] = MATCH_HEADER
|
||||
struct.pack_into("<iii", team, 0x14, 130000, 0x54001, 0x54002)
|
||||
struct.pack_into("<11i", team, 0x20, *range(11))
|
||||
struct.pack_into("<12i", team, 0x4C, *range(20, 32))
|
||||
config = bytearray(0x20 + 0x50)
|
||||
config[:0x20] = PARTICIPANT_PREFIX
|
||||
pair = 0x20
|
||||
struct.pack_into("<ii", config, pair, 130000, 130000)
|
||||
struct.pack_into("<4I", config, pair + 0x1C, F01, F01, F01, F01)
|
||||
struct.pack_into("<iiii", config, pair + 0x38, 11, 11, 0, 5)
|
||||
buf = b"X" * 32 + fixture + b"Y" * 32 + team + b"Z" * 32 + config
|
||||
fixtures = scan_fixture_buffer(buf, 0x1000, 0)
|
||||
teams = scan_match_team_buffer(buf, 0x1000)
|
||||
configs = scan_match_config_buffer(buf, 0x1000)
|
||||
assert len(fixtures) == 1 and fixtures[0].selected_team_id == 73
|
||||
assert len(teams) == 1 and teams[0].team_id == 130000
|
||||
assert len(configs) == 1 and configs[0].team_id_1 == 130000
|
||||
# The transient handle is never part of the anchor.
|
||||
struct.pack_into("<i", team, 0x18, -1)
|
||||
assert len(scan_match_team_buffer(bytes(team), 0)) == 1
|
||||
print("offline_match_locator selftest: PASS")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pid", nargs="?", type=int)
|
||||
parser.add_argument("--fixture-index", type=int, default=0)
|
||||
parser.add_argument("--json", action="store_true")
|
||||
parser.add_argument("--selftest", action="store_true")
|
||||
parser.add_argument("--writable-anon-only", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return 0
|
||||
pid = args.pid or find_pid()
|
||||
if not pid:
|
||||
print("FIFA17.exe not found", file=sys.stderr)
|
||||
return 2
|
||||
if not 0 <= args.fixture_index < len(DEFAULT_TEAMS):
|
||||
print("--fixture-index must be 0..9", file=sys.stderr)
|
||||
return 2
|
||||
result = scan_process(
|
||||
pid,
|
||||
args.fixture_index,
|
||||
writable_anon_only=args.writable_anon_only,
|
||||
)
|
||||
serial = {key: [asdict(value) for value in values] for key, values in result.items()}
|
||||
serial["pid"] = pid
|
||||
if args.json:
|
||||
print(json.dumps(serial, sort_keys=True))
|
||||
return 0
|
||||
print(f"pid={pid}")
|
||||
for fixture in result["fixtures"]:
|
||||
print(
|
||||
f"fixture @0x{fixture.address:x}; selected index {fixture.selected_index} "
|
||||
f"@0x{fixture.selected_address:x} teamId={fixture.selected_team_id}"
|
||||
)
|
||||
for config in result["match_configs"]:
|
||||
print(
|
||||
f"match config pair @0x{config.pair_address:x}: "
|
||||
f"[{config.team_id_0}, {config.team_id_1}]"
|
||||
)
|
||||
for team in result["match_teams"]:
|
||||
print(
|
||||
f"match team @0x{team.address:x}: teamId={team.team_id} "
|
||||
f"handles=[{team.marker_18}, {team.marker_1c}]"
|
||||
)
|
||||
print(
|
||||
f"counts: fixtures={len(result['fixtures'])} "
|
||||
f"configs={len(result['match_configs'])} teams={len(result['match_teams'])}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+394
@@ -0,0 +1,394 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trace FIFA17's PMA ScenarioModeStart-to-event-5 producer chain.
|
||||
|
||||
The generated GDB program uses hardware breakpoints, only reads registers and
|
||||
client memory, logs, and continues. Breakpoints are rotated so no more than four
|
||||
are enabled. It never calls client functions, writes client memory, emits an
|
||||
event, or drives input.
|
||||
|
||||
pma_producer_trace.py [pid] [--variant mode0|alternate] [--output PATH]
|
||||
pma_producer_trace.py --selftest
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import match_advance_trace as advance
|
||||
import match_transition_trace as transition
|
||||
|
||||
VARIANTS = {
|
||||
"mode0": {
|
||||
"scenario_rva": 0x07B1C190,
|
||||
"writer_rva": 0x07B1C26B,
|
||||
"register_rva": 0x07B1C282,
|
||||
"writer_context": "$rsi",
|
||||
"writer_async_requested": "1",
|
||||
"arm_condition": "1",
|
||||
},
|
||||
"alternate": {
|
||||
"scenario_rva": 0x07B1C050,
|
||||
"writer_rva": 0x07B1C12F,
|
||||
"register_rva": 0x07B1C146,
|
||||
"writer_context": "$rbp",
|
||||
"writer_async_requested": "$sil",
|
||||
"arm_condition": "$tracked_ctx != 0 && $rcx == $tracked_ctx",
|
||||
},
|
||||
}
|
||||
PMA_COMPLETION_ARM_RVA = 0x07B1AE60
|
||||
PMA_COMPLETION_ARM_WRITER_RVA = 0x07B1AF33
|
||||
ASYNC_COMPLETION_RVA = 0x07B046C0
|
||||
CALLBACK_DISPATCHER_RVA = 0x07AC87B0
|
||||
PMA_INSTRUCTIONS_HANDLER_RVA = 0x07AC91E0
|
||||
GAMEPLAY_GLOBAL_RVA = 0x04BFB910
|
||||
PMA_INSTRUCTIONS_VTABLE_RVA = 0x03AF2750
|
||||
|
||||
|
||||
def addresses(base: int, variant: str) -> dict[str, int]:
|
||||
config = VARIANTS[variant]
|
||||
return {
|
||||
"scenario": base + config["scenario_rva"],
|
||||
"writer": base + config["writer_rva"],
|
||||
"register": base + config["register_rva"],
|
||||
"arm": base + PMA_COMPLETION_ARM_RVA,
|
||||
"arm_writer": base + PMA_COMPLETION_ARM_WRITER_RVA,
|
||||
"completion": base + ASYNC_COMPLETION_RVA,
|
||||
"dispatcher": base + CALLBACK_DISPATCHER_RVA,
|
||||
"instructions": base + PMA_INSTRUCTIONS_HANDLER_RVA,
|
||||
"gameplay_global": base + GAMEPLAY_GLOBAL_RVA,
|
||||
"instructions_vtable": base + PMA_INSTRUCTIONS_VTABLE_RVA,
|
||||
}
|
||||
|
||||
|
||||
def gdb_prelude(pid: int, output: str) -> str:
|
||||
if any(character in output for character in "\n\r"):
|
||||
raise ValueError("output path cannot contain a newline")
|
||||
return f"""set pagination off
|
||||
set confirm off
|
||||
set print thread-events off
|
||||
set breakpoint always-inserted off
|
||||
set logging file {output}
|
||||
set logging overwrite on
|
||||
set logging redirect off
|
||||
set logging enabled on
|
||||
handle SIGSEGV nostop noprint pass
|
||||
handle SIGILL nostop noprint pass
|
||||
handle SIGFPE nostop noprint pass
|
||||
handle SIGPIPE nostop noprint pass
|
||||
handle SIGALRM nostop noprint pass
|
||||
handle SIGUSR1 nostop noprint pass
|
||||
handle SIGUSR2 nostop noprint pass
|
||||
|
||||
attach {pid}
|
||||
"""
|
||||
|
||||
|
||||
def build_script(pid: int, fifa_base: int, output: str, variant: str) -> str:
|
||||
address = addresses(fifa_base, variant)
|
||||
config = VARIANTS[variant]
|
||||
return (
|
||||
gdb_prelude(pid, output)
|
||||
+ f"""define snapshot_pma_context
|
||||
set $snap_ctx = $arg0
|
||||
set $snap_flag40 = -1
|
||||
set $snap_callback_vtable = 0
|
||||
set $snap_callback_owner = 0
|
||||
set $snap_dispatcher = 0
|
||||
set $snap_dispatcher_vtable = 0
|
||||
set $snap_pma = 0
|
||||
set $snap_pma_flag18 = -1
|
||||
set $snap_pma_parent = 0
|
||||
set $snap_pma_machine = 0
|
||||
set $snap_pma_current = 0
|
||||
if $snap_ctx != 0
|
||||
set $snap_flag40 = *(unsigned char*)($snap_ctx+0x40)
|
||||
set $snap_callback_vtable = *(void**)($snap_ctx+0x48)
|
||||
set $snap_callback_owner = *(void**)($snap_ctx+0x78)
|
||||
set $snap_dispatcher = $snap_ctx+0x80
|
||||
set $snap_dispatcher_vtable = *(void**)$snap_dispatcher
|
||||
set $snap_sentinel = $snap_ctx+0x88
|
||||
set $snap_node = *(void**)$snap_sentinel
|
||||
set $snap_scan = 0
|
||||
while $snap_node != 0 && $snap_node != $snap_sentinel && $snap_scan < 8
|
||||
set $snap_candidate = *(void**)($snap_node+0x10)
|
||||
if $snap_candidate != 0
|
||||
if *(void**)$snap_candidate == 0x{address['instructions_vtable']:x}
|
||||
set $snap_pma = $snap_candidate
|
||||
end
|
||||
end
|
||||
set $snap_node = *(void**)$snap_node
|
||||
set $snap_scan = $snap_scan+1
|
||||
end
|
||||
if $snap_pma != 0
|
||||
set $snap_pma_flag18 = *(unsigned char*)($snap_pma+0x18)
|
||||
set $snap_pma_parent = *(void**)($snap_pma+0x8)
|
||||
if $snap_pma_parent != 0
|
||||
set $snap_pma_machine = *(void**)($snap_pma_parent+0x8)
|
||||
end
|
||||
if $snap_pma_machine != 0
|
||||
set $snap_pma_current = *(void**)($snap_pma_machine+0x10)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
define snapshot_gameplay
|
||||
set $snap_gameplay_global = *(void**)0x{address['gameplay_global']:x}
|
||||
set $snap_listener_manager = 0
|
||||
set $snap_listener_table = 0
|
||||
set $snap_listener_index = -1
|
||||
set $snap_free_roam = 0
|
||||
set $snap_free_roam_state = -1
|
||||
set $snap_free_roam_111 = -1
|
||||
set $snap_free_roam_112 = -1
|
||||
set $snap_free_roam_124 = -1
|
||||
set $snap_selected = 0
|
||||
set $snap_selected_vtable = 0
|
||||
set $snap_selected_mode = -1
|
||||
if $snap_gameplay_global != 0
|
||||
set $snap_listener_manager = *(void**)($snap_gameplay_global+0x58)
|
||||
end
|
||||
if $snap_listener_manager != 0
|
||||
set $snap_listener_table = *(void**)$snap_listener_manager
|
||||
end
|
||||
if $snap_listener_table != 0
|
||||
set $snap_free_roam = *(void**)$snap_listener_table
|
||||
set $snap_listener_index = *(int*)($snap_listener_table+0x20)
|
||||
if $snap_listener_index >= 0 && $snap_listener_index < 3
|
||||
set $snap_selected = *(void**)($snap_listener_table+$snap_listener_index*8)
|
||||
end
|
||||
end
|
||||
if $snap_free_roam != 0
|
||||
set $snap_free_roam_state = *(int*)($snap_free_roam+0x30)
|
||||
set $snap_free_roam_111 = *(unsigned char*)($snap_free_roam+0x111)
|
||||
set $snap_free_roam_112 = *(unsigned char*)($snap_free_roam+0x112)
|
||||
set $snap_free_roam_124 = *(int*)($snap_free_roam+0x124)
|
||||
end
|
||||
if $snap_selected != 0
|
||||
set $snap_selected_vtable = *(void**)$snap_selected
|
||||
set $snap_selected_mode = *(int*)($snap_selected+0x18)
|
||||
end
|
||||
end
|
||||
set $tracked_ctx = 0
|
||||
|
||||
|
||||
hbreak *0x{address['scenario']:x}
|
||||
commands
|
||||
silent
|
||||
set $ctx = $rcx
|
||||
set $tracked_ctx = $ctx
|
||||
snapshot_pma_context $ctx
|
||||
snapshot_gameplay
|
||||
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d SCENARIO_MODE_START" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d function=%p caller_return=%p ctx=%p ctx_vtable=%p arg_descriptor=%p arg_scenario=%p async_requested=%d flag40=%d callback_vtable=%p callback_owner=%p dispatcher=%p dispatcher_vtable=%p pma=%p pma_flag18=%d pma_parent=%p pma_machine=%p pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, *(void**)$ctx, $rdx, $r8, $r9b, $snap_flag40, $snap_callback_vtable, $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable, $snap_pma, $snap_pma_flag18, $snap_pma_parent, $snap_pma_machine, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
disable 1
|
||||
enable 2
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['writer']:x}
|
||||
condition 2 $tracked_ctx != 0 && {config['writer_context']} == $tracked_ctx
|
||||
disable 2
|
||||
commands
|
||||
silent
|
||||
set $ctx = {config['writer_context']}
|
||||
snapshot_pma_context $ctx
|
||||
snapshot_gameplay
|
||||
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d CONTEXT_ARM_WRITER" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d instruction=%p caller_return=%p ctx=%p original_async_requested=%d flag40_before=%d callback_vtable=%p callback_owner_before=%p dispatcher=%p dispatcher_vtable=%p pma=%p pma_flag18=%d pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, {config['writer_async_requested']}, $snap_flag40, $snap_callback_vtable, $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable, $snap_pma, $snap_pma_flag18, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
disable 2
|
||||
enable 3
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['register']:x}
|
||||
condition 3 $tracked_ctx != 0 && $rdx == $tracked_ctx+0x48
|
||||
disable 3
|
||||
commands
|
||||
silent
|
||||
set $callback = $rdx
|
||||
set $ctx = $callback-0x48
|
||||
snapshot_pma_context $ctx
|
||||
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d ASYNC_REGISTER_CALL" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d callsite=%p caller_return=%p service=%p service_vtable=%p callback=%p callback_vtable=%p ctx=%p flag40=%d callback_owner=%p dispatcher=%p dispatcher_vtable=%p\\n", $_thread, $pc, *(void**)$rsp, $rcx, *(void**)$rcx, $callback, *(void**)$callback, $ctx, $snap_flag40, $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable
|
||||
disable 3
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['arm']:x}
|
||||
condition 4 {config['arm_condition']}
|
||||
commands
|
||||
silent
|
||||
set $ctx = $rcx
|
||||
if $tracked_ctx == 0
|
||||
set $tracked_ctx = $ctx
|
||||
end
|
||||
snapshot_pma_context $ctx
|
||||
snapshot_gameplay
|
||||
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d COMPLETION_ARM_ENTRY" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d function=%p caller_return=%p ctx=%p ctx_vtable=%p flag40_before=%d callback_vtable=%p callback_owner=%p dispatcher=%p dispatcher_vtable=%p result_source=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, *(void**)$ctx, $snap_flag40, $snap_callback_vtable, $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable, *(void**)($ctx+0xa8), $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
disable 4
|
||||
enable 5
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['arm_writer']:x}
|
||||
condition 5 $tracked_ctx != 0 && $rsi == $tracked_ctx
|
||||
disable 5
|
||||
commands
|
||||
silent
|
||||
set $ctx = $rsi
|
||||
snapshot_pma_context $ctx
|
||||
snapshot_gameplay
|
||||
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d COMPLETION_ARM_WRITER" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d instruction=%p caller_return=%p ctx=%p flag40_before=%d result_object=%p result_state28=%d callback_owner=%p dispatcher=%p dispatcher_vtable=%p pma=%p pma_flag18=%d pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $ctx, $snap_flag40, $rax, *(int*)($rax+0x28), $snap_callback_owner, $snap_dispatcher, $snap_dispatcher_vtable, $snap_pma, $snap_pma_flag18, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
disable 5
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['completion']:x}
|
||||
condition 6 $tracked_ctx != 0 && $rcx == $tracked_ctx+0x48
|
||||
commands
|
||||
silent
|
||||
set $callback = $rcx
|
||||
set $ctx = *(void**)($callback+0x30)
|
||||
snapshot_pma_context $ctx
|
||||
snapshot_gameplay
|
||||
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d ASYNC_COMPLETION" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d function=%p caller_return=%p callback=%p callback_vtable=%p ctx=%p callback_matches_ctx48=%d flag40_before=%d arg_rdx=%p arg_r8=%p arg_r9=%p dispatcher=%p dispatcher_vtable=%p pma=%p pma_flag18=%d pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $callback, *(void**)$callback, $ctx, $callback == $ctx+0x48, $snap_flag40, $rdx, $r8, $r9, $snap_dispatcher, $snap_dispatcher_vtable, $snap_pma, $snap_pma_flag18, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['dispatcher']:x}
|
||||
condition 7 $tracked_ctx != 0 && $rcx == $tracked_ctx+0x80 && $edx == 5
|
||||
commands
|
||||
silent
|
||||
set $ctx = $rcx-0x80
|
||||
snapshot_pma_context $ctx
|
||||
snapshot_gameplay
|
||||
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d DISPATCHER_EVENT_5" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d function=%p caller_return=%p dispatcher=%p event=%d arg_r8=%p arg_r9=%p ctx=%p flag40=%d callback_owner=%p pma=%p pma_flag18=%d pma_current=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $rcx, $edx, $r8, $r9, $ctx, $snap_flag40, $snap_callback_owner, $snap_pma, $snap_pma_flag18, $snap_pma_current, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
disable 7
|
||||
enable 8
|
||||
continue
|
||||
end
|
||||
|
||||
hbreak *0x{address['instructions']:x}
|
||||
disable 8
|
||||
commands
|
||||
silent
|
||||
set $listener = $rcx
|
||||
set $parent = *(void**)($listener+0x8)
|
||||
set $machine = 0
|
||||
set $current = 0
|
||||
if $parent != 0
|
||||
set $machine = *(void**)($parent+0x8)
|
||||
end
|
||||
if $machine != 0
|
||||
set $current = *(void**)($machine+0x10)
|
||||
end
|
||||
snapshot_gameplay
|
||||
python import time; print("PMAPRODUCER epoch_ns=%d mono_ns=%d INSTRUCTIONS_AFTER_EVENT_5" % (time.time_ns(), time.monotonic_ns()), end=" ")
|
||||
printf "thread=%d handler=%p caller_return=%p listener=%p listener_vtable=%p event=%d flag18=%d parent=%p machine=%p current=%p current_vtable=%p free_roam=%p free_state=%d free111=%d free112=%d free124=%d selected_index=%d selected=%p selected_vtable=%p selected_mode=%d\\n", $_thread, $pc, *(void**)$rsp, $listener, *(void**)$listener, $edx, *(unsigned char*)($listener+0x18), $parent, $machine, $current, $current ? *(void**)$current : 0, $snap_free_roam, $snap_free_roam_state, $snap_free_roam_111, $snap_free_roam_112, $snap_free_roam_124, $snap_listener_index, $snap_selected, $snap_selected_vtable, $snap_selected_mode
|
||||
disable 6
|
||||
continue
|
||||
end
|
||||
|
||||
printf "PMAPRODUCER ARMED pid={pid} variant={variant} scenario=0x{address['scenario']:x} writer=0x{address['writer']:x} register=0x{address['register']:x} arm=0x{address['arm']:x} arm_writer=0x{address['arm_writer']:x} completion=0x{address['completion']:x} dispatcher=0x{address['dispatcher']:x} instructions=0x{address['instructions']:x}\\n"
|
||||
continue
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def effective_environment(pid: int) -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
for item in Path(f"/proc/{pid}/environ").read_bytes().split(b"\0"):
|
||||
if not item.startswith(b"OPENFUT_FIFA17_"):
|
||||
continue
|
||||
key, _, value = item.decode("utf-8", errors="replace").partition("=")
|
||||
values[key] = value
|
||||
return values
|
||||
|
||||
|
||||
def selftest() -> None:
|
||||
mode0 = addresses(0x140000000, "mode0")
|
||||
alternate = addresses(0x140000000, "alternate")
|
||||
script = build_script(1234, 0x140000000, "/tmp/pma-producer.log", "mode0")
|
||||
assert mode0["scenario"] == 0x147B1C190
|
||||
assert mode0["writer"] == 0x147B1C26B
|
||||
assert mode0["register"] == 0x147B1C282
|
||||
assert alternate["scenario"] == 0x147B1C050
|
||||
assert alternate["writer"] == 0x147B1C12F
|
||||
assert alternate["register"] == 0x147B1C146
|
||||
assert mode0["completion"] == 0x147B046C0
|
||||
assert mode0["dispatcher"] == 0x147AC87B0
|
||||
assert mode0["instructions"] == 0x147AC91E0
|
||||
assert mode0["arm"] == 0x147B1AE60
|
||||
assert mode0["arm_writer"] == 0x147B1AF33
|
||||
assert script.count("hbreak *") == 8
|
||||
assert "condition 7 $tracked_ctx != 0" in script
|
||||
assert "disable 2" in script and "enable 2" in script
|
||||
assert "disable 3" in script and "enable 3" in script
|
||||
assert "disable 5" in script and "enable 5" in script
|
||||
assert "disable 8" in script and "enable 8" in script
|
||||
assert "set *(" not in script
|
||||
print("pma_producer_trace selftest: PASS")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pid", nargs="?", type=int)
|
||||
parser.add_argument("--output")
|
||||
parser.add_argument("--variant", choices=tuple(VARIANTS), default="mode0")
|
||||
parser.add_argument("--print-script", action="store_true")
|
||||
parser.add_argument("--selftest", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return 0
|
||||
|
||||
pid = args.pid or transition.find_pid()
|
||||
if not pid:
|
||||
print("FIFA17.exe not found", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
fifa_base, fifa_path = advance.module_mapping(pid, advance.FIFA_MODULE)
|
||||
advance.validate_file(
|
||||
fifa_path,
|
||||
advance.PINNED_FIFA_SHA256,
|
||||
advance.FIFA_MODULE,
|
||||
)
|
||||
cards_base, cards_path = transition.cards_mapping(pid)
|
||||
transition.validate_cards(cards_path)
|
||||
output = args.output or f"/tmp/fifa17-pma-producer-{args.variant}-{pid}.log"
|
||||
script = build_script(pid, fifa_base, output, args.variant)
|
||||
environment = effective_environment(pid)
|
||||
print(
|
||||
"PMAPRODUCER PREPARED "
|
||||
f"pid={pid} variant={args.variant} fifa_base={fifa_base:#x} cards_base={cards_base:#x} "
|
||||
f"team_compat={environment.get('OPENFUT_FIFA17_SEASON_TEAM_COMPAT', '<absent>')} "
|
||||
f"pma_fix={environment.get('OPENFUT_FIFA17_OFFLINE_SEASONS_PMA_FIX', '<absent>')}"
|
||||
)
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.print_script:
|
||||
print(script, end="")
|
||||
return 0
|
||||
if not shutil.which("gdb"):
|
||||
print("gdb not found", file=sys.stderr)
|
||||
return 2
|
||||
script_path = f"/tmp/fifa17-pma-producer-{args.variant}-{pid}.gdb"
|
||||
Path(script_path).write_text(script, encoding="utf-8")
|
||||
import os
|
||||
|
||||
os.execvp("gdb", ["gdb", "-q", "-nx", "-batch", "-x", script_path])
|
||||
return 127
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(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
+67
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Scan for FIFA17 match-team records by the invariant header prefix.
|
||||
|
||||
Anchors ONLY on (11,7,0,0,76) at +0x00..+0x10. Never filter on +0x18: it is a
|
||||
per-record marker whose value varies between sessions (-1 on 2026-08-24,
|
||||
344065/344064 on 2026-08-25), and filtering on it produced a false negative.
|
||||
|
||||
scan_mt.py [pid]
|
||||
"""
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
|
||||
PAT = struct.pack("<5i", 11, 7, 0, 0, 76)
|
||||
|
||||
|
||||
def find_pid():
|
||||
for d in glob.glob("/proc/[0-9]*"):
|
||||
try:
|
||||
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
|
||||
return int(os.path.basename(d))
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
pid = int(sys.argv[1]) if len(sys.argv) > 1 else find_pid()
|
||||
if not pid:
|
||||
print(" no FIFA17.exe")
|
||||
raise SystemExit(2)
|
||||
|
||||
mem = open(f"/proc/{pid}/mem", "rb", 0)
|
||||
found = []
|
||||
for line in open(f"/proc/{pid}/maps"):
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)", line)
|
||||
if not m or m.group(3)[0] != "r":
|
||||
continue
|
||||
lo, hi, path = int(m.group(1), 16), int(m.group(2), 16), m.group(4)
|
||||
if path.startswith(("/dev", "/memfd")) or hi - lo > 512 * 1024 * 1024:
|
||||
continue
|
||||
try:
|
||||
mem.seek(lo)
|
||||
buf = mem.read(hi - lo)
|
||||
except (OSError, ValueError, OverflowError):
|
||||
continue
|
||||
i = buf.find(PAT)
|
||||
while i >= 0:
|
||||
rec = buf[i:i + 0x80]
|
||||
if len(rec) >= 0x80:
|
||||
tid = struct.unpack_from("<i", rec, 0x14)[0]
|
||||
m18 = struct.unpack_from("<i", rec, 0x18)[0]
|
||||
m1c = struct.unpack_from("<i", rec, 0x1c)[0]
|
||||
xi = list(struct.unpack_from("<11i", rec, 0x20))
|
||||
subs = list(struct.unpack_from("<12i", rec, 0x4c))
|
||||
found.append((lo + i, tid, m18, m1c, xi, subs))
|
||||
i = buf.find(PAT, i + 4)
|
||||
|
||||
print(f" pid={pid} {len(found)} match-team record(s)")
|
||||
for addr, tid, m18, m1c, xi, subs in found:
|
||||
print(f"\n @0x{addr:x}")
|
||||
print(f" +0x14 teamId = {tid}")
|
||||
print(f" +0x18 marker = {m18} +0x1c marker = {m1c}")
|
||||
print(f" XI = {xi}")
|
||||
print(f" subs = {subs}")
|
||||
print(f"\n distinct teamIds: {sorted({t for _a, t, *_r in found})}")
|
||||
+1101
File diff suppressed because it is too large
Load Diff
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())
|
||||
+512
@@ -0,0 +1,512 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Supervise one hardware-only FIFA17 match-team writer capture.
|
||||
|
||||
This is the robust fresh-client entry point. It waits for the largest-RSS
|
||||
FIFA17.exe process that has CardsDLL loaded, attaches gdb before FUT navigation
|
||||
can construct match teams, and loads a hardware-only GDB Python payload.
|
||||
|
||||
The concurrent read-only structural locator proves when the fixture and final
|
||||
match-team records exist. A zero-hit result is trusted only if gdb is still
|
||||
alive, TracerPid is the gdb process, the payload reported `trace_armed`, no
|
||||
records pre-existed the trace, and two final records then appeared.
|
||||
|
||||
The default payload traces FUN_1800fc500 and derives a 4-byte teamId[1]
|
||||
watchpoint from live RDX. Other payloads trace the final engine writer or its
|
||||
caller; all expose the same `start_trace(log, cards_base)` entry point.
|
||||
|
||||
No INT3/software breakpoints. No client memory writes. /proc/<pid>/mem is opened
|
||||
'rb'. The operator alone drives the game.
|
||||
|
||||
trace_match_team_writer.py --status /tmp/mt-status.json \
|
||||
--trace /tmp/mt-trace.jsonl --gdb-log /tmp/mt-gdb.log --fixture-index 0
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
|
||||
from offline_match_locator import find_pids, scan_process
|
||||
|
||||
CARDS_IMAGE_BASE = 0x180000000
|
||||
DEFAULT_TIMEOUT = 45 * 60
|
||||
|
||||
|
||||
def cards_base(pid: int) -> int | None:
|
||||
try:
|
||||
with open(f"/proc/{pid}/maps") as maps:
|
||||
for line in maps:
|
||||
if "CardsDLL_Win64_retail.dll" in line:
|
||||
return int(line.split("-", 1)[0], 16)
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def tracer_pid(pid: int) -> int | None:
|
||||
try:
|
||||
with open(f"/proc/{pid}/status") as status:
|
||||
for line in status:
|
||||
if line.startswith("TracerPid:"):
|
||||
return int(line.split()[1])
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def target_state(pid: int) -> str | None:
|
||||
try:
|
||||
with open(f"/proc/{pid}/status") as status:
|
||||
for line in status:
|
||||
if line.startswith("State:"):
|
||||
return line.split()[1]
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def read_events(path: Path) -> list[dict]:
|
||||
if not path.exists():
|
||||
return []
|
||||
events = []
|
||||
try:
|
||||
with path.open(encoding="utf-8", errors="replace") as handle:
|
||||
for line in handle:
|
||||
try:
|
||||
events.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except OSError:
|
||||
return []
|
||||
return events
|
||||
|
||||
|
||||
def event_counts(events: list[dict]) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
for event in events:
|
||||
kind = event.get("event", "unknown")
|
||||
counts[kind] = counts.get(kind, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
class Status:
|
||||
def __init__(self, path: Path, monitor_log: Path):
|
||||
self.path = path
|
||||
self.monitor_log = monitor_log
|
||||
self.data: dict = {"started_unix": time.time(), "state": "starting"}
|
||||
self.write()
|
||||
|
||||
def write(self, **updates):
|
||||
self.data.update(updates)
|
||||
self.data["updated_unix"] = time.time()
|
||||
temporary = self.path.with_suffix(self.path.suffix + ".tmp")
|
||||
temporary.write_text(json.dumps(self.data, indent=2, sort_keys=True) + "\n")
|
||||
os.replace(temporary, self.path)
|
||||
|
||||
def log(self, message: str, **payload):
|
||||
record = {"time_unix": time.time(), "message": message, **payload}
|
||||
with self.monitor_log.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(record, sort_keys=True) + "\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
print(message, flush=True)
|
||||
|
||||
|
||||
def gdb_commands(pid: int, cards: int, payload: Path, trace: Path) -> str:
|
||||
# Wine uses these signals for thread suspension/runtime plumbing. They must
|
||||
# pass through, or batch gdb stops and silently detaches.
|
||||
signals = ["SIGUSR1", "SIGUSR2", "SIGPIPE", "SIGCHLD"] + [
|
||||
f"SIG{number}" for number in range(32, 40)
|
||||
]
|
||||
lines = [
|
||||
"set confirm off",
|
||||
"set pagination off",
|
||||
"set height 0",
|
||||
"set width 0",
|
||||
f"attach {pid}",
|
||||
]
|
||||
lines.extend(f"handle {name} nostop noprint pass" for name in signals)
|
||||
lines.extend(
|
||||
[
|
||||
f"source {payload}",
|
||||
f'python start_trace({json.dumps(str(trace))}, {cards})',
|
||||
"continue",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def serialise_locations(locations: dict) -> dict:
|
||||
return {key: [asdict(value) for value in values] for key, values in locations.items()}
|
||||
|
||||
|
||||
def terminate_gdb(process: subprocess.Popen, status: Status, pid: int):
|
||||
if process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=12)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
deadline = time.time() + 8
|
||||
while time.time() < deadline and tracer_pid(pid):
|
||||
time.sleep(0.25)
|
||||
status.log(
|
||||
"gdb detached",
|
||||
gdb_returncode=process.returncode,
|
||||
tracer_pid=tracer_pid(pid),
|
||||
target_state=target_state(pid),
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--status", type=Path, required=True)
|
||||
parser.add_argument("--trace", type=Path, required=True)
|
||||
parser.add_argument("--gdb-log", type=Path, required=True)
|
||||
parser.add_argument("--monitor-log", type=Path, default=Path("/tmp/mt-monitor.jsonl"))
|
||||
parser.add_argument("--fixture-index", type=int, default=0)
|
||||
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT)
|
||||
parser.add_argument("--post-record-wait", type=int, default=12)
|
||||
parser.add_argument(
|
||||
"--arm-check-seconds",
|
||||
type=int,
|
||||
default=0,
|
||||
help="attach, prove hardware breakpoints arm, then detach without claiming a capture",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--wait-for-record-clear",
|
||||
action="store_true",
|
||||
help="keep tracing through abandon; accept creation only after old records disappear",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--exclude-pid",
|
||||
action="append",
|
||||
type=int,
|
||||
default=[],
|
||||
help="ignore an existing FIFA process and attach only after process replacement",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--payload",
|
||||
default="gdb_match_team_writer_trace.py",
|
||||
help="GDB Python payload in this tool directory; must expose start_trace(log, cards_base)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
for path in (args.status, args.trace, args.gdb_log, args.monitor_log):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
for path in (args.trace, args.gdb_log, args.monitor_log):
|
||||
path.unlink(missing_ok=True)
|
||||
status = Status(args.status, args.monitor_log)
|
||||
payload = Path(__file__).with_name(args.payload).resolve()
|
||||
if not payload.exists():
|
||||
status.write(state="failed", error=f"missing gdb payload: {payload}")
|
||||
return 2
|
||||
|
||||
deadline = time.time() + args.timeout
|
||||
status.write(state="waiting_for_ready_process", excluded_pids=args.exclude_pid)
|
||||
status.log(
|
||||
"waiting for FIFA17.exe with CardsDLL",
|
||||
excluded_pids=args.exclude_pid,
|
||||
)
|
||||
pid = None
|
||||
cards = None
|
||||
while time.time() < deadline:
|
||||
# UMU/Proton creates a short-lived small FIFA17.exe before the real
|
||||
# client. Never bind to the first comm match. Require CardsDLL and prefer
|
||||
# the largest-RSS process (find_pids is ordered that way).
|
||||
for candidate in find_pids():
|
||||
if candidate in args.exclude_pid:
|
||||
continue
|
||||
candidate_cards = cards_base(candidate)
|
||||
if candidate_cards:
|
||||
pid, cards = candidate, candidate_cards
|
||||
break
|
||||
if pid:
|
||||
break
|
||||
time.sleep(0.25)
|
||||
if not pid or not cards:
|
||||
status.write(state="timed_out", phase="ready_process")
|
||||
return 3
|
||||
|
||||
status.write(state="ready_process_found", pid=pid, cards_base=cards)
|
||||
status.log("real FIFA17.exe with CardsDLL found", pid=pid, cards_base=cards)
|
||||
|
||||
command_path = Path(tempfile.gettempdir()) / f"mt-trace-{pid}.gdb"
|
||||
command_path.write_text(gdb_commands(pid, cards, payload, args.trace))
|
||||
gdb_handle = args.gdb_log.open("w", encoding="utf-8")
|
||||
process = subprocess.Popen(
|
||||
["gdb", "-q", "-nx", "-x", str(command_path)],
|
||||
stdout=gdb_handle,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
status.write(
|
||||
state="attaching",
|
||||
pid=pid,
|
||||
cards_base=cards,
|
||||
cards_image_base=CARDS_IMAGE_BASE,
|
||||
gdb_pid=process.pid,
|
||||
gdb_command_file=str(command_path),
|
||||
payload=args.payload,
|
||||
hardware_only=True,
|
||||
client_memory_writes=False,
|
||||
)
|
||||
status.log("gdb launched", pid=pid, gdb_pid=process.pid, cards_base=cards)
|
||||
|
||||
armed = False
|
||||
arm_deadline = min(deadline, time.time() + 60)
|
||||
while time.time() < arm_deadline:
|
||||
if process.poll() is not None:
|
||||
break
|
||||
events = read_events(args.trace)
|
||||
if any(event.get("event") == "trace_armed" for event in events):
|
||||
armed = True
|
||||
break
|
||||
time.sleep(0.25)
|
||||
if not armed:
|
||||
gdb_handle.close()
|
||||
status.write(
|
||||
state="failed",
|
||||
phase="arm",
|
||||
gdb_returncode=process.poll(),
|
||||
tracer_pid=tracer_pid(pid),
|
||||
trace_events=event_counts(read_events(args.trace)),
|
||||
)
|
||||
if process.poll() is None:
|
||||
terminate_gdb(process, status, pid)
|
||||
return 4
|
||||
|
||||
attached = tracer_pid(pid) == process.pid
|
||||
status.write(
|
||||
state="armed",
|
||||
tracer_pid=tracer_pid(pid),
|
||||
target_state=target_state(pid),
|
||||
trace_events=event_counts(read_events(args.trace)),
|
||||
execution_breakpoints_armed=True,
|
||||
team1_watchpoint_armed=False,
|
||||
)
|
||||
status.log("trace armed", attached=attached, tracer_pid=tracer_pid(pid))
|
||||
if not attached:
|
||||
terminate_gdb(process, status, pid)
|
||||
gdb_handle.close()
|
||||
status.write(state="failed", phase="attach_verification")
|
||||
return 4
|
||||
if args.arm_check_seconds > 0:
|
||||
time.sleep(args.arm_check_seconds)
|
||||
events = read_events(args.trace)
|
||||
counts = event_counts(events)
|
||||
still_attached = tracer_pid(pid) == process.pid and process.poll() is None
|
||||
terminate_gdb(process, status, pid)
|
||||
gdb_handle.close()
|
||||
passed = (
|
||||
still_attached
|
||||
and counts.get("trace_armed", 0) == 1
|
||||
and counts.get("trace_error", 0) == 0
|
||||
and tracer_pid(pid) == 0
|
||||
and target_state(pid) != "T"
|
||||
)
|
||||
status.write(
|
||||
state="arm_check_passed" if passed else "arm_check_failed",
|
||||
trace_events=counts,
|
||||
attached_before_detach=still_attached,
|
||||
tracer_pid_after_detach=tracer_pid(pid),
|
||||
target_state_after_detach=target_state(pid),
|
||||
)
|
||||
status.log("arm check complete", passed=passed, trace_events=counts)
|
||||
return 0 if passed else 5
|
||||
|
||||
# A final record that already exists before arming cannot prove execution
|
||||
# crossed creation under the debugger. Fail closed instead of converting an
|
||||
# already-built match into a trusted zero-hit result.
|
||||
initial_heap = scan_process(
|
||||
pid,
|
||||
args.fixture_index,
|
||||
include_fixture=False,
|
||||
writable_anon_only=True,
|
||||
)
|
||||
records_preexisting = len(initial_heap["match_teams"]) >= 2
|
||||
records_cleared = not records_preexisting
|
||||
if records_preexisting and args.wait_for_record_clear:
|
||||
status.write(
|
||||
state="waiting_for_record_clear",
|
||||
locations=serialise_locations(initial_heap),
|
||||
target_crossed_match_team_creation=False,
|
||||
)
|
||||
status.log(
|
||||
"trace armed; waiting for old match-team records to disappear",
|
||||
team_ids=[team.team_id for team in initial_heap["match_teams"]],
|
||||
)
|
||||
while time.time() < deadline:
|
||||
if process.poll() is not None or not Path(f"/proc/{pid}").exists():
|
||||
terminate_gdb(process, status, pid)
|
||||
gdb_handle.close()
|
||||
status.write(state="failed", phase="record_clear")
|
||||
return 5
|
||||
heap = scan_process(
|
||||
pid,
|
||||
args.fixture_index,
|
||||
include_fixture=False,
|
||||
writable_anon_only=True,
|
||||
)
|
||||
if not heap["match_teams"]:
|
||||
records_cleared = True
|
||||
status.write(
|
||||
state="records_cleared",
|
||||
cleared_unix=time.time(),
|
||||
tracer_pid=tracer_pid(pid),
|
||||
gdb_alive=process.poll() is None,
|
||||
target_state=target_state(pid),
|
||||
)
|
||||
status.log(
|
||||
"old match-team records disappeared; next records are a fresh creation",
|
||||
tracer_pid=tracer_pid(pid),
|
||||
)
|
||||
break
|
||||
time.sleep(2)
|
||||
if not records_cleared:
|
||||
terminate_gdb(process, status, pid)
|
||||
gdb_handle.close()
|
||||
status.write(state="timed_out", phase="record_clear")
|
||||
return 3
|
||||
elif records_preexisting:
|
||||
counts = event_counts(read_events(args.trace))
|
||||
terminate_gdb(process, status, pid)
|
||||
gdb_handle.close()
|
||||
status.write(
|
||||
state="armed_too_late",
|
||||
phase="preexisting_records",
|
||||
trace_events=counts,
|
||||
locations=serialise_locations(initial_heap),
|
||||
target_crossed_match_team_creation=False,
|
||||
tracer_pid_after_detach=tracer_pid(pid),
|
||||
target_state_after_detach=target_state(pid),
|
||||
)
|
||||
status.log(
|
||||
"match-team records pre-existed trace; no writer claim",
|
||||
team_ids=[team.team_id for team in initial_heap["match_teams"]],
|
||||
)
|
||||
return 6
|
||||
|
||||
|
||||
fixture = None
|
||||
latest_locations = {"fixtures": [], "match_teams": [], "match_configs": []}
|
||||
last_fixture_scan = 0.0
|
||||
records_seen_at = None
|
||||
record_control = None
|
||||
try:
|
||||
while time.time() < deadline:
|
||||
if process.poll() is not None or not Path(f"/proc/{pid}").exists():
|
||||
status.write(
|
||||
state="failed",
|
||||
phase="monitor",
|
||||
gdb_returncode=process.poll(),
|
||||
target_exists=Path(f"/proc/{pid}").exists(),
|
||||
)
|
||||
return 5
|
||||
|
||||
now = time.time()
|
||||
if fixture is None and now - last_fixture_scan >= 8:
|
||||
full = scan_process(pid, args.fixture_index, include_fixture=True)
|
||||
last_fixture_scan = now
|
||||
if full["fixtures"]:
|
||||
fixture = full["fixtures"][0]
|
||||
latest_locations["fixtures"] = full["fixtures"]
|
||||
status.log(
|
||||
"fixture located",
|
||||
address=fixture.address,
|
||||
selected_address=fixture.selected_address,
|
||||
selected_index=fixture.selected_index,
|
||||
selected_team_id=fixture.selected_team_id,
|
||||
)
|
||||
|
||||
heap = scan_process(
|
||||
pid,
|
||||
args.fixture_index,
|
||||
include_fixture=False,
|
||||
writable_anon_only=True,
|
||||
)
|
||||
latest_locations["match_teams"] = heap["match_teams"]
|
||||
latest_locations["match_configs"] = heap["match_configs"]
|
||||
events = read_events(args.trace)
|
||||
counts = event_counts(events)
|
||||
is_attached = tracer_pid(pid) == process.pid
|
||||
watch_armed = counts.get("team1_watchpoint_armed", 0) > 0
|
||||
status.write(
|
||||
state="capturing" if len(heap["match_teams"]) < 2 else "records_observed",
|
||||
tracer_pid=tracer_pid(pid),
|
||||
gdb_alive=process.poll() is None,
|
||||
target_state=target_state(pid),
|
||||
trace_events=counts,
|
||||
team1_watchpoint_armed=watch_armed,
|
||||
locations=serialise_locations(latest_locations),
|
||||
)
|
||||
|
||||
if len(heap["match_teams"]) >= 2:
|
||||
if records_seen_at is None:
|
||||
if not is_attached or process.poll() is not None:
|
||||
status.write(
|
||||
state="failed",
|
||||
phase="record_creation_control",
|
||||
tracer_pid=tracer_pid(pid),
|
||||
gdb_alive=process.poll() is None,
|
||||
trace_events=counts,
|
||||
)
|
||||
return 5
|
||||
records_seen_at = now
|
||||
record_control = {
|
||||
"gdb_alive": process.poll() is None,
|
||||
"tracer_pid": tracer_pid(pid),
|
||||
"attached": is_attached,
|
||||
"execution_breakpoints_armed": counts.get("trace_armed", 0) == 1,
|
||||
"team1_watchpoint_armed": watch_armed,
|
||||
}
|
||||
status.log(
|
||||
"two match-team records located",
|
||||
team_ids=[team.team_id for team in heap["match_teams"]],
|
||||
trace_events=counts,
|
||||
**record_control,
|
||||
)
|
||||
if now - records_seen_at >= args.post_record_wait:
|
||||
break
|
||||
time.sleep(3)
|
||||
finally:
|
||||
terminate_gdb(process, status, pid)
|
||||
gdb_handle.close()
|
||||
|
||||
events = read_events(args.trace)
|
||||
counts = event_counts(events)
|
||||
final = {
|
||||
"state": "captured",
|
||||
"pid": pid,
|
||||
"cards_base": cards,
|
||||
"fixture": asdict(fixture) if fixture else None,
|
||||
"locations": serialise_locations(latest_locations),
|
||||
"trace_events": counts,
|
||||
"record_creation_control": record_control,
|
||||
"gdb_alive_at_record_creation": bool(
|
||||
record_control and record_control["gdb_alive"] and record_control["attached"]
|
||||
),
|
||||
"target_crossed_match_team_creation": len(latest_locations["match_teams"]) >= 2,
|
||||
"candidate_entry_hit": counts.get("candidate_entry", 0) > 0,
|
||||
"team1_write_hit": counts.get("team1_write_post", 0) > 0,
|
||||
"opponent_lookup_store_hit": counts.get("opponent_lookup_store_pre", 0) > 0,
|
||||
"tracer_pid_after_detach": tracer_pid(pid),
|
||||
"target_state_after_detach": target_state(pid),
|
||||
"records_preexisting": records_preexisting,
|
||||
"records_cleared_before_capture": records_cleared,
|
||||
}
|
||||
status.write(**final)
|
||||
status.log("capture complete", **final)
|
||||
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())
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Dump a CardsDLL vtable as image VAs, and find sibling vtables that hold a
|
||||
different function in the same slot (a type/mode dispatch).
|
||||
|
||||
vtab.py <slot_image_va_hex> [before] [after]
|
||||
"""
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
|
||||
CARDS_IMG = 0x180000000
|
||||
|
||||
|
||||
def pid():
|
||||
for d in glob.glob("/proc/[0-9]*"):
|
||||
try:
|
||||
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
|
||||
return int(os.path.basename(d))
|
||||
except OSError:
|
||||
pass
|
||||
raise SystemExit("no FIFA17.exe")
|
||||
|
||||
|
||||
P = pid()
|
||||
BASE = [int(l.split("-")[0], 16) for l in open(f"/proc/{P}/maps") if "CardsDLL" in l][0]
|
||||
|
||||
|
||||
def img2live(va):
|
||||
return BASE + (va - CARDS_IMG)
|
||||
|
||||
|
||||
def live2img(la):
|
||||
return CARDS_IMG + (la - BASE)
|
||||
|
||||
|
||||
slot = int(sys.argv[1], 16)
|
||||
before = int(sys.argv[2]) if len(sys.argv) > 2 else 10
|
||||
after = int(sys.argv[3]) if len(sys.argv) > 3 else 10
|
||||
|
||||
mem = open(f"/proc/{P}/mem", "rb", 0)
|
||||
start = slot - before * 8
|
||||
mem.seek(img2live(start))
|
||||
buf = mem.read((before + after) * 8)
|
||||
print(f" vtable neighbourhood of image 0x{slot:x}")
|
||||
target = None
|
||||
for k in range(0, len(buf) - 7, 8):
|
||||
a = start + k
|
||||
p = struct.unpack_from("<Q", buf, k)[0]
|
||||
ivа = live2img(p) if BASE <= p < BASE + 0x400000 else None
|
||||
mark = " <== the team-pair assigner" if a == slot else ""
|
||||
if a == slot:
|
||||
target = ivа
|
||||
print(f" 0x{a:x} [{a-slot:+#5x}] -> "
|
||||
+ (f"image 0x{ivа:x}" if ivа else f"raw 0x{p:x}") + mark)
|
||||
|
||||
# Find every other .rdata slot pointing at a DIFFERENT function but whose
|
||||
# neighbours overlap this vtable -> sibling implementations of the same slot.
|
||||
print("\n === sibling vtables: same neighbour, different slot function ===")
|
||||
mem.seek(img2live(0x1801e5000))
|
||||
rdata = mem.read(0x28a000 - 0x1e5000)
|
||||
# take the two neighbours around the slot as a signature
|
||||
sig_prev = struct.unpack_from("<Q", buf, (before - 1) * 8)[0]
|
||||
sig_next = struct.unpack_from("<Q", buf, (before + 1) * 8)[0]
|
||||
found = 0
|
||||
for name, sig in (("preceding", sig_prev), ("following", sig_next)):
|
||||
pat = struct.pack("<Q", sig)
|
||||
i = rdata.find(pat)
|
||||
while i >= 0:
|
||||
if i % 8 == 0:
|
||||
here = 0x1801e5000 + i
|
||||
# the slot in THIS vtable at the same relative position
|
||||
off = i + (8 if name == "preceding" else -8)
|
||||
if 0 <= off <= len(rdata) - 8:
|
||||
fn = struct.unpack_from("<Q", rdata, off)[0]
|
||||
if BASE <= fn < BASE + 0x400000:
|
||||
fimg = live2img(fn)
|
||||
if fimg != target:
|
||||
print(f" vtable @image 0x{here:x} ({name} matches) "
|
||||
f"slot -> image 0x{fimg:x} DIFFERENT")
|
||||
found += 1
|
||||
i = rdata.find(pat, i + 1)
|
||||
print(f" {found} sibling implementation(s)")
|
||||
Executable
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Find references to an image VA inside a live module's .text/.rdata/.data.
|
||||
|
||||
xref.py <target_image_va_hex> [--exe]
|
||||
|
||||
Reports:
|
||||
call rel32 (e8) / jmp rel32 (e9) -- direct callers
|
||||
lea rip-rel (48 8d 0x) -- address-taken
|
||||
absolute 8-byte pointer -- vtable / table slot
|
||||
|
||||
Read-only. Section ranges are recomputed from /proc/<pid>/maps every run.
|
||||
"""
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
|
||||
CARDS_IMG = 0x180000000
|
||||
EXE_IMG = 0x140000000
|
||||
|
||||
|
||||
def pid():
|
||||
for d in glob.glob("/proc/[0-9]*"):
|
||||
try:
|
||||
if open(os.path.join(d, "comm")).read().strip() == "FIFA17.exe":
|
||||
return int(os.path.basename(d))
|
||||
except OSError:
|
||||
pass
|
||||
raise SystemExit("FIFA17.exe not running")
|
||||
|
||||
|
||||
P = pid()
|
||||
|
||||
|
||||
def module_base(needle):
|
||||
for l in open(f"/proc/{P}/maps"):
|
||||
if needle.lower() in l.lower():
|
||||
return int(l.split("-")[0], 16)
|
||||
raise SystemExit(f"{needle} not mapped")
|
||||
|
||||
|
||||
def spans(base, limit=0x400000):
|
||||
"""Contiguous mappings belonging to this module, as (live_lo, live_hi, perms)."""
|
||||
out = []
|
||||
for l in open(f"/proc/{P}/maps"):
|
||||
m = re.match(r"([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)", l)
|
||||
if not m:
|
||||
continue
|
||||
lo, hi, perms, path = int(m.group(1), 16), int(m.group(2), 16), m.group(3), m.group(4)
|
||||
if lo == base:
|
||||
out.append((lo, hi, perms))
|
||||
continue
|
||||
if out and lo == out[-1][1] and not path.strip():
|
||||
out.append((lo, hi, perms))
|
||||
elif out and lo > out[-1][1]:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
a = [x for x in sys.argv[1:] if x != "--exe"]
|
||||
exe = "--exe" in sys.argv
|
||||
target = int(a[0], 16)
|
||||
img = EXE_IMG if exe else CARDS_IMG
|
||||
base = module_base("FIFA17.exe" if exe else "CardsDLL")
|
||||
tgt_live = base + (target - img)
|
||||
|
||||
mem = open(f"/proc/{P}/mem", "rb", 0)
|
||||
print(f" pid={P} module_base=0x{base:x} target image 0x{target:x} live 0x{tgt_live:x}")
|
||||
hits = 0
|
||||
for lo, hi, perms in spans(base):
|
||||
try:
|
||||
mem.seek(lo)
|
||||
buf = mem.read(hi - lo)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
img_lo = img + (lo - base)
|
||||
# rel32 call/jmp
|
||||
for op, name in ((0xE8, "call"), (0xE9, "jmp ")):
|
||||
i = buf.find(bytes([op]))
|
||||
while i >= 0:
|
||||
if i + 5 <= len(buf):
|
||||
rel = struct.unpack_from("<i", buf, i + 1)[0]
|
||||
if img_lo + i + 5 + rel == target:
|
||||
print(f" {name} rel32 from image 0x{img_lo+i:x} [{perms}]")
|
||||
hits += 1
|
||||
i = buf.find(bytes([op]), i + 1)
|
||||
# lea reg,[rip+rel32] (48 8d /r with mod=00 rm=101)
|
||||
i = buf.find(b"\x48\x8d")
|
||||
while i >= 0:
|
||||
if i + 7 <= len(buf):
|
||||
modrm = buf[i + 2]
|
||||
if (modrm & 0xC7) == 0x05:
|
||||
rel = struct.unpack_from("<i", buf, i + 3)[0]
|
||||
if img_lo + i + 7 + rel == target:
|
||||
print(f" lea rip-rel from image 0x{img_lo+i:x} [{perms}]")
|
||||
hits += 1
|
||||
i = buf.find(b"\x48\x8d", i + 1)
|
||||
# absolute pointer (live address stored in a table)
|
||||
pat = struct.pack("<Q", tgt_live)
|
||||
i = buf.find(pat)
|
||||
while i >= 0:
|
||||
if i % 8 == 0:
|
||||
print(f" abs ptr slot at image 0x{img_lo+i:x} [{perms}]")
|
||||
hits += 1
|
||||
i = buf.find(pat, i + 1)
|
||||
print(f" {hits} reference(s)")
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,167 @@
|
||||
//! Emit the discard-pricing matrix for an entire FIFA 17 corpus, and audit it.
|
||||
//!
|
||||
//! Uses the SHIPPED implementation (`fut::discard::value_for_definition`) rather
|
||||
//! than reimplementing the formula, so the matrix cannot drift from what the
|
||||
//! server actually pays.
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -p openfut-adapter-fifa17 --example discard_matrix -- \
|
||||
//! <catalog.json> <cards.json> [--csv out.csv]
|
||||
//! ```
|
||||
//!
|
||||
//! Prints an audit summary and, with `--csv`, the full per-definition matrix.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use openfut_adapter_fifa17::fut::discard;
|
||||
use openfut_adapter_fifa17::fut::item::legacy_discard_value;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.len() < 3 {
|
||||
eprintln!("usage: discard_matrix <catalog.json> <cards.json> [--csv <path>]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let catalog: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&args[1]).expect("read catalog"))
|
||||
.expect("parse catalog");
|
||||
let cards: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&args[2]).expect("read cards"))
|
||||
.expect("parse cards");
|
||||
let csv_path = args
|
||||
.iter()
|
||||
.position(|a| a == "--csv")
|
||||
.map(|i| args[i + 1].clone());
|
||||
|
||||
// Core's rating per definition id (non-players are 0, which is exactly why
|
||||
// the catalog rating matters).
|
||||
let mut core_rating: HashMap<String, u8> = HashMap::new();
|
||||
if let Some(arr) = cards.as_array() {
|
||||
for c in arr {
|
||||
let id = c["id"].as_str().unwrap_or_default().to_string();
|
||||
let r = c["overall"].as_i64().unwrap_or(0).clamp(0, 255) as u8;
|
||||
core_rating.insert(id, r);
|
||||
}
|
||||
}
|
||||
|
||||
let entries = catalog
|
||||
.get("cards")
|
||||
.and_then(|c| c.as_object())
|
||||
.expect("catalog has cards{}");
|
||||
|
||||
let mut rows: Vec<String> = Vec::new();
|
||||
rows.push("definition,kind,subtype,cardtype,rareflag,rating_src,rating,level,legacy,recovered,verdict".into());
|
||||
|
||||
let mut by_kind: BTreeMap<String, (usize, usize, i64, i64)> = BTreeMap::new(); // n, declined, legacy, recovered
|
||||
let (mut negatives, mut zero_priced, mut declined_total, mut overflow) =
|
||||
(0usize, 0usize, 0usize, 0usize);
|
||||
let mut boundary_probe_failures = Vec::new();
|
||||
|
||||
for (id, e) in entries {
|
||||
let kind = e["kind"].as_str().unwrap_or("player").to_string();
|
||||
let subtype = e["subtype"].as_i64().unwrap_or(0);
|
||||
let rareflag = e["rareflag"].as_i64().unwrap_or(0);
|
||||
let cat_rating = e["rating"].as_i64().map(|r| r.clamp(0, 255) as u8);
|
||||
let core = *core_rating.get(id).unwrap_or(&0);
|
||||
let cardtype = discard::cardtype_for_subtype(subtype);
|
||||
|
||||
let recovered = discard::value_for_definition(subtype, rareflag, cat_rating, core);
|
||||
let effective_rating = cat_rating.unwrap_or(core);
|
||||
let level = discard::discard_level(effective_rating);
|
||||
let legacy = legacy_discard_value(core);
|
||||
|
||||
let verdict = match recovered {
|
||||
None => {
|
||||
declined_total += 1;
|
||||
"DECLINES->legacy"
|
||||
}
|
||||
Some(v) if v < 0 => {
|
||||
negatives += 1;
|
||||
"NEGATIVE"
|
||||
}
|
||||
Some(0) => {
|
||||
zero_priced += 1;
|
||||
"ZERO"
|
||||
}
|
||||
Some(v) if v > 1_000_000 => {
|
||||
overflow += 1;
|
||||
"IMPLAUSIBLE"
|
||||
}
|
||||
Some(_) => "ok",
|
||||
};
|
||||
|
||||
let ent = by_kind.entry(kind.clone()).or_insert((0, 0, 0, 0));
|
||||
ent.0 += 1;
|
||||
ent.2 += legacy;
|
||||
match recovered {
|
||||
Some(v) => ent.3 += v,
|
||||
None => {
|
||||
ent.1 += 1;
|
||||
ent.3 += legacy; // declining means the legacy ladder is what pays
|
||||
}
|
||||
}
|
||||
|
||||
rows.push(format!(
|
||||
"{id},{kind},{subtype},{cardtype},{rareflag},{},{effective_rating},{level},{legacy},{},{verdict}",
|
||||
if cat_rating.is_some() { "catalog" } else { "core" },
|
||||
recovered.map(|v| v.to_string()).unwrap_or_else(|| "-".into()),
|
||||
));
|
||||
}
|
||||
|
||||
// Rating-boundary audit against the client's own ladder (cmp 0x4b / 0x41).
|
||||
for (rating, want) in [(0u8, 1u8), (64, 1), (65, 2), (74, 2), (75, 3), (99, 3)] {
|
||||
let got = discard::discard_level(rating);
|
||||
if got != want {
|
||||
boundary_probe_failures.push(format!("rating {rating}: level {got}, expected {want}"));
|
||||
}
|
||||
}
|
||||
|
||||
println!("== DISCARD MATRIX AUDIT ==");
|
||||
println!("definitions : {}", entries.len());
|
||||
println!("declined -> legacy : {declined_total}");
|
||||
println!("priced zero : {zero_priced}");
|
||||
println!("negative : {negatives}");
|
||||
println!("implausible (>1e6) : {overflow}");
|
||||
println!(
|
||||
"rating boundaries : {}",
|
||||
if boundary_probe_failures.is_empty() {
|
||||
"OK (1/2/3 at <65 / 65..74 / >=75)".to_string()
|
||||
} else {
|
||||
boundary_probe_failures.join("; ")
|
||||
}
|
||||
);
|
||||
println!();
|
||||
println!(
|
||||
"{:<12} {:>6} {:>9} {:>14} {:>14}",
|
||||
"kind", "n", "declined", "legacy", "recovered"
|
||||
);
|
||||
let (mut tl, mut tr) = (0i64, 0i64);
|
||||
for (kind, (n, dec, legacy, rec)) in &by_kind {
|
||||
println!("{kind:<12} {n:>6} {dec:>9} {legacy:>14} {rec:>14}");
|
||||
tl += legacy;
|
||||
tr += rec;
|
||||
}
|
||||
println!(
|
||||
"{:<12} {:>6} {:>9} {:>14} {:>14}",
|
||||
"TOTAL",
|
||||
entries.len(),
|
||||
declined_total,
|
||||
tl,
|
||||
tr
|
||||
);
|
||||
if tl > 0 {
|
||||
println!("ratio recovered/legacy : {:.2}x", tr as f64 / tl as f64);
|
||||
}
|
||||
|
||||
if let Some(path) = csv_path {
|
||||
std::fs::write(&path, rows.join("\n") + "\n").expect("write csv");
|
||||
println!("\nwrote {} rows to {path}", rows.len() - 1);
|
||||
}
|
||||
|
||||
let fatal = negatives + overflow + boundary_probe_failures.len();
|
||||
if fatal > 0 {
|
||||
eprintln!("\nFAIL: {fatal} fatal finding(s)");
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!("\nRESULT: OK");
|
||||
}
|
||||
@@ -90,7 +90,13 @@ kill_test 11 "FIFA wire item id stored in canonical replacement" \
|
||||
|
||||
kill_test 12 "projector rebuilds items independently of shared shaper" \
|
||||
persisted_read_round_trips_via_reconstructed_canonical_and_extension "$FUT/squad_projection.rs" \
|
||||
'"itemData": shape_item(item, id, ent),' '"itemData": json!({"id": id.item_id}),'
|
||||
'"itemData": shape_item(
|
||||
item,
|
||||
id,
|
||||
ent,
|
||||
ident.discard_value(item),
|
||||
item.contract_matches.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
|
||||
),' '"itemData": json!({"id": id.item_id}),'
|
||||
|
||||
kill_test 13 "extension schema version ignored on read" \
|
||||
unknown_schema_version_is_rejected_not_coerced "$FUT/squad_ext.rs" \
|
||||
|
||||
@@ -38,6 +38,69 @@ 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,
|
||||
/// The wire `assetId` for a CLUB item (record `+0x20`), which is family
|
||||
/// specific and is NOT the carddbid: a kit carries the art class from
|
||||
/// `fcc_kitcards.assetid` (`14` home/third band, `15` away band), a badge
|
||||
/// carries its team id, a stadium and a ball their own asset number.
|
||||
///
|
||||
/// Distinct from [`Self::asset_id`], which for these definitions is the
|
||||
/// carddbid and is what `resource_id` is derived from — so the two cannot be
|
||||
/// the same field. Shipping the carddbid here is what left the client
|
||||
/// holding `assetId 6300006` at record `+0x20` where its own table says
|
||||
/// `14`, with both pre-match kit tiles rendering identically.
|
||||
///
|
||||
/// Defaults to `asset_id` when a catalog does not specify it, which is the
|
||||
/// pre-existing behaviour and is correct for every non-club kind.
|
||||
pub club_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,
|
||||
/// Kit slot family (`club_items.json → kits[].category`): `2` home, `3`
|
||||
/// away, `5` third. Zero for definitions that do not use it.
|
||||
///
|
||||
/// Load-bearing for the pre-match kit selector, not cosmetic. The client's
|
||||
/// active-kit resolver (`FUN_1800d73d0`) reads it at record `+0xb8` and maps
|
||||
/// it to the engine's kit SLOT — 2→0, 3→1, 5→3 — which then forms part of
|
||||
/// the `(teamid, year, slot)` triple the kit descriptor
|
||||
/// (`sub_180033430`) must match. Omit it and the triple cannot match, so the
|
||||
/// engine falls through to its own catalogue kit and reports the kit as
|
||||
/// locked.
|
||||
pub category: i64,
|
||||
/// Kit season (`club_items.json → kits[].year`), `0` for a current-season
|
||||
/// kit and e.g. `2002` for a historical one.
|
||||
///
|
||||
/// Record `+0xba`, atom `0x389`. The third member of the identity triple
|
||||
/// above, and the key the runtime `teamkits` clone queries on.
|
||||
pub year: 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 +121,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 +217,36 @@ 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>,
|
||||
/// Wire `assetId` for a club item; defaults to `asset_id`. See
|
||||
/// [`Fifa17CardIdentity::club_asset_id`].
|
||||
club_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>,
|
||||
/// Kit slot family (2 home / 3 away / 5 third); absent → `0`.
|
||||
#[serde(default)]
|
||||
category: Option<i64>,
|
||||
/// Kit season; absent → `0` (current season).
|
||||
#[serde(default)]
|
||||
year: Option<i64>,
|
||||
}
|
||||
|
||||
fn default_rareflag() -> i64 {
|
||||
@@ -187,6 +303,16 @@ 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),
|
||||
club_asset_id: rc.club_asset_id.unwrap_or(rc.asset_id),
|
||||
team_id: rc.team_id.unwrap_or(0),
|
||||
category: rc.category.unwrap_or(0),
|
||||
year: rc.year.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 +398,95 @@ mod tests {
|
||||
assert_eq!(cat.lookup("card_missing"), None);
|
||||
}
|
||||
|
||||
/// A club item's wire `assetId` is family specific and is NOT the carddbid.
|
||||
///
|
||||
/// Regression: the catalog shipped `asset_id` (the carddbid) as the wire
|
||||
/// `assetId`, so the client held `assetId 6300006` at record `+0x20` where
|
||||
/// its own `fcc_kitcards` says `14`, and both pre-match kit tiles rendered
|
||||
/// identically. `resource_id` is derived from `asset_id`, and every home kit
|
||||
/// shares art class 14, so the two genuinely cannot be one field.
|
||||
#[test]
|
||||
fn club_items_carry_their_own_wire_asset_id_distinct_from_the_carddbid() {
|
||||
let cat = Fifa17CardCatalog::from_json_str(
|
||||
r#"{"schema_version":1,"game":"fifa17","cards":{
|
||||
"fifa17_6300006":{"asset_id":6300006,"kind":"kit","subtype":9,
|
||||
"card_asset_id":35,"club_asset_id":14,"team_id":21,"category":2,"year":0},
|
||||
"fifa17_6400003":{"asset_id":6400003,"kind":"kit","subtype":9,
|
||||
"card_asset_id":35,"club_asset_id":15,"team_id":21,"category":3,"year":0},
|
||||
"fifa17_20801":{"asset_id":20801}
|
||||
}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let home = cat.lookup("fifa17_6300006").unwrap();
|
||||
let away = cat.lookup("fifa17_6400003").unwrap();
|
||||
|
||||
// resourceId stays the carddbid — it is what the staff/kit merge keys on.
|
||||
assert_eq!(home.resource_id, 6300006);
|
||||
assert_eq!(away.resource_id, 6400003);
|
||||
// The card frame art is shared by the whole kit family.
|
||||
assert_eq!(home.card_asset_id, 35);
|
||||
assert_eq!(away.card_asset_id, 35);
|
||||
// The art class is what distinguishes home from away on the wire.
|
||||
assert_eq!(home.club_asset_id, 14);
|
||||
assert_eq!(away.club_asset_id, 15);
|
||||
assert_ne!(
|
||||
home.club_asset_id, away.club_asset_id,
|
||||
"home and away must not present the same assetId"
|
||||
);
|
||||
|
||||
// Absent: defaults to asset_id, which is correct for every non-club kind
|
||||
// and preserves the behaviour of a catalog that predates the field.
|
||||
let player = cat.lookup("fifa17_20801").unwrap();
|
||||
assert_eq!(player.club_asset_id, 20801);
|
||||
}
|
||||
|
||||
/// 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 +612,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 +624,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,34 +10,114 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::content_taxonomy::ContentKind;
|
||||
use crate::fut::contract_cards::PACK_FRESH_CONTRACT_MATCHES;
|
||||
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),
|
||||
item.contract_matches.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
|
||||
));
|
||||
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,
|
||||
item.contract_matches.unwrap_or(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)
|
||||
@@ -46,6 +126,7 @@ pub fn shape_club_response<I: ItemIdentityResolver + ?Sized>(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::fut::contract_cards::CONTRACT_MATCH_CAP;
|
||||
use crate::fut::entities::Fifa17Entities;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -75,6 +156,11 @@ mod tests {
|
||||
league: league.into(),
|
||||
club: club.into(),
|
||||
attributes: [90, 88, 70, 85, 40, 78],
|
||||
// Untracked by default, so these fixtures exercise the pack-fresh
|
||||
// fallback; a test that cares sets it explicitly.
|
||||
contract_matches: None,
|
||||
source_rating: None,
|
||||
core_content_kind: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,11 +293,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 +315,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 +327,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 +359,287 @@ 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,
|
||||
"this fixture is UNTRACKED (contract_matches None), so the wire shows \
|
||||
the pack-fresh fallback — not because the shaper hardcodes it"
|
||||
);
|
||||
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,
|
||||
"untracked fixture => pack-fresh fallback"
|
||||
);
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `/club` is the screen a contract apply is judged on: if the envelope keeps
|
||||
/// reporting the pack-fresh count, a committed apply is invisible and the
|
||||
/// operator sees a 200 that did nothing. Both families must carry the number
|
||||
/// Core persisted.
|
||||
#[test]
|
||||
fn club_reports_the_contract_core_persisted_for_players_and_staff() {
|
||||
let ent = entities();
|
||||
let ident = KindMapIdentity {
|
||||
ids: HashMap::from([(
|
||||
"card_player".to_string(),
|
||||
Fifa17Identity {
|
||||
item_id: 100000001,
|
||||
asset_id: 20801,
|
||||
resource_id: 20801,
|
||||
rareflag: 1,
|
||||
},
|
||||
)]),
|
||||
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_player".to_string(), ContentKind::Player),
|
||||
("card_manager".to_string(), ContentKind::Staff),
|
||||
]),
|
||||
};
|
||||
// A player mid-way through its contracts, a fully topped-up manager, and
|
||||
// one untracked player that must fall back.
|
||||
let mut played = item(
|
||||
"oc-played",
|
||||
"card_player",
|
||||
86,
|
||||
"ST",
|
||||
"Argentina",
|
||||
"Premier League",
|
||||
"Chelsea",
|
||||
);
|
||||
played.contract_matches = Some(3);
|
||||
let mut manager = item("oc-mgr", "card_manager", 0, "", "", "", "");
|
||||
manager.contract_matches = Some(CONTRACT_MATCH_CAP);
|
||||
let untracked = item(
|
||||
"oc-fresh",
|
||||
"card_player",
|
||||
86,
|
||||
"ST",
|
||||
"Argentina",
|
||||
"Premier League",
|
||||
"Chelsea",
|
||||
);
|
||||
|
||||
let (body, stats) = shape_club_response(&[played, manager, untracked], &ent, &ident);
|
||||
assert_eq!(stats.emitted, 3);
|
||||
let arr = body["itemData"].as_array().unwrap();
|
||||
assert_eq!(arr[0]["contract"], 3, "the player's persisted count");
|
||||
assert_eq!(
|
||||
arr[1]["contract"], CONTRACT_MATCH_CAP,
|
||||
"staff read the same persisted field, not STAFF_CONTRACT"
|
||||
);
|
||||
assert_eq!(
|
||||
arr[2]["contract"], PACK_FRESH_CONTRACT_MATCHES,
|
||||
"only an untracked instance falls back"
|
||||
);
|
||||
}
|
||||
|
||||
#[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,
|
||||
category: 2,
|
||||
year: 0,
|
||||
};
|
||||
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_eq!(body["itemData"][0]["itemType"], "kit");
|
||||
}
|
||||
|
||||
/// 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,
|
||||
category: 2,
|
||||
year: 0,
|
||||
};
|
||||
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,336 @@
|
||||
//! 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::discard;
|
||||
use crate::fut::item::{shape_consumable_item, Fifa17ConsumableIdentity, ShapeStats};
|
||||
|
||||
/// The stack's `discardValue` (atom 0xd7) — the number the consumables screen
|
||||
/// DISPLAYS, per card.
|
||||
///
|
||||
/// This used to be hard-coded `0`, on the theory that the client would compute
|
||||
/// the price itself from `fcc_discardcoins` the way it does for a card whose
|
||||
/// `discardValue` we omit. That theory was wrong, and the screen showed
|
||||
/// "Quick sell for 0 coins" on a real production club (operator-observed,
|
||||
/// 2026-08-22) while Core would have paid 3/13/32 for those same contracts.
|
||||
///
|
||||
/// Why the old reasoning failed, from evidence rather than re-derivation:
|
||||
///
|
||||
/// * `item+0x38` (the `discardValue` we send) non-zero makes the client SKIP its
|
||||
/// local computation and display our number — live-proven again on the
|
||||
/// production client, 16/16 resident cards `SERVER-SHOWN`.
|
||||
/// * We send no `discardValue` inside a consumable's `item`, so `+0x38` is 0 and
|
||||
/// the client's local computation DOES run, filling `+0x3c` with the right
|
||||
/// value — Milestone 1 measured exactly that (3/3/32/38, matching this table).
|
||||
/// * The screen nonetheless showed 0. So the screen is not reading the item's
|
||||
/// computed `+0x3c`; it reads the STACK's atom 0xd7, which we were sending as
|
||||
/// 0.
|
||||
///
|
||||
/// So the value belongs here, and it is the SAME number
|
||||
/// [`discard::value_for_definition`] gives the quick-sell payout — one source, so
|
||||
/// the screen and the wallet cannot disagree. Per CARD, not per stack: FUT
|
||||
/// prices a card, and the stack is only a quantity badge over identical copies.
|
||||
///
|
||||
/// `None` (definition not priceable) stays `0` rather than inventing a number.
|
||||
fn stack_discard_value(id: &Fifa17ConsumableIdentity) -> i64 {
|
||||
discard::value_for_definition(id.subtype, id.rareflag, Some(id.rating), id.rating).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// 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` carries the card's real quick-sell price — see
|
||||
/// [`stack_discard_value`]. It used to be `0` on the theory that the client
|
||||
/// priced the card itself; the production screen showed "Quick sell for 0 coins"
|
||||
/// instead, so the stack atom is what the screen reads.
|
||||
///
|
||||
/// 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": stack_discard_value(id),
|
||||
"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,
|
||||
}
|
||||
}
|
||||
|
||||
/// A contract card of the given subtype/rating — the family the production
|
||||
/// screen showed as "0 coins".
|
||||
fn contract(
|
||||
item_id: u32,
|
||||
resource_id: u32,
|
||||
subtype: i64,
|
||||
rating: u8,
|
||||
) -> Fifa17ConsumableIdentity {
|
||||
Fifa17ConsumableIdentity {
|
||||
item_id,
|
||||
resource_id,
|
||||
asset_id: resource_id,
|
||||
card_asset_id: 7,
|
||||
subtype,
|
||||
rareflag: 0,
|
||||
rating,
|
||||
amount: None,
|
||||
contract: Some(1),
|
||||
untradeable: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The stack atom the screen reads MUST carry the same number the quick-sell
|
||||
/// pays. A production club displayed "Quick sell for 0 coins" for contracts
|
||||
/// Core would have paid 3/13/32 for; nothing may reintroduce that gap.
|
||||
#[test]
|
||||
fn stack_discard_value_is_the_payout_and_never_a_silent_zero() {
|
||||
// The three contracts owned by the real production club.
|
||||
let items = vec![
|
||||
contract(1, 5_001_004, 201, 60),
|
||||
contract(2, 5_001_008, 202, 65),
|
||||
contract(3, 5_001_009, 202, 80),
|
||||
];
|
||||
let (body, _) = consumables_response(&items);
|
||||
let stacks = body["itemData"].as_array().unwrap();
|
||||
assert_eq!(stacks.len(), 3);
|
||||
for (stack, id) in stacks.iter().zip(items.iter()) {
|
||||
let shown = stack["discardValue"].as_i64().unwrap();
|
||||
let paid =
|
||||
discard::value_for_definition(id.subtype, id.rareflag, Some(id.rating), id.rating)
|
||||
.expect("a contract definition is priceable");
|
||||
assert_eq!(
|
||||
shown, paid,
|
||||
"displayed must equal payout for {}",
|
||||
id.resource_id
|
||||
);
|
||||
assert!(
|
||||
shown > 0,
|
||||
"{} priced at 0 is the bug we just fixed",
|
||||
id.resource_id
|
||||
);
|
||||
}
|
||||
// The exact recovered values, so a table regression is visible here too.
|
||||
assert_eq!(stacks[0]["discardValue"], 3);
|
||||
assert_eq!(stacks[1]["discardValue"], 13);
|
||||
assert_eq!(stacks[2]["discardValue"], 32);
|
||||
}
|
||||
|
||||
/// Collapsing copies must not multiply the price: FUT prices a CARD, and the
|
||||
/// stack is a quantity badge over identical copies.
|
||||
#[test]
|
||||
fn stack_discard_value_is_per_card_not_per_stack() {
|
||||
let items = vec![
|
||||
contract(1, 5_001_009, 202, 80),
|
||||
contract(2, 5_001_009, 202, 80),
|
||||
contract(3, 5_001_009, 202, 80),
|
||||
];
|
||||
let (body, _) = consumables_response(&items);
|
||||
let stacks = body["itemData"].as_array().unwrap();
|
||||
assert_eq!(stacks.len(), 1);
|
||||
assert_eq!(stacks[0]["count"], 3);
|
||||
assert_eq!(stacks[0]["discardValue"], 32, "per card, not 3 x 32");
|
||||
}
|
||||
|
||||
#[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,230 @@ 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 client's own CONSUMABLE_TYPE→segment switch,
|
||||
/// recovered live 2026-08-22 from CardsDLL: the literal table at `0x1801f5a38`
|
||||
/// (under `MyClubAdapterClass`/`CONSUMABLE_TYPE`) and the jump table at
|
||||
/// `0x180048820`, which indexes by `enum + 1` through the byte table at
|
||||
/// `0x180048a90`. Nine segments, not seven:
|
||||
///
|
||||
/// | enum | segment |
|
||||
/// |------|---------|
|
||||
/// | -1 (unset) | `development` |
|
||||
/// | 1, 2 | `contracts` |
|
||||
/// | 3 | `healing` |
|
||||
/// | 4 | `fitness` |
|
||||
/// | 16 | `formation` |
|
||||
/// | 17 | `position` |
|
||||
/// | 23 | `playStyle` |
|
||||
/// | 24 | `managerLeagueModifier` |
|
||||
/// | 0, 5..15, 18..22 | `training` (the switch default) |
|
||||
///
|
||||
/// This CORRECTS the previous note here, which read the seven-code UI group
|
||||
/// table at `0x180203260` and concluded the two formation-modifier families
|
||||
/// "have NO group code, so no segment can reach them — the client's own gap".
|
||||
/// The client does have a `formation` segment (enum 16), and it asked for
|
||||
/// `development` live, so both were server-side gaps, not client ones.
|
||||
///
|
||||
/// `development` is the **type-unset** bucket: index 0 of a table indexed by
|
||||
/// `enum + 1`, i.e. no type filter was set. It is therefore the unfiltered view
|
||||
/// and maps to every family — which is consistent, since the eight TYPED
|
||||
/// segments already reach all thirteen families exactly once.
|
||||
///
|
||||
/// COMPETING INFERENCE, recorded rather than buried. `fut_consumables.py`'s
|
||||
/// `TYPE_CATEGORIES` maps `development` to card-categories `{6,7,8,9,10}`
|
||||
/// (formation/position/playstyle/manager-league) — i.e. the modifier families
|
||||
/// only, not everything. That grouping is explicitly flagged there as INFERRED
|
||||
/// from `FUN_180048780`'s UI-bucket names, with "the tab-to-arm binding has
|
||||
/// NEVER been observed on the wire".
|
||||
///
|
||||
/// They are not the same enum: the oracle's is the 0..10 CARD-category space of
|
||||
/// `FUN_18013f4d0`, this is the 0..24 CONSUMABLE_TYPE space that actually
|
||||
/// produces the URL segment. The tiebreaker is the switch itself — it gives
|
||||
/// formation (16), position (17), playStyle (23) and managerLeagueModifier (24)
|
||||
/// their OWN segment strings, so those types are not folded into `development`,
|
||||
/// which is what the oracle's grouping would require. The unfiltered reading is
|
||||
/// therefore the better-supported one, but it is still a reading: what the
|
||||
/// SCREEN expects to list has not been observed, and one live capture of the
|
||||
/// development tab would settle it.
|
||||
///
|
||||
/// `training` and `contracts` are CONFIRMED on the wire, `development` was
|
||||
/// observed live, and the singular `contract` is accepted because the client has
|
||||
/// used both spellings. Segments are matched lower-cased.
|
||||
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"],
|
||||
"formation" => &["manager_formation_mod", "formation_mod"],
|
||||
"development" => ALL_CONSUMABLE_FAMILIES,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Every consumable family, i.e. the `development` (type-unset) view. Kept as one
|
||||
/// list so a new family cannot be added to the taxonomy and silently omitted from
|
||||
/// the unfiltered screen.
|
||||
pub const ALL_CONSUMABLE_FAMILIES: &[&str] = &[
|
||||
"gk_training",
|
||||
"player_training",
|
||||
"player_contract",
|
||||
"manager_contract",
|
||||
"player_fitness",
|
||||
"squad_fitness",
|
||||
"healing",
|
||||
"position_mod",
|
||||
"player_playstyle",
|
||||
"gk_playstyle",
|
||||
"manager_league",
|
||||
"manager_formation_mod",
|
||||
"formation_mod",
|
||||
];
|
||||
|
||||
/// 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 +407,178 @@ 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 eight TYPED segments of the client's own switch (enum 1,2,3,4,16,
|
||||
// 17,23,24 plus the default), and the singular `contract` spelling.
|
||||
let segments = [
|
||||
"training",
|
||||
"contracts",
|
||||
"fitness",
|
||||
"healing",
|
||||
"position",
|
||||
"playstyle",
|
||||
"managerleaguemodifier",
|
||||
"formation",
|
||||
];
|
||||
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"
|
||||
);
|
||||
// All THIRTEEN families are reachable: the client does have a `formation`
|
||||
// segment (enum 16), so the two formation modifiers were a server-side
|
||||
// gap, not the client gap this test used to assert.
|
||||
assert_eq!(seen.len(), 13, "no duplicates: {seen:?}");
|
||||
for subtype in [51, 61, 71, 91, 121, 201, 202, 211, 219, 220, 250, 269, 300] {
|
||||
let (family, _) = consumable_family(subtype).unwrap();
|
||||
assert!(seen.contains(&family), "no category serves {family}");
|
||||
}
|
||||
// `development` is the type-UNSET bucket (index 0 of an `enum + 1` table),
|
||||
// i.e. the unfiltered view. It deliberately overlaps the typed segments,
|
||||
// and must stay exactly the union of them so a new family cannot be added
|
||||
// to the taxonomy and silently vanish from the unfiltered screen.
|
||||
let mut dev = consumable_families_for_category("development")
|
||||
.unwrap()
|
||||
.to_vec();
|
||||
dev.sort_unstable();
|
||||
let mut all = seen.clone();
|
||||
all.sort_unstable();
|
||||
assert_eq!(dev, all, "development must be exactly the unfiltered set");
|
||||
// Not a consumables segment (and NOT a `?type=` token either).
|
||||
for s in ["", "player", "kit", "Training"] {
|
||||
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,317 @@
|
||||
//! FIFA 17 **contract consumables** — the shipped EA grant table.
|
||||
//!
|
||||
//! A contract card adds match-contracts to a TARGET card. The number granted is
|
||||
//! selected by two keys: the consumable's own `resourceId` (which card it is)
|
||||
//! and the **TARGET's** rating tier (bronze/silver/gold). The target's tier, not
|
||||
//! the card's — a gold contract card dropped on a bronze player grants the
|
||||
//! BRONZE column. Getting that backwards silently mis-credits every apply, so it
|
||||
//! is stated here as the module's first invariant.
|
||||
//!
|
||||
//! ## Provenance
|
||||
//!
|
||||
//! [`CONTRACT_CARDS`] is the shipped EA table `fcc_contractcards`, transcribed
|
||||
//! verbatim. It was cross-validated cell by cell against the published FIFA 17
|
||||
//! contract matrix: **36 of 36 cells agree** (12 cards × 3 tiers; the 99-special
|
||||
//! is not part of the published matrix). That is the whole basis for these
|
||||
//! numbers — do not compute, interpolate or "correct" them. The table is
|
||||
//! deliberately NOT monotonic in the target's tier: `5001003` grants 15 to a
|
||||
//! bronze target, 11 to a silver one and 13 to a gold one. A "fix" that made it
|
||||
//! monotonic would be an invention.
|
||||
//!
|
||||
//! ## Why the server must own the effect
|
||||
//!
|
||||
//! No client binary reads this table. A full string scan of every `.exe` and
|
||||
//! `.dll` in the FIFA 17 install finds `fcc_contractcards` referenced **nowhere**
|
||||
//! — the client ships the rows but never queries them, so it cannot compute the
|
||||
//! grant and cannot second-guess ours. The effect is therefore
|
||||
//! server-authoritative, and this table is the only non-invented source for it.
|
||||
//!
|
||||
//! ## Deliberately NOT implemented
|
||||
//!
|
||||
//! The "stored managers give up to 50% bonus contracts" mechanic. Its rule is
|
||||
//! UNKNOWN: we have neither the multiplier's rounding, nor which stored managers
|
||||
//! count, nor whether it stacks. Guessing it would corrupt the proven part of the
|
||||
//! grant, so it is absent rather than approximated. This note is the record; it
|
||||
//! is not a TODO, and nothing here reserves a hook for it.
|
||||
//!
|
||||
//! ## Family gating
|
||||
//!
|
||||
//! [`PLAYER_CONTRACT_SUBTYPE`] (201) applies to PLAYERS only and
|
||||
//! [`MANAGER_CONTRACT_SUBTYPE`] (202) to MANAGERS only. A 202 target's tier comes
|
||||
//! from [`staff_tier`], whose input is Core's authored definition rating for the
|
||||
//! staff card (EA's `value` column). When Core carries none, [`staff_tier`]
|
||||
//! answers `None` and the caller must REFUSE the apply — inventing gold (or
|
||||
//! bronze, or the card's own tier) would silently pay out the wrong number with
|
||||
//! no error anywhere.
|
||||
//!
|
||||
//! A contract also cannot be applied to a LOAN item. This crate models no loan
|
||||
//! state, so that gate — like the 201/202 family check — is the caller's: this
|
||||
//! module answers only "how many matches does card X grant a tier-Y target".
|
||||
|
||||
/// `(resource_id, [bronze, silver, gold])` — the grant a contract card makes to
|
||||
/// a target of each tier, keyed by the consumable's FIFA `resourceId`.
|
||||
///
|
||||
/// Rows `5001001`–`5001006` are player contracts (`cardsubtypeid` 201),
|
||||
/// `5001007`–`5001012` their manager counterparts (202), and `5001013` is the
|
||||
/// EASFC 99-contract special (201). Sorted by `resource_id`; keys are unique.
|
||||
const CONTRACT_CARDS: [(u32, [i64; 3]); 13] = [
|
||||
(5_001_001, [8, 2, 1]),
|
||||
(5_001_002, [10, 10, 8]),
|
||||
(5_001_003, [15, 11, 13]),
|
||||
(5_001_004, [15, 6, 3]),
|
||||
(5_001_005, [20, 24, 18]),
|
||||
(5_001_006, [28, 24, 28]),
|
||||
(5_001_007, [8, 2, 1]),
|
||||
(5_001_008, [8, 10, 8]),
|
||||
(5_001_009, [11, 11, 13]),
|
||||
(5_001_010, [15, 6, 3]),
|
||||
(5_001_011, [18, 24, 18]),
|
||||
(5_001_012, [24, 24, 28]),
|
||||
(5_001_013, [99, 99, 99]),
|
||||
];
|
||||
|
||||
/// Which column of [`CONTRACT_CARDS`] a target's rating selects.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ContractTier {
|
||||
Bronze,
|
||||
Silver,
|
||||
Gold,
|
||||
}
|
||||
|
||||
impl ContractTier {
|
||||
/// Column index into a [`CONTRACT_CARDS`] row.
|
||||
const fn column(self) -> usize {
|
||||
match self {
|
||||
ContractTier::Bronze => 0,
|
||||
ContractTier::Silver => 1,
|
||||
ContractTier::Gold => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// The tier's lowercase log token. The three names are the game's own tier
|
||||
/// names, so a log line reads the same as the screen.
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
ContractTier::Bronze => "bronze",
|
||||
ContractTier::Silver => "silver",
|
||||
ContractTier::Gold => "gold",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Card tier from a rating: gold `>= 75`, silver `65..=74`, bronze `< 65`.
|
||||
///
|
||||
/// These are the client's OWN card-level thresholds, not cutoffs chosen here:
|
||||
/// they are the same ladder [`super::discard::discard_level`] reads to key
|
||||
/// `fcc_discardcoins` (`3` if `>= 75`, `2` if `65..=74`, else `1`). The two
|
||||
/// tables index the same three tiers, so a rating that prices as gold also
|
||||
/// contracts as gold.
|
||||
pub fn tier_for_rating(rating: u8) -> ContractTier {
|
||||
if rating >= 75 {
|
||||
ContractTier::Gold
|
||||
} else if rating >= 65 {
|
||||
ContractTier::Silver
|
||||
} else {
|
||||
ContractTier::Bronze
|
||||
}
|
||||
}
|
||||
|
||||
/// Tier of a STAFF target. `None` when Core carries no authoritative value —
|
||||
/// the caller MUST fail closed and never substitute a tier.
|
||||
///
|
||||
/// `source_rating` is EA's authored `value` for the staff definition, i.e. the
|
||||
/// number the CLIENT ITSELF re-rates the card to: it merges a staff record from
|
||||
/// its own `managercards`/`headcoachcards`/`fitnesscoachcards`/`physiocards`/
|
||||
/// `gkcoachcards` table keyed on `carddbid`, ignoring whatever `rating` the
|
||||
/// server sent. Core's `overall` is deliberately 0 for a non-player (it feeds
|
||||
/// pricing and projection), so `overall` is NOT the tier source and must not be
|
||||
/// read as one.
|
||||
///
|
||||
/// The ladder is [`tier_for_rating`], unchanged and not re-thresholded here:
|
||||
/// staff are LIVE-PROVEN to use the SAME ladder as players. `coach_probe.py` and
|
||||
/// `discard_probe.py` agree 4/4 against the running client — manager `value` 88
|
||||
/// re-rates to discard level 3 (gold) and coaches at `value` 66 to level 2
|
||||
/// (silver), exactly as [`super::discard::discard_level`] scores a player.
|
||||
pub fn staff_tier(source_rating: Option<u8>) -> Option<ContractTier> {
|
||||
source_rating.map(tier_for_rating)
|
||||
}
|
||||
|
||||
/// Matches granted by contract consumable `resource_id` against a target of
|
||||
/// `tier`.
|
||||
///
|
||||
/// `None` when `resource_id` is not a known contract card — the caller must
|
||||
/// refuse, never substitute a floor or a neighbouring row. A consumable outside
|
||||
/// the 13 rows has no proven grant, and an invented one is a silent mis-credit.
|
||||
pub fn contract_grant(resource_id: u32, tier: ContractTier) -> Option<i64> {
|
||||
CONTRACT_CARDS
|
||||
.iter()
|
||||
.find(|&&(id, _)| id == resource_id)
|
||||
.map(|&(_, grants)| grants[tier.column()])
|
||||
}
|
||||
|
||||
/// Hard ceiling on match-contracts held by one player or manager: `new =
|
||||
/// min(99, current + grant)`. A card that would overflow the cap is not an
|
||||
/// error — the surplus is simply lost, as in retail.
|
||||
pub const CONTRACT_MATCH_CAP: i64 = 99;
|
||||
|
||||
/// Contracts a pack-fresh player or manager starts with.
|
||||
///
|
||||
/// This is the FIFA-side default for an instance Core tracks no contract for:
|
||||
/// Core stores NULL for "untracked", and the game-specific number to substitute
|
||||
/// lives here rather than in Core.
|
||||
pub const PACK_FRESH_CONTRACT_MATCHES: i64 = 7;
|
||||
|
||||
/// `cardsubtypeid` of a PLAYER contract card. Applies to players only.
|
||||
pub const PLAYER_CONTRACT_SUBTYPE: i64 = 201;
|
||||
|
||||
/// `cardsubtypeid` of a MANAGER contract card. Applies to managers only; the
|
||||
/// target's tier comes from [`staff_tier`] over Core's authored staff rating.
|
||||
pub const MANAGER_CONTRACT_SUBTYPE: i64 = 202;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The three tiers are the client's own rating ladder, so the boundaries are
|
||||
/// exact: 64/65 and 74/75. An off-by-one here reads the wrong COLUMN and
|
||||
/// silently grants the wrong number.
|
||||
#[test]
|
||||
fn tier_boundaries_are_the_clients_own_thresholds() {
|
||||
assert_eq!(tier_for_rating(0), ContractTier::Bronze);
|
||||
assert_eq!(tier_for_rating(64), ContractTier::Bronze);
|
||||
assert_eq!(tier_for_rating(65), ContractTier::Silver);
|
||||
assert_eq!(tier_for_rating(74), ContractTier::Silver);
|
||||
assert_eq!(tier_for_rating(75), ContractTier::Gold);
|
||||
assert_eq!(tier_for_rating(99), ContractTier::Gold);
|
||||
}
|
||||
|
||||
/// A staff target Core carries no authored rating for has NO tier. `None` is
|
||||
/// what lets the caller refuse; defaulting to bronze would silently under-pay
|
||||
/// a gold manager, and defaulting to gold would over-pay every unknown one.
|
||||
#[test]
|
||||
fn an_unrated_staff_target_has_no_tier() {
|
||||
assert_eq!(staff_tier(None), None);
|
||||
}
|
||||
|
||||
/// Staff read the SAME ladder as players, so the boundaries are the same
|
||||
/// exact 64/65 and 74/75 — `staff_tier` must not re-threshold.
|
||||
#[test]
|
||||
fn staff_tier_boundaries_are_the_player_ladder() {
|
||||
assert_eq!(staff_tier(Some(64)), Some(ContractTier::Bronze));
|
||||
assert_eq!(staff_tier(Some(65)), Some(ContractTier::Silver));
|
||||
assert_eq!(staff_tier(Some(74)), Some(ContractTier::Silver));
|
||||
assert_eq!(staff_tier(Some(75)), Some(ContractTier::Gold));
|
||||
for rating in 0..=99u8 {
|
||||
assert_eq!(
|
||||
staff_tier(Some(rating)),
|
||||
Some(tier_for_rating(rating)),
|
||||
"rating {rating} must not diverge from the shared ladder"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The two values the live client was actually observed re-rating: the
|
||||
/// squad manager at `value` 88 scored discard level 3 (gold) and the coaches
|
||||
/// at `value` 66 scored level 2 (silver), 4/4 across `coach_probe.py` and
|
||||
/// `discard_probe.py`. These are the ONLY staff tiers with live proof, so
|
||||
/// they are pinned here rather than left to the generic boundary test.
|
||||
#[test]
|
||||
fn the_live_probed_staff_values_score_their_observed_tiers() {
|
||||
assert_eq!(staff_tier(Some(88)), Some(ContractTier::Gold), "manager 88");
|
||||
assert_eq!(staff_tier(Some(66)), Some(ContractTier::Silver), "coach 66");
|
||||
}
|
||||
|
||||
/// The tier ladder must stay locked to the discard ladder it was taken from:
|
||||
/// both index the same three card tiers, and a divergence would mean one of
|
||||
/// the two is no longer the client's.
|
||||
#[test]
|
||||
fn the_tier_ladder_is_the_discard_ladder() {
|
||||
for rating in 0..=99u8 {
|
||||
let expected = match crate::fut::discard::discard_level(rating) {
|
||||
1 => ContractTier::Bronze,
|
||||
2 => ContractTier::Silver,
|
||||
_ => ContractTier::Gold,
|
||||
};
|
||||
assert_eq!(tier_for_rating(rating), expected, "rating {rating}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Spot-check across both families, including the row that proves the table
|
||||
/// is not monotonic and the 99-special that has no published row.
|
||||
#[test]
|
||||
fn grant_matrix_cells_are_the_shipped_ea_values() {
|
||||
// Player contracts.
|
||||
assert_eq!(contract_grant(5_001_001, ContractTier::Bronze), Some(8));
|
||||
assert_eq!(contract_grant(5_001_001, ContractTier::Gold), Some(1));
|
||||
assert_eq!(contract_grant(5_001_004, ContractTier::Silver), Some(6));
|
||||
assert_eq!(contract_grant(5_001_006, ContractTier::Silver), Some(24));
|
||||
// Manager contracts. 5001008 is where the two families diverge: the
|
||||
// player row grants 10 to a bronze target, the manager row 8.
|
||||
assert_eq!(contract_grant(5_001_002, ContractTier::Bronze), Some(10));
|
||||
assert_eq!(contract_grant(5_001_008, ContractTier::Bronze), Some(8));
|
||||
assert_eq!(contract_grant(5_001_011, ContractTier::Gold), Some(18));
|
||||
// The EASFC special pays 99 on every tier.
|
||||
for tier in [
|
||||
ContractTier::Bronze,
|
||||
ContractTier::Silver,
|
||||
ContractTier::Gold,
|
||||
] {
|
||||
assert_eq!(contract_grant(5_001_013, tier), Some(99), "{tier:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The matrix is authored EA data, NOT a formula: `5001003` grants MORE to a
|
||||
/// bronze target (15) than to a gold one (13), and dips at silver (11). Any
|
||||
/// "corrected" monotonic table fails here.
|
||||
#[test]
|
||||
fn the_matrix_is_deliberately_not_monotonic() {
|
||||
let bronze = contract_grant(5_001_003, ContractTier::Bronze).unwrap();
|
||||
let silver = contract_grant(5_001_003, ContractTier::Silver).unwrap();
|
||||
let gold = contract_grant(5_001_003, ContractTier::Gold).unwrap();
|
||||
assert_eq!((bronze, silver, gold), (15, 11, 13));
|
||||
assert!(bronze > gold, "bronze target out-grants gold on this row");
|
||||
assert!(silver < gold, "and silver is the trough, not the middle");
|
||||
}
|
||||
|
||||
/// A consumable outside the 13 contract rows has NO proven grant. Returning
|
||||
/// `None` is what lets the caller refuse; a floor or a nearest-row guess
|
||||
/// would be a silent mis-credit.
|
||||
#[test]
|
||||
fn a_non_contract_resource_id_has_no_grant() {
|
||||
// 5003012 is a training card — a different consumable family entirely.
|
||||
for tier in [
|
||||
ContractTier::Bronze,
|
||||
ContractTier::Silver,
|
||||
ContractTier::Gold,
|
||||
] {
|
||||
assert_eq!(contract_grant(5_003_012, tier), None);
|
||||
assert_eq!(contract_grant(0, tier), None);
|
||||
assert_eq!(contract_grant(5_001_000, tier), None, "just below the run");
|
||||
assert_eq!(contract_grant(5_001_014, tier), None, "just above the run");
|
||||
}
|
||||
}
|
||||
|
||||
/// The table is a lookup keyed on exact ids: 13 rows, no duplicates, sorted.
|
||||
/// A duplicated key would make `find` silently prefer whichever came first.
|
||||
#[test]
|
||||
fn the_table_keys_are_unique_and_sorted() {
|
||||
for pair in CONTRACT_CARDS.windows(2) {
|
||||
assert!(pair[0].0 < pair[1].0, "{:?} then {:?}", pair[0], pair[1]);
|
||||
}
|
||||
assert_eq!(CONTRACT_CARDS.len(), 13);
|
||||
}
|
||||
|
||||
/// Every grant is a real number of matches within the cap — the cap can
|
||||
/// truncate an ADDITION, but no single card grants more than a full card.
|
||||
#[test]
|
||||
fn every_grant_is_positive_and_within_the_cap() {
|
||||
for (id, grants) in CONTRACT_CARDS {
|
||||
for grant in grants {
|
||||
assert!(
|
||||
grant > 0 && grant <= CONTRACT_MATCH_CAP,
|
||||
"{id} grants {grant}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
//! 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
|
||||
}
|
||||
|
||||
/// THE authoritative FIFA 17 discard price for one owned definition, or `None`
|
||||
/// when an input the client itself uses is not in hand.
|
||||
///
|
||||
/// This is the single entry point every caller must use — the wire shaper and
|
||||
/// the quick-sell payout both reach it through
|
||||
/// [`super::item::ItemIdentityResolver::discard_value`], so the number displayed
|
||||
/// and the number credited cannot diverge.
|
||||
///
|
||||
/// `None` means "not known", never "worthless", and the caller falls back to the
|
||||
/// legacy ladder rather than inventing a price:
|
||||
///
|
||||
/// * `cardtype == 0` — the subtype decodes to no table row at all.
|
||||
/// * a CLIENT-RE-RATED cardtype ([`client_rerates`]: the five staff families)
|
||||
/// with no `catalog_rating`. Those price from the client's OWN database, so
|
||||
/// without that value we cannot match what it displays.
|
||||
///
|
||||
/// Everything else uses `catalog_rating`, falling back to Core's rating. For the
|
||||
/// cardtypes the client does NOT re-rate (1, 6, 7, 8, 9) the server's rating is
|
||||
/// authoritative — whatever we send is what the client prices with — so Core's
|
||||
/// value is the right answer even when it is 0.
|
||||
///
|
||||
/// That zero is not a gap. A club item's wire record
|
||||
/// ([`super::item::shape_club_item`]) carries neither `rating` nor
|
||||
/// `discardValue`, so the client computes for itself from `+0xb4 == 0`: level 1,
|
||||
/// and `0 * price / 100 == 0`. **The client displays 0, so 0 is the correct
|
||||
/// payout.** Paying anything else would invent value the player was never shown.
|
||||
pub fn value_for_definition(
|
||||
subtype: i64,
|
||||
rareflag: i64,
|
||||
catalog_rating: Option<u8>,
|
||||
core_rating: u8,
|
||||
) -> Option<i64> {
|
||||
let cardtype = cardtype_for_subtype(subtype);
|
||||
if cardtype == 0 {
|
||||
return None;
|
||||
}
|
||||
let rating = match catalog_rating {
|
||||
Some(r) => r,
|
||||
// Not re-rated by the client => whatever the server sends is what it
|
||||
// prices with, so Core's rating is authoritative even at 0.
|
||||
None if !client_rerates(cardtype) => core_rating,
|
||||
// Re-rated => the client substitutes its own value and we cannot match it.
|
||||
None => return None,
|
||||
};
|
||||
Some(discard_value(cardtype, rating, rareflag))
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
/// A player is priced from Core's rating and the catalog's `rareflag`, so a
|
||||
/// special and a common of the SAME rating price differently. The legacy
|
||||
/// ladder paid 1500 for every one of these.
|
||||
#[test]
|
||||
fn a_players_price_follows_its_rareflag_not_just_its_rating() {
|
||||
// rare 3 (TOTW) at level 3 -> price 12200; 90 * 12200 / 100.
|
||||
assert_eq!(value_for_definition(0, 3, None, 90), Some(10_980));
|
||||
// Same rating, rare 0 (gold common) -> 4 * rating.
|
||||
assert_eq!(value_for_definition(0, 0, None, 90), Some(360));
|
||||
// Same rating, rare 1 (gold rare) -> 8 * rating.
|
||||
assert_eq!(value_for_definition(0, 1, None, 90), Some(720));
|
||||
}
|
||||
|
||||
/// A consumable's rating is EA's authored one from the catalog, never Core's
|
||||
/// 0 — and cardtype 6 is not re-rated, so the server's values are what the
|
||||
/// client itself prices with.
|
||||
#[test]
|
||||
fn a_consumable_prices_from_its_catalog_rating() {
|
||||
// subtype 201 -> cardtype 6, rating 60 -> level 1, rare 0 -> price 5.
|
||||
assert_eq!(value_for_definition(201, 0, Some(60), 0), Some(3));
|
||||
assert!(!client_rerates(cardtype_for_subtype(201)));
|
||||
}
|
||||
|
||||
/// The two "not known" cases MUST decline rather than pay 0.
|
||||
#[test]
|
||||
fn an_unknown_input_declines_instead_of_paying_zero() {
|
||||
// Staff: cardtype 10, no catalog rating. Core's rating is 0, which would
|
||||
// price the card at 0 coins.
|
||||
assert_eq!(cardtype_for_subtype(6), 10);
|
||||
assert_eq!(value_for_definition(6, 0, None, 0), None);
|
||||
// A subtype with no table row at all.
|
||||
assert_eq!(value_for_definition(600, 0, Some(80), 80), None);
|
||||
// Once the rating IS known, staff price normally.
|
||||
assert_eq!(value_for_definition(6, 0, Some(66), 0), Some(36));
|
||||
}
|
||||
|
||||
/// CLUB ITEMS ARE ZERO-VALUE under the current projection, and that is a
|
||||
/// derived fact rather than a gap. `shape_club_item` sends no `rating` and no
|
||||
/// `discardValue`, so the client computes for itself from record `+0xb4 == 0`:
|
||||
/// level 1, `0 * price / 100 == 0`. It DISPLAYS 0, so 0 is the only payout
|
||||
/// that matches. The old ladder invented 150 for each of these.
|
||||
#[test]
|
||||
fn club_items_are_zero_value_not_a_fallback_to_an_invented_price() {
|
||||
use crate::fut::content_taxonomy as tax;
|
||||
for subtype in [
|
||||
tax::KIT_SUBTYPE,
|
||||
tax::STADIUM_SUBTYPE,
|
||||
tax::BADGE_SUBTYPE,
|
||||
tax::BALL_SUBTYPE,
|
||||
tax::LEAGUE_LOGO_SUBTYPE,
|
||||
] {
|
||||
let ct = cardtype_for_subtype(subtype);
|
||||
assert!(
|
||||
!client_rerates(ct),
|
||||
"subtype {subtype} must not be re-rated"
|
||||
);
|
||||
assert_eq!(
|
||||
value_for_definition(subtype, 0, None, 0),
|
||||
Some(0),
|
||||
"subtype {subtype} must price at exactly 0, not decline to a ladder"
|
||||
);
|
||||
}
|
||||
// A staff family, by contrast, DECLINES without its rating -- we cannot
|
||||
// know what the client re-rated it to.
|
||||
assert_eq!(value_for_definition(6, 0, None, 0), None);
|
||||
}
|
||||
|
||||
/// 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, STADIUM_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)]
|
||||
@@ -44,6 +48,20 @@ pub struct CoreOwnedItem {
|
||||
pub club: String,
|
||||
/// [pace, shooting, passing, dribbling, defending, physical].
|
||||
pub attributes: [u8; 6],
|
||||
/// Match-contracts remaining on this instance, as persisted by Core.
|
||||
/// `None` = Core tracks none, so the caller substitutes the pack-fresh
|
||||
/// default ([`super::contract_cards::PACK_FRESH_CONTRACT_MATCHES`] for a
|
||||
/// player, [`STAFF_CONTRACT`] for staff). Core deliberately stores NULL for
|
||||
/// "untracked" rather than seeding a number, so the game-specific default
|
||||
/// stays on this side of the boundary.
|
||||
pub contract_matches: Option<i64>,
|
||||
/// EA's authored definition rating for a non-player, from Core. `None` = Core
|
||||
/// tracks none; callers MUST fail closed rather than substitute a tier.
|
||||
pub source_rating: Option<u8>,
|
||||
/// Core's own `content_kind` token for this instance, verbatim. Distinct from
|
||||
/// the adapter catalog's kind: Core calls the squad manager `manager` while the
|
||||
/// catalog classifies it `staff` + subtype 4.
|
||||
pub core_content_kind: Option<String>,
|
||||
}
|
||||
|
||||
/// The FIFA-side numeric identity of an owned item. `asset_id` MUST be a real
|
||||
@@ -66,12 +84,177 @@ 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,
|
||||
/// Kit slot family: `2` home, `3` away, `5` third. Read at record `+0xb8`
|
||||
/// and mapped to the engine kit SLOT (2→0, 3→1, 5→3).
|
||||
pub category: i64,
|
||||
/// Kit season; `0` = current season. Record `+0xba`.
|
||||
pub year: 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 +268,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,
|
||||
@@ -113,10 +313,21 @@ fn discard_value(rating: u8) -> i64 {
|
||||
/// identical by construction. `id` is the owned instance's resolved FIFA
|
||||
/// identity — pass the resolver's answer for *this* owned copy so two copies of
|
||||
/// one definition stay distinct on the wire.
|
||||
///
|
||||
/// `discard_value` and `contract` are explicit scalars for the same reason: both
|
||||
/// are per-instance numbers this shaper must not invent. `contract` used to be a
|
||||
/// hardcoded `7`, which made every card look pack-fresh no matter how many
|
||||
/// matches it had played or how many contracts had been applied to it. The
|
||||
/// caller passes [`CoreOwnedItem::contract_matches`] resolved against
|
||||
/// [`super::contract_cards::PACK_FRESH_CONTRACT_MATCHES`] — the substitution for
|
||||
/// Core's "untracked" NULL belongs to the caller, because the default is
|
||||
/// FIFA-specific and Core stores no number to speak for it.
|
||||
pub fn shape_item(
|
||||
item: &CoreOwnedItem,
|
||||
id: Fifa17Identity,
|
||||
ent: &impl ReverseEntityResolver,
|
||||
discard_value: i64,
|
||||
contract: i64,
|
||||
) -> Value {
|
||||
let asset = id.asset_id;
|
||||
let league_id = ent.league_id(&item.league).unwrap_or(0);
|
||||
@@ -144,7 +355,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
|
||||
@@ -152,15 +363,256 @@ pub fn shape_item(
|
||||
// "our own data showing through" bug the Python oracle fixed by forcing
|
||||
// this off for owned copies (item_def keeps `true`; instances do not).
|
||||
"untradeable": false,
|
||||
"contract": 7,
|
||||
"contract": contract,
|
||||
"fitness": 99,
|
||||
"discardValue": discard_value(item.rating),
|
||||
"discardValue": discard_value,
|
||||
})
|
||||
}
|
||||
|
||||
/// Wire `itemType` (atom 0x173) for a cardtype-7 club item.
|
||||
///
|
||||
/// Sent for WIRE FIDELITY only. Every real EA item in the capture corpus carries
|
||||
/// `itemType`, and the two families OpenFUT already shaped (`player`, `staff`)
|
||||
/// carry it, so omitting it on the club families was an inconsistency. Tokens
|
||||
/// come from the `?type=` vocabulary decoded from the `FUN_18012ec50` jump table
|
||||
/// (`kit` 12, `stadium` 13, `badge` 11).
|
||||
///
|
||||
/// It does NOT fix the pre-match kit selector, and the reasoning that first
|
||||
/// introduced it was WRONG. That reasoning was: kit/badge/stadium omitted
|
||||
/// `itemType` and were not resident as item records, while player and staff sent
|
||||
/// it and were, so `itemType` must gate ingestion. Adding it changed nothing —
|
||||
/// the client was relaunched, `?type=kit` answered `total=2 emitted=2` with
|
||||
/// `itemType` present, and still no cardtype-7 record was resident.
|
||||
///
|
||||
/// The correlation was an artefact of the CONTROL, not the field. Measured
|
||||
/// 2026-08-23 read-only over `/proc/PID/mem`: the "resident" players and staff
|
||||
/// were all SQUAD members, which arrive via `userMassInfo`. Testing players that
|
||||
/// appear in `/club?type=player` but NOT in `userMassInfo` shows they are not
|
||||
/// resident either — 0 records for 6 of 6 sampled, 5 with no byte match at all,
|
||||
/// out of 1966 served. So residency tracks the ROUTE, not this field:
|
||||
/// `/club?type=` responses do not enter the persistent card collection, and no
|
||||
/// value of `itemType` changes that.
|
||||
///
|
||||
/// `CARD_SYSTEM.md`'s "parsed into a heap string and never stored" therefore
|
||||
/// stands unchallenged; the earlier note here that it was "evidence the string is
|
||||
/// consulted" is withdrawn.
|
||||
///
|
||||
/// INFERRED, not proven: no capture of a real EA club item exists anywhere in the
|
||||
/// corpus, so the exact token for these three families is taken from the atom
|
||||
/// vocabulary rather than observed on the wire.
|
||||
fn club_item_type(subtype: i64) -> &'static str {
|
||||
match subtype {
|
||||
KIT_SUBTYPE => "kit",
|
||||
STADIUM_SUBTYPE => "stadium",
|
||||
BADGE_SUBTYPE => "badge",
|
||||
_ => "misc",
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
"itemType": club_item_type(id.subtype),
|
||||
"itemState": item_state,
|
||||
"owners": 1,
|
||||
"untradeable": false,
|
||||
});
|
||||
if matches!(id.subtype, KIT_SUBTYPE | BADGE_SUBTYPE) {
|
||||
item["teamid"] = json!(id.team_id);
|
||||
}
|
||||
// Kits only. `category` and `year` complete the `(teamid, year, slot)`
|
||||
// identity the client's active-kit resolver builds at record `+0xb8`/`+0xba`
|
||||
// (`FUN_1800d73d0`), and which the engine's kit descriptor
|
||||
// (`sub_180033430`) compares against before it will name — rather than
|
||||
// lock — a kit. Without them the triple can never match and the pre-match
|
||||
// selector reports "This kit is currently locked".
|
||||
//
|
||||
// Deliberately NOT emitted for badges or stadiums: the slot mapping is
|
||||
// kit-specific, and those families resolve their caption by other fields.
|
||||
if id.subtype == KIT_SUBTYPE {
|
||||
item["category"] = json!(id.category);
|
||||
item["year"] = json!(id.year);
|
||||
}
|
||||
item
|
||||
}
|
||||
|
||||
/// Pack-fresh contracts on a staff card — the FALLBACK for an instance Core
|
||||
/// tracks no contract for, no longer an unconditional constant.
|
||||
///
|
||||
/// Staff consume contracts exactly as players do (`rec+0x8c`), and the client
|
||||
/// refuses to start a match when the manager's has run out. A caller holding an
|
||||
/// owned item passes `item.contract_matches.unwrap_or(STAFF_CONTRACT)`, so a
|
||||
/// tracked staff instance now reports its real remaining matches and only an
|
||||
/// untracked one falls back here. The value matches
|
||||
/// [`super::contract_cards::PACK_FRESH_CONTRACT_MATCHES`] rather than inventing a
|
||||
/// second, different default for the staff families.
|
||||
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::contract_cards::PACK_FRESH_CONTRACT_MATCHES;
|
||||
use crate::fut::entities::Fifa17Entities;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -182,6 +634,12 @@ mod tests {
|
||||
league: "Premier League".into(),
|
||||
club: "Chelsea".into(),
|
||||
attributes: [90, 88, 70, 85, 40, 78],
|
||||
// Untracked by default; the contract tests below set it explicitly.
|
||||
contract_matches: None,
|
||||
// Players: their rating IS `overall`, so Core carries no separate
|
||||
// authored definition rating, and these fixtures are player items.
|
||||
source_rating: None,
|
||||
core_content_kind: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +655,8 @@ mod tests {
|
||||
rareflag: 1,
|
||||
},
|
||||
&ent,
|
||||
legacy_discard_value(86),
|
||||
PACK_FRESH_CONTRACT_MATCHES,
|
||||
);
|
||||
assert_eq!(it["id"], 100000001, "wire instance id");
|
||||
assert_eq!(it["resourceId"], 20801);
|
||||
@@ -230,6 +690,8 @@ mod tests {
|
||||
rareflag: 1,
|
||||
},
|
||||
&ent,
|
||||
legacy_discard_value(84),
|
||||
PACK_FRESH_CONTRACT_MATCHES,
|
||||
);
|
||||
let b = shape_item(
|
||||
&item("oc-b", "fifa17_101490", 84, "ST"),
|
||||
@@ -240,6 +702,8 @@ mod tests {
|
||||
rareflag: 1,
|
||||
},
|
||||
&ent,
|
||||
legacy_discard_value(84),
|
||||
PACK_FRESH_CONTRACT_MATCHES,
|
||||
);
|
||||
assert_eq!(
|
||||
a["resourceId"], b["resourceId"],
|
||||
@@ -268,6 +732,8 @@ mod tests {
|
||||
rareflag: 3,
|
||||
},
|
||||
&ent,
|
||||
legacy_discard_value(92),
|
||||
PACK_FRESH_CONTRACT_MATCHES,
|
||||
);
|
||||
assert_eq!(
|
||||
it["resourceId"], 117617092,
|
||||
@@ -281,4 +747,362 @@ mod tests {
|
||||
"special rareflag carried, not hardcoded 1"
|
||||
);
|
||||
}
|
||||
|
||||
/// `contract` USED to be a hardcoded `7`, so every card looked pack-fresh no
|
||||
/// matter what Core had persisted — a contract consumable could be applied,
|
||||
/// committed and then be invisible on the very screen that spends it. The
|
||||
/// shaper must emit the number it was PASSED.
|
||||
#[test]
|
||||
fn contract_is_the_passed_value_not_a_constant() {
|
||||
let ent = entities();
|
||||
let id = Fifa17Identity {
|
||||
item_id: 100000001,
|
||||
asset_id: 20801,
|
||||
resource_id: 20801,
|
||||
rareflag: 1,
|
||||
};
|
||||
let base = item("oc1", "card_ch_1", 86, "CDM");
|
||||
let it = shape_item(&base, id, &ent, legacy_discard_value(86), 22);
|
||||
assert_eq!(it["contract"], 22, "the passed count, not 7");
|
||||
// The whole 0..=99 range reaches the wire verbatim, including a spent
|
||||
// card (0) and a capped one (99) — no clamping, no substitution.
|
||||
for contract in [0, 1, 7, 22, 99] {
|
||||
let it = shape_item(&base, id, &ent, legacy_discard_value(86), contract);
|
||||
assert_eq!(it["contract"], contract);
|
||||
}
|
||||
// `fitness` is the same class of hardcode and deliberately out of scope
|
||||
// here; asserting it keeps this test honest about what it proved.
|
||||
assert_eq!(it["fitness"], 99);
|
||||
}
|
||||
|
||||
/// Core stores NULL for an instance it tracks no contract for, so the
|
||||
/// FIFA-specific pack-fresh default is substituted by the CALLER — the same
|
||||
/// `unwrap_or` both production call sites use.
|
||||
#[test]
|
||||
fn an_untracked_instance_falls_back_to_pack_fresh() {
|
||||
let ent = entities();
|
||||
let id = Fifa17Identity {
|
||||
item_id: 100000001,
|
||||
asset_id: 20801,
|
||||
resource_id: 20801,
|
||||
rareflag: 1,
|
||||
};
|
||||
let mut untracked = item("oc1", "card_ch_1", 86, "CDM");
|
||||
untracked.contract_matches = None;
|
||||
let it = shape_item(
|
||||
&untracked,
|
||||
id,
|
||||
&ent,
|
||||
legacy_discard_value(86),
|
||||
untracked
|
||||
.contract_matches
|
||||
.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
|
||||
);
|
||||
assert_eq!(it["contract"], PACK_FRESH_CONTRACT_MATCHES);
|
||||
assert_eq!(it["contract"], 7, "the proven pack-fresh count");
|
||||
|
||||
// A tracked instance is NOT overwritten by the fallback.
|
||||
let mut tracked = item("oc1", "card_ch_1", 86, "CDM");
|
||||
tracked.contract_matches = Some(31);
|
||||
let it = shape_item(
|
||||
&tracked,
|
||||
id,
|
||||
&ent,
|
||||
legacy_discard_value(86),
|
||||
tracked
|
||||
.contract_matches
|
||||
.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
|
||||
);
|
||||
assert_eq!(it["contract"], 31);
|
||||
}
|
||||
|
||||
/// 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),
|
||||
PACK_FRESH_CONTRACT_MATCHES,
|
||||
);
|
||||
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,
|
||||
category: 2,
|
||||
year: 0,
|
||||
};
|
||||
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,
|
||||
category: 2,
|
||||
year: 0,
|
||||
};
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The pre-match kit selector needs the WHOLE identity triple, not just
|
||||
/// `teamid`.
|
||||
///
|
||||
/// The client's active-kit resolver `FUN_1800d73d0` reads `category` at
|
||||
/// record `+0xb8` (mapping 2→slot 0, 3→slot 1, 5→slot 3) and `year` at
|
||||
/// `+0xba`, and the engine's kit descriptor `sub_180033430` will only NAME a
|
||||
/// kit whose decoded `(teamid, year, slot)` equals the club's active home or
|
||||
/// away triple. A descriptor it does not match is left completely unwritten
|
||||
/// and the engine reports "This kit is currently locked" instead.
|
||||
///
|
||||
/// Regression: we shipped kits with `teamid` alone, which cannot match.
|
||||
#[test]
|
||||
fn a_kit_carries_the_full_identity_triple_the_selector_matches_on() {
|
||||
let kit = Fifa17KitIdentity {
|
||||
item_id: 100_004_874,
|
||||
asset_id: 6_300_006,
|
||||
resource_id: 6_300_006,
|
||||
card_asset_id: 35,
|
||||
subtype: KIT_SUBTYPE,
|
||||
team_id: 21,
|
||||
category: 2,
|
||||
year: 0,
|
||||
};
|
||||
let home = shape_club_item(kit, item_state::ACTIVE_HOME_KIT);
|
||||
assert_eq!(home["teamid"], 21);
|
||||
assert_eq!(home["category"], 2, "category is the SLOT source");
|
||||
assert_eq!(home["year"], 0, "year completes the triple");
|
||||
assert_eq!(home["itemState"], item_state::ACTIVE_HOME_KIT);
|
||||
|
||||
// A historical kit must round-trip its real season, not be flattened.
|
||||
let historical = shape_club_item(
|
||||
Fifa17KitIdentity {
|
||||
year: 2002,
|
||||
category: 5,
|
||||
..kit
|
||||
},
|
||||
item_state::ACTIVE_HOME_KIT,
|
||||
);
|
||||
assert_eq!(historical["year"], 2002);
|
||||
assert_eq!(historical["category"], 5);
|
||||
|
||||
// Badges and stadiums must NOT gain the kit-only fields: the slot map is
|
||||
// kit-specific and this project has frozen the client before by sending
|
||||
// a family a field its resolver does not read.
|
||||
for subtype in [BADGE_SUBTYPE, STADIUM_SUBTYPE] {
|
||||
let other = shape_club_item(Fifa17KitIdentity { subtype, ..kit }, item_state::FREE);
|
||||
assert!(other.get("category").is_none(), "subtype {subtype}");
|
||||
assert!(other.get("year").is_none(), "subtype {subtype}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every cardtype-7 family MUST carry a DISTINCT `itemType`.
|
||||
///
|
||||
/// Measured live 2026-08-23: the two families that carry `itemType`
|
||||
/// (player, staff) become resident item records and the three that omitted
|
||||
/// it (kit, badge, stadium) did not, leaving the pre-match kit selector with
|
||||
/// an empty DataProvider and a "kit is currently locked" dialog.
|
||||
///
|
||||
/// The distinctness half is not pedantry. `STADIUM_SUBTYPE` was initially
|
||||
/// unimported here, so `match` read it as a fresh binding rather than a
|
||||
/// constant, silently made the stadium arm irrefutable, and typed badges as
|
||||
/// `"stadium"`. It compiled with only an unused-variable warning. Asserting
|
||||
/// three different tokens is what catches that class of mistake.
|
||||
#[test]
|
||||
fn every_cardtype7_family_carries_its_own_item_type() {
|
||||
let base = Fifa17KitIdentity {
|
||||
item_id: 100004874,
|
||||
asset_id: 6300006,
|
||||
resource_id: 6300006,
|
||||
card_asset_id: 35,
|
||||
subtype: KIT_SUBTYPE,
|
||||
team_id: 21,
|
||||
category: 2,
|
||||
year: 0,
|
||||
};
|
||||
let type_of = |subtype| {
|
||||
shape_club_item(Fifa17KitIdentity { subtype, ..base }, item_state::FREE)["itemType"]
|
||||
.as_str()
|
||||
.expect("itemType is always emitted")
|
||||
.to_string()
|
||||
};
|
||||
assert_eq!(type_of(KIT_SUBTYPE), "kit");
|
||||
assert_eq!(type_of(STADIUM_SUBTYPE), "stadium");
|
||||
assert_eq!(type_of(BADGE_SUBTYPE), "badge");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,16 +7,24 @@
|
||||
pub mod catalog;
|
||||
pub mod club_response;
|
||||
pub mod club_stats;
|
||||
pub mod consumables;
|
||||
pub mod content_taxonomy;
|
||||
pub mod contract_cards;
|
||||
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;
|
||||
pub mod store_catalog;
|
||||
pub mod store_session;
|
||||
pub mod training_cards;
|
||||
|
||||
@@ -24,9 +24,50 @@ pub fn accountinfo_body() -> Value {
|
||||
json!({})
|
||||
}
|
||||
|
||||
/// `GET …/settings` — production oracle returns an empty config list.
|
||||
pub fn settings_body() -> Value {
|
||||
json!({ "configs": [] })
|
||||
/// The commerce flags the client leaves OFF unless a `configs` row turns them on.
|
||||
///
|
||||
/// `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": [] });
|
||||
}
|
||||
let rows: Vec<Value> = COMMERCE_SETTINGS
|
||||
.iter()
|
||||
.map(|name| json!({ "type": name, "value": 1 }))
|
||||
.collect();
|
||||
json!({ "configs": rows })
|
||||
}
|
||||
|
||||
/// `GET …/leaderboards/options` — production oracle (FUT_MODES off) returns an
|
||||
@@ -67,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()
|
||||
@@ -365,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;
|
||||
@@ -415,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!(
|
||||
@@ -628,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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,20 +7,16 @@
|
||||
//! (only card ids that resolve in BOTH the FIFA catalogue and Core content),
|
||||
//! mints the drawn cards into Core, and shapes them onto the wire.
|
||||
//!
|
||||
//! ## Parity note — Python `open_pack` / `_pack_body`
|
||||
//! (`fifa17-recon/tools/fut_store.py:689`, `utas_server.py:3474`)
|
||||
//! 1. `open_pack(price, count, gold, tiers, special_chance)` deducts coins then
|
||||
//! draws `count` items (mostly players); the reveal body wraps them verbatim.
|
||||
//! 2. Non-tiered draws split the pool at rating 75 by `gold` (`p[1] >= 75 == gold`)
|
||||
//! and fall back to the whole pool when that tier is empty (`... or PACK_POOL`).
|
||||
//! 3. Each drawn player becomes a special with probability `special_chance`
|
||||
//! (`random.random() < special_chance`).
|
||||
//! 4. `FUT_PACK_MIX` swaps ~`count // 4` players for consumables/staff extras;
|
||||
//! we deliberately OMIT that mix (Core candidates are player defs — players-only).
|
||||
//! 5. Prices/counts/odds are the OpenFUT **PLACEHOLDER** economy (the audit found
|
||||
//! them invented); only the wire *shape* is EA-observed/oracle-verified.
|
||||
//! 6. This port reproduces the count + gold-tier split + `special_chance` gate as
|
||||
//! that same PLACEHOLDER policy, drawing with replacement from the pool.
|
||||
//! ## Policy (real FUT 17 composition, DESIGNED odds)
|
||||
//!
|
||||
//! A pack draws [`PackDef::count`] cards split across rating tiers by the pack's
|
||||
//! `n_bronze`/`n_silver`/`n_gold` composition (the same numbers the tile shows in
|
||||
//! `packContentInfo`), drawing with replacement from the candidate pool. Each pick
|
||||
//! is biased toward a special version with probability `special_chance` (a DESIGNED
|
||||
//! placeholder — FUT 17 pack odds are unrecoverable). An empty tier falls back to
|
||||
//! the whole pool so a draw is always possible even when the pool lacks that tier.
|
||||
//! `FUT_PACK_MIX` (consumable/staff extras) is deliberately OMITTED — Core
|
||||
//! candidates are player defs.
|
||||
|
||||
use rand::Rng;
|
||||
|
||||
@@ -77,50 +73,83 @@ pub struct GeneratedCard {
|
||||
pub attributes: [u8; 6],
|
||||
}
|
||||
|
||||
/// Draw `pack.count` cards from `pool` with the injected RNG. Pure and
|
||||
/// deterministic under a seeded RNG. Returns an empty `Vec` (fail-closed) when
|
||||
/// the pool is empty or the pack awards no cards.
|
||||
/// Draw a pack's cards from `pool` with the injected RNG. Pure and deterministic
|
||||
/// under a seeded RNG. Returns an empty `Vec` (fail-closed) when the pool is empty
|
||||
/// or the pack awards no cards.
|
||||
///
|
||||
/// Policy (PLACEHOLDER — see the module parity note): draw with replacement from
|
||||
/// the pack's tier (`gold`), biasing each draw toward a special card with
|
||||
/// probability `special_chance`. An empty tier or partition falls back to the
|
||||
/// next-wider set so a draw is always possible when the pool is non-empty.
|
||||
/// Draws the pack's per-tier composition (`n_gold` gold-tier, `n_silver` silver,
|
||||
/// `n_bronze` bronze), biasing each pick toward a special with `special_chance`.
|
||||
/// An empty tier falls back to the whole pool (so a draw is always possible).
|
||||
pub fn generate_pack_contents(
|
||||
pack: &PackDef,
|
||||
rng: &mut impl Rng,
|
||||
pool: &[GeneratedCandidate],
|
||||
) -> Vec<GeneratedCard> {
|
||||
if pool.is_empty() || pack.count == 0 {
|
||||
if pool.is_empty() || pack.count() == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
// Tier split: a gold pack draws gold-tier candidates, a non-gold pack draws
|
||||
// non-gold; an empty tier falls back to the whole pool (oracle `... or POOL`).
|
||||
let tier: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.gold == pack.gold).collect();
|
||||
let tier: Vec<&GeneratedCandidate> = if tier.is_empty() {
|
||||
pool.iter().collect()
|
||||
} else {
|
||||
tier
|
||||
};
|
||||
// Partition the tier by special so `special_chance` can bias a draw; either
|
||||
// partition falls back to the whole tier when empty.
|
||||
let special: Vec<&GeneratedCandidate> = tier.iter().copied().filter(|c| c.special).collect();
|
||||
let normal: Vec<&GeneratedCandidate> = tier.iter().copied().filter(|c| !c.special).collect();
|
||||
let chance = pack.special_chance.clamp(0.0, 1.0);
|
||||
let all: Vec<&GeneratedCandidate> = pool.iter().collect();
|
||||
let gold: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.rating >= 75).collect();
|
||||
let silver: Vec<&GeneratedCandidate> = pool
|
||||
.iter()
|
||||
.filter(|c| (65..75).contains(&c.rating))
|
||||
.collect();
|
||||
let bronze: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.rating < 65).collect();
|
||||
|
||||
let mut out = Vec::with_capacity(pack.count as usize);
|
||||
for _ in 0..pack.count {
|
||||
let mut out = Vec::with_capacity(pack.count() as usize);
|
||||
draw_tier(&mut out, &gold, &all, pack.n_gold, pack.special_chance, rng);
|
||||
draw_tier(
|
||||
&mut out,
|
||||
&silver,
|
||||
&all,
|
||||
pack.n_silver,
|
||||
pack.special_chance,
|
||||
rng,
|
||||
);
|
||||
draw_tier(
|
||||
&mut out,
|
||||
&bronze,
|
||||
&all,
|
||||
pack.n_bronze,
|
||||
pack.special_chance,
|
||||
rng,
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/// Draw `n` cards from `tier` — or the whole-pool `fallback` when `tier` is empty —
|
||||
/// biasing each pick toward a special version with probability `chance`. Either the
|
||||
/// special or normal partition falls back to the tier when empty.
|
||||
fn draw_tier(
|
||||
out: &mut Vec<GeneratedCard>,
|
||||
tier: &[&GeneratedCandidate],
|
||||
fallback: &[&GeneratedCandidate],
|
||||
n: u64,
|
||||
chance: f64,
|
||||
rng: &mut impl Rng,
|
||||
) {
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
let src: &[&GeneratedCandidate] = if tier.is_empty() { fallback } else { tier };
|
||||
if src.is_empty() {
|
||||
return;
|
||||
}
|
||||
let special: Vec<&GeneratedCandidate> = src.iter().copied().filter(|c| c.special).collect();
|
||||
let normal: Vec<&GeneratedCandidate> = src.iter().copied().filter(|c| !c.special).collect();
|
||||
let chance = chance.clamp(0.0, 1.0);
|
||||
for _ in 0..n {
|
||||
let want_special = chance > 0.0 && rng.gen_bool(chance);
|
||||
let sub: &[&GeneratedCandidate] = if want_special && !special.is_empty() {
|
||||
&special
|
||||
} else if !want_special && !normal.is_empty() {
|
||||
&normal
|
||||
} else {
|
||||
&tier
|
||||
src
|
||||
};
|
||||
let pick = sub[rng.gen_range(0..sub.len())];
|
||||
out.push(pick.to_card());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -156,13 +185,22 @@ mod tests {
|
||||
]
|
||||
}
|
||||
|
||||
fn pack(id: u64, count: u64, gold: bool, special_chance: f64) -> PackDef {
|
||||
fn pack(id: u64, n_bronze: u64, n_silver: u64, n_gold: u64, special_chance: f64) -> PackDef {
|
||||
PackDef {
|
||||
id,
|
||||
name: "Test Pack",
|
||||
price: 1000,
|
||||
count,
|
||||
gold,
|
||||
n_bronze,
|
||||
n_silver,
|
||||
n_gold,
|
||||
rares: 0,
|
||||
category: if n_gold > 0 {
|
||||
"gold"
|
||||
} else if n_silver > 0 {
|
||||
"silver"
|
||||
} else {
|
||||
"bronze"
|
||||
},
|
||||
special_chance,
|
||||
owned_only: false,
|
||||
}
|
||||
@@ -171,7 +209,7 @@ mod tests {
|
||||
#[test]
|
||||
fn same_seed_same_output() {
|
||||
let pool = pool();
|
||||
let p = pack(5, 7, true, 0.3);
|
||||
let p = pack(5, 0, 0, 7, 0.3);
|
||||
let mut a = StdRng::seed_from_u64(42);
|
||||
let mut b = StdRng::seed_from_u64(42);
|
||||
assert_eq!(
|
||||
@@ -183,7 +221,7 @@ mod tests {
|
||||
#[test]
|
||||
fn different_seeds_can_diverge() {
|
||||
let pool = pool();
|
||||
let p = pack(5, 7, true, 0.3);
|
||||
let p = pack(5, 0, 0, 7, 0.3);
|
||||
let a = generate_pack_contents(&p, &mut StdRng::seed_from_u64(1), &pool);
|
||||
let b = generate_pack_contents(&p, &mut StdRng::seed_from_u64(999), &pool);
|
||||
// Not a hard guarantee, but with this pool/count the two seeds differ.
|
||||
@@ -196,7 +234,7 @@ mod tests {
|
||||
let ids: std::collections::HashSet<&str> =
|
||||
pool.iter().map(|c| c.card_id.as_str()).collect();
|
||||
for &n in &[1u64, 5, 7, 11] {
|
||||
let p = pack(6, n, true, 0.08);
|
||||
let p = pack(6, 0, 0, n, 0.08);
|
||||
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(n), &pool);
|
||||
assert_eq!(cards.len() as u64, n);
|
||||
for c in &cards {
|
||||
@@ -212,7 +250,7 @@ mod tests {
|
||||
#[test]
|
||||
fn gold_pack_draws_only_gold_tier() {
|
||||
let pool = pool();
|
||||
let p = pack(5, 20, true, 0.03);
|
||||
let p = pack(5, 0, 0, 20, 0.03);
|
||||
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(7), &pool);
|
||||
assert!(
|
||||
cards.iter().all(|c| c.rating >= 75),
|
||||
@@ -223,7 +261,7 @@ mod tests {
|
||||
#[test]
|
||||
fn bronze_pack_draws_only_bronze_tier() {
|
||||
let pool = pool();
|
||||
let p = pack(1, 20, false, 0.005);
|
||||
let p = pack(1, 20, 0, 0, 0.005);
|
||||
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(7), &pool);
|
||||
assert!(
|
||||
cards.iter().all(|c| c.rating < 75),
|
||||
@@ -239,7 +277,7 @@ mod tests {
|
||||
.filter(|c| c.special)
|
||||
.map(|c| c.card_id.as_str())
|
||||
.collect();
|
||||
let p = pack(7, 11, true, 1.0);
|
||||
let p = pack(7, 0, 0, 11, 1.0);
|
||||
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(3), &pool);
|
||||
assert!(cards
|
||||
.iter()
|
||||
@@ -248,7 +286,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn empty_pool_fails_closed() {
|
||||
let p = pack(5, 7, true, 0.03);
|
||||
let p = pack(5, 0, 0, 7, 0.03);
|
||||
assert!(generate_pack_contents(&p, &mut StdRng::seed_from_u64(1), &[]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
//! FIFA 17 Squad Building Challenge wire shapes.
|
||||
//!
|
||||
//! Numeric category/set/challenge ids and container types mirror the reversed FIFA 17
|
||||
//! `/sbs/*` family. Core ids remain opaque strings and are mapped here, never in Core.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub const CATEGORY_ID: i64 = 1;
|
||||
pub const CATEGORY_NAME: &str = "Foundations";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ChallengeIdentity {
|
||||
pub core_id: &'static str,
|
||||
pub set_id: i64,
|
||||
pub challenge_id: i64,
|
||||
pub priority: i64,
|
||||
}
|
||||
|
||||
pub const CHALLENGES: [ChallengeIdentity; 2] = [
|
||||
ChallengeIdentity {
|
||||
core_id: "sbc_bronze_upgrade",
|
||||
set_id: 1,
|
||||
challenge_id: 101,
|
||||
priority: 1,
|
||||
},
|
||||
ChallengeIdentity {
|
||||
core_id: "sbc_hybrid_nations",
|
||||
set_id: 2,
|
||||
challenge_id: 201,
|
||||
priority: 2,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ChallengeView {
|
||||
pub identity: ChallengeIdentity,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub repeatable: bool,
|
||||
pub times_completed: i64,
|
||||
}
|
||||
|
||||
pub fn identity_for_core(core_id: &str) -> Option<ChallengeIdentity> {
|
||||
CHALLENGES
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|entry| entry.core_id == core_id)
|
||||
}
|
||||
|
||||
pub fn identity_for_challenge(challenge_id: i64) -> Option<ChallengeIdentity> {
|
||||
CHALLENGES
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|entry| entry.challenge_id == challenge_id)
|
||||
}
|
||||
|
||||
/// `GET sbs/sets`: object root; categories, sets and awards are always arrays.
|
||||
pub fn sets_body(challenges: &[ChallengeView]) -> Value {
|
||||
let sets: Vec<Value> = challenges
|
||||
.iter()
|
||||
.map(|challenge| {
|
||||
json!({
|
||||
"setId": challenge.identity.set_id,
|
||||
"categoryId": CATEGORY_ID,
|
||||
"name": challenge.name,
|
||||
"description": challenge.description,
|
||||
"priority": challenge.identity.priority,
|
||||
"challengesCount": 1,
|
||||
"challengesCompletedCount": i64::from(!challenge.repeatable && challenge.times_completed > 0),
|
||||
"awards": [],
|
||||
"hidden": false,
|
||||
"endTime": 4_102_444_800_i64
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"categories": [{
|
||||
"categoryId": CATEGORY_ID,
|
||||
"name": CATEGORY_NAME,
|
||||
"priority": 1,
|
||||
"sets": sets
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
/// `GET sbs/setId/{id}/challenges`: object root with a challenge array.
|
||||
pub fn challenges_body(set_id: i64, challenges: &[ChallengeView]) -> Value {
|
||||
let records: Vec<Value> = challenges
|
||||
.iter()
|
||||
.filter(|challenge| challenge.identity.set_id == set_id)
|
||||
.map(|challenge| {
|
||||
// A repeatable challenge is always available to enter again. The FIFA 17
|
||||
// client gates challenge re-entry on `timesCompleted` (not on `repeatable`):
|
||||
// a nonzero count renders the tile COMPLETED and refuses re-entry. So a
|
||||
// repeatable challenge never reports itself as terminally completed here.
|
||||
// Core keeps the true completion record (economy authority); this is
|
||||
// presentation only. Live-proven on the retail client 2026-08-18.
|
||||
let times_completed = if challenge.repeatable {
|
||||
0
|
||||
} else {
|
||||
challenge.times_completed
|
||||
};
|
||||
json!({
|
||||
"challengeId": challenge.identity.challenge_id,
|
||||
"setId": challenge.identity.set_id,
|
||||
"categoryId": CATEGORY_ID,
|
||||
"index": 0,
|
||||
"type": "OPEN_CHALLENGE",
|
||||
"name": challenge.name,
|
||||
"description": challenge.description,
|
||||
"challengeImageId": "",
|
||||
"formation": "f442",
|
||||
"endTime": 0,
|
||||
"repeatable": challenge.repeatable,
|
||||
"trophyId": 0,
|
||||
"status": "OPEN",
|
||||
"timesCompleted": times_completed,
|
||||
"awards": [],
|
||||
// `elgReq` stays empty deliberately. Reversed from the pinned CardsDLL
|
||||
// (2026-08-19, see ENDPOINT_MAP.md "Shared record shapes"): the client
|
||||
// consumes `eligibilityKey`/`eligibilityOperation` only as ordinals that
|
||||
// index the packed locale (`LOC_SBC_ELG_KEY_%d`) to render requirement
|
||||
// text — it does NOT validate on them. The ordinal->string map is not
|
||||
// recoverable from any asset we have, so any value we emit would show the
|
||||
// WRONG requirement to the player. Submission is validated server-side by
|
||||
// Core regardless; leaving this empty is display-only, never a correctness
|
||||
// gap. Populate ONLY once the locale ordinal map is captured.
|
||||
"elgReq": []
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({ "challenges": records })
|
||||
}
|
||||
|
||||
/// Empty-body POST starts a challenge. `squad` is object-root on this response class.
|
||||
pub fn start_body(challenge_id: i64) -> Value {
|
||||
json!({
|
||||
"challengeId": challenge_id,
|
||||
"squad": {},
|
||||
"playerRequirements": []
|
||||
})
|
||||
}
|
||||
|
||||
/// GET challenge squad. The reversed response requires array containers.
|
||||
pub fn squad_body(challenge_id: i64, wire_item_ids: &[i64]) -> Value {
|
||||
let squad: Vec<Value> = wire_item_ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, id)| {
|
||||
json!({
|
||||
"index": index,
|
||||
"itemData": { "id": id },
|
||||
"kitNumber": 0
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"id": challenge_id,
|
||||
"squad": squad,
|
||||
"playerRequirements": []
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save_body(challenge_id: i64) -> Value {
|
||||
json!({ "id": challenge_id })
|
||||
}
|
||||
|
||||
pub fn submit_body(challenge_id: i64, set_id: i64, credits: i64, unopened_packs: i64) -> Value {
|
||||
json!({
|
||||
"challengeId": challenge_id,
|
||||
"setId": set_id,
|
||||
"credits": credits,
|
||||
"preOrderPacks": 0,
|
||||
"recoveredPacks": unopened_packs,
|
||||
"grantedChallengeAwards": [],
|
||||
"grantedSetAwards": []
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum SbcWireError {
|
||||
Json(String),
|
||||
MissingSquad,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SbcWireError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Json(error) => write!(formatter, "invalid SBC squad JSON: {error}"),
|
||||
Self::MissingSquad => {
|
||||
formatter.write_str("SBC squad body contains no supported squad container")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SbcWireError {}
|
||||
|
||||
/// Extract FIFA wire item ids from a saved/submitted challenge squad.
|
||||
///
|
||||
/// The exact retail challenge-squad body is now captured (2026-08-18 Gate C,
|
||||
/// `PUT /ut/game/fifa17/sbs/challenge/101/squad`): a fixed 23-entry `players`
|
||||
/// array of `{index, itemData:{id, dream}}` (empty slots carry `id == 0`),
|
||||
/// alongside sibling `chemistry`, `rating`, `formation`, and a `manager` array
|
||||
/// of `{id, dream}`. Only `players[].itemData.id` selects the consumed cards;
|
||||
/// the manager, chemistry, rating, formation, dream, and index fields are
|
||||
/// presentation/validation hints and are never consumed. The `squad` array
|
||||
/// container remains accepted for the alternate proven shape. Within either,
|
||||
/// only non-zero `itemData.id` values are interpreted.
|
||||
pub fn parse_wire_item_ids(body: &[u8]) -> Result<Vec<i64>, SbcWireError> {
|
||||
let root: Value =
|
||||
serde_json::from_slice(body).map_err(|error| SbcWireError::Json(error.to_string()))?;
|
||||
let entries = root
|
||||
.get("players")
|
||||
.and_then(Value::as_array)
|
||||
.or_else(|| root.get("squad").and_then(Value::as_array))
|
||||
.ok_or(SbcWireError::MissingSquad)?;
|
||||
|
||||
let mut ids = Vec::new();
|
||||
for entry in entries {
|
||||
if let Some(id) = entry
|
||||
.get("itemData")
|
||||
.and_then(|item| item.get("id"))
|
||||
.and_then(Value::as_i64)
|
||||
.filter(|id| *id != 0)
|
||||
{
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn challenge() -> ChallengeView {
|
||||
ChallengeView {
|
||||
identity: CHALLENGES[0],
|
||||
name: "Bronze Upgrade".into(),
|
||||
description: "Submit players".into(),
|
||||
repeatable: true,
|
||||
times_completed: 2,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_containers_match_reversed_fifa17_shapes() {
|
||||
let sets = sets_body(&[challenge()]);
|
||||
assert!(sets.is_object());
|
||||
assert!(sets["categories"].is_array());
|
||||
assert!(sets["categories"][0]["sets"].is_array());
|
||||
assert!(sets["categories"][0]["sets"][0]["awards"].is_array());
|
||||
|
||||
let challenges = challenges_body(1, &[challenge()]);
|
||||
assert!(challenges.is_object());
|
||||
assert!(challenges["challenges"].is_array());
|
||||
assert!(challenges["challenges"][0]["awards"].is_array());
|
||||
assert!(challenges["challenges"][0]["elgReq"].is_array());
|
||||
|
||||
assert!(start_body(101)["squad"].is_object());
|
||||
assert!(start_body(101)["playerRequirements"].is_array());
|
||||
assert!(squad_body(101, &[100_000_001])["squad"].is_array());
|
||||
let submit = submit_body(101, 1, 1234, 1);
|
||||
assert!(submit["grantedChallengeAwards"].is_array());
|
||||
assert!(submit["grantedSetAwards"].is_array());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeatable_completed_challenge_stays_enterable() {
|
||||
// The FIFA 17 client refuses challenge re-entry when timesCompleted > 0, so
|
||||
// a repeatable challenge must always project as not-yet-completed while a
|
||||
// non-repeatable one keeps its true count. Core holds the real record.
|
||||
let repeatable = ChallengeView {
|
||||
identity: CHALLENGES[0],
|
||||
name: "Bronze Upgrade".into(),
|
||||
description: "Submit players".into(),
|
||||
repeatable: true,
|
||||
times_completed: 3,
|
||||
};
|
||||
let once = ChallengeView {
|
||||
identity: CHALLENGES[1],
|
||||
name: "Hybrid Nations".into(),
|
||||
description: "Submit a hybrid squad".into(),
|
||||
repeatable: false,
|
||||
times_completed: 1,
|
||||
};
|
||||
|
||||
let repeatable_view =
|
||||
challenges_body(CHALLENGES[0].set_id, std::slice::from_ref(&repeatable));
|
||||
assert_eq!(repeatable_view["challenges"][0]["timesCompleted"], 0);
|
||||
assert_eq!(repeatable_view["challenges"][0]["status"], "OPEN");
|
||||
let once_view = challenges_body(CHALLENGES[1].set_id, std::slice::from_ref(&once));
|
||||
assert_eq!(once_view["challenges"][0]["timesCompleted"], 1);
|
||||
|
||||
let sets = sets_body(&[repeatable, once]);
|
||||
let sets_arr = sets["categories"][0]["sets"].as_array().unwrap();
|
||||
let repeatable_set = sets_arr
|
||||
.iter()
|
||||
.find(|set| set["setId"] == CHALLENGES[0].set_id)
|
||||
.unwrap();
|
||||
let once_set = sets_arr
|
||||
.iter()
|
||||
.find(|set| set["setId"] == CHALLENGES[1].set_id)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
repeatable_set["challengesCompletedCount"], 0,
|
||||
"a repeatable set never reports itself terminally completed"
|
||||
);
|
||||
assert_eq!(
|
||||
once_set["challengesCompletedCount"], 1,
|
||||
"a one-shot set counts its single completion"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_accepts_only_known_squad_containers_and_item_ids() {
|
||||
let normal = br#"{"players":[{"index":0,"itemData":{"id":100000001}},{"index":1,"itemData":{"id":0}}]}"#;
|
||||
assert_eq!(parse_wire_item_ids(normal).unwrap(), [100_000_001]);
|
||||
let sbc = br#"{"squad":[{"itemData":{"id":100000002}}]}"#;
|
||||
assert_eq!(parse_wire_item_ids(sbc).unwrap(), [100_000_002]);
|
||||
assert_eq!(
|
||||
parse_wire_item_ids(br#"{"challengeId":101}"#),
|
||||
Err(SbcWireError::MissingSquad)
|
||||
);
|
||||
assert!(matches!(
|
||||
parse_wire_item_ids(br#"{"squad":"not-an-array"}"#),
|
||||
Err(SbcWireError::MissingSquad)
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_wire_item_ids(br#"{"squad":["#),
|
||||
Err(SbcWireError::Json(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_matches_captured_retail_challenge_squad_body() {
|
||||
// Verbatim shape from the 2026-08-18 Gate C retail capture of
|
||||
// PUT /ut/game/fifa17/sbs/challenge/101/squad (11 filled + 12 empty
|
||||
// slots, plus manager/chemistry/rating/formation siblings). Only the 11
|
||||
// non-zero player itemData.id values are consumed, in wire order; the
|
||||
// manager and all presentation fields are ignored.
|
||||
let retail = br#"{"chemistry":21,"rating":86,"formation":"f433",
|
||||
"manager":[{"id":100000427,"dream":false}],
|
||||
"players":[
|
||||
{"index":0,"itemData":{"id":100004227,"dream":false}},
|
||||
{"index":1,"itemData":{"id":100004233,"dream":false}},
|
||||
{"index":2,"itemData":{"id":100001317,"dream":false}},
|
||||
{"index":3,"itemData":{"id":100001531,"dream":false}},
|
||||
{"index":4,"itemData":{"id":100000966,"dream":false}},
|
||||
{"index":5,"itemData":{"id":100001947,"dream":false}},
|
||||
{"index":6,"itemData":{"id":100000169,"dream":false}},
|
||||
{"index":7,"itemData":{"id":100002017,"dream":false}},
|
||||
{"index":8,"itemData":{"id":100002765,"dream":false}},
|
||||
{"index":9,"itemData":{"id":100000147,"dream":false}},
|
||||
{"index":10,"itemData":{"id":100000311,"dream":false}},
|
||||
{"index":11,"itemData":{"id":0,"dream":false}},
|
||||
{"index":12,"itemData":{"id":0,"dream":false}},
|
||||
{"index":13,"itemData":{"id":0,"dream":false}},
|
||||
{"index":14,"itemData":{"id":0,"dream":false}},
|
||||
{"index":15,"itemData":{"id":0,"dream":false}},
|
||||
{"index":16,"itemData":{"id":0,"dream":false}},
|
||||
{"index":17,"itemData":{"id":0,"dream":false}},
|
||||
{"index":18,"itemData":{"id":0,"dream":false}},
|
||||
{"index":19,"itemData":{"id":0,"dream":false}},
|
||||
{"index":20,"itemData":{"id":0,"dream":false}},
|
||||
{"index":21,"itemData":{"id":0,"dream":false}},
|
||||
{"index":22,"itemData":{"id":0,"dream":false}}
|
||||
]}"#;
|
||||
assert_eq!(
|
||||
parse_wire_item_ids(retail).unwrap(),
|
||||
[
|
||||
100_004_227,
|
||||
100_004_233,
|
||||
100_001_317,
|
||||
100_001_531,
|
||||
100_000_966,
|
||||
100_001_947,
|
||||
100_000_169,
|
||||
100_002_017,
|
||||
100_002_765,
|
||||
100_000_147,
|
||||
100_000_311
|
||||
],
|
||||
"exactly the 11 non-zero players in wire order; manager and empty slots ignored"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
//! 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.
|
||||
///
|
||||
/// The club's OWN kit team is filtered out at schedule time — see
|
||||
/// [`season_list_body`]. Fixing this list to exclude one id would not do, because
|
||||
/// which team the club wears is ownership state, not a constant.
|
||||
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, opponents: &[i64]) -> SeasonMatch {
|
||||
SeasonMatch {
|
||||
team_id: opponents[(index as usize) % opponents.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).
|
||||
///
|
||||
/// `own_kit_team_id` is the team whose kit the club wears, taken from its active
|
||||
/// kit items. That team is EXCLUDED from the schedule, because the pre-match kit
|
||||
/// clone resolves both sides out of the same `teamkits` table keyed on
|
||||
/// `teamtechid`: drawing your own kit team makes the opponent render your kit, so
|
||||
/// both sides appear in identical strips. It is also simply wrong data — a club
|
||||
/// would be playing itself.
|
||||
///
|
||||
/// Passing `None` (or a team not in the rotation) keeps the full schedule.
|
||||
pub fn season_list_body(season_id: i64, division_id: i64, own_kit_team_id: Option<i64>) -> String {
|
||||
let opponents: Vec<i64> = OPPONENT_TEAM_IDS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| Some(*id) != own_kit_team_id)
|
||||
.collect();
|
||||
// Never emit an empty rotation: `matches` must be non-empty or StartSeason
|
||||
// dereferences NULL (see the module note), so an exclusion that would empty
|
||||
// the list is ignored rather than allowed to crash the client.
|
||||
let opponents: &[i64] = if opponents.is_empty() {
|
||||
OPPONENT_TEAM_IDS
|
||||
} else {
|
||||
&opponents
|
||||
};
|
||||
let list = SeasonList {
|
||||
seasons: vec![SeasonElement {
|
||||
kind: "OFFLINE",
|
||||
id: season_id,
|
||||
division_id,
|
||||
matches: (0..SEASON_ROUNDS).map(|i| round(i, opponents)).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, None));
|
||||
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, None));
|
||||
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, None);
|
||||
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}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The pre-match kit clone resolves both sides out of the same `teamkits`
|
||||
/// table keyed on `teamtechid`, so drawing the club's own kit team puts the
|
||||
/// opponent in the club's strip. It is also a club playing itself.
|
||||
#[test]
|
||||
fn own_kit_team_is_never_scheduled_as_an_opponent() {
|
||||
let own = OPPONENT_TEAM_IDS[0];
|
||||
let body = parsed(&season_list_body(1, 10, Some(own)));
|
||||
let matches = body["seasons"][0]["matches"].as_array().unwrap();
|
||||
assert_eq!(matches.len(), SEASON_ROUNDS as usize, "still a full ladder");
|
||||
for m in matches {
|
||||
assert_ne!(
|
||||
m["teamId"].as_i64().unwrap(),
|
||||
own,
|
||||
"the club's own kit team must not be an opponent: {m}"
|
||||
);
|
||||
}
|
||||
// The remaining teams are still cycled, so the schedule stays stable and
|
||||
// every round names a renderable team.
|
||||
for (i, m) in matches.iter().enumerate() {
|
||||
assert_eq!(m["roundId"], i as i64);
|
||||
assert!(m["teamId"].as_i64().is_some_and(|t| t > 0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Excluding a team that would empty the rotation must NOT produce an empty
|
||||
/// `matches` array, because that crashes StartSeason.
|
||||
#[test]
|
||||
fn an_exclusion_that_would_empty_the_rotation_is_ignored() {
|
||||
// Stand-in for the degenerate case: pretend every id is the own team by
|
||||
// excluding each in turn and asserting the ladder is always full.
|
||||
for own in OPPONENT_TEAM_IDS {
|
||||
let body = parsed(&season_list_body(1, 10, Some(*own)));
|
||||
let matches = body["seasons"][0]["matches"].as_array().unwrap();
|
||||
assert_eq!(matches.len(), SEASON_ROUNDS as usize);
|
||||
assert!(!matches.is_empty(), "matches must never be empty");
|
||||
}
|
||||
}
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
@@ -178,14 +200,77 @@ pub fn parse_squad_put(body: &[u8]) -> Result<Fifa17SquadPut, SquadError> {
|
||||
serde_json::from_slice(body).map_err(|e| SquadError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
/// A role-only squad update: the client changed the captain and/or the
|
||||
/// kick-taker assignments without touching the squad itself.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Fifa17SquadRolePatch {
|
||||
#[serde(default)]
|
||||
pub id: i64,
|
||||
/// Opaque 33-int array, verbatim. Differs from the replacement's `custom`
|
||||
/// in the captures (the role screen writes per-slot values into it), so it
|
||||
/// MUST be carried through rather than preserved from the stored copy.
|
||||
#[serde(default)]
|
||||
pub custom: Option<String>,
|
||||
#[serde(default)]
|
||||
pub captain: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub kicktakers: Vec<SquadKicktaker>,
|
||||
}
|
||||
|
||||
/// Which mutation a `PUT …/squad/<id>` body actually expresses.
|
||||
///
|
||||
/// FIFA 17 sends two different operations down one path, so the BODY SHAPE is
|
||||
/// the operation discriminator. Across 73 captured squad PUTs spanning five
|
||||
/// captures there are exactly two shapes:
|
||||
///
|
||||
/// * 68x with `players` — a full replacement, also carrying `squadName`,
|
||||
/// `formation`, `squadType`, `manager`, `chemistry`/`rating`/`starRating`,
|
||||
/// and (redundantly) `captain`/`kicktakers`.
|
||||
/// * 5x without `players` — `{id, custom, captain, kicktakers}` only, emitted
|
||||
/// by the captain/kick-taker screen.
|
||||
///
|
||||
/// `players` is therefore the discriminator: its PRESENCE means "this body
|
||||
/// describes the whole squad". Its ABSENCE means the squad was not part of the
|
||||
/// edit at all and must be left alone — which is NOT the same as an empty
|
||||
/// `players` array, and that distinction is the whole point. Serde's
|
||||
/// `#[serde(default)]` collapses both to an empty vec, so key presence is
|
||||
/// tested on the raw JSON before deserialising.
|
||||
///
|
||||
/// An explicit `"players": []` still classifies as a replacement, so the
|
||||
/// empty-replacement guard in Core keeps seeing it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SquadMutation {
|
||||
/// Full replacement of the squad's slots and metadata.
|
||||
Replace(Box<Fifa17SquadPut>),
|
||||
/// Role-only patch: captain and/or kick-takers, nothing else.
|
||||
PatchRoles(Fifa17SquadRolePatch),
|
||||
}
|
||||
|
||||
/// Classify a squad PUT body. See [`SquadMutation`] for the discriminator and
|
||||
/// the capture evidence behind it.
|
||||
pub fn classify_squad_put(body: &[u8]) -> Result<SquadMutation, SquadError> {
|
||||
let raw: serde_json::Value =
|
||||
serde_json::from_slice(body).map_err(|e| SquadError::Parse(e.to_string()))?;
|
||||
let has_players = raw.as_object().is_some_and(|o| o.contains_key("players"));
|
||||
if has_players {
|
||||
return parse_squad_put(body).map(|p| SquadMutation::Replace(Box::new(p)));
|
||||
}
|
||||
serde_json::from_slice(body)
|
||||
.map(SquadMutation::PatchRoles)
|
||||
.map_err(|e| SquadError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
/// Resolve a parsed save into a **canonical** [`ProposedSquad`]: drop empty
|
||||
/// (`id == 0`) slots, reverse-map each occupied slot's wire id to a Core
|
||||
/// `owned_card_id`, flag the captain, and derive the bench split from the fixed
|
||||
/// 23-slot array. FIFA-only state (`custom`, manager, kicktakers, kit numbers,
|
||||
/// squadType) and client-reported evaluation are NOT canonical — they are built
|
||||
/// separately into [`crate::fut::squad_ext::Fifa17SquadExtensionV1`]. The
|
||||
/// formation token is carried verbatim (never mapped). Unresolvable occupied ids
|
||||
/// are reported, never guessed or dropped.
|
||||
/// `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 +290,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 +336,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 +384,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
|
||||
@@ -70,7 +61,7 @@ pub struct KicktakerRef {
|
||||
}
|
||||
|
||||
/// FIFA 17 Squad Extension, version 1. Serialized to the opaque payload Core stores.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Fifa17SquadExtensionV1 {
|
||||
/// Opaque 33-int array as a JSON-encoded string, verbatim. Never decoded.
|
||||
#[serde(default)]
|
||||
@@ -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();
|
||||
|
||||
@@ -35,8 +35,14 @@ use std::collections::HashMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::club_response::ActiveKitAssignments;
|
||||
use crate::fut::contract_cards::PACK_FRESH_CONTRACT_MATCHES;
|
||||
use crate::fut::entities::ReverseEntityResolver;
|
||||
use crate::fut::item::{shape_item, CoreOwnedItem, ItemIdentityResolver};
|
||||
use crate::fut::item::{
|
||||
shape_club_item, shape_item, shape_staff_item, CoreOwnedItem, ItemIdentityResolver,
|
||||
STAFF_CONTRACT,
|
||||
};
|
||||
use crate::fut::item_state;
|
||||
use crate::fut::squad::FIFA17_SQUAD_SLOTS;
|
||||
use crate::fut::squad_ext::Fifa17SquadExtensionV1;
|
||||
|
||||
@@ -77,6 +83,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 +170,13 @@ 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),
|
||||
item.contract_matches.unwrap_or(PACK_FRESH_CONTRACT_MATCHES),
|
||||
),
|
||||
"kitNumber": kit,
|
||||
}));
|
||||
}
|
||||
@@ -171,6 +188,55 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
|
||||
}
|
||||
}
|
||||
|
||||
// Manager: the ownership-backed assignment, resolved to its FIFA wire ref and
|
||||
// emitted as a BARE ITEM OBJECT with `dream` beside the item's own fields —
|
||||
// NOT wrapped in `itemData`.
|
||||
//
|
||||
// This is a wire-shape contract, recovered from the client rather than
|
||||
// guessed, after two earlier shapes both failed:
|
||||
//
|
||||
// `[{id, dream}]` — no merge key, so nothing resolves.
|
||||
// `[{id, itemData, dream}]` — `itemData` is never read on this path.
|
||||
//
|
||||
// The squad parser FUN_18013d1f0 treats the two slots differently, and that
|
||||
// is the whole point:
|
||||
//
|
||||
// players: atom 568 -> per-element atoms 355 `index`, 363 `itemData`,
|
||||
// 378 `kitNumber`; the 363 arm (0x18013d8d9) calls the ITEM
|
||||
// parser FUN_18013fe00 on the NESTED itemData object.
|
||||
// manager: atom 424 -> array loop at 0x18013da29 calls that same item
|
||||
// parser DIRECTLY on the array ELEMENT, into squad+0xC0. There is
|
||||
// no `itemData` step at all.
|
||||
//
|
||||
// So a manager element IS an item. Nesting the fields one level deeper left
|
||||
// the parser reading only the two keys that happen to be item atoms — `id`
|
||||
// (0x14c) and `dream` (0xe7) — and leaving `resourceId` at 0. Measured on a
|
||||
// cold client: the manager record existed at squad+0xC0 with the correct id
|
||||
// and `resourceId == 0`, while sibling players in the same response carried
|
||||
// theirs (83906881, 84053575). `resourceId` is the merge key compared RAW
|
||||
// against `carddbid`, so zero can never hit the managercards table: no name,
|
||||
// no rating, no art, and an empty manager slot in the UI.
|
||||
//
|
||||
// The client's own save corroborates the shape: it PUTs
|
||||
// `"manager":[{"id":…,"dream":false}]` — flat, and both keys are item atoms.
|
||||
//
|
||||
// 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).map(|id| (m, id)))
|
||||
{
|
||||
Some((mgr, id)) => {
|
||||
let mut item = shape_staff_item(id, mgr.contract_matches.unwrap_or(STAFF_CONTRACT));
|
||||
if let Some(obj) = item.as_object_mut() {
|
||||
obj.insert("dream".to_string(), json!(false));
|
||||
}
|
||||
json!([item])
|
||||
}
|
||||
None => json!([]),
|
||||
};
|
||||
let squad = json!({
|
||||
"id": input.fifa_squad_id,
|
||||
"squadName": input.name,
|
||||
@@ -180,7 +246,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,
|
||||
@@ -188,15 +254,76 @@ pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
|
||||
Ok(SquadProjection::Projected(squad))
|
||||
}
|
||||
|
||||
/// The active club items for `squad.actives`, in slot order.
|
||||
///
|
||||
/// ## Why this exists, and why it is NOT `[]`
|
||||
///
|
||||
/// `actives` is the ONLY carrier that makes a club item resident in FIFA 17.
|
||||
/// The squad parser's arm for atom 11 (`actives`) computes the address of the
|
||||
/// i-th element of the client's five-element club-item array and hands it to the
|
||||
/// item deserializer as the out-handle:
|
||||
///
|
||||
/// ```text
|
||||
/// cmp edi,0x5 ; at most five entries are read
|
||||
/// jge <skip>
|
||||
/// mov rax,QWORD PTR [r13+0x108] ; the club-item array
|
||||
/// lea rcx,[rax+rcx*8] ; &array[edi] (edi * 24)
|
||||
/// call 0x18013fe00 ; the item deserializer, writing that slot
|
||||
/// ```
|
||||
///
|
||||
/// That deserializer inserts the record into the client's resident item map
|
||||
/// (keyed by wire instance id, and its only gate is a non-zero id) and binds the
|
||||
/// slot handle to it. So each element must be a FULL item object, exactly like
|
||||
/// a `squad.manager[]` element — which reaches this same deserializer the same
|
||||
/// way, called directly on the array element with no `itemData` step. An id
|
||||
/// reference alone installs nothing, because the installer looks its id up in
|
||||
/// that same map and does nothing when it misses.
|
||||
///
|
||||
/// An empty array makes the client read the array-end token immediately and
|
||||
/// parse nothing, which leaves all five slots null. Every later consumer then
|
||||
/// resolves to the client's static not-found sentinel, whose item pointer is
|
||||
/// NULL — which is exactly why the pre-match kit selector had no kits.
|
||||
///
|
||||
/// Elements are shaped by the shared [`shape_club_item`], the same primitive
|
||||
/// `/club?type=kit` uses, so the two routes cannot drift. Ordering is positional
|
||||
/// on the wire but not semantic: both client consumers (the activate path and
|
||||
/// the store lookup) search the five slots by content — itemState, or
|
||||
/// cardtype/cardsubtypeid — never by index.
|
||||
///
|
||||
/// A designated kit whose owned row or FIFA kit identity cannot be resolved is
|
||||
/// omitted rather than emitted with a fabricated id, matching `/club`.
|
||||
pub fn squad_actives<I: ItemIdentityResolver + ?Sized>(
|
||||
owned: &HashMap<String, CoreOwnedItem>,
|
||||
ident: &I,
|
||||
active_kits: ActiveKitAssignments<'_>,
|
||||
) -> Value {
|
||||
let mut out = Vec::new();
|
||||
for (owned_card_id, state) in [
|
||||
(active_kits.home, item_state::ACTIVE_HOME_KIT),
|
||||
(active_kits.away, item_state::ACTIVE_AWAY_KIT),
|
||||
] {
|
||||
let Some(owned_card_id) = owned_card_id else {
|
||||
continue;
|
||||
};
|
||||
let Some(item) = owned.get(owned_card_id) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(id) = ident.resolve_kit(item) {
|
||||
out.push(shape_club_item(id, state));
|
||||
}
|
||||
}
|
||||
Value::Array(out)
|
||||
}
|
||||
|
||||
/// Wrap a projected squad object into the `userMassInfo.squad` shape, injecting
|
||||
/// the session-envelope fields the projector does not own (`personaId`, plus the
|
||||
/// observed constants `changed: 0`, `actives: []`).
|
||||
pub fn user_mass_info_squad(projected: Value, persona_id: i64) -> Value {
|
||||
/// the session-envelope fields the projector does not own: `personaId`, the
|
||||
/// observed constant `changed: 0`, and `actives` from [`squad_actives`].
|
||||
pub fn user_mass_info_squad(projected: Value, persona_id: i64, actives: Value) -> Value {
|
||||
let mut obj = projected;
|
||||
if let Value::Object(map) = &mut obj {
|
||||
map.insert("personaId".into(), json!(persona_id));
|
||||
map.insert("changed".into(), json!(0));
|
||||
map.insert("actives".into(), json!([]));
|
||||
map.insert("actives".into(), actives);
|
||||
}
|
||||
obj
|
||||
}
|
||||
@@ -219,14 +346,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 {
|
||||
@@ -243,6 +378,9 @@ mod tests {
|
||||
league: "l".into(),
|
||||
club: "c".into(),
|
||||
attributes: [80, 80, 80, 80, 40, 80],
|
||||
contract_matches: None,
|
||||
source_rating: None,
|
||||
core_content_kind: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,6 +400,7 @@ mod tests {
|
||||
}],
|
||||
ext,
|
||||
owned,
|
||||
manager: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,7 +411,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 +420,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 +459,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 +470,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 +481,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 +496,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 +547,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 +568,103 @@ 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()));
|
||||
// A manager mid-way through its contracts: the projection must report
|
||||
// Core's persisted count, not the pack-fresh constant, or the pre-match
|
||||
// screen contradicts the club screen.
|
||||
let mut manager = owned_item("oc-mgr", "fifa17_mgr");
|
||||
manager.contract_matches = Some(12);
|
||||
input.manager = Some(manager);
|
||||
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
|
||||
panic!("expected Projected");
|
||||
};
|
||||
// The element IS the item: the squad parser's manager branch calls the
|
||||
// item parser on the array element itself, with no `itemData` step, so
|
||||
// the fields must be flat. Nesting them left `resourceId` — the merge
|
||||
// key — at 0 on a cold client and the slot rendered empty.
|
||||
assert_eq!(
|
||||
v["manager"],
|
||||
json!([{
|
||||
"id": 100000427,
|
||||
"resourceId": 1_000_509,
|
||||
"cardsubtypeid": 4,
|
||||
"itemType": "staff",
|
||||
"nation": 45,
|
||||
"leagueId": 53,
|
||||
"teamid": 241,
|
||||
"contract": 12,
|
||||
"itemState": "free",
|
||||
"owners": 1,
|
||||
"untradeable": false,
|
||||
"dream": false,
|
||||
}]),
|
||||
"manager element is a bare item object carrying `dream`"
|
||||
);
|
||||
let element = &v["manager"][0];
|
||||
assert!(
|
||||
element.get("itemData").is_none(),
|
||||
"an `itemData` wrapper is never descended into on the manager path, \
|
||||
so its presence means the merge key is invisible to the client"
|
||||
);
|
||||
assert_eq!(
|
||||
element["resourceId"], 1_000_509,
|
||||
"resourceId must be readable at element level: it is the merge key \
|
||||
compared RAW against carddbid, and 0 resolves no manager"
|
||||
);
|
||||
}
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,92 +1,217 @@
|
||||
//! FIFA 17 Store pack catalogue + `/store/purchasegroup` wire shaping.
|
||||
//!
|
||||
//! A faithful Rust port of the Python oracle's `PACK_CATALOG` + `_pack_body` +
|
||||
//! `store_catalog` assembly (`fifa17-recon/tools/{fut_store,utas_server}.py`) at the
|
||||
//! **production flag defaults** (`FUT_STORE_DISPLAYGROUP=1` on, `FUT_STORE_GROUPID=0`
|
||||
//! off, `FUT_PRICE_PROBE=0` off). Parity is pinned by differential fixtures generated
|
||||
//! from the Python oracle (`tests/fixtures/purchasegroup_*.json`).
|
||||
//! Rust is the authoritative store owner: [`build_purchasegroup`] is served live
|
||||
//! by the host via `EconomyRoute::PurchaseGroup` over Core economy authority (Core
|
||||
//! owns coins + unopened packs), so there is no Python dependency and no
|
||||
//! dual-write/split-brain.
|
||||
//!
|
||||
//! ## Scope / split-brain safety
|
||||
//! ## Relationship to the Python oracle
|
||||
//!
|
||||
//! This is **pure wire shaping** — no economy state, no IO. [`build_purchasegroup`]
|
||||
//! is a function of `(owned unopened pack ids, empty-My-Packs StoreMode)`. It is
|
||||
//! deliberately **not yet wired** into the live host: serving purchasegroup from Rust
|
||||
//! requires an authoritative Rust owner of `unopenedPackIds`, and today Python is the
|
||||
//! single writer of coins + unopened packs (BUY, quick-sell, rewards). Wiring this
|
||||
//! before that economy authority exists would create a dual-write/split-brain. See
|
||||
//! the R3 economy-authority prerequisite in the vault (`Rust UTAS Migration`).
|
||||
//! The wire *shape* was RE'd from the client and cross-checked against the Python
|
||||
//! oracle's `_pack_body`/`store_catalog`
|
||||
//! (`fifa17-recon/tools/{fut_store,utas_server}.py`). Rust now diverges from the
|
||||
//! oracle where the RE proved the oracle wrong: it does NOT emit `extPrice`, whose
|
||||
//! parser side-effect creates an `"mtx"` currency row and switches on the broken
|
||||
//! `or %1s` FIFA-Points tile line (plan-2026-08-05-store-subsystem.md §3.4). The
|
||||
//! Python oracle stays the rollback baseline and is never modified; the
|
||||
//! `tests/fixtures/purchasegroup_*.json` goldens pin Rust's authoritative output.
|
||||
//!
|
||||
//! ## Economy-parameter provenance
|
||||
//!
|
||||
//! Prices, counts and odds are the current OpenFUT **PLACEHOLDER** economy, NOT
|
||||
//! EA-authentic (the overnight audit established the store economy is invented). The
|
||||
//! wire *shape* is EA-observed/oracle-verified; the *numbers* are placeholders.
|
||||
//! Pack prices and tier composition are the real always-available FUT 17
|
||||
//! regular-store packs (community-documented on fifauteam). Pack ODDS
|
||||
//! (`special_chance`) are DESIGNED placeholders, NOT EA-authentic — EA never
|
||||
//! published FUT 17 pack probabilities. The wire *shape* is EA-observed/RE-verified.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::store_session::{StoreMode, SENTINEL_PACK_ID};
|
||||
|
||||
/// A FIFA 17 Store pack definition. Wire shape is oracle-verified; the economy
|
||||
/// numbers (`price`/`count`/`special_chance`) are OpenFUT PLACEHOLDER, not EA-authentic.
|
||||
/// A FIFA 17 Store pack definition. The wire *shape* is RE-verified; the price and
|
||||
/// per-tier composition are the real always-available FUT 17 regular-store packs,
|
||||
/// with DESIGNED (not EA-authentic) `special_chance` odds.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct PackDef {
|
||||
pub id: u64,
|
||||
pub name: &'static str,
|
||||
pub price: u64,
|
||||
pub count: u64,
|
||||
pub gold: bool,
|
||||
/// Cards awarded per rating-tier band: bronze `< 65`, silver `65..=74`, gold
|
||||
/// `>= 75`. These are ALSO the wire `packContentInfo` per-tier quantities, so a
|
||||
/// pack's displayed composition matches what its generator draws.
|
||||
pub n_bronze: u64,
|
||||
pub n_silver: u64,
|
||||
pub n_gold: u64,
|
||||
/// `rareQuantity` shown on the tile (wire display only).
|
||||
pub rares: u64,
|
||||
/// StoreFront category token (`displayGroup.value`): one of the six hard-coded
|
||||
/// client tokens — here `"bronze"`, `"silver"` or `"gold"`.
|
||||
pub category: &'static str,
|
||||
/// Per-draw probability the awarded card is a special version (DESIGNED
|
||||
/// placeholder; FUT 17 odds are unrecoverable).
|
||||
pub special_chance: f64,
|
||||
/// Reward-only pack (no purchase path): excluded from the normal catalogue,
|
||||
/// rendered only when owned (in `unopenedPackIds`).
|
||||
pub owned_only: bool,
|
||||
}
|
||||
|
||||
/// The current supported FIFA 17 pack catalogue (`fut_store.py:820`). Only observed/
|
||||
/// currently-supported ids. The 65534 sentinel is deliberately ABSENT — it is a
|
||||
/// compatibility shim, never a catalogue pack (never purchasable/openable).
|
||||
impl PackDef {
|
||||
/// Total cards awarded / wire `itemQuantity` — the sum of the per-tier counts.
|
||||
pub fn count(&self) -> u64 {
|
||||
self.n_bronze + self.n_silver + self.n_gold
|
||||
}
|
||||
}
|
||||
|
||||
/// The always-available FIFA 17 FUT regular-store packs (real fifauteam-documented
|
||||
/// prices + tier composition), plus the OpenFUT reward pack. Two packs per client
|
||||
/// category (bronze/silver/gold), which the client renders as separate buyable tiles
|
||||
/// on drill-in. The 65534 sentinel is deliberately ABSENT — a compatibility shim,
|
||||
/// never purchasable/openable.
|
||||
pub const PACK_CATALOG: &[PackDef] = &[
|
||||
// ── Bronze category ──
|
||||
PackDef {
|
||||
id: 1,
|
||||
name: "Bronze Pack",
|
||||
price: 400,
|
||||
count: 5,
|
||||
gold: false,
|
||||
special_chance: 0.005,
|
||||
n_bronze: 10,
|
||||
n_silver: 2,
|
||||
n_gold: 0,
|
||||
rares: 1,
|
||||
category: "bronze",
|
||||
special_chance: 0.01,
|
||||
owned_only: false,
|
||||
},
|
||||
PackDef {
|
||||
id: 2,
|
||||
name: "Premium Bronze Pack",
|
||||
price: 750,
|
||||
n_bronze: 10,
|
||||
n_silver: 2,
|
||||
n_gold: 0,
|
||||
rares: 3,
|
||||
category: "bronze",
|
||||
special_chance: 0.02,
|
||||
owned_only: false,
|
||||
},
|
||||
// ── Silver category ──
|
||||
PackDef {
|
||||
id: 3,
|
||||
name: "Silver Pack",
|
||||
price: 2500,
|
||||
n_bronze: 1,
|
||||
n_silver: 11,
|
||||
n_gold: 0,
|
||||
rares: 1,
|
||||
category: "silver",
|
||||
special_chance: 0.015,
|
||||
owned_only: false,
|
||||
},
|
||||
PackDef {
|
||||
id: 4,
|
||||
name: "Premium Silver Pack",
|
||||
price: 3750,
|
||||
n_bronze: 1,
|
||||
n_silver: 11,
|
||||
n_gold: 0,
|
||||
rares: 3,
|
||||
category: "silver",
|
||||
special_chance: 0.03,
|
||||
owned_only: false,
|
||||
},
|
||||
// ── Gold category ──
|
||||
PackDef {
|
||||
id: 5,
|
||||
name: "Gold Pack",
|
||||
price: 5000,
|
||||
count: 7,
|
||||
gold: true,
|
||||
special_chance: 0.03,
|
||||
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",
|
||||
price: 15000,
|
||||
count: 11,
|
||||
gold: true,
|
||||
special_chance: 0.08,
|
||||
owned_only: false,
|
||||
},
|
||||
PackDef {
|
||||
id: 7,
|
||||
name: "Special Players Pack",
|
||||
price: 25000,
|
||||
count: 11,
|
||||
gold: true,
|
||||
special_chance: 1.0,
|
||||
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 Special Players Pack",
|
||||
name: "Reward Gold Pack",
|
||||
price: 0,
|
||||
count: 11,
|
||||
gold: true,
|
||||
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,
|
||||
},
|
||||
@@ -97,27 +222,88 @@ pub fn pack_by_id(id: u64) -> Option<&'static PackDef> {
|
||||
PACK_CATALOG.iter().find(|p| p.id == id)
|
||||
}
|
||||
|
||||
/// The FIFA17 StoreFront category token for a NORMAL pack tile (`utas_server.py:3579`):
|
||||
/// one of the six hard-coded tokens the client resolves.
|
||||
fn category(p: &PackDef) -> &'static str {
|
||||
if p.special_chance >= 1.0 {
|
||||
"special"
|
||||
} else if p.gold {
|
||||
"gold"
|
||||
} else {
|
||||
"bronze"
|
||||
/// Resolve a Core entitlement's opaque `definition_id` to the FIFA 17 numeric
|
||||
/// owned-only pack it renders and opens as. Accepts a numeric id (imported
|
||||
/// entitlements, e.g. `"70"`) or one of the symbolic reward-pack names Core's
|
||||
/// reward services grant (SBC, draft, season, check-in, FUT Champions). Only
|
||||
/// owned-only packs qualify, so an unknown or non-reward entitlement resolves to
|
||||
/// `None` and is simply not shown as an openable pack rather than faked.
|
||||
pub fn owned_pack_id_for_definition(definition_id: &str) -> Option<u64> {
|
||||
if let Ok(numeric) = definition_id.parse::<u64>() {
|
||||
return pack_by_id(numeric)
|
||||
.filter(|pack| pack.owned_only)
|
||||
.map(|pack| pack.id);
|
||||
}
|
||||
let id = match definition_id {
|
||||
"bronze_pack" => 71,
|
||||
"silver_pack" => 72,
|
||||
"gold_pack" => 73,
|
||||
"rare_gold_pack" => 74,
|
||||
"icon_pack" => 75,
|
||||
_ => return None,
|
||||
};
|
||||
Some(id)
|
||||
}
|
||||
|
||||
/// The FIFA 17 StoreFront category token for a pack tile (`displayGroup.value`):
|
||||
/// one of the six hard-coded tokens the client resolves. Each catalogue pack
|
||||
/// carries its own token; owned/reward packs take `mypacks` instead (see
|
||||
/// [`pack_body`]).
|
||||
fn category(p: &PackDef) -> &'static str {
|
||||
p.category
|
||||
}
|
||||
|
||||
/// One `purchase[]` entry — the faithful `_pack_body` port (`utas_server.py:3474`) at
|
||||
/// production flag defaults. `owned` packs (My Packs / reward / sentinel) drop the
|
||||
/// purchase fields and take the `mypacks` display group.
|
||||
pub fn pack_body(p: &PackDef, idx: u64, owned: bool) -> Value {
|
||||
let mtx = std::cmp::max(1, p.price / 100);
|
||||
let pack_type = match p.category {
|
||||
"gold" => "GOLD",
|
||||
"silver" => "SILVER",
|
||||
_ => "BRONZE",
|
||||
};
|
||||
// Group background texture: FIFA 17's all-groups landing (shown on first store
|
||||
// entry) renders one tile per group whose art is the client-bundled
|
||||
// `packs_backgrounds_%d.dds` selected by this field. LIVE-PROBED 2026-08-18:
|
||||
// index 0 is blank; 1/2/3 render real pack art — so assign non-zero per
|
||||
// category and that landing shows native art instead of blank shields. The
|
||||
// persistent tabbed store draws its pack art from the packs themselves and
|
||||
// does not depend on this.
|
||||
let group_bg: u64 = if owned {
|
||||
3
|
||||
} else {
|
||||
match p.category {
|
||||
"gold" => 3,
|
||||
"silver" => 2,
|
||||
_ => 1,
|
||||
}
|
||||
};
|
||||
// My Packs cover art. LIVE-MAPPED on the retail client 2026-08-18 across all
|
||||
// three store tabs and two reward tiles:
|
||||
// * `assetId` only gates whether art renders AT ALL. A reward pack's own id
|
||||
// (70-75) is not a known client asset, so its tile renders BLANK; any valid
|
||||
// catalogue asset (1-6) makes art appear.
|
||||
// * WHICH art is drawn comes from `packType` + `packContentInfo.rareQuantity`,
|
||||
// not from `assetId`: BRONZE+1rare -> bronze card, BRONZE+3 -> silver,
|
||||
// SILVER+1 -> gold, SILVER+3 -> silver trio, GOLD+1 -> blue special,
|
||||
// GOLD+3 -> red inform. (Remapping assetId 5->3 and 3->2 left both frames
|
||||
// unchanged and only rotated the featured player, which proves this.)
|
||||
// So a reward tile automatically shows the same art as the equivalent
|
||||
// purchasable pack; we only need a valid asset, and we use the tier's own store
|
||||
// pack for clarity. `id` stays the pack's own id (the open packId / SERVER_ID).
|
||||
let art_asset: u64 = if owned {
|
||||
match p.category {
|
||||
"gold" => 5,
|
||||
"silver" => 3,
|
||||
_ => 1,
|
||||
}
|
||||
} else {
|
||||
p.id
|
||||
};
|
||||
let mut body = json!({
|
||||
"assetId": p.id,
|
||||
"assetId": art_asset,
|
||||
"id": p.id,
|
||||
"packType": if p.gold { "GOLD" } else { "BRONZE" },
|
||||
"packType": pack_type,
|
||||
"description": p.name,
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
@@ -127,26 +313,37 @@ pub fn pack_body(p: &PackDef, idx: u64, owned: bool) -> Value {
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": idx,
|
||||
"displayGroupAssetId": group_bg,
|
||||
// The tile renders `finalFunds` as its coin price; the HUD balance reads
|
||||
// `funds` from the /credits currencies array. We keep the pair equal.
|
||||
//
|
||||
// NO `extPrice`. Its parser has a SIDE EFFECT: `finalPrice`/`originalPrice`
|
||||
// both CREATE an `"mtx"` currency row, which the tile adapter reads as "has
|
||||
// a real-money price" and switches on the broken `or %1s` label — the
|
||||
// Origin/Dime commerce catalogue that would fill it no longer exists
|
||||
// offline, so every string stays at its constructor default. Omitting the
|
||||
// key is the documented fix (plan-2026-08-05-store-subsystem.md §3.4 /
|
||||
// experiment #4): it strictly reduces executed client code and leaves every
|
||||
// tile buyable. LIVE-observed `or %1s` on the Store tiles, 2026-08-18.
|
||||
"currencies": [{ "name": "coins", "funds": p.price, "finalFunds": p.price }],
|
||||
"extPrice": {
|
||||
"finalPrice": { "amount": mtx, "currency": "mtx" },
|
||||
"originalPrice": { "amount": mtx, "currency": "mtx" },
|
||||
},
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": if p.gold { 0 } else { p.count },
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": if p.gold { p.count } else { 0 },
|
||||
"rareQuantity": if p.gold { p.count } else { 0 },
|
||||
"itemQuantity": p.count,
|
||||
"bronzeQuantity": p.n_bronze,
|
||||
"silverQuantity": p.n_silver,
|
||||
"goldQuantity": p.n_gold,
|
||||
"rareQuantity": p.rares,
|
||||
"itemQuantity": p.count(),
|
||||
},
|
||||
"unopened": owned,
|
||||
});
|
||||
let obj = body.as_object_mut().expect("pack body is a JSON object");
|
||||
if owned {
|
||||
// Reward/My-Packs tiles have no purchase path; leaving zero-value coin/mtx
|
||||
// objects makes the client render the price label as literal "undefined".
|
||||
obj.remove("currencies");
|
||||
obj.remove("extPrice");
|
||||
// Reward/My-Packs tiles KEEP the coins currency at the pack price (0 for
|
||||
// reward packs). My Packs opens through the store purchase flow, so a tile
|
||||
// with no currency row is not actionable — clicking navigates instead of
|
||||
// opening. With a free coin row the client sends POST /purchased and the
|
||||
// server (owned-only) opens it for free. No extPrice (that is the `or %1s`
|
||||
// mtx bug), so the free coin row formats as "0", not an unavailable label.
|
||||
// LIVE-PROVEN 2026-08-18: a reward Silver Pack opened and revealed cards.
|
||||
obj.insert(
|
||||
"displayGroup".into(),
|
||||
json!({ "value": "mypacks", "priority": idx }),
|
||||
@@ -165,8 +362,11 @@ pub fn sentinel_body(idx: u64) -> Value {
|
||||
id: SENTINEL_PACK_ID,
|
||||
name: "",
|
||||
price: 0,
|
||||
count: 0,
|
||||
gold: true,
|
||||
n_bronze: 0,
|
||||
n_silver: 0,
|
||||
n_gold: 0,
|
||||
rares: 0,
|
||||
category: "gold",
|
||||
special_chance: 0.0,
|
||||
owned_only: true,
|
||||
};
|
||||
@@ -176,13 +376,19 @@ pub fn sentinel_body(idx: u64) -> Value {
|
||||
.expect("sentinel body is a JSON object");
|
||||
obj.insert("state".into(), json!("active"));
|
||||
obj.insert("unopened".into(), json!(false));
|
||||
// The sentinel must stay non-openable (it is only a resolve-without-crash shim
|
||||
// for an empty My Packs), so it keeps no purchase path.
|
||||
obj.remove("currencies");
|
||||
// Keep the sentinel's own id as its asset: it must render as an inert blank
|
||||
// placeholder, never borrow a real pack's cover.
|
||||
obj.insert("assetId".into(), json!(SENTINEL_PACK_ID));
|
||||
body
|
||||
}
|
||||
|
||||
/// Build the full `/store/purchasegroup` body from the authoritative unopened-pack ids
|
||||
/// and the frozen empty-My-Packs mode. Pure — mirrors `store_catalog` (`3627`):
|
||||
/// normal packs (1,5,6,7) first, then any owned packs, then the empty-My-Packs shim
|
||||
/// (sentinel for [`StoreMode::Sentinel`], nothing for [`StoreMode::CleanV1`]).
|
||||
/// Build the full `/store/purchasegroup` body from the authoritative unopened-pack
|
||||
/// ids and the frozen empty-My-Packs mode. Pure: the six regular packs (ids 1–6)
|
||||
/// first, then any owned packs, then the empty-My-Packs shim (sentinel for
|
||||
/// [`StoreMode::Sentinel`], nothing for [`StoreMode::CleanV1`]).
|
||||
pub fn build_purchasegroup(unopened_ids: &[u64], mode: StoreMode) -> Value {
|
||||
let mut packs: Vec<Value> = PACK_CATALOG
|
||||
.iter()
|
||||
@@ -203,10 +409,11 @@ pub fn build_purchasegroup(unopened_ids: &[u64], mode: StoreMode) -> Value {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Differential parity against the Python oracle. The fixtures under
|
||||
//! `tests/fixtures/purchasegroup_*.json` are generated by calling the oracle's
|
||||
//! `_pack_body`/`store_catalog` at production flag defaults; Rust must match
|
||||
//! them semantically (object key order is irrelevant to `serde_json::Value` eq).
|
||||
//! Golden tests pinning the authoritative Rust `/store/purchasegroup` body. The
|
||||
//! fixtures under `tests/fixtures/purchasegroup_*.json` are Rust's own output
|
||||
//! (object key order is irrelevant to `serde_json::Value` eq). They track the
|
||||
//! RE-driven divergence from the Python oracle — notably no `extPrice` (see the
|
||||
//! module header).
|
||||
use super::*;
|
||||
|
||||
fn parse(s: &str) -> Value {
|
||||
@@ -214,7 +421,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purchasegroup_zero_sentinel_matches_oracle() {
|
||||
fn purchasegroup_zero_sentinel_matches_golden() {
|
||||
let got = build_purchasegroup(&[], StoreMode::Sentinel);
|
||||
let want = parse(include_str!(
|
||||
"../../tests/fixtures/purchasegroup_zero_sentinel.json"
|
||||
@@ -223,7 +430,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purchasegroup_zero_clean_matches_oracle() {
|
||||
fn purchasegroup_zero_clean_matches_golden() {
|
||||
let got = build_purchasegroup(&[], StoreMode::CleanV1);
|
||||
let want = parse(include_str!(
|
||||
"../../tests/fixtures/purchasegroup_zero_clean.json"
|
||||
@@ -232,7 +439,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purchasegroup_pack70_matches_oracle() {
|
||||
fn purchasegroup_pack70_matches_golden() {
|
||||
// Owned pack present -> no sentinel regardless of mode.
|
||||
let got = build_purchasegroup(&[70], StoreMode::Sentinel);
|
||||
let want = parse(include_str!(
|
||||
@@ -247,6 +454,68 @@ mod tests {
|
||||
assert!(PACK_CATALOG.iter().all(|p| p.id != SENTINEL_PACK_ID));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reward_pack_definitions_resolve_to_openable_owned_packs() {
|
||||
// Core reward services grant symbolic pack names; each must resolve to an
|
||||
// owned-only catalogue pack so it renders as an openable My Packs tile.
|
||||
for (def, want) in [
|
||||
("bronze_pack", 71),
|
||||
("silver_pack", 72),
|
||||
("gold_pack", 73),
|
||||
("rare_gold_pack", 74),
|
||||
("icon_pack", 75),
|
||||
] {
|
||||
let id = owned_pack_id_for_definition(def).expect("reward def resolves");
|
||||
assert_eq!(id, want);
|
||||
assert!(
|
||||
pack_by_id(id).unwrap().owned_only,
|
||||
"a reward pack must be owned-only"
|
||||
);
|
||||
}
|
||||
// Imported numeric owned-pack ids resolve to themselves.
|
||||
assert_eq!(owned_pack_id_for_definition("70"), Some(70));
|
||||
// Purchasable (non-owned) numeric ids and unknown names never resolve.
|
||||
assert_eq!(owned_pack_id_for_definition("5"), None);
|
||||
assert_eq!(owned_pack_id_for_definition("mystery_pack"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reward_tiles_carry_a_renderable_cover_asset() {
|
||||
// A reward pack's own id is not a client art asset, so its My Packs tile
|
||||
// renders blank. Each reward tile must therefore carry a valid catalogue
|
||||
// assetId (its tier's store pack) while `id` stays the open packId.
|
||||
for (reward_id, want_asset) in [(71, 1), (72, 3), (73, 5), (74, 5), (75, 5)] {
|
||||
let pack = pack_by_id(reward_id).expect("reward pack in catalogue");
|
||||
let tile = pack_body(pack, 1, true);
|
||||
assert_eq!(tile["id"], reward_id, "open packId stays the pack's own id");
|
||||
assert_eq!(
|
||||
tile["assetId"], want_asset,
|
||||
"reward tile borrows its tier's store-pack cover asset"
|
||||
);
|
||||
assert!(
|
||||
pack_by_id(tile["assetId"].as_u64().unwrap()).is_some_and(|p| !p.owned_only),
|
||||
"the cover asset must be a real purchasable catalogue pack"
|
||||
);
|
||||
}
|
||||
// The sentinel must NOT borrow a real cover — it stays an inert placeholder.
|
||||
assert_eq!(sentinel_body(1)["assetId"], SENTINEL_PACK_ID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbolic_reward_pack_renders_as_openable_my_packs_tile() {
|
||||
// A granted silver reward pack (resolved to id 72) must appear as an owned
|
||||
// My Packs tile, not the non-openable sentinel shim.
|
||||
let got = build_purchasegroup(&[72], StoreMode::Sentinel);
|
||||
let packs = got["purchase"].as_array().unwrap();
|
||||
let reward = packs
|
||||
.iter()
|
||||
.find(|p| p["id"] == 72)
|
||||
.expect("reward pack tile present");
|
||||
assert_eq!(reward["displayGroup"]["value"], "mypacks");
|
||||
assert!(reward["unopened"].as_bool().unwrap());
|
||||
assert!(packs.iter().all(|p| p["id"] != SENTINEL_PACK_ID));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_v1_empty_emits_no_mypacks_group() {
|
||||
let got = build_purchasegroup(&[], StoreMode::CleanV1);
|
||||
@@ -256,13 +525,13 @@ mod tests {
|
||||
.iter()
|
||||
.map(|e| e["id"].as_u64().unwrap())
|
||||
.collect();
|
||||
assert_eq!(ids, vec![1, 5, 6, 7]);
|
||||
assert_eq!(ids, vec![1, 2, 3, 4, 5, 6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_tokens_are_canonical() {
|
||||
assert_eq!(category(pack_by_id(1).unwrap()), "bronze");
|
||||
assert_eq!(category(pack_by_id(3).unwrap()), "silver");
|
||||
assert_eq!(category(pack_by_id(5).unwrap()), "gold");
|
||||
assert_eq!(category(pack_by_id(7).unwrap()), "special");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
//! FIFA 17 attribute training cards: which attribute a card trains, and by how
|
||||
//! much.
|
||||
//!
|
||||
//! ## Where this comes from
|
||||
//!
|
||||
//! Two independent shipped sources, no invention:
|
||||
//!
|
||||
//! * **which attribute** — `cardsubtypeid`. `FUN_18013f4d0` derives a
|
||||
//! consumable's whole presentation from that one field, and for the training
|
||||
//! families it writes an attribute selector to `rec+0xbc` and the magnitude to
|
||||
//! `rec+0xbf`. The selector per subtype is recorded in
|
||||
//! `fifa17-recon/data/consumables.json` (`subtypes[].bc`, with the client's own
|
||||
//! `FUT_UC_*` / `FUT_MC_*` string for each). STATIC_REVERSED.
|
||||
//! * **how much** — `fcc_trainingcards.amount`, EA's shipped table. Every owned
|
||||
//! consumable's wire `amount` matches that column 8/8. TABLE_PROVEN.
|
||||
//!
|
||||
//! ## Why the effect is ours to define at all
|
||||
//!
|
||||
//! No binary in the FIFA 17 install reads `fcc_trainingcards` at any casing, so
|
||||
//! unlike quick-sell (`fcc_discardcoins`, which the client DOES read) there is no
|
||||
//! client-side oracle for a consumable effect and never will be. The client ACKs
|
||||
//! an apply on transport code alone and then re-reads state. Whatever the server
|
||||
//! durably stores and re-serves IS what the player sees. That makes the
|
||||
//! *magnitude* and the *target attribute* recoverable facts — the two above — and
|
||||
//! everything about the effect's LIFECYCLE a server policy we must state
|
||||
//! explicitly rather than pretend to have reversed. See
|
||||
//! `TRAINING_MATCH_EXPIRY` below.
|
||||
//!
|
||||
//! ## Slot numbering
|
||||
//!
|
||||
//! The `attribute_index` this module produces is a slot in CORE's six-attribute
|
||||
//! model (0 pace, 1 shooting, 2 passing, 3 dribbling, 4 defending, 5 physical),
|
||||
//! not a FIFA attribute id. A goalkeeper's six attributes occupy those same six
|
||||
//! slots on the wire — DIV/HAN/KIC/REF/SPD/POS in that order — which is why a GK
|
||||
//! card and an outfield card can share one slot vocabulary.
|
||||
|
||||
/// Which class of player a training card may be applied to.
|
||||
///
|
||||
/// FIFA 17 authors the two families separately (`FUT_UC_*` for keepers,
|
||||
/// `FUT_MC_*` for outfielders) and their slots mean different attributes, so
|
||||
/// applying one to the wrong class would silently train the wrong stat.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TrainingClass {
|
||||
Goalkeeper,
|
||||
Outfield,
|
||||
}
|
||||
|
||||
/// A resolved training effect: one attribute slot (or all six), one magnitude,
|
||||
/// one legal target class.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TrainingEffect {
|
||||
pub class: TrainingClass,
|
||||
/// Slot in Core's six-attribute model, or `None` for the rare card that
|
||||
/// boosts ALL SIX.
|
||||
pub attribute_index: Option<i64>,
|
||||
pub amount: i64,
|
||||
}
|
||||
|
||||
/// The largest magnitude EA authors for a SINGLE-attribute training card.
|
||||
///
|
||||
/// `fcc_trainingcards` authors exactly 5, 10 and 15 for all twelve
|
||||
/// single-attribute families. Declared to Core on every apply so Core can refuse
|
||||
/// a larger boost than any real card could grant, which is what keeps the closed
|
||||
/// vocabulary from being a blank cheque.
|
||||
pub const TRAINING_MAX_AMOUNT: i64 = 15;
|
||||
|
||||
/// The largest magnitude EA authors for the RARE all-six training card.
|
||||
///
|
||||
/// The two all-six subtypes author 3/6/10 only, so a +15 all-six card does not
|
||||
/// exist and must not be describable to Core.
|
||||
pub const TRAINING_ALL_MAX_AMOUNT: i64 = 10;
|
||||
|
||||
/// GK attribute training subtypes → Core attribute slot.
|
||||
///
|
||||
/// The client's own order is DIV, HAN, KIC, REF, SPD, POS, and `bc` follows it;
|
||||
/// note that the subtype ids do NOT (54 is SPEED at slot 4, 56 is REFLEXES at
|
||||
/// slot 3). Reading these off in subtype order instead of `bc` order is exactly
|
||||
/// the mistake this table exists to prevent.
|
||||
const GK_TRAINING: &[(i64, i64)] = &[
|
||||
(51, 0), // FUT_UC_DIVING
|
||||
(52, 1), // FUT_UC_HANDLING
|
||||
(53, 2), // FUT_UC_KICKING
|
||||
(56, 3), // FUT_UC_REFLEXES
|
||||
(54, 4), // FUT_UC_SPEED
|
||||
(55, 5), // FUT_UC_POSITIONING
|
||||
];
|
||||
|
||||
/// Outfield attribute training subtypes → Core attribute slot.
|
||||
///
|
||||
/// Same trap as the keepers: 65 is HEADING at slot 5 (Core's `physical`) and 66
|
||||
/// is DEFENDING at slot 4.
|
||||
const OUTFIELD_TRAINING: &[(i64, i64)] = &[
|
||||
(61, 0), // FUT_MC_PACE
|
||||
(62, 1), // FUT_MC_SHOOTING
|
||||
(63, 2), // FUT_MC_PASSING
|
||||
(64, 3), // FUT_MC_DRIBBLING
|
||||
(66, 4), // FUT_MC_DEFENDING
|
||||
(65, 5), // FUT_MC_HEADING -> Core's `physical` slot
|
||||
];
|
||||
|
||||
/// The RARE training cards, which boost ALL SIX attributes at once.
|
||||
///
|
||||
/// These were previously mistaken for "squad fitness" on the strength of the
|
||||
/// reversed label `FUT_FITNESS_UC` / `FUT_FITNESS_MC`. That label is
|
||||
/// tool-authored (`build_consumables.py` names the 7th element of its attribute
|
||||
/// array) and has no documented provenance; four independent facts say all-six:
|
||||
///
|
||||
/// * each family holds exactly 21 rows = 7 card types x 3 levels, and the
|
||||
/// published FIFA 17 card list is 6 single attributes + 1 "ALL";
|
||||
/// * these six rows are the ONLY ones in the table with `weightrare = 2`; all 36
|
||||
/// single-attribute rows are `weightrare = 0`. The published list marks the
|
||||
/// ALL card RARE and every single-attribute card non-rare;
|
||||
/// * their amounts are exactly 3/6/10, matching the published ALL card's
|
||||
/// +3 bronze / +6 silver / +10 gold, while single attributes are 5/10/15;
|
||||
/// * `bc = 6` is one past the six real slots (0..5) -- an "all" sentinel -- and
|
||||
/// `c0 = 0` reads as "not single-ATTRIBUTE", not "not single-target".
|
||||
///
|
||||
/// The genuine squad-fitness card is subtype 220 in a DIFFERENT table
|
||||
/// (`fcc_healingcards`, amounts 10/20/30), and player fitness is 219 — that
|
||||
/// family is separately identified and remains unsupported.
|
||||
const ALL_ATTRIBUTE_TRAINING: &[(i64, TrainingClass)] = &[
|
||||
(57, TrainingClass::Goalkeeper),
|
||||
(67, TrainingClass::Outfield),
|
||||
];
|
||||
|
||||
/// Resolve a consumable into a training effect, or `None` if it is not an
|
||||
/// attribute training card.
|
||||
///
|
||||
/// `amount` is the wire/catalog magnitude for the card. It is required: the
|
||||
/// client parser initialises its amount temp to `-1` and reads it signed, so a
|
||||
/// missing magnitude is not "zero", it is a card that would draw and grant
|
||||
/// nonsense. Absent or out-of-range, this refuses.
|
||||
pub fn training_effect(subtype: i64, amount: Option<i64>) -> Option<TrainingEffect> {
|
||||
let (class, attribute_index, ceiling) = GK_TRAINING
|
||||
.iter()
|
||||
.find(|&&(s, _)| s == subtype)
|
||||
.map(|&(_, slot)| (TrainingClass::Goalkeeper, Some(slot), TRAINING_MAX_AMOUNT))
|
||||
.or_else(|| {
|
||||
OUTFIELD_TRAINING
|
||||
.iter()
|
||||
.find(|&&(s, _)| s == subtype)
|
||||
.map(|&(_, slot)| (TrainingClass::Outfield, Some(slot), TRAINING_MAX_AMOUNT))
|
||||
})
|
||||
.or_else(|| {
|
||||
ALL_ATTRIBUTE_TRAINING
|
||||
.iter()
|
||||
.find(|&&(s, _)| s == subtype)
|
||||
.map(|&(_, class)| (class, None, TRAINING_ALL_MAX_AMOUNT))
|
||||
})?;
|
||||
|
||||
let amount = amount?;
|
||||
if !(1..=ceiling).contains(&amount) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(TrainingEffect {
|
||||
class,
|
||||
attribute_index,
|
||||
amount,
|
||||
})
|
||||
}
|
||||
|
||||
/// The ceiling Core must be told for a resolved effect: the two families author
|
||||
/// different maxima, and sending the wrong one either lets an impossible boost
|
||||
/// through or refuses a legitimate card.
|
||||
pub fn ceiling_for(effect: &TrainingEffect) -> i64 {
|
||||
match effect.attribute_index {
|
||||
Some(_) => TRAINING_MAX_AMOUNT,
|
||||
None => TRAINING_ALL_MAX_AMOUNT,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a target playing in `position` may receive `class` training.
|
||||
///
|
||||
/// The client's own `pos` vocabulary numbers GK 0 and gives every outfield role
|
||||
/// its own id, so the distinction is exactly "is the target a keeper".
|
||||
pub fn class_accepts_position(class: TrainingClass, position: &str) -> bool {
|
||||
let is_gk = position.eq_ignore_ascii_case("GK");
|
||||
match class {
|
||||
TrainingClass::Goalkeeper => is_gk,
|
||||
TrainingClass::Outfield => !is_gk,
|
||||
}
|
||||
}
|
||||
|
||||
/// What clears an applied training effect, if anything.
|
||||
///
|
||||
/// UNKNOWN, and deliberately recorded as a constant so it cannot be quietly
|
||||
/// assumed. FIFA 17 ships no table describing a training lifetime, the client
|
||||
/// holds no consumable-effect logic to reverse one from, and "training is
|
||||
/// temporary in FUT" is a recollection about other titles, not evidence about
|
||||
/// this one. Until an experiment settles it, an applied effect PERSISTS, and no
|
||||
/// code decrements or expires it.
|
||||
pub const TRAINING_MATCH_EXPIRY: &str = "UNKNOWN";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The slot must come from `bc`, never from the subtype's ordinal position.
|
||||
/// 54/56 (keeper) and 65/66 (outfield) are the pairs that catch a
|
||||
/// sequential misreading.
|
||||
#[test]
|
||||
fn out_of_order_subtypes_map_to_their_reversed_slots() {
|
||||
assert_eq!(
|
||||
training_effect(54, Some(10)).unwrap().attribute_index,
|
||||
Some(4)
|
||||
); // SPEED
|
||||
assert_eq!(
|
||||
training_effect(56, Some(10)).unwrap().attribute_index,
|
||||
Some(3)
|
||||
); // REFLEXES
|
||||
assert_eq!(
|
||||
training_effect(65, Some(10)).unwrap().attribute_index,
|
||||
Some(5)
|
||||
); // HEADING
|
||||
assert_eq!(
|
||||
training_effect(66, Some(10)).unwrap().attribute_index,
|
||||
Some(4)
|
||||
); // DEFENDING
|
||||
}
|
||||
|
||||
/// Every attribute training subtype resolves, and the two families cover
|
||||
/// Core's six slots exactly once each.
|
||||
#[test]
|
||||
fn both_families_cover_all_six_slots_exactly_once() {
|
||||
for (family, subtypes) in [
|
||||
(TrainingClass::Goalkeeper, GK_TRAINING),
|
||||
(TrainingClass::Outfield, OUTFIELD_TRAINING),
|
||||
] {
|
||||
let mut slots: Vec<i64> = subtypes
|
||||
.iter()
|
||||
.map(|&(s, _)| {
|
||||
let e = training_effect(s, Some(5)).expect("subtype resolves");
|
||||
assert_eq!(e.class, family);
|
||||
e.attribute_index
|
||||
.expect("single-attribute card names a slot")
|
||||
})
|
||||
.collect();
|
||||
slots.sort_unstable();
|
||||
assert_eq!(slots, vec![0, 1, 2, 3, 4, 5]);
|
||||
}
|
||||
}
|
||||
|
||||
/// The rare pair boost ALL SIX attributes, so they resolve with NO slot.
|
||||
/// Reading them as single-attribute would apply their magnitude to whatever
|
||||
/// slot 0 happens to be and drop the other five.
|
||||
#[test]
|
||||
fn the_rare_cards_boost_all_six_attributes() {
|
||||
for (subtype, class) in [
|
||||
(57, TrainingClass::Goalkeeper),
|
||||
(67, TrainingClass::Outfield),
|
||||
] {
|
||||
for amount in [3, 6, 10] {
|
||||
let e = training_effect(subtype, Some(amount)).expect("rare card resolves");
|
||||
assert_eq!(e.attribute_index, None, "subtype {subtype} must be all-six");
|
||||
assert_eq!(e.class, class);
|
||||
assert_eq!(e.amount, amount);
|
||||
assert_eq!(ceiling_for(&e), TRAINING_ALL_MAX_AMOUNT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The all-six card authors 3/6/10 only. A +15 all-six card does not exist,
|
||||
/// and letting one through would grant 90 attribute points from a card that
|
||||
/// grants at most 60.
|
||||
#[test]
|
||||
fn the_rare_card_cannot_carry_a_single_attribute_magnitude() {
|
||||
assert_eq!(training_effect(57, Some(15)), None);
|
||||
assert_eq!(training_effect(67, Some(15)), None);
|
||||
// ...while a single-attribute card still may.
|
||||
assert_eq!(training_effect(51, Some(15)).unwrap().amount, 15);
|
||||
}
|
||||
|
||||
/// A missing magnitude is a refusal, not a zero: the client reads the byte
|
||||
/// signed from a -1 initial value.
|
||||
#[test]
|
||||
fn a_missing_or_impossible_amount_refuses() {
|
||||
assert_eq!(training_effect(52, None), None);
|
||||
assert_eq!(training_effect(52, Some(0)), None);
|
||||
assert_eq!(training_effect(52, Some(-1)), None);
|
||||
assert_eq!(training_effect(52, Some(TRAINING_MAX_AMOUNT + 1)), None);
|
||||
}
|
||||
|
||||
/// Only the shipped magnitudes are accepted, and all three are.
|
||||
#[test]
|
||||
fn the_three_authored_magnitudes_all_resolve() {
|
||||
for a in [5, 10, 15] {
|
||||
assert_eq!(training_effect(61, Some(a)).unwrap().amount, a);
|
||||
}
|
||||
}
|
||||
|
||||
/// Family/target gating is the whole reason `class` exists.
|
||||
#[test]
|
||||
fn each_family_accepts_only_its_own_target_class() {
|
||||
assert!(class_accepts_position(TrainingClass::Goalkeeper, "GK"));
|
||||
assert!(!class_accepts_position(TrainingClass::Goalkeeper, "ST"));
|
||||
assert!(class_accepts_position(TrainingClass::Outfield, "ST"));
|
||||
assert!(!class_accepts_position(TrainingClass::Outfield, "GK"));
|
||||
// The wire's casing is not guaranteed to be ours.
|
||||
assert!(class_accepts_position(TrainingClass::Goalkeeper, "gk"));
|
||||
}
|
||||
|
||||
/// A non-training consumable must never resolve here — contracts (201/202),
|
||||
/// healing (211-218), fitness (219/220), position (91-110) and play styles
|
||||
/// (250-273) all share the consumable space.
|
||||
#[test]
|
||||
fn other_consumable_families_do_not_resolve_as_training() {
|
||||
for s in [201, 202, 211, 218, 219, 220, 91, 110, 250, 271, 300] {
|
||||
assert_eq!(training_effect(s, Some(5)), None, "subtype {s} resolved");
|
||||
}
|
||||
}
|
||||
}
|
||||
+188
-150
@@ -2,197 +2,235 @@
|
||||
"purchase": [
|
||||
{
|
||||
"assetId": 1,
|
||||
"id": 1,
|
||||
"packType": "BRONZE",
|
||||
"description": "Bronze Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 1,
|
||||
"displayGroupAssetId": 1,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 400,
|
||||
"name": "coins",
|
||||
"funds": 400,
|
||||
"name": "coins"
|
||||
"finalFunds": 400
|
||||
}
|
||||
],
|
||||
"description": "Bronze Pack",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 10,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "bronze"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 4,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 4,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 1,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 5,
|
||||
"goldQuantity": 0,
|
||||
"itemQuantity": 5,
|
||||
"rareQuantity": 0,
|
||||
"silverQuantity": 0
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 2,
|
||||
"id": 2,
|
||||
"packType": "BRONZE",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 1,
|
||||
"description": "Premium Bronze Pack",
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 2,
|
||||
"displayGroupAssetId": 1,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 750,
|
||||
"finalFunds": 750
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 10,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "bronze"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 3,
|
||||
"id": 3,
|
||||
"packType": "SILVER",
|
||||
"description": "Silver Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 3,
|
||||
"displayGroupAssetId": 2,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 2500,
|
||||
"finalFunds": 2500
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 1,
|
||||
"silverQuantity": 11,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "silver"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 4,
|
||||
"id": 4,
|
||||
"packType": "SILVER",
|
||||
"description": "Premium Silver Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 4,
|
||||
"displayGroupAssetId": 2,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 3750,
|
||||
"finalFunds": 3750
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 1,
|
||||
"silverQuantity": 11,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "silver"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 5,
|
||||
"id": 5,
|
||||
"packType": "GOLD",
|
||||
"description": "Gold Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 5,
|
||||
"displayGroupAssetId": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 5000,
|
||||
"name": "coins",
|
||||
"funds": 5000,
|
||||
"name": "coins"
|
||||
"finalFunds": 5000
|
||||
}
|
||||
],
|
||||
"description": "Gold Pack",
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 50,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 50,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 5,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"goldQuantity": 7,
|
||||
"itemQuantity": 7,
|
||||
"rareQuantity": 7,
|
||||
"silverQuantity": 0
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 10,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 2,
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 6,
|
||||
"id": 6,
|
||||
"packType": "GOLD",
|
||||
"description": "Premium Gold Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 6,
|
||||
"displayGroupAssetId": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 15000,
|
||||
"funds": 15000,
|
||||
"name": "coins"
|
||||
"name": "coins",
|
||||
"funds": 7500,
|
||||
"finalFunds": 7500
|
||||
}
|
||||
],
|
||||
"description": "Premium Gold",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 10,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 150,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 150,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 6,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"goldQuantity": 11,
|
||||
"itemQuantity": 11,
|
||||
"rareQuantity": 11,
|
||||
"silverQuantity": 0
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 3,
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 7,
|
||||
"assetId": 5,
|
||||
"id": 70,
|
||||
"packType": "GOLD",
|
||||
"description": "Reward Gold Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 1,
|
||||
"displayGroupAssetId": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 25000,
|
||||
"funds": 25000,
|
||||
"name": "coins"
|
||||
"name": "coins",
|
||||
"funds": 0,
|
||||
"finalFunds": 0
|
||||
}
|
||||
],
|
||||
"description": "Special Players Pack",
|
||||
"displayGroup": {
|
||||
"value": "special"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 250,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 250,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 7,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": 11,
|
||||
"itemQuantity": 11,
|
||||
"rareQuantity": 11,
|
||||
"silverQuantity": 0
|
||||
"itemQuantity": 11
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 4,
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
},
|
||||
{
|
||||
"assetId": 70,
|
||||
"description": "Reward Special Players Pack",
|
||||
"unopened": true,
|
||||
"displayGroup": {
|
||||
"priority": 1,
|
||||
"value": "mypacks"
|
||||
},
|
||||
"id": 70,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"goldQuantity": 11,
|
||||
"itemQuantity": 11,
|
||||
"rareQuantity": 11,
|
||||
"silverQuantity": 0
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 1,
|
||||
"state": "active",
|
||||
"unopened": true
|
||||
"value": "mypacks",
|
||||
"priority": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"timestamp": 1596326400
|
||||
|
||||
+166
-136
@@ -2,171 +2,201 @@
|
||||
"purchase": [
|
||||
{
|
||||
"assetId": 1,
|
||||
"id": 1,
|
||||
"packType": "BRONZE",
|
||||
"description": "Bronze Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 1,
|
||||
"displayGroupAssetId": 1,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 400,
|
||||
"name": "coins",
|
||||
"funds": 400,
|
||||
"name": "coins"
|
||||
"finalFunds": 400
|
||||
}
|
||||
],
|
||||
"description": "Bronze Pack",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 10,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "bronze"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 4,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 4,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 1,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 5,
|
||||
"goldQuantity": 0,
|
||||
"itemQuantity": 5,
|
||||
"rareQuantity": 0,
|
||||
"silverQuantity": 0
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 2,
|
||||
"id": 2,
|
||||
"packType": "BRONZE",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 1,
|
||||
"description": "Premium Bronze Pack",
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 2,
|
||||
"displayGroupAssetId": 1,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 750,
|
||||
"finalFunds": 750
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 10,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "bronze"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 3,
|
||||
"id": 3,
|
||||
"packType": "SILVER",
|
||||
"description": "Silver Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 3,
|
||||
"displayGroupAssetId": 2,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 2500,
|
||||
"finalFunds": 2500
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 1,
|
||||
"silverQuantity": 11,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "silver"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 4,
|
||||
"id": 4,
|
||||
"packType": "SILVER",
|
||||
"description": "Premium Silver Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 4,
|
||||
"displayGroupAssetId": 2,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 3750,
|
||||
"finalFunds": 3750
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 1,
|
||||
"silverQuantity": 11,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "silver"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 5,
|
||||
"id": 5,
|
||||
"packType": "GOLD",
|
||||
"description": "Gold Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 5,
|
||||
"displayGroupAssetId": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 5000,
|
||||
"name": "coins",
|
||||
"funds": 5000,
|
||||
"name": "coins"
|
||||
"finalFunds": 5000
|
||||
}
|
||||
],
|
||||
"description": "Gold Pack",
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 50,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 50,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 5,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"goldQuantity": 7,
|
||||
"itemQuantity": 7,
|
||||
"rareQuantity": 7,
|
||||
"silverQuantity": 0
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 10,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 2,
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 6,
|
||||
"id": 6,
|
||||
"packType": "GOLD",
|
||||
"description": "Premium Gold Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 6,
|
||||
"displayGroupAssetId": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 15000,
|
||||
"funds": 15000,
|
||||
"name": "coins"
|
||||
"name": "coins",
|
||||
"funds": 7500,
|
||||
"finalFunds": 7500
|
||||
}
|
||||
],
|
||||
"description": "Premium Gold",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 10,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 150,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 150,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 6,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"goldQuantity": 11,
|
||||
"itemQuantity": 11,
|
||||
"rareQuantity": 11,
|
||||
"silverQuantity": 0
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 3,
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
},
|
||||
{
|
||||
"assetId": 7,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 25000,
|
||||
"funds": 25000,
|
||||
"name": "coins"
|
||||
}
|
||||
],
|
||||
"description": "Special Players Pack",
|
||||
"displayGroup": {
|
||||
"value": "special"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 250,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 250,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 7,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"goldQuantity": 11,
|
||||
"itemQuantity": 11,
|
||||
"rareQuantity": 11,
|
||||
"silverQuantity": 0
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 4,
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"timestamp": 1596326400
|
||||
|
||||
+183
-152
@@ -2,197 +2,228 @@
|
||||
"purchase": [
|
||||
{
|
||||
"assetId": 1,
|
||||
"id": 1,
|
||||
"packType": "BRONZE",
|
||||
"description": "Bronze Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 1,
|
||||
"displayGroupAssetId": 1,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 400,
|
||||
"name": "coins",
|
||||
"funds": 400,
|
||||
"name": "coins"
|
||||
"finalFunds": 400
|
||||
}
|
||||
],
|
||||
"description": "Bronze Pack",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 10,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "bronze"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 4,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 4,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 1,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 5,
|
||||
"goldQuantity": 0,
|
||||
"itemQuantity": 5,
|
||||
"rareQuantity": 0,
|
||||
"silverQuantity": 0
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 2,
|
||||
"id": 2,
|
||||
"packType": "BRONZE",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 1,
|
||||
"description": "Premium Bronze Pack",
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 2,
|
||||
"displayGroupAssetId": 1,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 750,
|
||||
"finalFunds": 750
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 10,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "bronze"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 3,
|
||||
"id": 3,
|
||||
"packType": "SILVER",
|
||||
"description": "Silver Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 3,
|
||||
"displayGroupAssetId": 2,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 2500,
|
||||
"finalFunds": 2500
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 1,
|
||||
"silverQuantity": 11,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "silver"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 4,
|
||||
"id": 4,
|
||||
"packType": "SILVER",
|
||||
"description": "Premium Silver Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 4,
|
||||
"displayGroupAssetId": 2,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 3750,
|
||||
"finalFunds": 3750
|
||||
}
|
||||
],
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 1,
|
||||
"silverQuantity": 11,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "silver"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 5,
|
||||
"id": 5,
|
||||
"packType": "GOLD",
|
||||
"description": "Gold Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 5,
|
||||
"displayGroupAssetId": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 5000,
|
||||
"name": "coins",
|
||||
"funds": 5000,
|
||||
"name": "coins"
|
||||
"finalFunds": 5000
|
||||
}
|
||||
],
|
||||
"description": "Gold Pack",
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 50,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 50,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 5,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"goldQuantity": 7,
|
||||
"itemQuantity": 7,
|
||||
"rareQuantity": 7,
|
||||
"silverQuantity": 0
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 10,
|
||||
"rareQuantity": 1,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 2,
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 6,
|
||||
"id": 6,
|
||||
"packType": "GOLD",
|
||||
"description": "Premium Gold Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 6,
|
||||
"displayGroupAssetId": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 15000,
|
||||
"funds": 15000,
|
||||
"name": "coins"
|
||||
"name": "coins",
|
||||
"funds": 7500,
|
||||
"finalFunds": 7500
|
||||
}
|
||||
],
|
||||
"description": "Premium Gold",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 2,
|
||||
"goldQuantity": 10,
|
||||
"rareQuantity": 3,
|
||||
"itemQuantity": 12
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 150,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 150,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 6,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"goldQuantity": 11,
|
||||
"itemQuantity": 11,
|
||||
"rareQuantity": 11,
|
||||
"silverQuantity": 0
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 3,
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
},
|
||||
{
|
||||
"assetId": 7,
|
||||
"currencies": [
|
||||
{
|
||||
"finalFunds": 25000,
|
||||
"funds": 25000,
|
||||
"name": "coins"
|
||||
}
|
||||
],
|
||||
"description": "Special Players Pack",
|
||||
"displayGroup": {
|
||||
"value": "special"
|
||||
},
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 250,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 250,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"id": 7,
|
||||
"isPremium": false,
|
||||
"limitType": "NONE",
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"goldQuantity": 11,
|
||||
"itemQuantity": 11,
|
||||
"rareQuantity": 11,
|
||||
"silverQuantity": 0
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 4,
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 65534,
|
||||
"description": "",
|
||||
"displayGroup": {
|
||||
"priority": 1,
|
||||
"value": "mypacks"
|
||||
},
|
||||
"id": 65534,
|
||||
"isPremium": false,
|
||||
"packType": "GOLD",
|
||||
"description": "",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 1,
|
||||
"displayGroupAssetId": 3,
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": 0,
|
||||
"itemQuantity": 0,
|
||||
"rareQuantity": 0,
|
||||
"silverQuantity": 0
|
||||
"itemQuantity": 0
|
||||
},
|
||||
"packType": "GOLD",
|
||||
"purchaseCount": 0,
|
||||
"purchaseLimit": 0,
|
||||
"quantity": 0,
|
||||
"saleType": "promo",
|
||||
"sortPriority": 1,
|
||||
"state": "active",
|
||||
"unopened": false
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "mypacks",
|
||||
"priority": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"timestamp": 1596326400
|
||||
|
||||
@@ -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,14 +24,18 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use openfut_adapter_fifa17::fut::item::{CoreOwnedItem, Fifa17Identity, ItemIdentityResolver};
|
||||
use openfut_adapter_fifa17::fut::club_response::ActiveKitAssignments;
|
||||
use openfut_adapter_fifa17::fut::item::{
|
||||
CoreOwnedItem, Fifa17Identity, Fifa17KitIdentity, Fifa17StaffIdentity, ItemIdentityResolver,
|
||||
STAFF_CONTRACT,
|
||||
};
|
||||
use openfut_adapter_fifa17::fut::squad::{parse_squad_put, Fifa17SquadPut, SquadWireResolver};
|
||||
use openfut_adapter_fifa17::fut::squad_ext::{build_squad_write, SquadWriteBuild};
|
||||
use openfut_adapter_fifa17::fut::squad_projection::{
|
||||
project_squad, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput,
|
||||
project_squad, squad_actives, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput,
|
||||
SquadProjection, SquadProjectionInput,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const PUT_BASELINE: &str = include_str!("../fixtures/utas/squad_put_f442.json");
|
||||
const PUT_SWAP: &str = include_str!("../fixtures/utas/squad_put_swap_f442.json");
|
||||
@@ -49,11 +54,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
|
||||
@@ -103,6 +116,12 @@ fn oracle_tables() -> (HashMap<String, CoreOwnedItem>, TableIdentity) {
|
||||
league: String::new(),
|
||||
club: String::new(),
|
||||
attributes: [attrs[0], attrs[1], attrs[2], attrs[3], attrs[4], attrs[5]],
|
||||
// The captured wire carries the club's real per-instance
|
||||
// contract count, so the round trip proves the PERSISTED number
|
||||
// reaches the wire rather than a constant.
|
||||
contract_matches: it["contract"].as_i64(),
|
||||
source_rating: None,
|
||||
core_content_kind: None,
|
||||
},
|
||||
);
|
||||
ident.insert(
|
||||
@@ -115,7 +134,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 +165,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 +216,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 +292,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 +316,49 @@ 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],
|
||||
// The captured `manager` ref is the bare `{id, dream}` form, so the wire
|
||||
// carries no staff contract to mirror: this instance is untracked and
|
||||
// must fall back to the pack-fresh default.
|
||||
contract_matches: None,
|
||||
// Core's authored staff `value`; the squad projection never reads it (the
|
||||
// client re-rates a manager from its own table), so the round trip is
|
||||
// unaffected either way.
|
||||
source_rating: Some(88),
|
||||
core_content_kind: Some("manager".to_string()),
|
||||
};
|
||||
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 +373,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 +410,32 @@ 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 AT ELEMENT LEVEL. 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.
|
||||
// Wrapping the fields in `itemData` did not work either: the squad parser's
|
||||
// manager branch calls the item parser on the element itself, so a nested
|
||||
// item is never read and the merge key stays 0.
|
||||
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];
|
||||
assert!(
|
||||
mgr_item.get("itemData").is_none(),
|
||||
"the manager element IS the item; a wrapper hides the merge key"
|
||||
);
|
||||
assert_eq!(mgr_item["cardsubtypeid"], 4);
|
||||
assert_eq!(mgr_item["resourceId"], 1_000_509);
|
||||
assert_eq!(
|
||||
mgr_item["contract"], STAFF_CONTRACT,
|
||||
"this manager instance is untracked, so the pack-fresh fallback shows"
|
||||
);
|
||||
assert_eq!(projected["kicktakers"], oracle["kicktakers"]);
|
||||
assert_eq!(projected["squadType"], oracle["squadType"]);
|
||||
assert_eq!(projected["chemistry"], oracle["chemistry"]);
|
||||
@@ -428,11 +511,17 @@ fn one_projector_serves_every_endpoint_no_divergence() {
|
||||
&ident,
|
||||
);
|
||||
|
||||
// userMassInfo.squad = the projected object + session envelope.
|
||||
let ummi = user_mass_info_squad(projected.clone(), 33068179);
|
||||
// userMassInfo.squad = the projected object + session envelope. `actives` is
|
||||
// supplied by the caller now, so the envelope must carry it through verbatim
|
||||
// rather than hardcoding an empty array.
|
||||
let actives = json!([{ "id": 100004874, "itemState": "activeHomeKit" }]);
|
||||
let ummi = user_mass_info_squad(projected.clone(), 33068179, actives.clone());
|
||||
assert_eq!(ummi["personaId"], 33068179);
|
||||
assert_eq!(ummi["changed"], 0);
|
||||
assert!(ummi["actives"].is_array());
|
||||
assert_eq!(
|
||||
ummi["actives"], actives,
|
||||
"the envelope must pass actives through, not replace it"
|
||||
);
|
||||
assert_eq!(ummi["players"], projected["players"], "same projected body");
|
||||
assert_eq!(ummi["formation"], projected["formation"]);
|
||||
|
||||
@@ -455,3 +544,153 @@ fn one_projector_serves_every_endpoint_no_divergence() {
|
||||
// The summary carries only those six keys — no divergent squad shape.
|
||||
assert_eq!(entry.as_object().unwrap().len(), 6);
|
||||
}
|
||||
|
||||
// ---- squad.actives: the only carrier that makes a club item resident --------
|
||||
|
||||
/// A resolver that can answer `resolve_kit`, which the default trait method
|
||||
/// cannot (it returns `None` for player-only resolvers).
|
||||
struct KitIdentity(HashMap<String, Fifa17KitIdentity>);
|
||||
impl ItemIdentityResolver for KitIdentity {
|
||||
fn resolve(&self, _it: &CoreOwnedItem) -> Option<Fifa17Identity> {
|
||||
None
|
||||
}
|
||||
fn resolve_kit(&self, it: &CoreOwnedItem) -> Option<Fifa17KitIdentity> {
|
||||
self.0.get(&it.owned_card_id).copied()
|
||||
}
|
||||
}
|
||||
|
||||
fn kit_owned(owned_card_id: &str) -> CoreOwnedItem {
|
||||
CoreOwnedItem {
|
||||
owned_card_id: owned_card_id.to_string(),
|
||||
card_id: format!("def-{owned_card_id}"),
|
||||
rating: 0,
|
||||
position: String::new(),
|
||||
nation: String::new(),
|
||||
league: String::new(),
|
||||
club: String::new(),
|
||||
attributes: [0; 6],
|
||||
contract_matches: None,
|
||||
source_rating: None,
|
||||
core_content_kind: Some("kit".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn home_away_fixture() -> (HashMap<String, CoreOwnedItem>, KitIdentity) {
|
||||
let owned = HashMap::from([
|
||||
("oc-home".to_string(), kit_owned("oc-home")),
|
||||
("oc-away".to_string(), kit_owned("oc-away")),
|
||||
]);
|
||||
// Real `fcc_kitcards` rows for team 21: 6300006 is the home card (category 2,
|
||||
// assetid 14) and 6400003 the away card (category 3, assetid 15).
|
||||
let ident = KitIdentity(HashMap::from([
|
||||
(
|
||||
"oc-home".to_string(),
|
||||
Fifa17KitIdentity {
|
||||
item_id: 100004874,
|
||||
asset_id: 14,
|
||||
resource_id: 6300006,
|
||||
card_asset_id: 35,
|
||||
subtype: 9,
|
||||
team_id: 21,
|
||||
category: 2,
|
||||
year: 0,
|
||||
},
|
||||
),
|
||||
(
|
||||
"oc-away".to_string(),
|
||||
Fifa17KitIdentity {
|
||||
item_id: 100004873,
|
||||
asset_id: 15,
|
||||
resource_id: 6400003,
|
||||
card_asset_id: 35,
|
||||
subtype: 9,
|
||||
team_id: 21,
|
||||
category: 3,
|
||||
year: 0,
|
||||
},
|
||||
),
|
||||
]));
|
||||
(owned, ident)
|
||||
}
|
||||
|
||||
/// The client's squad parser reads at most five `actives` entries and parses each
|
||||
/// one straight into a slot of its five-element club-item array, so each element
|
||||
/// must be a full item object carrying a non-zero `id` — an id reference alone
|
||||
/// installs nothing.
|
||||
#[test]
|
||||
fn squad_actives_emits_full_items_for_the_designated_kits() {
|
||||
let (owned, ident) = home_away_fixture();
|
||||
let actives = squad_actives(
|
||||
&owned,
|
||||
&ident,
|
||||
ActiveKitAssignments {
|
||||
home: Some("oc-home"),
|
||||
away: Some("oc-away"),
|
||||
},
|
||||
);
|
||||
let arr = actives.as_array().expect("actives is an array");
|
||||
assert_eq!(arr.len(), 2, "one entry per designated kit");
|
||||
|
||||
assert_eq!(arr[0]["id"], 100004874);
|
||||
assert_eq!(arr[0]["itemState"], "activeHomeKit");
|
||||
assert_eq!(arr[0]["resourceId"], 6300006);
|
||||
assert_eq!(arr[1]["id"], 100004873);
|
||||
assert_eq!(arr[1]["itemState"], "activeAwayKit");
|
||||
assert_eq!(arr[1]["resourceId"], 6400003);
|
||||
|
||||
for entry in arr {
|
||||
assert_eq!(entry["itemType"], "kit");
|
||||
assert_eq!(
|
||||
entry["cardsubtypeid"], 9,
|
||||
"cardsubtypeid 9 derives cardtype 7"
|
||||
);
|
||||
assert_eq!(entry["teamid"], 21, "the clone path keys kit art on teamid");
|
||||
assert_ne!(entry["id"], 0, "a zero id is never made resident");
|
||||
}
|
||||
}
|
||||
|
||||
/// Undesignated slots contribute nothing, and an unresolvable designation is
|
||||
/// omitted rather than emitted with a fabricated id — the same policy `/club`
|
||||
/// applies when a card has no FIFA identity.
|
||||
#[test]
|
||||
fn squad_actives_omits_absent_and_unresolvable_designations() {
|
||||
let (owned, ident) = home_away_fixture();
|
||||
|
||||
let home_only = squad_actives(
|
||||
&owned,
|
||||
&ident,
|
||||
ActiveKitAssignments {
|
||||
home: Some("oc-home"),
|
||||
away: None,
|
||||
},
|
||||
);
|
||||
assert_eq!(home_only.as_array().unwrap().len(), 1);
|
||||
assert_eq!(home_only[0]["itemState"], "activeHomeKit");
|
||||
|
||||
// Designated but not present in the owned collection.
|
||||
let dangling = squad_actives(
|
||||
&owned,
|
||||
&ident,
|
||||
ActiveKitAssignments {
|
||||
home: Some("oc-missing"),
|
||||
away: None,
|
||||
},
|
||||
);
|
||||
assert_eq!(dangling.as_array().unwrap().len(), 0);
|
||||
|
||||
// Present and designated, but with no resolvable FIFA kit identity.
|
||||
let unresolvable = squad_actives(
|
||||
&HashMap::from([("oc-x".to_string(), kit_owned("oc-x"))]),
|
||||
&ident,
|
||||
ActiveKitAssignments {
|
||||
home: Some("oc-x"),
|
||||
away: None,
|
||||
},
|
||||
);
|
||||
assert_eq!(unresolvable.as_array().unwrap().len(), 0);
|
||||
|
||||
// Nothing designated at all is an empty array, which is what left every
|
||||
// club-item slot null before this projector existed.
|
||||
let none = squad_actives(&owned, &ident, ActiveKitAssignments::default());
|
||||
assert_eq!(none.as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user